From 0bbc000dbf5bef6408c1b117877557b72bd8e61b Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 2 Jun 2025 13:55:33 +0000 Subject: [PATCH] Updated via schema editor on 2025-06-02 13:54 --- .gitignore | 5 + app-shell/src/_schema.json | 7 +- backend/src/db/api/settings.js | 383 ++++++++++++++ backend/src/db/api/status.js | 304 +++++++++++ backend/src/db/migrations/1748872417660.js | 52 ++ backend/src/db/models/settings.js | 85 +++ backend/src/db/models/status.js | 67 +++ .../db/seeders/20200430130760-user-roles.js | 220 +------- .../db/seeders/20231127130745-sample-data.js | 324 ++++-------- backend/src/db/seeders/20250602135337.js | 103 ++++ backend/src/index.js | 18 +- backend/src/routes/settings.js | 469 +++++++++++++++++ backend/src/routes/status.js | 437 +++++++++++++++ backend/src/services/search.js | 16 +- backend/src/services/settings.js | 114 ++++ backend/src/services/status.js | 114 ++++ .../src/components/Settings/CardSettings.tsx | 197 +++++++ .../src/components/Settings/ListSettings.tsx | 133 +++++ .../src/components/Settings/TableSettings.tsx | 497 ++++++++++++++++++ .../Settings/configureSettingsCols.tsx | 174 ++++++ frontend/src/components/Status/CardStatus.tsx | 142 +++++ frontend/src/components/Status/ListStatus.tsx | 114 ++++ .../src/components/Status/TableStatus.tsx | 497 ++++++++++++++++++ .../components/Status/configureStatusCols.tsx | 116 ++++ frontend/src/menuAside.ts | 38 +- frontend/src/pages/dashboard.tsx | 110 ++-- frontend/src/pages/settings/[settingsId].tsx | 188 +++++++ frontend/src/pages/settings/settings-edit.tsx | 186 +++++++ frontend/src/pages/settings/settings-list.tsx | 188 +++++++ frontend/src/pages/settings/settings-new.tsx | 162 ++++++ .../src/pages/settings/settings-table.tsx | 187 +++++++ frontend/src/pages/settings/settings-view.tsx | 123 +++++ frontend/src/pages/status/[statusId].tsx | 157 ++++++ frontend/src/pages/status/status-edit.tsx | 155 ++++++ frontend/src/pages/status/status-list.tsx | 167 ++++++ frontend/src/pages/status/status-new.tsx | 131 +++++ frontend/src/pages/status/status-table.tsx | 166 ++++++ frontend/src/pages/status/status-view.tsx | 105 ++++ frontend/src/stores/settings/settingsSlice.ts | 236 +++++++++ frontend/src/stores/status/statusSlice.ts | 236 +++++++++ frontend/src/stores/store.ts | 12 +- 41 files changed, 6626 insertions(+), 509 deletions(-) create mode 100644 backend/src/db/api/settings.js create mode 100644 backend/src/db/api/status.js create mode 100644 backend/src/db/migrations/1748872417660.js create mode 100644 backend/src/db/models/settings.js create mode 100644 backend/src/db/models/status.js create mode 100644 backend/src/db/seeders/20250602135337.js create mode 100644 backend/src/routes/settings.js create mode 100644 backend/src/routes/status.js create mode 100644 backend/src/services/settings.js create mode 100644 backend/src/services/status.js create mode 100644 frontend/src/components/Settings/CardSettings.tsx create mode 100644 frontend/src/components/Settings/ListSettings.tsx create mode 100644 frontend/src/components/Settings/TableSettings.tsx create mode 100644 frontend/src/components/Settings/configureSettingsCols.tsx create mode 100644 frontend/src/components/Status/CardStatus.tsx create mode 100644 frontend/src/components/Status/ListStatus.tsx create mode 100644 frontend/src/components/Status/TableStatus.tsx create mode 100644 frontend/src/components/Status/configureStatusCols.tsx create mode 100644 frontend/src/pages/settings/[settingsId].tsx create mode 100644 frontend/src/pages/settings/settings-edit.tsx create mode 100644 frontend/src/pages/settings/settings-list.tsx create mode 100644 frontend/src/pages/settings/settings-new.tsx create mode 100644 frontend/src/pages/settings/settings-table.tsx create mode 100644 frontend/src/pages/settings/settings-view.tsx create mode 100644 frontend/src/pages/status/[statusId].tsx create mode 100644 frontend/src/pages/status/status-edit.tsx create mode 100644 frontend/src/pages/status/status-list.tsx create mode 100644 frontend/src/pages/status/status-new.tsx create mode 100644 frontend/src/pages/status/status-table.tsx create mode 100644 frontend/src/pages/status/status-view.tsx create mode 100644 frontend/src/stores/settings/settingsSlice.ts create mode 100644 frontend/src/stores/status/statusSlice.ts diff --git a/.gitignore b/.gitignore index e427ff3..d0eb167 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ node_modules/ */node_modules/ */build/ + +**/node_modules/ +**/build/ +.DS_Store +.env \ No newline at end of file diff --git a/app-shell/src/_schema.json b/app-shell/src/_schema.json index 004ad2f..89b2f74 100644 --- a/app-shell/src/_schema.json +++ b/app-shell/src/_schema.json @@ -1,5 +1,4 @@ - - { - "Initial version": "{\"iv\":\"Y4cr06vhTK5XXsdt\",\"encryptedData\":\"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\"}" -} + "Initial version": "{\"iv\":\"Y4cr06vhTK5XXsdt\",\"encryptedData\":\"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\"}", + "Updated via schema editor on 2025-06-02 13:54": "{\"iv\":\"Xe0Geaf6lxiRmT1q\",\"encryptedData\":\"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\"}" +} \ No newline at end of file diff --git a/backend/src/db/api/settings.js b/backend/src/db/api/settings.js new file mode 100644 index 0000000..086ff1a --- /dev/null +++ b/backend/src/db/api/settings.js @@ -0,0 +1,383 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class SettingsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const settings = await db.settings.create( + { + id: data.id || undefined, + + system_name: data.system_name || null, + location_text: data.location_text || null, + latitude: data.latitude || null, + longitude: data.longitude || null, + wifi_ssid: data.wifi_ssid || null, + wifi_password: data.wifi_password || null, + ip_mode: data.ip_mode || null, + ip_address: data.ip_address || null, + sensor_selector: data.sensor_selector || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return settings; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const settingsData = data.map((item, index) => ({ + id: item.id || undefined, + + system_name: item.system_name || null, + location_text: item.location_text || null, + latitude: item.latitude || null, + longitude: item.longitude || null, + wifi_ssid: item.wifi_ssid || null, + wifi_password: item.wifi_password || null, + ip_mode: item.ip_mode || null, + ip_address: item.ip_address || null, + sensor_selector: item.sensor_selector || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const settings = await db.settings.bulkCreate(settingsData, { + transaction, + }); + + // For each item created, replace relation files + + return settings; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const settings = await db.settings.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.system_name !== undefined) + updatePayload.system_name = data.system_name; + + if (data.location_text !== undefined) + updatePayload.location_text = data.location_text; + + if (data.latitude !== undefined) updatePayload.latitude = data.latitude; + + if (data.longitude !== undefined) updatePayload.longitude = data.longitude; + + if (data.wifi_ssid !== undefined) updatePayload.wifi_ssid = data.wifi_ssid; + + if (data.wifi_password !== undefined) + updatePayload.wifi_password = data.wifi_password; + + if (data.ip_mode !== undefined) updatePayload.ip_mode = data.ip_mode; + + if (data.ip_address !== undefined) + updatePayload.ip_address = data.ip_address; + + if (data.sensor_selector !== undefined) + updatePayload.sensor_selector = data.sensor_selector; + + updatePayload.updatedById = currentUser.id; + + await settings.update(updatePayload, { transaction }); + + return settings; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const settings = await db.settings.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of settings) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of settings) { + await record.destroy({ transaction }); + } + }); + + return settings; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const settings = await db.settings.findByPk(id, options); + + await settings.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await settings.destroy({ + transaction, + }); + + return settings; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const settings = await db.settings.findOne({ where }, { transaction }); + + if (!settings) { + return settings; + } + + const output = settings.get({ plain: true }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = []; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.system_name) { + where = { + ...where, + [Op.and]: Utils.ilike('settings', 'system_name', filter.system_name), + }; + } + + if (filter.location_text) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'settings', + 'location_text', + filter.location_text, + ), + }; + } + + if (filter.wifi_ssid) { + where = { + ...where, + [Op.and]: Utils.ilike('settings', 'wifi_ssid', filter.wifi_ssid), + }; + } + + if (filter.wifi_password) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'settings', + 'wifi_password', + filter.wifi_password, + ), + }; + } + + if (filter.ip_address) { + where = { + ...where, + [Op.and]: Utils.ilike('settings', 'ip_address', filter.ip_address), + }; + } + + if (filter.latitudeRange) { + const [start, end] = filter.latitudeRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + latitude: { + ...where.latitude, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + latitude: { + ...where.latitude, + [Op.lte]: end, + }, + }; + } + } + + if (filter.longitudeRange) { + const [start, end] = filter.longitudeRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + longitude: { + ...where.longitude, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + longitude: { + ...where.longitude, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.ip_mode) { + where = { + ...where, + ip_mode: filter.ip_mode, + }; + } + + if (filter.sensor_selector) { + where = { + ...where, + sensor_selector: filter.sensor_selector, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.settings.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('settings', 'system_name', query), + ], + }; + } + + const records = await db.settings.findAll({ + attributes: ['id', 'system_name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['system_name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.system_name, + })); + } +}; diff --git a/backend/src/db/api/status.js b/backend/src/db/api/status.js new file mode 100644 index 0000000..03fda24 --- /dev/null +++ b/backend/src/db/api/status.js @@ -0,0 +1,304 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class StatusDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const status = await db.status.create( + { + id: data.id || undefined, + + wifi_status: data.wifi_status || null, + wifi_strength: data.wifi_strength || null, + sensor_status: data.sensor_status || false, + + thingsboard_status: data.thingsboard_status || false, + + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return status; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const statusData = data.map((item, index) => ({ + id: item.id || undefined, + + wifi_status: item.wifi_status || null, + wifi_strength: item.wifi_strength || null, + sensor_status: item.sensor_status || false, + + thingsboard_status: item.thingsboard_status || false, + + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const status = await db.status.bulkCreate(statusData, { transaction }); + + // For each item created, replace relation files + + return status; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const status = await db.status.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.wifi_status !== undefined) + updatePayload.wifi_status = data.wifi_status; + + if (data.wifi_strength !== undefined) + updatePayload.wifi_strength = data.wifi_strength; + + if (data.sensor_status !== undefined) + updatePayload.sensor_status = data.sensor_status; + + if (data.thingsboard_status !== undefined) + updatePayload.thingsboard_status = data.thingsboard_status; + + updatePayload.updatedById = currentUser.id; + + await status.update(updatePayload, { transaction }); + + return status; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const status = await db.status.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of status) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of status) { + await record.destroy({ transaction }); + } + }); + + return status; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const status = await db.status.findByPk(id, options); + + await status.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await status.destroy({ + transaction, + }); + + return status; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const status = await db.status.findOne({ where }, { transaction }); + + if (!status) { + return status; + } + + const output = status.get({ plain: true }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = []; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.wifi_status) { + where = { + ...where, + [Op.and]: Utils.ilike('status', 'wifi_status', filter.wifi_status), + }; + } + + if (filter.wifi_strengthRange) { + const [start, end] = filter.wifi_strengthRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + wifi_strength: { + ...where.wifi_strength, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + wifi_strength: { + ...where.wifi_strength, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.sensor_status) { + where = { + ...where, + sensor_status: filter.sensor_status, + }; + } + + if (filter.thingsboard_status) { + where = { + ...where, + thingsboard_status: filter.thingsboard_status, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.status.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('status', 'wifi_status', query), + ], + }; + } + + const records = await db.status.findAll({ + attributes: ['id', 'wifi_status'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['wifi_status', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.wifi_status, + })); + } +}; diff --git a/backend/src/db/migrations/1748872417660.js b/backend/src/db/migrations/1748872417660.js new file mode 100644 index 0000000..b4ee663 --- /dev/null +++ b/backend/src/db/migrations/1748872417660.js @@ -0,0 +1,52 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.renameTable('system_settings', 'settings', { + transaction, + }); + + await queryInterface.renameTable('system_status', 'status', { + transaction, + }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.renameTable('status', 'system_status', { + transaction, + }); + + await queryInterface.renameTable('settings', 'system_settings', { + transaction, + }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/models/settings.js b/backend/src/db/models/settings.js new file mode 100644 index 0000000..eff6e06 --- /dev/null +++ b/backend/src/db/models/settings.js @@ -0,0 +1,85 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const settings = sequelize.define( + 'settings', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + system_name: { + type: DataTypes.TEXT, + }, + + location_text: { + type: DataTypes.TEXT, + }, + + latitude: { + type: DataTypes.DECIMAL, + }, + + longitude: { + type: DataTypes.DECIMAL, + }, + + wifi_ssid: { + type: DataTypes.TEXT, + }, + + wifi_password: { + type: DataTypes.TEXT, + }, + + ip_mode: { + type: DataTypes.ENUM, + + values: ['static', 'dhcp'], + }, + + ip_address: { + type: DataTypes.TEXT, + }, + + sensor_selector: { + type: DataTypes.ENUM, + + values: ['none', 'DHT11', 'DHT22', 'DS18B20'], + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + settings.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.settings.belongsTo(db.users, { + as: 'createdBy', + }); + + db.settings.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return settings; +}; diff --git a/backend/src/db/models/status.js b/backend/src/db/models/status.js new file mode 100644 index 0000000..ba9c1fa --- /dev/null +++ b/backend/src/db/models/status.js @@ -0,0 +1,67 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const status = sequelize.define( + 'status', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + wifi_status: { + type: DataTypes.TEXT, + }, + + wifi_strength: { + type: DataTypes.INTEGER, + }, + + sensor_status: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + }, + + thingsboard_status: { + type: DataTypes.BOOLEAN, + + allowNull: false, + defaultValue: false, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + status.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.status.belongsTo(db.users, { + as: 'createdBy', + }); + + db.status.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return status; +}; diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.js index eb9aed3..872428f 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.js +++ b/backend/src/db/seeders/20200430130760-user-roles.js @@ -85,10 +85,10 @@ module.exports = { const entities = [ 'users', - 'integrations', + 'status', 'sensors', - 'system_settings', - 'system_status', + 'integrations', + 'settings', 'roles', 'permissions', , @@ -202,83 +202,6 @@ primary key ("roles_permissionsId", "permissionId") permissionId: getId('CREATE_USERS'), }, - { - createdAt, - updatedAt, - roles_permissionsId: getId('HiveMaster'), - permissionId: getId('CREATE_INTEGRATIONS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('HiveMaster'), - permissionId: getId('READ_INTEGRATIONS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('HiveMaster'), - permissionId: getId('UPDATE_INTEGRATIONS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('HiveMaster'), - permissionId: getId('DELETE_INTEGRATIONS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('BeeKeeper'), - permissionId: getId('CREATE_INTEGRATIONS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('BeeKeeper'), - permissionId: getId('READ_INTEGRATIONS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('TechSpecialist'), - permissionId: getId('CREATE_INTEGRATIONS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('TechSpecialist'), - permissionId: getId('READ_INTEGRATIONS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('DataViewer'), - permissionId: getId('CREATE_INTEGRATIONS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('DataViewer'), - permissionId: getId('READ_INTEGRATIONS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('GuestUser'), - permissionId: getId('CREATE_INTEGRATIONS'), - }, - { createdAt, updatedAt, @@ -367,168 +290,77 @@ primary key ("roles_permissionsId", "permissionId") createdAt, updatedAt, roles_permissionsId: getId('HiveMaster'), - permissionId: getId('CREATE_SYSTEM_SETTINGS'), + permissionId: getId('CREATE_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('HiveMaster'), - permissionId: getId('READ_SYSTEM_SETTINGS'), + permissionId: getId('READ_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('HiveMaster'), - permissionId: getId('UPDATE_SYSTEM_SETTINGS'), + permissionId: getId('UPDATE_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('HiveMaster'), - permissionId: getId('DELETE_SYSTEM_SETTINGS'), + permissionId: getId('DELETE_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('BeeKeeper'), - permissionId: getId('CREATE_SYSTEM_SETTINGS'), + permissionId: getId('CREATE_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('BeeKeeper'), - permissionId: getId('READ_SYSTEM_SETTINGS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('BeeKeeper'), - permissionId: getId('UPDATE_SYSTEM_SETTINGS'), + permissionId: getId('READ_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('TechSpecialist'), - permissionId: getId('CREATE_SYSTEM_SETTINGS'), + permissionId: getId('CREATE_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('TechSpecialist'), - permissionId: getId('READ_SYSTEM_SETTINGS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('TechSpecialist'), - permissionId: getId('UPDATE_SYSTEM_SETTINGS'), + permissionId: getId('READ_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('DataViewer'), - permissionId: getId('CREATE_SYSTEM_SETTINGS'), + permissionId: getId('CREATE_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('DataViewer'), - permissionId: getId('READ_SYSTEM_SETTINGS'), + permissionId: getId('READ_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('GuestUser'), - permissionId: getId('CREATE_SYSTEM_SETTINGS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('HiveMaster'), - permissionId: getId('CREATE_SYSTEM_STATUS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('HiveMaster'), - permissionId: getId('READ_SYSTEM_STATUS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('HiveMaster'), - permissionId: getId('UPDATE_SYSTEM_STATUS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('HiveMaster'), - permissionId: getId('DELETE_SYSTEM_STATUS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('BeeKeeper'), - permissionId: getId('CREATE_SYSTEM_STATUS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('BeeKeeper'), - permissionId: getId('READ_SYSTEM_STATUS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('TechSpecialist'), - permissionId: getId('CREATE_SYSTEM_STATUS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('TechSpecialist'), - permissionId: getId('READ_SYSTEM_STATUS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('DataViewer'), - permissionId: getId('CREATE_SYSTEM_STATUS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('DataViewer'), - permissionId: getId('READ_SYSTEM_STATUS'), - }, - - { - createdAt, - updatedAt, - roles_permissionsId: getId('GuestUser'), - permissionId: getId('CREATE_SYSTEM_STATUS'), + permissionId: getId('CREATE_INTEGRATIONS'), }, { @@ -595,25 +427,25 @@ primary key ("roles_permissionsId", "permissionId") createdAt, updatedAt, roles_permissionsId: getId('Administrator'), - permissionId: getId('CREATE_INTEGRATIONS'), + permissionId: getId('CREATE_STATUS'), }, { createdAt, updatedAt, roles_permissionsId: getId('Administrator'), - permissionId: getId('READ_INTEGRATIONS'), + permissionId: getId('READ_STATUS'), }, { createdAt, updatedAt, roles_permissionsId: getId('Administrator'), - permissionId: getId('UPDATE_INTEGRATIONS'), + permissionId: getId('UPDATE_STATUS'), }, { createdAt, updatedAt, roles_permissionsId: getId('Administrator'), - permissionId: getId('DELETE_INTEGRATIONS'), + permissionId: getId('DELETE_STATUS'), }, { @@ -645,50 +477,50 @@ primary key ("roles_permissionsId", "permissionId") createdAt, updatedAt, roles_permissionsId: getId('Administrator'), - permissionId: getId('CREATE_SYSTEM_SETTINGS'), + permissionId: getId('CREATE_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('Administrator'), - permissionId: getId('READ_SYSTEM_SETTINGS'), + permissionId: getId('READ_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('Administrator'), - permissionId: getId('UPDATE_SYSTEM_SETTINGS'), + permissionId: getId('UPDATE_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('Administrator'), - permissionId: getId('DELETE_SYSTEM_SETTINGS'), + permissionId: getId('DELETE_INTEGRATIONS'), }, { createdAt, updatedAt, roles_permissionsId: getId('Administrator'), - permissionId: getId('CREATE_SYSTEM_STATUS'), + permissionId: getId('CREATE_SETTINGS'), }, { createdAt, updatedAt, roles_permissionsId: getId('Administrator'), - permissionId: getId('READ_SYSTEM_STATUS'), + permissionId: getId('READ_SETTINGS'), }, { createdAt, updatedAt, roles_permissionsId: getId('Administrator'), - permissionId: getId('UPDATE_SYSTEM_STATUS'), + permissionId: getId('UPDATE_SETTINGS'), }, { createdAt, updatedAt, roles_permissionsId: getId('Administrator'), - permissionId: getId('DELETE_SYSTEM_STATUS'), + permissionId: getId('DELETE_SETTINGS'), }, { diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index 6e6626b..b4c933b 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -1,13 +1,77 @@ const db = require('../models'); const Users = db.users; -const Integrations = db.integrations; +const Status = db.status; const Sensors = db.sensors; -const SystemSettings = db.system_settings; +const Integrations = db.integrations; -const SystemStatus = db.system_status; +const Settings = db.settings; + +const StatusData = [ + { + wifi_status: 'Gregor Mendel', + + wifi_strength: 1, + + sensor_status: true, + + thingsboard_status: false, + }, + + { + wifi_status: 'Werner Heisenberg', + + wifi_strength: 3, + + sensor_status: false, + + thingsboard_status: false, + }, + + { + wifi_status: 'Max Born', + + wifi_strength: 3, + + sensor_status: false, + + thingsboard_status: false, + }, +]; + +const SensorsData = [ + { + name: 'Left Scale', + + type: 'scale', + + value: 12.5, + + last_updated: new Date('2023-10-01T10:00:00Z'), + }, + + { + name: 'Right Scale', + + type: 'temperature', + + value: 13, + + last_updated: new Date('2023-10-01T10:05:00Z'), + }, + + { + name: 'Temperature Sensor', + + type: 'humidity', + + value: 25.3, + + last_updated: new Date('2023-10-01T10:10:00Z'), + }, +]; const IntegrationsData = [ { @@ -23,7 +87,7 @@ const IntegrationsData = [ }, { - connection_status: false, + connection_status: true, customer_id: 'cust456', @@ -45,249 +109,81 @@ const IntegrationsData = [ thingsboard_key: 'key789', }, - - { - connection_status: true, - - customer_id: 'cust012', - - server_address: 'thingsboard.example.co', - - provision_secret: 'secret012', - - thingsboard_key: 'key012', - }, - - { - connection_status: true, - - customer_id: 'cust345', - - server_address: 'thingsboard.example.io', - - provision_secret: 'secret345', - - thingsboard_key: 'key345', - }, ]; -const SensorsData = [ +const SettingsData = [ { - name: 'Left Scale', + system_name: 'Jean Baptiste Lamarck', - type: 'scale', + location_text: 'Johannes Kepler', - value: 12.5, + latitude: 80.25, - last_updated: new Date('2023-10-01T10:00:00Z'), + longitude: 72.91, + + wifi_ssid: 'Thomas Hunt Morgan', + + wifi_password: 'Michael Faraday', + + ip_mode: 'static', + + ip_address: 'August Kekule', + + sensor_selector: 'DS18B20', }, { - name: 'Right Scale', + system_name: 'Jonas Salk', - type: 'scale', + location_text: 'Louis Victor de Broglie', - value: 13, + latitude: 91.51, - last_updated: new Date('2023-10-01T10:05:00Z'), + longitude: 12.35, + + wifi_ssid: 'Murray Gell-Mann', + + wifi_password: 'Albert Einstein', + + ip_mode: 'static', + + ip_address: 'Isaac Newton', + + sensor_selector: 'DHT22', }, { - name: 'Temperature Sensor', + system_name: 'Louis Victor de Broglie', - type: 'humidity', + location_text: 'Dmitri Mendeleev', - value: 25.3, + latitude: 47.18, - last_updated: new Date('2023-10-01T10:10:00Z'), - }, + longitude: 19.03, - { - name: 'Humidity Sensor', + wifi_ssid: 'Ludwig Boltzmann', - type: 'temperature', - - value: 60.2, - - last_updated: new Date('2023-10-01T10:15:00Z'), - }, - - { - name: 'Backup Scale', - - type: 'scale', - - value: 11.8, - - last_updated: new Date('2023-10-01T10:20:00Z'), - }, -]; - -const SystemSettingsData = [ - { - system_name: 'Hive Monitor 1', - - location_text: 'Garden Apiary', - - latitude: 34.0522, - - longitude: -118.2437, - - wifi_ssid: 'BeehiveNet', - - wifi_password: 'securepassword', + wifi_password: 'Ernest Rutherford', ip_mode: 'dhcp', - ip_address: '192.168.1.10', + ip_address: 'Andreas Vesalius', sensor_selector: 'none', }, - - { - system_name: 'Hive Monitor 2', - - location_text: 'Rooftop Apiary', - - latitude: 40.7128, - - longitude: -74.006, - - wifi_ssid: 'HiveNetwork', - - wifi_password: 'anotherpassword', - - ip_mode: 'static', - - ip_address: '192.168.1.11', - - sensor_selector: 'DHT11', - }, - - { - system_name: 'Hive Monitor 3', - - location_text: 'Field Apiary', - - latitude: 51.5074, - - longitude: -0.1278, - - wifi_ssid: 'FieldNet', - - wifi_password: 'fieldpassword', - - ip_mode: 'static', - - ip_address: '192.168.1.12', - - sensor_selector: 'DHT11', - }, - - { - system_name: 'Hive Monitor 4', - - location_text: 'Forest Apiary', - - latitude: 48.8566, - - longitude: 2.3522, - - wifi_ssid: 'ForestNet', - - wifi_password: 'forestpassword', - - ip_mode: 'static', - - ip_address: '192.168.1.13', - - sensor_selector: 'DHT11', - }, - - { - system_name: 'Hive Monitor 5', - - location_text: 'Urban Apiary', - - latitude: 35.6895, - - longitude: 139.6917, - - wifi_ssid: 'UrbanNet', - - wifi_password: 'urbanpassword', - - ip_mode: 'static', - - ip_address: '192.168.1.14', - - sensor_selector: 'DHT11', - }, -]; - -const SystemStatusData = [ - { - wifi_status: 'Connected', - - wifi_strength: 75, - - sensor_status: true, - - thingsboard_status: true, - }, - - { - wifi_status: 'Disconnected', - - wifi_strength: 0, - - sensor_status: false, - - thingsboard_status: false, - }, - - { - wifi_status: 'Connected', - - wifi_strength: 85, - - sensor_status: true, - - thingsboard_status: true, - }, - - { - wifi_status: 'Connected', - - wifi_strength: 65, - - sensor_status: true, - - thingsboard_status: true, - }, - - { - wifi_status: 'Disconnected', - - wifi_strength: 0, - - sensor_status: false, - - thingsboard_status: true, - }, ]; // Similar logic for "relation_many" module.exports = { up: async (queryInterface, Sequelize) => { - await Integrations.bulkCreate(IntegrationsData); + await Status.bulkCreate(StatusData); await Sensors.bulkCreate(SensorsData); - await SystemSettings.bulkCreate(SystemSettingsData); + await Integrations.bulkCreate(IntegrationsData); - await SystemStatus.bulkCreate(SystemStatusData); + await Settings.bulkCreate(SettingsData); await Promise.all([ // Similar logic for "relation_many" @@ -295,12 +191,12 @@ module.exports = { }, down: async (queryInterface, Sequelize) => { - await queryInterface.bulkDelete('integrations', null, {}); + await queryInterface.bulkDelete('status', null, {}); await queryInterface.bulkDelete('sensors', null, {}); - await queryInterface.bulkDelete('system_settings', null, {}); + await queryInterface.bulkDelete('integrations', null, {}); - await queryInterface.bulkDelete('system_status', null, {}); + await queryInterface.bulkDelete('settings', null, {}); }, }; diff --git a/backend/src/db/seeders/20250602135337.js b/backend/src/db/seeders/20250602135337.js new file mode 100644 index 0000000..591cbe9 --- /dev/null +++ b/backend/src/db/seeders/20250602135337.js @@ -0,0 +1,103 @@ +const { v4: uuid } = require('uuid'); +const db = require('../models'); +const Sequelize = require('sequelize'); +const config = require('../../config'); + +module.exports = { + /** + * @param{import("sequelize").QueryInterface} queryInterface + * @return {Promise} + */ + async up(queryInterface) { + const createdAt = new Date(); + const updatedAt = new Date(); + + /** @type {Map} */ + const idMap = new Map(); + + /** + * @param {string} key + * @return {string} + */ + function getId(key) { + if (idMap.has(key)) { + return idMap.get(key); + } + const id = uuid(); + idMap.set(key, id); + return id; + } + + /** + * @param {string} name + */ + function createPermissions(name) { + return [ + { + id: getId(`CREATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `CREATE_${name.toUpperCase()}`, + }, + { + id: getId(`READ_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `READ_${name.toUpperCase()}`, + }, + { + id: getId(`UPDATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `UPDATE_${name.toUpperCase()}`, + }, + { + id: getId(`DELETE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `DELETE_${name.toUpperCase()}`, + }, + ]; + } + + const entities = ['settings', 'status']; + + const previousValues = ['system_settings', 'system_status']; + const createdPreviousPermissions = + previousValues.flatMap(createPermissions); + const namesPreviousPermissions = createdPreviousPermissions.map( + (p) => p.name, + ); + + const createdPermissions = entities.flatMap(createPermissions); + + // Add permissions to database + await queryInterface.bulkInsert('permissions', createdPermissions); + // Get permissions ids + const permissionsIds = createdPermissions.map((p) => p.id); + // Get admin role + const adminRole = await db.roles.findOne({ + where: { name: config.roles.admin }, + }); + + if (adminRole) { + // Add permissions to admin role if it exists + await adminRole.addPermissions(permissionsIds); + } + + // Remove previous permissions + await db.permissions.destroy({ + where: { + name: { + [Sequelize.Op.in]: namesPreviousPermissions, + }, + }, + }); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete( + 'permissions', + entities.flatMap(createPermissions), + ); + }, +}; diff --git a/backend/src/index.js b/backend/src/index.js index 4e7792c..d4887da 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -19,13 +19,13 @@ const openaiRoutes = require('./routes/openai'); const usersRoutes = require('./routes/users'); -const integrationsRoutes = require('./routes/integrations'); +const statusRoutes = require('./routes/status'); const sensorsRoutes = require('./routes/sensors'); -const system_settingsRoutes = require('./routes/system_settings'); +const integrationsRoutes = require('./routes/integrations'); -const system_statusRoutes = require('./routes/system_status'); +const settingsRoutes = require('./routes/settings'); const rolesRoutes = require('./routes/roles'); @@ -103,9 +103,9 @@ app.use( ); app.use( - '/api/integrations', + '/api/status', passport.authenticate('jwt', { session: false }), - integrationsRoutes, + statusRoutes, ); app.use( @@ -115,15 +115,15 @@ app.use( ); app.use( - '/api/system_settings', + '/api/integrations', passport.authenticate('jwt', { session: false }), - system_settingsRoutes, + integrationsRoutes, ); app.use( - '/api/system_status', + '/api/settings', passport.authenticate('jwt', { session: false }), - system_statusRoutes, + settingsRoutes, ); app.use( diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js new file mode 100644 index 0000000..70b4359 --- /dev/null +++ b/backend/src/routes/settings.js @@ -0,0 +1,469 @@ +const express = require('express'); + +const SettingsService = require('../services/settings'); +const SettingsDBApi = require('../db/api/settings'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('settings')); + +/** + * @swagger + * components: + * schemas: + * Settings: + * type: object + * properties: + + * system_name: + * type: string + * default: system_name + * location_text: + * type: string + * default: location_text + * wifi_ssid: + * type: string + * default: wifi_ssid + * wifi_password: + * type: string + * default: wifi_password + * ip_address: + * type: string + * default: ip_address + + * latitude: + * type: integer + * format: int64 + * longitude: + * type: integer + * format: int64 + + * + * + */ + +/** + * @swagger + * tags: + * name: Settings + * description: The Settings managing API + */ + +/** + * @swagger + * /api/settings: + * post: + * security: + * - bearerAuth: [] + * tags: [Settings] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Settings" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Settings" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await SettingsService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Settings] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Settings" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Settings" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await SettingsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/settings/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Settings] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Settings" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Settings" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await SettingsService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/settings/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Settings] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Settings" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await SettingsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/settings/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Settings] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Settings" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await SettingsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/settings: + * get: + * security: + * - bearerAuth: [] + * tags: [Settings] + * summary: Get all settings + * description: Get all settings + * responses: + * 200: + * description: Settings list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Settings" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await SettingsDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = [ + 'id', + 'system_name', + 'location_text', + 'wifi_ssid', + 'wifi_password', + 'ip_address', + + 'latitude', + 'longitude', + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/settings/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Settings] + * summary: Count all settings + * description: Count all settings + * responses: + * 200: + * description: Settings count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Settings" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await SettingsDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/settings/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Settings] + * summary: Find all settings that match search criteria + * description: Find all settings that match search criteria + * responses: + * 200: + * description: Settings list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Settings" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await SettingsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/settings/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Settings] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Settings" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await SettingsDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/status.js b/backend/src/routes/status.js new file mode 100644 index 0000000..40c3350 --- /dev/null +++ b/backend/src/routes/status.js @@ -0,0 +1,437 @@ +const express = require('express'); + +const StatusService = require('../services/status'); +const StatusDBApi = require('../db/api/status'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('status')); + +/** + * @swagger + * components: + * schemas: + * Status: + * type: object + * properties: + + * wifi_status: + * type: string + * default: wifi_status + + * wifi_strength: + * type: integer + * format: int64 + + */ + +/** + * @swagger + * tags: + * name: Status + * description: The Status managing API + */ + +/** + * @swagger + * /api/status: + * post: + * security: + * - bearerAuth: [] + * tags: [Status] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Status" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Status" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await StatusService.create(req.body.data, req.currentUser, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Status] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Status" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Status" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await StatusService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/status/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Status] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Status" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Status" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await StatusService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/status/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Status] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Status" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await StatusService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/status/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Status] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Status" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await StatusService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/status: + * get: + * security: + * - bearerAuth: [] + * tags: [Status] + * summary: Get all status + * description: Get all status + * responses: + * 200: + * description: Status list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Status" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await StatusDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'wifi_status', 'wifi_strength']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/status/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Status] + * summary: Count all status + * description: Count all status + * responses: + * 200: + * description: Status count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Status" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await StatusDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/status/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Status] + * summary: Find all status that match search criteria + * description: Find all status that match search criteria + * responses: + * 200: + * description: Status list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Status" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await StatusDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/status/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Status] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Status" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await StatusDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/search.js b/backend/src/services/search.js index 22e1237..a86b8d0 100644 --- a/backend/src/services/search.js +++ b/backend/src/services/search.js @@ -43,6 +43,10 @@ module.exports = class SearchService { const tableColumns = { users: ['firstName', 'lastName', 'phoneNumber', 'email'], + status: ['wifi_status'], + + sensors: ['name'], + integrations: [ 'customer_id', @@ -53,9 +57,7 @@ module.exports = class SearchService { 'thingsboard_key', ], - sensors: ['name'], - - system_settings: [ + settings: [ 'system_name', 'location_text', @@ -66,15 +68,13 @@ module.exports = class SearchService { 'ip_address', ], - - system_status: ['wifi_status'], }; const columnsInt = { + status: ['wifi_strength'], + sensors: ['value'], - system_settings: ['latitude', 'longitude'], - - system_status: ['wifi_strength'], + settings: ['latitude', 'longitude'], }; let allFoundRecords = []; diff --git a/backend/src/services/settings.js b/backend/src/services/settings.js new file mode 100644 index 0000000..38705b6 --- /dev/null +++ b/backend/src/services/settings.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const SettingsDBApi = require('../db/api/settings'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class SettingsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await SettingsDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await SettingsDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let settings = await SettingsDBApi.findBy({ id }, { transaction }); + + if (!settings) { + throw new ValidationError('settingsNotFound'); + } + + const updatedSettings = await SettingsDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedSettings; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await SettingsDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await SettingsDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/status.js b/backend/src/services/status.js new file mode 100644 index 0000000..f9dbdf5 --- /dev/null +++ b/backend/src/services/status.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const StatusDBApi = require('../db/api/status'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class StatusService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await StatusDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await StatusDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let status = await StatusDBApi.findBy({ id }, { transaction }); + + if (!status) { + throw new ValidationError('statusNotFound'); + } + + const updatedStatus = await StatusDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedStatus; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await StatusDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await StatusDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/frontend/src/components/Settings/CardSettings.tsx b/frontend/src/components/Settings/CardSettings.tsx new file mode 100644 index 0000000..a324ccf --- /dev/null +++ b/frontend/src/components/Settings/CardSettings.tsx @@ -0,0 +1,197 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + settings: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardSettings = ({ + settings, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_SETTINGS'); + + return ( +
+ {loading && } +
    + {!loading && + settings.map((item, index) => ( +
  • +
    + + {item.system_name} + + +
    + +
    +
    +
    +
    +
    + SystemName +
    +
    +
    + {item.system_name} +
    +
    +
    + +
    +
    + LocationText +
    +
    +
    + {item.location_text} +
    +
    +
    + +
    +
    + Latitude +
    +
    +
    + {item.latitude} +
    +
    +
    + +
    +
    + Longitude +
    +
    +
    + {item.longitude} +
    +
    +
    + +
    +
    + Wi-FiSSID +
    +
    +
    + {item.wifi_ssid} +
    +
    +
    + +
    +
    + Wi-FiPassword +
    +
    +
    + {item.wifi_password} +
    +
    +
    + +
    +
    + IPMode +
    +
    +
    + {item.ip_mode} +
    +
    +
    + +
    +
    + IPAddress +
    +
    +
    + {item.ip_address} +
    +
    +
    + +
    +
    + SensorSelector +
    +
    +
    + {item.sensor_selector} +
    +
    +
    +
    +
  • + ))} + {!loading && settings.length === 0 && ( +
    +

    No data to display

    +
    + )} +
+
+ +
+
+ ); +}; + +export default CardSettings; diff --git a/frontend/src/components/Settings/ListSettings.tsx b/frontend/src/components/Settings/ListSettings.tsx new file mode 100644 index 0000000..761d97e --- /dev/null +++ b/frontend/src/components/Settings/ListSettings.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + settings: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListSettings = ({ + settings, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_SETTINGS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
+ {loading && } + {!loading && + settings.map((item) => ( +
+ +
+ dark:divide-dark-700 overflow-x-auto' + } + > +
+

SystemName

+

{item.system_name}

+
+ +
+

LocationText

+

{item.location_text}

+
+ +
+

Latitude

+

{item.latitude}

+
+ +
+

Longitude

+

{item.longitude}

+
+ +
+

Wi-FiSSID

+

{item.wifi_ssid}

+
+ +
+

+ Wi-FiPassword +

+

{item.wifi_password}

+
+ +
+

IPMode

+

{item.ip_mode}

+
+ +
+

IPAddress

+

{item.ip_address}

+
+ +
+

+ SensorSelector +

+

{item.sensor_selector}

+
+ + +
+
+
+ ))} + {!loading && settings.length === 0 && ( +
+

No data to display

+
+ )} +
+
+ +
+ + ); +}; + +export default ListSettings; diff --git a/frontend/src/components/Settings/TableSettings.tsx b/frontend/src/components/Settings/TableSettings.tsx new file mode 100644 index 0000000..1e417cc --- /dev/null +++ b/frontend/src/components/Settings/TableSettings.tsx @@ -0,0 +1,497 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/settings/settingsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureSettingsCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import ListSettings from './ListSettings'; + +const perPage = 10; + +const TableSampleSettings = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + settings, + loading, + count, + notify: settingsNotify, + refetch, + } = useAppSelector((state) => state.settings); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (settingsNotify.showNotification) { + notify(settingsNotify.typeNotification, settingsNotify.textNotification); + } + }, [settingsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `settings`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
+ `datagrid--row`} + rows={settings ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
+ ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
+ <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
+
+
+ Filter +
+ + {filters.map((selectOption) => ( + + ))} + +
+ {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
+
Value
+ + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : ( +
+
+ Contains +
+ +
+ )} +
+
+ Action +
+ { + deleteFilter(filterItem.id); + }} + /> +
+
+ ); + })} +
+ + +
+ +
+
+
+ ) : null} + +

Are you sure you want to delete this item?

+
+ + {settings && Array.isArray(settings) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleSettings; diff --git a/frontend/src/components/Settings/configureSettingsCols.tsx b/frontend/src/components/Settings/configureSettingsCols.tsx new file mode 100644 index 0000000..5cfc4a5 --- /dev/null +++ b/frontend/src/components/Settings/configureSettingsCols.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_SETTINGS'); + + return [ + { + field: 'system_name', + headerName: 'SystemName', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'location_text', + headerName: 'LocationText', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'latitude', + headerName: 'Latitude', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'number', + }, + + { + field: 'longitude', + headerName: 'Longitude', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'number', + }, + + { + field: 'wifi_ssid', + headerName: 'Wi-FiSSID', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'wifi_password', + headerName: 'Wi-FiPassword', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'ip_mode', + headerName: 'IPMode', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'ip_address', + headerName: 'IPAddress', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'sensor_selector', + headerName: 'SensorSelector', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
+ +
, + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/Status/CardStatus.tsx b/frontend/src/components/Status/CardStatus.tsx new file mode 100644 index 0000000..41edc09 --- /dev/null +++ b/frontend/src/components/Status/CardStatus.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + status: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardStatus = ({ + status, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_STATUS'); + + return ( +
+ {loading && } +
    + {!loading && + status.map((item, index) => ( +
  • +
    + + {item.wifi_status} + + +
    + +
    +
    +
    +
    +
    + Wi-FiStatus +
    +
    +
    + {item.wifi_status} +
    +
    +
    + +
    +
    + Wi-FiStrength +
    +
    +
    + {item.wifi_strength} +
    +
    +
    + +
    +
    + SensorStatus +
    +
    +
    + {dataFormatter.booleanFormatter(item.sensor_status)} +
    +
    +
    + +
    +
    + ThingsBoardStatus +
    +
    +
    + {dataFormatter.booleanFormatter(item.thingsboard_status)} +
    +
    +
    +
    +
  • + ))} + {!loading && status.length === 0 && ( +
    +

    No data to display

    +
    + )} +
+
+ +
+
+ ); +}; + +export default CardStatus; diff --git a/frontend/src/components/Status/ListStatus.tsx b/frontend/src/components/Status/ListStatus.tsx new file mode 100644 index 0000000..86aab8f --- /dev/null +++ b/frontend/src/components/Status/ListStatus.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + status: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListStatus = ({ + status, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_STATUS'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
+ {loading && } + {!loading && + status.map((item) => ( +
+ +
+ dark:divide-dark-700 overflow-x-auto' + } + > +
+

Wi-FiStatus

+

{item.wifi_status}

+
+ +
+

+ Wi-FiStrength +

+

{item.wifi_strength}

+
+ +
+

SensorStatus

+

+ {dataFormatter.booleanFormatter(item.sensor_status)} +

+
+ +
+

+ ThingsBoardStatus +

+

+ {dataFormatter.booleanFormatter( + item.thingsboard_status, + )} +

+
+ + +
+
+
+ ))} + {!loading && status.length === 0 && ( +
+

No data to display

+
+ )} +
+
+ +
+ + ); +}; + +export default ListStatus; diff --git a/frontend/src/components/Status/TableStatus.tsx b/frontend/src/components/Status/TableStatus.tsx new file mode 100644 index 0000000..b112c45 --- /dev/null +++ b/frontend/src/components/Status/TableStatus.tsx @@ -0,0 +1,497 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/status/statusSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureStatusCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +import CardStatus from './CardStatus'; + +const perPage = 10; + +const TableSampleStatus = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + status, + loading, + count, + notify: statusNotify, + refetch, + } = useAppSelector((state) => state.status); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (statusNotify.showNotification) { + notify(statusNotify.typeNotification, statusNotify.textNotification); + } + }, [statusNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `status`, currentUser).then( + (newCols) => setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
+ `datagrid--row`} + rows={status ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
+ ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
+ <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
+
+
+ Filter +
+ + {filters.map((selectOption) => ( + + ))} + +
+ {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
+
Value
+ + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : ( +
+
+ Contains +
+ +
+ )} +
+
+ Action +
+ { + deleteFilter(filterItem.id); + }} + /> +
+
+ ); + })} +
+ + +
+ +
+
+
+ ) : null} + +

Are you sure you want to delete this item?

+
+ + {status && Array.isArray(status) && !showGrid && ( + + )} + + {showGrid && dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleStatus; diff --git a/frontend/src/components/Status/configureStatusCols.tsx b/frontend/src/components/Status/configureStatusCols.tsx new file mode 100644 index 0000000..811d6c7 --- /dev/null +++ b/frontend/src/components/Status/configureStatusCols.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_STATUS'); + + return [ + { + field: 'wifi_status', + headerName: 'Wi-FiStatus', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'wifi_strength', + headerName: 'Wi-FiStrength', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'number', + }, + + { + field: 'sensor_status', + headerName: 'SensorStatus', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'boolean', + }, + + { + field: 'thingsboard_status', + headerName: 'ThingsBoardStatus', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'boolean', + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
+ +
, + ]; + }, + }, + ]; +}; diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 1afdd45..f49e81a 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -17,15 +17,15 @@ const menuAside: MenuAsideItem[] = [ permissions: 'READ_USERS', }, { - href: '/integrations/integrations-list', - label: 'Integrations', + href: '/status/status-list', + label: 'Status', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: - 'mdiCloudOutline' in icon - ? icon['mdiCloudOutline' as keyof typeof icon] + 'mdiSignal' in icon + ? icon['mdiSignal' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_INTEGRATIONS', + permissions: 'READ_STATUS', }, { href: '/sensors/sensors-list', @@ -39,26 +39,26 @@ const menuAside: MenuAsideItem[] = [ permissions: 'READ_SENSORS', }, { - href: '/system_settings/system_settings-list', - label: 'System settings', + href: '/integrations/integrations-list', + label: 'Integrations', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: + 'mdiCloudOutline' in icon + ? icon['mdiCloudOutline' as keyof typeof icon] + : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_INTEGRATIONS', + }, + { + href: '/settings/settings-list', + label: 'Settings', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiSettings' in icon ? icon['mdiSettings' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_SYSTEM_SETTINGS', - }, - { - href: '/system_status/system_status-list', - label: 'System status', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: - 'mdiSignal' in icon - ? icon['mdiSignal' as keyof typeof icon] - : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_SYSTEM_STATUS', + permissions: 'READ_SETTINGS', }, { href: '/roles/roles-list', diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 2372b8a..3f282d1 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -29,10 +29,10 @@ const Dashboard = () => { }); const [users, setUsers] = React.useState(loadingMessage); - const [integrations, setIntegrations] = React.useState(loadingMessage); + const [status, setStatus] = React.useState(loadingMessage); const [sensors, setSensors] = React.useState(loadingMessage); - const [system_settings, setSystem_settings] = React.useState(loadingMessage); - const [system_status, setSystem_status] = React.useState(loadingMessage); + const [integrations, setIntegrations] = React.useState(loadingMessage); + const [settings, setSettings] = React.useState(loadingMessage); const [roles, setRoles] = React.useState(loadingMessage); const [permissions, setPermissions] = React.useState(loadingMessage); @@ -47,19 +47,19 @@ const Dashboard = () => { async function loadData() { const entities = [ 'users', - 'integrations', + 'status', 'sensors', - 'system_settings', - 'system_status', + 'integrations', + 'settings', 'roles', 'permissions', ]; const fns = [ setUsers, - setIntegrations, + setStatus, setSensors, - setSystem_settings, - setSystem_status, + setIntegrations, + setSettings, setRoles, setPermissions, ]; @@ -208,8 +208,8 @@ const Dashboard = () => { )} - {hasPermission(currentUser, 'READ_INTEGRATIONS') && ( - + {hasPermission(currentUser, 'READ_STATUS') && ( +
{
- Integrations + Status
- {integrations} + {status}
@@ -233,8 +233,8 @@ const Dashboard = () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore path={ - 'mdiCloudOutline' in icon - ? icon['mdiCloudOutline' as keyof typeof icon] + 'mdiSignal' in icon + ? icon['mdiSignal' as keyof typeof icon] : icon.mdiTable || icon.mdiTable } /> @@ -280,8 +280,8 @@ const Dashboard = () => { )} - {hasPermission(currentUser, 'READ_SYSTEM_SETTINGS') && ( - + {hasPermission(currentUser, 'READ_INTEGRATIONS') && ( +
{
- System settings + Integrations
- {system_settings} + {integrations} +
+
+
+ +
+
+
+ + )} + + {hasPermission(currentUser, 'READ_SETTINGS') && ( + +
+
+
+
+ Settings +
+
+ {settings}
@@ -316,42 +352,6 @@ const Dashboard = () => { )} - {hasPermission(currentUser, 'READ_SYSTEM_STATUS') && ( - -
-
-
-
- System status -
-
- {system_status} -
-
-
- -
-
-
- - )} - {hasPermission(currentUser, 'READ_ROLES') && (
{ + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + system_name: '', + + location_text: '', + + latitude: '', + + longitude: '', + + wifi_ssid: '', + + wifi_password: '', + + ip_mode: '', + + ip_address: '', + + sensor_selector: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { settings } = useAppSelector((state) => state.settings); + + const { settingsId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: settingsId })); + }, [settingsId]); + + useEffect(() => { + if (typeof settings === 'object') { + setInitialValues(settings); + } + }, [settings]); + + useEffect(() => { + if (typeof settings === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = settings[el])); + + setInitialValues(newInitialVal); + } + }, [settings]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: settingsId, data })); + await router.push('/settings/settings-list'); + }; + + return ( + <> + + {getPageTitle('Edit settings')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/settings/settings-list')} + /> + + +
+
+
+ + ); +}; + +EditSettings.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditSettings; diff --git a/frontend/src/pages/settings/settings-edit.tsx b/frontend/src/pages/settings/settings-edit.tsx new file mode 100644 index 0000000..435eb09 --- /dev/null +++ b/frontend/src/pages/settings/settings-edit.tsx @@ -0,0 +1,186 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/settings/settingsSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditSettingsPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + system_name: '', + + location_text: '', + + latitude: '', + + longitude: '', + + wifi_ssid: '', + + wifi_password: '', + + ip_mode: '', + + ip_address: '', + + sensor_selector: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { settings } = useAppSelector((state) => state.settings); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof settings === 'object') { + setInitialValues(settings); + } + }, [settings]); + + useEffect(() => { + if (typeof settings === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = settings[el])); + setInitialValues(newInitialVal); + } + }, [settings]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/settings/settings-list'); + }; + + return ( + <> + + {getPageTitle('Edit settings')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/settings/settings-list')} + /> + + +
+
+
+ + ); +}; + +EditSettingsPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditSettingsPage; diff --git a/frontend/src/pages/settings/settings-list.tsx b/frontend/src/pages/settings/settings-list.tsx new file mode 100644 index 0000000..885399c --- /dev/null +++ b/frontend/src/pages/settings/settings-list.tsx @@ -0,0 +1,188 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableSettings from '../../components/Settings/TableSettings'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/settings/settingsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const SettingsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'SystemName', title: 'system_name' }, + { label: 'LocationText', title: 'location_text' }, + { label: 'Wi-FiSSID', title: 'wifi_ssid' }, + { label: 'Wi-FiPassword', title: 'wifi_password' }, + { label: 'IPAddress', title: 'ip_address' }, + + { label: 'Latitude', title: 'latitude', number: 'true' }, + { label: 'Longitude', title: 'longitude', number: 'true' }, + + { + label: 'IPMode', + title: 'ip_mode', + type: 'enum', + options: ['static', 'dhcp'], + }, + { + label: 'SensorSelector', + title: 'sensor_selector', + type: 'enum', + options: ['none', 'DHT11', 'DHT22', 'DS18B20'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_SETTINGS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getSettingsCSV = async () => { + const response = await axios({ + url: '/settings?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'settingsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Settings')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+ +
+ Switch to Table +
+
+ + + + +
+ + + + + ); +}; + +SettingsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default SettingsTablesPage; diff --git a/frontend/src/pages/settings/settings-new.tsx b/frontend/src/pages/settings/settings-new.tsx new file mode 100644 index 0000000..048014b --- /dev/null +++ b/frontend/src/pages/settings/settings-new.tsx @@ -0,0 +1,162 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/settings/settingsSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + system_name: '', + + location_text: '', + + latitude: '', + + longitude: '', + + wifi_ssid: '', + + wifi_password: '', + + ip_mode: 'static', + + ip_address: '', + + sensor_selector: 'none', +}; + +const SettingsNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/settings/settings-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/settings/settings-list')} + /> + + +
+
+
+ + ); +}; + +SettingsNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default SettingsNew; diff --git a/frontend/src/pages/settings/settings-table.tsx b/frontend/src/pages/settings/settings-table.tsx new file mode 100644 index 0000000..6d7868c --- /dev/null +++ b/frontend/src/pages/settings/settings-table.tsx @@ -0,0 +1,187 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableSettings from '../../components/Settings/TableSettings'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/settings/settingsSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const SettingsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'SystemName', title: 'system_name' }, + { label: 'LocationText', title: 'location_text' }, + { label: 'Wi-FiSSID', title: 'wifi_ssid' }, + { label: 'Wi-FiPassword', title: 'wifi_password' }, + { label: 'IPAddress', title: 'ip_address' }, + + { label: 'Latitude', title: 'latitude', number: 'true' }, + { label: 'Longitude', title: 'longitude', number: 'true' }, + + { + label: 'IPMode', + title: 'ip_mode', + type: 'enum', + options: ['static', 'dhcp'], + }, + { + label: 'SensorSelector', + title: 'sensor_selector', + type: 'enum', + options: ['none', 'DHT11', 'DHT22', 'DS18B20'], + }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_SETTINGS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getSettingsCSV = async () => { + const response = await axios({ + url: '/settings?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'settingsCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Settings')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+ + + Back to list + +
+
+ + + +
+ + + + + ); +}; + +SettingsTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default SettingsTablesPage; diff --git a/frontend/src/pages/settings/settings-view.tsx b/frontend/src/pages/settings/settings-view.tsx new file mode 100644 index 0000000..bb5c709 --- /dev/null +++ b/frontend/src/pages/settings/settings-view.tsx @@ -0,0 +1,123 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/settings/settingsSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const SettingsView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { settings } = useAppSelector((state) => state.settings); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View settings')} + + + + + + +
+

SystemName

+

{settings?.system_name}

+
+ +
+

LocationText

+

{settings?.location_text}

+
+ +
+

Latitude

+

{settings?.latitude || 'No data'}

+
+ +
+

Longitude

+

{settings?.longitude || 'No data'}

+
+ +
+

Wi-FiSSID

+

{settings?.wifi_ssid}

+
+ +
+

Wi-FiPassword

+

{settings?.wifi_password}

+
+ +
+

IPMode

+

{settings?.ip_mode ?? 'No data'}

+
+ +
+

IPAddress

+

{settings?.ip_address}

+
+ +
+

SensorSelector

+

{settings?.sensor_selector ?? 'No data'}

+
+ + + + router.push('/settings/settings-list')} + /> +
+
+ + ); +}; + +SettingsView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default SettingsView; diff --git a/frontend/src/pages/status/[statusId].tsx b/frontend/src/pages/status/[statusId].tsx new file mode 100644 index 0000000..83d2d17 --- /dev/null +++ b/frontend/src/pages/status/[statusId].tsx @@ -0,0 +1,157 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/status/statusSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditStatus = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + wifi_status: '', + + wifi_strength: '', + + sensor_status: false, + + thingsboard_status: false, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { status } = useAppSelector((state) => state.status); + + const { statusId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: statusId })); + }, [statusId]); + + useEffect(() => { + if (typeof status === 'object') { + setInitialValues(status); + } + }, [status]); + + useEffect(() => { + if (typeof status === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = status[el])); + + setInitialValues(newInitialVal); + } + }, [status]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: statusId, data })); + await router.push('/status/status-list'); + }; + + return ( + <> + + {getPageTitle('Edit status')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + + + + + router.push('/status/status-list')} + /> + + +
+
+
+ + ); +}; + +EditStatus.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditStatus; diff --git a/frontend/src/pages/status/status-edit.tsx b/frontend/src/pages/status/status-edit.tsx new file mode 100644 index 0000000..90f0341 --- /dev/null +++ b/frontend/src/pages/status/status-edit.tsx @@ -0,0 +1,155 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/status/statusSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditStatusPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + wifi_status: '', + + wifi_strength: '', + + sensor_status: false, + + thingsboard_status: false, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { status } = useAppSelector((state) => state.status); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof status === 'object') { + setInitialValues(status); + } + }, [status]); + + useEffect(() => { + if (typeof status === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = status[el])); + setInitialValues(newInitialVal); + } + }, [status]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/status/status-list'); + }; + + return ( + <> + + {getPageTitle('Edit status')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + + + + + router.push('/status/status-list')} + /> + + +
+
+
+ + ); +}; + +EditStatusPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditStatusPage; diff --git a/frontend/src/pages/status/status-list.tsx b/frontend/src/pages/status/status-list.tsx new file mode 100644 index 0000000..9327de4 --- /dev/null +++ b/frontend/src/pages/status/status-list.tsx @@ -0,0 +1,167 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableStatus from '../../components/Status/TableStatus'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/status/statusSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const StatusTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Wi-FiStatus', title: 'wifi_status' }, + { label: 'Wi-FiStrength', title: 'wifi_strength', number: 'true' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_STATUS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getStatusCSV = async () => { + const response = await axios({ + url: '/status?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'statusCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Status')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+ +
+ Switch to Table +
+
+ + + + +
+ + + + + ); +}; + +StatusTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default StatusTablesPage; diff --git a/frontend/src/pages/status/status-new.tsx b/frontend/src/pages/status/status-new.tsx new file mode 100644 index 0000000..dabb368 --- /dev/null +++ b/frontend/src/pages/status/status-new.tsx @@ -0,0 +1,131 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/status/statusSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + wifi_status: '', + + wifi_strength: '', + + sensor_status: false, + + thingsboard_status: false, +}; + +const StatusNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/status/status-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + + + + + router.push('/status/status-list')} + /> + + +
+
+
+ + ); +}; + +StatusNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default StatusNew; diff --git a/frontend/src/pages/status/status-table.tsx b/frontend/src/pages/status/status-table.tsx new file mode 100644 index 0000000..83d1510 --- /dev/null +++ b/frontend/src/pages/status/status-table.tsx @@ -0,0 +1,166 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableStatus from '../../components/Status/TableStatus'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/status/statusSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const StatusTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Wi-FiStatus', title: 'wifi_status' }, + { label: 'Wi-FiStrength', title: 'wifi_strength', number: 'true' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_STATUS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getStatusCSV = async () => { + const response = await axios({ + url: '/status?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'statusCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Status')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+ + + Back to card + +
+
+ + + +
+ + + + + ); +}; + +StatusTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default StatusTablesPage; diff --git a/frontend/src/pages/status/status-view.tsx b/frontend/src/pages/status/status-view.tsx new file mode 100644 index 0000000..1abfbfa --- /dev/null +++ b/frontend/src/pages/status/status-view.tsx @@ -0,0 +1,105 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/status/statusSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const StatusView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { status } = useAppSelector((state) => state.status); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View status')} + + + + + + +
+

Wi-FiStatus

+

{status?.wifi_status}

+
+ +
+

Wi-FiStrength

+

{status?.wifi_strength || 'No data'}

+
+ + + null }} + disabled + /> + + + + null }} + disabled + /> + + + + + router.push('/status/status-list')} + /> +
+
+ + ); +}; + +StatusView.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default StatusView; diff --git a/frontend/src/stores/settings/settingsSlice.ts b/frontend/src/stores/settings/settingsSlice.ts new file mode 100644 index 0000000..e03e776 --- /dev/null +++ b/frontend/src/stores/settings/settingsSlice.ts @@ -0,0 +1,236 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + settings: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + settings: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk('settings/fetch', async (data: any) => { + const { id, query } = data; + const result = await axios.get(`settings${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'settings/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('settings/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'settings/deleteSettings', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`settings/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'settings/createSettings', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('settings', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'settings/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('settings/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'settings/updateSettings', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`settings/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const settingsSlice = createSlice({ + name: 'settings', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.settings = action.payload.rows; + state.count = action.payload.count; + } else { + state.settings = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Settings has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Settings'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Settings'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Settings'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Settings has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = settingsSlice.actions; + +export default settingsSlice.reducer; diff --git a/frontend/src/stores/status/statusSlice.ts b/frontend/src/stores/status/statusSlice.ts new file mode 100644 index 0000000..1552260 --- /dev/null +++ b/frontend/src/stores/status/statusSlice.ts @@ -0,0 +1,236 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + status: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + status: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk('status/fetch', async (data: any) => { + const { id, query } = data; + const result = await axios.get(`status${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'status/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('status/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'status/deleteStatus', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`status/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'status/createStatus', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('status', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'status/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('status/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'status/updateStatus', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`status/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const statusSlice = createSlice({ + name: 'status', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.status = action.payload.rows; + state.count = action.payload.count; + } else { + state.status = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Status has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Status'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Status'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Status'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Status has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = statusSlice.actions; + +export default statusSlice.reducer; diff --git a/frontend/src/stores/store.ts b/frontend/src/stores/store.ts index 0be6dae..ce4c2ba 100644 --- a/frontend/src/stores/store.ts +++ b/frontend/src/stores/store.ts @@ -5,10 +5,10 @@ import authSlice from './authSlice'; import openAiSlice from './openAiSlice'; import usersSlice from './users/usersSlice'; -import integrationsSlice from './integrations/integrationsSlice'; +import statusSlice from './status/statusSlice'; import sensorsSlice from './sensors/sensorsSlice'; -import system_settingsSlice from './system_settings/system_settingsSlice'; -import system_statusSlice from './system_status/system_statusSlice'; +import integrationsSlice from './integrations/integrationsSlice'; +import settingsSlice from './settings/settingsSlice'; import rolesSlice from './roles/rolesSlice'; import permissionsSlice from './permissions/permissionsSlice'; @@ -20,10 +20,10 @@ export const store = configureStore({ openAi: openAiSlice, users: usersSlice, - integrations: integrationsSlice, + status: statusSlice, sensors: sensorsSlice, - system_settings: system_settingsSlice, - system_status: system_statusSlice, + integrations: integrationsSlice, + settings: settingsSlice, roles: rolesSlice, permissions: permissionsSlice, },