diff --git a/assets/pasted-20260215-205037-3f24985a.png b/assets/pasted-20260215-205037-3f24985a.png new file mode 100644 index 0000000..c316630 Binary files /dev/null and b/assets/pasted-20260215-205037-3f24985a.png differ diff --git a/assets/pasted-20260215-205929-0da7605c.png b/assets/pasted-20260215-205929-0da7605c.png new file mode 100644 index 0000000..81b0c63 Binary files /dev/null and b/assets/pasted-20260215-205929-0da7605c.png differ diff --git a/backend/check_dbs.js b/backend/check_dbs.js new file mode 100644 index 0000000..6772af2 --- /dev/null +++ b/backend/check_dbs.js @@ -0,0 +1,28 @@ + +const { Sequelize } = require('sequelize'); +require('dotenv').config(); + +const sequelize = new Sequelize( + 'postgres', // Connect to default DB to list others + process.env.DB_USER, + process.env.DB_PASS, + { + host: process.env.DB_HOST, + port: process.env.DB_PORT, + dialect: 'postgres', + logging: false, + } +); + +async function check() { + try { + const [results, metadata] = await sequelize.query("SELECT datname FROM pg_database WHERE datistemplate = false"); + console.log('Databases:', results.map(r => r.datname)); + } catch (err) { + console.error('Error:', err); + } finally { + await sequelize.close(); + } +} + +check(); diff --git a/backend/check_meta.js b/backend/check_meta.js new file mode 100644 index 0000000..801fa0a --- /dev/null +++ b/backend/check_meta.js @@ -0,0 +1,28 @@ + +const { Sequelize } = require('sequelize'); +require('dotenv').config(); + +const sequelize = new Sequelize( + process.env.DB_NAME, + process.env.DB_USER, + process.env.DB_PASS, + { + host: process.env.DB_HOST, + port: process.env.DB_PORT, + dialect: 'postgres', + logging: false, + } +); + +async function check() { + try { + const [results, metadata] = await sequelize.query("SELECT * FROM \"SequelizeMeta\""); + console.log('Migrations:', results); + } catch (err) { + console.error('Error:', err); + } finally { + await sequelize.close(); + } +} + +check(); diff --git a/backend/check_old_db.js b/backend/check_old_db.js new file mode 100644 index 0000000..142331c --- /dev/null +++ b/backend/check_old_db.js @@ -0,0 +1,26 @@ + +const { Sequelize } = require('sequelize'); + +const sequelize = new Sequelize( + 'db_greenhouse_trials_tracker', + 'postgres', + '', + { + host: 'localhost', // or process.env.DB_HOST which is 127.0.0.1 + dialect: 'postgres', + logging: false, + } +); + +async function check() { + try { + const [results, metadata] = await sequelize.query("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"); + console.log('Tables:', results); + } catch (err) { + console.error('Error:', err); + } finally { + await sequelize.close(); + } +} + +check(); diff --git a/backend/check_perms.js b/backend/check_perms.js new file mode 100644 index 0000000..1ae4fdf --- /dev/null +++ b/backend/check_perms.js @@ -0,0 +1,28 @@ + +const { Sequelize } = require('sequelize'); +require('dotenv').config(); + +const sequelize = new Sequelize( + process.env.DB_NAME, + process.env.DB_USER, + process.env.DB_PASS, + { + host: process.env.DB_HOST, + port: process.env.DB_PORT, + dialect: 'postgres', + logging: false, + } +); + +async function check() { + try { + const [results] = await sequelize.query("SELECT * FROM permissions WHERE name = 'READ_PROJECTS'"); + console.log('Permissions:', results); + } catch (err) { + console.error('Error:', err); + } finally { + await sequelize.close(); + } +} + +check(); diff --git a/backend/check_tables.js b/backend/check_tables.js new file mode 100644 index 0000000..50d8519 --- /dev/null +++ b/backend/check_tables.js @@ -0,0 +1,27 @@ +const { Sequelize } = require('sequelize'); +require('dotenv').config(); + +const sequelize = new Sequelize( + 'app_38100', + process.env.DB_USER, + process.env.DB_PASS, + { + host: process.env.DB_HOST, + port: process.env.DB_PORT, + dialect: 'postgres', + logging: false, + } +); + +async function check() { + try { + const [results, metadata] = await sequelize.query("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"); + console.log('Tables in app_38100:', results); + } catch (err) { + console.error('Error:', err); + } finally { + await sequelize.close(); + } +} + +check(); \ No newline at end of file diff --git a/backend/fix_meta.js b/backend/fix_meta.js new file mode 100644 index 0000000..f97f410 --- /dev/null +++ b/backend/fix_meta.js @@ -0,0 +1,28 @@ + +const { Sequelize } = require('sequelize'); +require('dotenv').config(); + +const sequelize = new Sequelize( + process.env.DB_NAME, + process.env.DB_USER, + process.env.DB_PASS, + { + host: process.env.DB_HOST, + port: process.env.DB_PORT, + dialect: 'postgres', + logging: false, + } +); + +async function fix() { + try { + await sequelize.query("INSERT INTO \"SequelizeMeta\" (name) VALUES ('1771187473079.js')"); + console.log('Inserted 1771187473079.js into SequelizeMeta'); + } catch (err) { + console.error('Error:', err); + } finally { + await sequelize.close(); + } +} + +fix(); diff --git a/backend/reset_meta.js b/backend/reset_meta.js new file mode 100644 index 0000000..9fe771c --- /dev/null +++ b/backend/reset_meta.js @@ -0,0 +1,28 @@ + +const { Sequelize } = require('sequelize'); +require('dotenv').config(); + +const sequelize = new Sequelize( + process.env.DB_NAME, // app_38460 + process.env.DB_USER, + process.env.DB_PASS, + { + host: process.env.DB_HOST, + port: process.env.DB_PORT, + dialect: 'postgres', + logging: false, + } +); + +async function reset() { + try { + await sequelize.query('TRUNCATE TABLE "SequelizeMeta"'); + console.log('SequelizeMeta truncated.'); + } catch (err) { + console.error('Error:', err); + } finally { + await sequelize.close(); + } +} + +reset(); diff --git a/backend/src/config.js b/backend/src/config.js index a243342..a6dc862 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -1,6 +1,3 @@ - - - const os = require('os'); const config = { @@ -71,11 +68,11 @@ const config = { config.pexelsKey = process.env.PEXELS_KEY || ''; -config.pexelsQuery = 'Seedlings growing toward sunlight'; +config.pexelsQuery = 'Greenhouse interior agriculture'; config.host = process.env.NODE_ENV === "production" ? config.remote : "http://localhost"; config.apiUrl = `${config.host}${config.port ? `:${config.port}` : ``}/api`; config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`; config.uiUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}/#`; config.backUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}`; -module.exports = config; +module.exports = config; \ No newline at end of file diff --git a/backend/src/db/api/projects.js b/backend/src/db/api/projects.js new file mode 100644 index 0000000..84c7af9 --- /dev/null +++ b/backend/src/db/api/projects.js @@ -0,0 +1,298 @@ + +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 ProjectsDBApi { + + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const projects = await db.projects.create( + { + id: data.id || undefined, + name: data.name || null, + description: data.description || null, + status: data.status || null, + startDate: data.startDate || null, + endDate: data.endDate || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await projects.setTenant(data.tenant || null, { transaction }); + await projects.setOrganizations(data.organizations || null, { transaction }); + + if (data.trials) { + await projects.setTrials(data.trials, { transaction }); + } + + if (data.documentation) { + // Expecting array of file objects or IDs + // Since association is hasMany (Project hasMany Files), we can use setDocumentation + // But File model is polymorphic-ish. + // If we use standard setter, Sequelize handles foreign key update on File table. + // But we need to make sure 'belongsTo' and 'belongsToColumn' are set if we rely on scope. + // Usually, we update files manually or use a helper. + // Let's try standard setter first. + // If data.documentation is array of IDs: + let fileIds = data.documentation; + if (fileIds.length > 0 && typeof fileIds[0] === 'object') { + fileIds = fileIds.map(f => f.id); + } + + // We need to update the files to point to this project + await db.file.update( + { + belongsTo: 'projects', + belongsToColumn: 'documentation', + belongsToId: projects.id + }, + { + where: { + id: { [Op.in]: fileIds } + }, + transaction + } + ); + } + + return projects; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const projects = await db.projects.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + if (data.name !== undefined) updatePayload.name = data.name; + if (data.description !== undefined) updatePayload.description = data.description; + if (data.status !== undefined) updatePayload.status = data.status; + if (data.startDate !== undefined) updatePayload.startDate = data.startDate; + if (data.endDate !== undefined) updatePayload.endDate = data.endDate; + + updatePayload.updatedById = currentUser.id; + + await projects.update(updatePayload, { transaction }); + + if (data.tenant !== undefined) { + await projects.setTenant(data.tenant, { transaction }); + } + if (data.organizations !== undefined) { + await projects.setOrganizations(data.organizations, { transaction }); + } + if (data.trials !== undefined) { + await projects.setTrials(data.trials, { transaction }); + } + + if (data.documentation !== undefined) { + let fileIds = data.documentation; + if (fileIds && fileIds.length > 0 && typeof fileIds[0] === 'object') { + fileIds = fileIds.map(f => f.id); + } + + // Unlink old files? + // Ideally we should unset old ones. + // For now, let's just update new ones. + // If we want to support removal, we need to know which ones are removed. + // Assuming data.documentation is the NEW complete list. + + // First, clear existing files for this project (optional, depends on logic) + // or just overwrite. + // If we want to keep files that are still in the list, and remove others: + + // 1. Set all files belonging to this project to null (orphaned) + await db.file.update( + { belongsToId: null, belongsTo: null, belongsToColumn: null }, + { where: { belongsTo: 'projects', belongsToId: projects.id }, transaction } + ); + + // 2. Set new files + if (fileIds && fileIds.length > 0) { + await db.file.update( + { + belongsTo: 'projects', + belongsToColumn: 'documentation', + belongsToId: projects.id + }, + { + where: { + id: { [Op.in]: fileIds } + }, + transaction + } + ); + } + } + + return projects; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const projects = await db.projects.findAll({ + where: { id: { [Op.in]: ids } }, + transaction, + }); + + for (const record of projects) { + await record.destroy({ transaction }); + } + return projects; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const projects = await db.projects.findByPk(id, options); + await projects.destroy({ transaction }); + return projects; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const projects = await db.projects.findOne( + { where }, + { transaction }, + ); + + if (!projects) { + return projects; + } + + const output = projects.get({ plain: true }); + + output.tenant = await projects.getTenant({ transaction }); + output.organizations = await projects.getOrganizations({ transaction }); + output.trials = await projects.getTrials({ transaction }); + output.documentation = await db.file.findAll({ + where: { + belongsTo: 'projects', + belongsToId: projects.id, + belongsToColumn: 'documentation' + }, + transaction + }); + + return output; + } + + static async findAll(filter, globalAccess, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + const userOrganizations = (user && user.organizations?.id) || null; + + if (userOrganizations) { + if (options?.currentUser?.organizationsId) { + where.organizationsId = options.currentUser.organizationsId; + } + } + + offset = currentPage * limit; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.tenants, + as: 'tenant', + }, + { + model: db.organizations, + as: 'organizations', + }, + { + model: db.trials, + as: 'trials', + } + ]; + + if (filter) { + if (filter.id) { + where = { ...where, ['id']: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { ...where, [Op.and]: Utils.ilike('projects', 'name', filter.name) }; + } + if (filter.status) { + where = { ...where, status: filter.status }; + } + } + + if (globalAccess) { + delete where.organizationsId; + } + + const queryOptions = { + where, + include, + distinct: true, + order: filter.field && filter.sort ? [[filter.field, filter.sort]] : [['createdAt', 'desc']], + transaction: options?.transaction, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.projects.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, globalAccess, organizationId) { + let where = {}; + if (!globalAccess && organizationId) { + where.organizationsId = organizationId; + } + + if (query) { + where = { + ...where, + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('projects', 'name', query), + ], + }; + } + + const records = await db.projects.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + order: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +}; diff --git a/backend/src/db/db.config.js b/backend/src/db/db.config.js index bb73f2f..8f37ad8 100644 --- a/backend/src/db/db.config.js +++ b/backend/src/db/db.config.js @@ -1,4 +1,4 @@ - +require('dotenv').config(); module.exports = { production: { @@ -12,11 +12,12 @@ module.exports = { seederStorage: 'sequelize', }, development: { - username: 'postgres', dialect: 'postgres', - password: '', - database: 'db_greenhouse_trials_tracker', - host: process.env.DB_HOST || 'localhost', + username: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + host: process.env.DB_HOST, + port: process.env.DB_PORT, logging: console.log, seederStorage: 'sequelize', }, @@ -30,4 +31,4 @@ module.exports = { logging: console.log, seederStorage: 'sequelize', } -}; +}; \ No newline at end of file diff --git a/backend/src/db/migrations/20260216005901-create-projects.js b/backend/src/db/migrations/20260216005901-create-projects.js new file mode 100644 index 0000000..617caf6 --- /dev/null +++ b/backend/src/db/migrations/20260216005901-create-projects.js @@ -0,0 +1,119 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.createTable( + 'projects', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: Sequelize.DataTypes.TEXT, + }, + description: { + type: Sequelize.DataTypes.TEXT, + }, + status: { + type: Sequelize.DataTypes.ENUM, + values: ['active', 'completed', 'archived'], + }, + startDate: { + type: Sequelize.DataTypes.DATE, + }, + endDate: { + type: Sequelize.DataTypes.DATE, + }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + tenantId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'tenants', + key: 'id', + }, + }, + organizationsId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'organizations', + key: 'id', + }, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + }, + { transaction } + ); + + await queryInterface.createTable( + 'projects_trials', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + projectId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'projects', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + trialId: { + type: Sequelize.DataTypes.UUID, + references: { + model: 'trials', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + }, + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.dropTable('projects_trials', { transaction }); + await queryInterface.dropTable('projects', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; \ No newline at end of file diff --git a/backend/src/db/migrations/20260216013000-add-projects-permissions.js b/backend/src/db/migrations/20260216013000-add-projects-permissions.js new file mode 100644 index 0000000..ac1ef6e --- /dev/null +++ b/backend/src/db/migrations/20260216013000-add-projects-permissions.js @@ -0,0 +1,90 @@ + +const { v4: uuid } = require('uuid'); + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + const entities = ['PROJECTS']; + const actions = ['CREATE', 'READ', 'UPDATE', 'DELETE']; + const timestamp = new Date(); + + // 1. Insert Permissions + const newPermissions = []; + for (const entity of entities) { + for (const action of actions) { + newPermissions.push({ + id: uuid(), + name: `${action}_${entity}`, + createdAt: timestamp, + updatedAt: timestamp, + }); + } + } + + await queryInterface.bulkInsert('permissions', newPermissions, { transaction }); + + // 2. Get Roles + const [roles] = await queryInterface.sequelize.query( + "SELECT id, name FROM roles WHERE name IN ('Administrator', 'Super Administrator')", + { transaction } + ); + + // 3. Link Permissions to Roles + const rolePermissions = []; + for (const role of roles) { + for (const perm of newPermissions) { + rolePermissions.push({ + roles_permissionsId: role.id, + permissionId: perm.id, + createdAt: timestamp, + updatedAt: timestamp, + }); + } + } + + if (rolePermissions.length > 0) { + await queryInterface.bulkInsert('rolesPermissionsPermissions', rolePermissions, { transaction }); + } + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + // Find permissions to delete + const [permissions] = await queryInterface.sequelize.query( + "SELECT id FROM permissions WHERE name LIKE '%_PROJECTS'", + { transaction } + ); + + const permissionIds = permissions.map(p => p.id); + + if (permissionIds.length > 0) { + // Delete from join table + await queryInterface.bulkDelete( + 'rolesPermissionsPermissions', + { permissionId: { [Sequelize.Op.in]: permissionIds } }, + { transaction } + ); + + // Delete from permissions table + await queryInterface.bulkDelete( + 'permissions', + { id: { [Sequelize.Op.in]: permissionIds } }, + { transaction } + ); + } + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/models/projects.js b/backend/src/db/models/projects.js new file mode 100644 index 0000000..1713d90 --- /dev/null +++ b/backend/src/db/models/projects.js @@ -0,0 +1,95 @@ +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 projects = sequelize.define( + 'projects', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: DataTypes.TEXT, + }, + description: { + type: DataTypes.TEXT, + }, + status: { + type: DataTypes.ENUM, + values: ['active', 'completed', 'archived'], + }, + startDate: { + type: DataTypes.DATE, + }, + endDate: { + type: DataTypes.DATE, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + projects.associate = (db) => { + db.projects.belongsTo(db.users, { + as: 'createdBy', + }); + + db.projects.belongsTo(db.users, { + as: 'updatedBy', + }); + + db.projects.belongsTo(db.tenants, { + as: 'tenant', + foreignKey: { + name: 'tenantId', + }, + constraints: false, + }); + + db.projects.belongsTo(db.organizations, { + as: 'organizations', + foreignKey: { + name: 'organizationsId', + }, + constraints: false, + }); + + db.projects.belongsToMany(db.trials, { + as: 'trials', + foreignKey: 'projectId', + otherKey: 'trialId', + through: 'projects_trials', + constraints: false, + }); + + // File attachment association (polymorphic usually handled in service, but we can define hasMany if we use a specific convention) + // The `file` model uses `belongsTo` string. + // So we don't strictly need an association here unless we want Sequelize to include it automatically. + // But `file` model has `belongsTo` (string) and `belongsToId` (UUID). + // So we can define: + db.projects.hasMany(db.file, { + as: 'documentation', + foreignKey: 'belongsToId', + constraints: false, + scope: { + belongsTo: 'projects', + belongsToColumn: 'documentation', // User mentioned "supporting documentation". I'll use this as the 'column' differentiator if needed. + }, + }); + }; + + return projects; +}; diff --git a/backend/src/routes/projects.js b/backend/src/routes/projects.js new file mode 100644 index 0000000..2c25e6d --- /dev/null +++ b/backend/src/routes/projects.js @@ -0,0 +1,133 @@ + +const express = require('express'); +const ProjectsService = require('../services/projects'); +const ProjectsDBApi = require('../db/api/projects'); +const wrapAsync = require('../helpers').wrapAsync; +const router = express.Router(); +const { parse } = require('json2csv'); +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +// Assuming 'projects' permission exists. If not, I might need to add it to DB or use a generic one. +// For now, I'll use checkCrudPermissions('projects'). +// Note: If 'projects' is not in permissions table, this might fail or default to allow/deny depending on implementation. +// Usually we need to add permissions in migration. +// But user didn't ask for granular permissions setup, so I'll assume admin has access or I might need to skip it if it blocks. +// I'll assume 'projects' is the resource name. +// If it fails, I'll remove it. +router.use(checkCrudPermissions('projects')); + +/** + * @swagger + * components: + * schemas: + * Projects: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * status: + * type: string + * startDate: + * type: string + * format: date-time + * endDate: + * type: string + * format: date-time + */ + +router.post('/', wrapAsync(async (req, res) => { + const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await ProjectsService.create(req.body.data, req.currentUser, true, link.host); + const payload = true; + res.status(200).send(payload); +})); + +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 ProjectsService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); +})); + +router.put('/:id', wrapAsync(async (req, res) => { + await ProjectsService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); +})); + +router.delete('/:id', wrapAsync(async (req, res) => { + await ProjectsService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); +})); + +router.post('/deleteByIds', wrapAsync(async (req, res) => { + await ProjectsService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + })); + +router.get('/', wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + const globalAccess = req.currentUser.app_role.globalAccess; + const currentUser = req.currentUser; + + const payload = await ProjectsDBApi.findAll( + req.query, globalAccess, { currentUser } + ); + + if (filetype && filetype === 'csv') { + const fields = ['id', 'name', 'description', 'status', 'startDate', 'endDate']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + res.status(500).send('Error generating CSV'); + } + } else { + res.status(200).send(payload); + } +})); + +router.get('/count', wrapAsync(async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + const currentUser = req.currentUser; + const payload = await ProjectsDBApi.findAll( + req.query, + globalAccess, + { countOnly: true, currentUser } + ); + res.status(200).send(payload); +})); + +router.get('/autocomplete', async (req, res) => { + const globalAccess = req.currentUser.app_role.globalAccess; + const organizationId = req.currentUser.organization?.id; + + const payload = await ProjectsDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + globalAccess, + organizationId, + ); + res.status(200).send(payload); +}); + +router.get('/:id', wrapAsync(async (req, res) => { + const payload = await ProjectsDBApi.findBy( + { id: req.params.id }, + ); + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/projects.js b/backend/src/services/projects.js new file mode 100644 index 0000000..d2ec883 --- /dev/null +++ b/backend/src/services/projects.js @@ -0,0 +1,131 @@ + +const db = require('../db/models'); +const ProjectsDBApi = require('../db/api/projects'); +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 ProjectsService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await ProjectsDBApi.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")); + + 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 ProjectsDBApi.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 projects = await ProjectsDBApi.findBy( + {id}, + {transaction}, + ); + + if (!projects) { + throw new ValidationError( + 'projectsNotFound', + ); + } + + const updatedProjects = await ProjectsDBApi.update( + id, + data, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + return updatedProjects; + + } catch (error) { + await transaction.rollback(); + throw error; + } + }; + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ProjectsDBApi.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 ProjectsDBApi.remove( + id, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..81b0c63 Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/src/components/ActivityFeed.tsx b/frontend/src/components/ActivityFeed.tsx new file mode 100644 index 0000000..afbbd8f --- /dev/null +++ b/frontend/src/components/ActivityFeed.tsx @@ -0,0 +1,129 @@ +import React, { useEffect } from 'react' +import { useAppDispatch, useAppSelector } from '../stores/hooks' +import { fetch } from '../stores/media_assets/media_assetsSlice' +import BaseButton from './BaseButton' +import { mdiHeart, mdiComment, mdiShareVariant, mdiDotsHorizontal } from '@mdi/js' +import BaseIcon from './BaseIcon' +import LoadingSpinner from './LoadingSpinner' + +const ActivityFeed = () => { + const dispatch = useAppDispatch() + const { media_assets, loading } = useAppSelector((state) => state.media_assets) + + useEffect(() => { + dispatch(fetch({ query: '?limit=10' })) + }, [dispatch]) + + if (loading && media_assets.length === 0) { + return ( +
+ +
+ ) + } + + if (media_assets.length === 0) { + return ( +
+ No recent activities found. +
+ ) + } + + return ( +
+ {media_assets.map((asset: any) => ( +
+ {/* Header */} +
+
+
+ {asset.uploaded_by?.firstName?.charAt(0) || 'U'} +
+
+

+ {asset.uploaded_by ? `${asset.uploaded_by.firstName} ${asset.uploaded_by.lastName || ''}` : 'System User'} +

+

+ {asset.captured_at ? new Date(asset.captured_at).toLocaleString() : new Date(asset.createdAt).toLocaleString()} +

+
+
+ +
+ + {/* Caption */} + {asset.caption && ( +
+

{asset.caption}

+ {asset.tags && ( +
+ {asset.tags.split(',').map((tag: string) => ( + + #{tag.trim()} + + ))} +
+ )} +
+ )} + + {/* Media/Thumbnails */} + {(asset.images?.length > 0 || asset.attachments?.length > 0) && ( +
1 ? 'grid-cols-2' : 'grid-cols-1'}`}> + {[...(asset.images || []), ...(asset.attachments || [])].map((file: any, index: number) => { + const isImage = file.name?.match(/\.(jpg|jpeg|png|gif|webp)$/i) + return ( +
+ {isImage ? ( + {file.name} + ) : ( +
+
+ {file.name?.split('.').pop()} +
+ {file.name} +
+ )} +
+ ) + })} +
+ )} + + {/* Actions */} +
+
+ + + +
+ {asset.source && ( + Source: {asset.source} + )} +
+
+ ))} +
+ +
+
+ ) +} + +export default ActivityFeed diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 1798f9d..a895a80 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppSelector, useAppDispatch } from '../stores/hooks' import Link from 'next/link'; -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; @@ -91,4 +90,4 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props ) -} +} \ No newline at end of file diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index c270ae0..5959b60 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -6,10 +6,11 @@ import NavBarItemPlain from './NavBarItemPlain' import NavBarMenuList from './NavBarMenuList' import { MenuNavBarItem } from '../interfaces' import { useAppSelector } from '../stores/hooks'; +import Search from './Search'; type Props = { menu: MenuNavBarItem[] - className: string + className?: string children: ReactNode } @@ -35,23 +36,30 @@ export default function NavBar({ menu, className = '', children }: Props) { return ( ) -} +} \ No newline at end of file diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..d363a4b 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, {useEffect, useRef, useState} from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' @@ -87,13 +86,15 @@ export default function NavBarItem({ item }: Props) { onClick={handleMenuClick} > {item.icon && } - - {itemLabel} - + {itemLabel && ( + + {itemLabel} + + )} {item.isCurrentUser && } {item.menu && ( state.auth); + + if (!currentUser) return null; + return ( <> - {menu.map((item, index) => ( + {menu.map((item, index) => { + if (!hasPermission(currentUser, item.permissions)) return null; + + return (
- ))} + ) + })} ) -} +} \ No newline at end of file diff --git a/frontend/src/components/Projects/TableProjects.tsx b/frontend/src/components/Projects/TableProjects.tsx new file mode 100644 index 0000000..003ab35 --- /dev/null +++ b/frontend/src/components/Projects/TableProjects.tsx @@ -0,0 +1,430 @@ +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/projects/projectsSlice' +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 "./configureProjectsCols"; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter' +import {dataGridStyles} from "../../styles"; + +const perPage = 100 + +const TableProjects = ({ filterItems, setFilterItems, filters }) => { + const notify = (type, msg) => toast( msg, {type, position: "bottom-center"}); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + 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 { projects, loading, count, notify: projectsNotify, refetch } = useAppSelector((state) => state.projects) + 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 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 (projectsNotify.showNotification) { + notify(projectsNotify.typeNotification, projectsNotify.textNotification); + } + }, [projectsNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalTrashActive, setIsModalTrashActive] = useState(false) + + const handleModalAction = () => { + 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, + `projects`, + 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'; + + 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?

+
+ +
+ `datagrid--row`} + rows={projects ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + 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); + }} + /> +
+ + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ) +} + +export default TableProjects diff --git a/frontend/src/components/Projects/configureProjectsCols.tsx b/frontend/src/components/Projects/configureProjectsCols.tsx new file mode 100644 index 0000000..a153218 --- /dev/null +++ b/frontend/src/components/Projects/configureProjectsCols.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +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 +) => { + const hasUpdatePermission = hasPermission(user, 'UPDATE_PROJECTS') + + return [ + { + field: 'name', + headerName: 'Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + }, + { + field: 'status', + headerName: 'Status', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + type: 'singleSelect', + valueOptions: ['active', 'completed', 'archived'], + }, + { + field: 'startDate', + headerName: 'Start Date', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + params.row.startDate ? new Date(params.row.startDate) : null, + }, + { + field: 'endDate', + headerName: 'Finish Date', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + type: 'dateTime', + valueGetter: (params: GridValueGetterParams) => + params.row.endDate ? new Date(params.row.endDate) : null, + }, + { + field: 'description', + headerName: 'Description', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + }, + { + field: 'trials', + headerName: 'Associated Trials', + flex: 1, + minWidth: 150, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: false, + sortable: false, + type: 'singleSelect', + valueFormatter: ({ value }) => + // Assuming trials is an array of objects with name or title + Array.isArray(value) ? value.map(t => t.name || t.id).join(', ') : '', + // If we want editable trials in grid, we can enable it, but might be complex for M-to-M + renderEditCell: (params) => ( + + ), + }, + { + field: 'actions', + type: 'actions', + minWidth: 80, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
+ +
, + ] + }, + }, + ]; +}; \ No newline at end of file diff --git a/frontend/src/components/RecentTrialsTable.tsx b/frontend/src/components/RecentTrialsTable.tsx new file mode 100644 index 0000000..10e3f0e --- /dev/null +++ b/frontend/src/components/RecentTrialsTable.tsx @@ -0,0 +1,98 @@ +import React, { useEffect } from 'react' +import { useAppDispatch, useAppSelector } from '../stores/hooks' +import { fetch } from '../stores/trials/trialsSlice' +import BaseButton from './BaseButton' +import BaseButtons from './BaseButtons' +import { mdiEye, mdiPlus } from '@mdi/js' +import Link from 'next/link' + +const RecentTrialsTable = () => { + const dispatch = useAppDispatch() + const { trials, loading } = useAppSelector((state) => state.trials) + + useEffect(() => { + dispatch(fetch({ query: '?limit=5' })) + }, [dispatch]) + + const getStatusColor = (status: string) => { + switch (status) { + case 'active': + return 'bg-emerald-100 text-emerald-800 border-emerald-200' + case 'planned': + return 'bg-blue-100 text-blue-800 border-blue-200' + case 'completed': + return 'bg-purple-100 text-purple-800 border-purple-200' + case 'draft': + return 'bg-gray-100 text-gray-800 border-gray-200' + default: + return 'bg-gray-50 text-gray-600 border-gray-200' + } + } + + return ( +
+
+

Recent Trials

+ + + +
+
+ + + + + + + + + + + + {loading && trials.length === 0 ? ( + + + + ) : trials.length > 0 ? ( + trials.slice(0, 5).map((trial: any) => ( + + + + + + + + )) + ) : ( + + + + )} + +
Trial NameCodeStatusCreated
+
+
+ Loading trials... +
+
{trial.name}{trial.code || '-'} + + {trial.status} + + + {new Date(trial.createdAt).toLocaleDateString()} + + + + +
+

No trials found in the system.

+ + + +
+
+
+ ) +} + +export default RecentTrialsTable diff --git a/frontend/src/components/Search.tsx b/frontend/src/components/Search.tsx index b7beb98..9a120af 100644 --- a/frontend/src/components/Search.tsx +++ b/frontend/src/components/Search.tsx @@ -31,7 +31,7 @@ const Search = () => { validateOnChange={false} > {({ errors, touched, values }) => ( -
+ { ); }; -export default Search; +export default Search; \ No newline at end of file diff --git a/frontend/src/config.ts b/frontend/src/config.ts index a9783c8..981d73b 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -8,8 +8,8 @@ export const localStorageStyleKey = 'style' export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20' -export const appTitle = 'created by Flatlogic generator!' +export const appTitle = 'Trial Tracker' export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle} — ${appTitle}` -export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || '' +export const tinyKey = process.env.NEXT_PUBLIC_TINY_KEY || '' \ No newline at end of file diff --git a/frontend/src/helpers/dataFormatter.js b/frontend/src/helpers/dataFormatter.js index ca2c56d..0fd0c33 100644 --- a/frontend/src/helpers/dataFormatter.js +++ b/frontend/src/helpers/dataFormatter.js @@ -394,10 +394,28 @@ export default { if (!val) return '' return {label: val.name, id: val.id} }, + + + projectsManyListFormatter(val) { + if (!val || !val.length) return [] + return val.map((item) => item.name) + }, + projectsOneListFormatter(val) { + if (!val) return '' + return val.name + }, + projectsManyListFormatterEdit(val) { + if (!val || !val.length) return [] + return val.map((item) => { + return {id: item.id, label: item.name} + }); + }, + projectsOneListFormatterEdit(val) { + if (!val) return '' + return {label: val.name, id: val.id} + }, - - -} +} \ No newline at end of file diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 0c7dd74..45c7e19 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -27,6 +27,7 @@ export type MenuNavBarItem = { isToggleLightDark?: boolean isCurrentUser?: boolean menu?: MenuNavBarItem[] + permissions?: string | string[] } export type ColorKey = 'white' | 'light' | 'contrast' | 'success' | 'danger' | 'warning' | 'info' @@ -106,4 +107,4 @@ export type StyleKey = 'white' | 'basic' export type UserForm = { name: string email: string -} +} \ No newline at end of file diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..f5791f8 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,16 +1,10 @@ import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' import jwt from 'jsonwebtoken'; -import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' -import menuAside from '../menuAside' import menuNavBar from '../menuNavBar' -import BaseIcon from '../components/BaseIcon' import NavBar from '../components/NavBar' import NavBarItemPlain from '../components/NavBarItemPlain' -import AsideMenu from '../components/AsideMenu' import FooterBar from '../components/FooterBar' import { useAppDispatch, useAppSelector } from '../stores/hooks' -import Search from '../components/Search'; import { useRouter } from 'next/router' import {findMe, logoutUser} from "../stores/authSlice"; @@ -34,6 +28,7 @@ export default function LayoutAuthenticated({ const router = useRouter() const { token, currentUser } = useAppSelector((state) => state.auth) const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + let localToken if (typeof window !== 'undefined') { // Perform localStorage action @@ -67,63 +62,28 @@ export default function LayoutAuthenticated({ const darkMode = useAppSelector((state) => state.style.darkMode) - const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false) - const [isAsideLgActive, setIsAsideLgActive] = useState(false) - - useEffect(() => { - const handleRouteChangeStart = () => { - setIsAsideMobileExpanded(false) - setIsAsideLgActive(false) - } - - router.events.on('routeChangeStart', handleRouteChangeStart) - - // If the component is unmounted, unsubscribe - // from the event with the `off` method: - return () => { - router.events.off('routeChangeStart', handleRouteChangeStart) - } - }, [router.events, dispatch]) - - - const layoutAsidePadding = 'xl:pl-60' - return (
- setIsAsideMobileExpanded(!isAsideMobileExpanded)} - > - - - setIsAsideLgActive(true)} - > - - - - + router.push('/dashboard')}> +
+ Trial Tracker Logo +
- setIsAsideLgActive(false)} - /> {children} Hand-crafted & Made with ❤️
) -} +} \ No newline at end of file diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 3e0a9fa..70fd28a 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -5,9 +5,134 @@ const menuAside: MenuAsideItem[] = [ { href: '/dashboard', icon: icon.mdiViewDashboardOutline, - label: 'Dashboard', + label: 'Home', + }, + { + label: 'Projects', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiGreenhouse' in icon ? icon['mdiGreenhouse' as keyof typeof icon] : icon.mdiTable, + menu: [ + { + href: '/projects/projects-list', + label: 'Projects List', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiGreenhouse' in icon ? icon['mdiGreenhouse' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_PROJECTS' + }, + { + href: '/trial_sites/trial_sites-list', + label: 'Trial Sites', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_TRIAL_SITES' + }, + { + href: '/organizations/organizations-list', + label: 'Organizations', + icon: icon.mdiTable, + permissions: 'READ_ORGANIZATIONS' + }, + { + href: '/tenants/tenants-list', + label: 'Tenants', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiDomain' in icon ? icon['mdiDomain' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_TENANTS' + }, + ] + }, + { + label: 'Trials', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiFlask' in icon ? icon['mdiFlask' as keyof typeof icon] : icon.mdiTable, + menu: [ + { + href: '/trials/trials-list', + label: 'Trials List', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiFlask' in icon ? icon['mdiFlask' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_TRIALS' + }, + { + href: '/trial_entries/trial_entries-list', + label: 'Trial Entries', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiTagMultiple' in icon ? icon['mdiTagMultiple' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_TRIAL_ENTRIES' + }, + { + href: '/varieties/varieties-list', + label: 'Varieties', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiSprout' in icon ? icon['mdiSprout' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_VARIETIES' + }, + { + href: '/plots/plots-list', + label: 'Plots', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiGrid' in icon ? icon['mdiGrid' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_PLOTS' + }, + { + href: '/plants/plants-list', + label: 'Plants', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiFlower' in icon ? icon['mdiFlower' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_PLANTS' + }, + ] + }, + { + label: 'Tracking', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiChartBellCurve' in icon ? icon['mdiChartBellCurve' as keyof typeof icon] : icon.mdiTable, + menu: [ + { + href: '/observations/observations-list', + label: 'Observations', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiChartBellCurve' in icon ? icon['mdiChartBellCurve' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_OBSERVATIONS' + }, + { + href: '/observation_events/observation_events-list', + label: 'Observation Events', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiClipboardText' in icon ? icon['mdiClipboardText' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_OBSERVATION_EVENTS' + }, + { + href: '/traits/traits-list', + label: 'Traits', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiRuler' in icon ? icon['mdiRuler' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_TRAITS' + }, + { + href: '/media_assets/media_assets-list', + label: 'Media Assets', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiImageMultiple' in icon ? icon['mdiImageMultiple' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_MEDIA_ASSETS' + }, + ] }, - { href: '/users/users-list', label: 'Users', @@ -17,163 +142,67 @@ const menuAside: MenuAsideItem[] = [ permissions: 'READ_USERS' }, { - href: '/roles/roles-list', - label: 'Roles', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable, - permissions: 'READ_ROLES' - }, - { - href: '/permissions/permissions-list', - label: 'Permissions', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: icon.mdiShieldAccountOutline ?? icon.mdiTable, - permissions: 'READ_PERMISSIONS' - }, - { - href: '/organizations/organizations-list', - label: 'Organizations', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_ORGANIZATIONS' - }, - { - href: '/tenants/tenants-list', - label: 'Tenants', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiDomain' in icon ? icon['mdiDomain' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_TENANTS' - }, - { - href: '/farms/farms-list', - label: 'Farms', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiGreenhouse' in icon ? icon['mdiGreenhouse' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_FARMS' - }, - { - href: '/trials/trials-list', - label: 'Trials', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiFlask' in icon ? icon['mdiFlask' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_TRIALS' - }, - { - href: '/trial_sites/trial_sites-list', - label: 'Trial sites', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_TRIAL_SITES' - }, - { - href: '/varieties/varieties-list', - label: 'Varieties', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiSprout' in icon ? icon['mdiSprout' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_VARIETIES' - }, - { - href: '/trial_entries/trial_entries-list', - label: 'Trial entries', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiTagMultiple' in icon ? icon['mdiTagMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_TRIAL_ENTRIES' - }, - { - href: '/plots/plots-list', - label: 'Plots', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiGrid' in icon ? icon['mdiGrid' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_PLOTS' - }, - { - href: '/plants/plants-list', - label: 'Plants', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiFlower' in icon ? icon['mdiFlower' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_PLANTS' - }, - { - href: '/observation_events/observation_events-list', - label: 'Observation events', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiClipboardText' in icon ? icon['mdiClipboardText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_OBSERVATION_EVENTS' - }, - { - href: '/traits/traits-list', - label: 'Traits', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiRuler' in icon ? icon['mdiRuler' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_TRAITS' - }, - { - href: '/observations/observations-list', - label: 'Observations', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiChartBellCurve' in icon ? icon['mdiChartBellCurve' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_OBSERVATIONS' - }, - { - href: '/media_assets/media_assets-list', - label: 'Media assets', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiImageMultiple' in icon ? icon['mdiImageMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_MEDIA_ASSETS' - }, - { - href: '/report_templates/report_templates-list', - label: 'Report templates', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiFileDocumentOutline' in icon ? icon['mdiFileDocumentOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_REPORT_TEMPLATES' - }, - { - href: '/reports/reports-list', label: 'Reports', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - icon: 'mdiFileChart' in icon ? icon['mdiFileChart' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_REPORTS' + icon: 'mdiFileChart' in icon ? icon['mdiFileChart' as keyof typeof icon] : icon.mdiTable, + menu: [ + { + href: '/reports/reports-list', + label: 'Reports List', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiFileChart' in icon ? icon['mdiFileChart' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_REPORTS' + }, + { + href: '/report_templates/report_templates-list', + label: 'Report Templates', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiFileDocumentOutline' in icon ? icon['mdiFileDocumentOutline' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_REPORT_TEMPLATES' + }, + ] }, { - href: '/api_clients/api_clients-list', - label: 'Api clients', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiApi' in icon ? icon['mdiApi' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_API_CLIENTS' - }, - { - href: '/profile', - label: 'Profile', - icon: icon.mdiAccountCircle, - }, - - - { - href: '/api-docs', - target: '_blank', - label: 'Swagger API', - icon: icon.mdiFileCode, - permissions: 'READ_API_DOCS' - }, + label: 'Settings', + icon: icon.mdiCog, + menu: [ + { + href: '/roles/roles-list', + label: 'Roles', + icon: icon.mdiShieldAccountVariantOutline, + permissions: 'READ_ROLES' + }, + { + href: '/permissions/permissions-list', + label: 'Permissions', + icon: icon.mdiShieldAccountOutline, + permissions: 'READ_PERMISSIONS' + }, + { + href: '/api_clients/api_clients-list', + label: 'API Clients', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiApi' in icon ? icon['mdiApi' as keyof typeof icon] : icon.mdiTable, + permissions: 'READ_API_CLIENTS' + }, + { + href: '/profile', + label: 'Profile', + icon: icon.mdiAccountCircle, + }, + { + href: '/api-docs', + target: '_blank', + label: 'Swagger API', + icon: icon.mdiFileCode, + permissions: 'READ_API_DOCS' + }, + ] + } ] export default menuAside diff --git a/frontend/src/menuNavBar.ts b/frontend/src/menuNavBar.ts index a5dd956..478258c 100644 --- a/frontend/src/menuNavBar.ts +++ b/frontend/src/menuNavBar.ts @@ -1,19 +1,195 @@ import { - mdiMenu, - mdiClockOutline, - mdiCloud, - mdiCrop, mdiAccount, - mdiCogOutline, - mdiEmail, mdiLogout, mdiThemeLightDark, - mdiGithub, - mdiVuejs, + mdiViewDashboardOutline, + mdiTable, + mdiAccountGroup, + mdiCog, + mdiShieldAccountVariantOutline, + mdiShieldAccountOutline, + mdiAccountCircle, + mdiFileCode, + mdiFlask, + mdiGreenhouse, + mdiMapMarker, + mdiDomain, + mdiTagMultiple, + mdiSprout, + mdiGrid, + mdiFlower, + mdiChartBellCurve, + mdiClipboardText, + mdiRuler, + mdiImageMultiple, + mdiFileChart, + mdiFileDocumentOutline, + mdiApi, } from '@mdi/js' import { MenuNavBarItem } from './interfaces' const menuNavBar: MenuNavBarItem[] = [ + { + href: '/dashboard', + icon: mdiViewDashboardOutline, + label: 'Home', + }, + { + label: 'Projects', + icon: mdiGreenhouse, + menu: [ + { + href: '/farms/farms-list', + label: 'Projects List', + icon: mdiGreenhouse, + permissions: 'READ_FARMS' + }, + { + href: '/trial_sites/trial_sites-list', + label: 'Trial Sites', + icon: mdiMapMarker, + permissions: 'READ_TRIAL_SITES' + }, + { + href: '/organizations/organizations-list', + label: 'Organizations', + icon: mdiTable, + permissions: 'READ_ORGANIZATIONS' + }, + { + href: '/tenants/tenants-list', + label: 'Tenants', + icon: mdiDomain, + permissions: 'READ_TENANTS' + }, + ] + }, + { + label: 'Trials', + icon: mdiFlask, + menu: [ + { + href: '/trials/trials-list', + label: 'Trials List', + icon: mdiFlask, + permissions: 'READ_TRIALS' + }, + { + href: '/trial_entries/trial_entries-list', + label: 'Trial Entries', + icon: mdiTagMultiple, + permissions: 'READ_TRIAL_ENTRIES' + }, + { + href: '/varieties/varieties-list', + label: 'Varieties', + icon: mdiSprout, + permissions: 'READ_VARIETIES' + }, + { + href: '/plots/plots-list', + label: 'Plots', + icon: mdiGrid, + permissions: 'READ_PLOTS' + }, + { + href: '/plants/plants-list', + label: 'Plants', + icon: mdiFlower, + permissions: 'READ_PLANTS' + }, + ] + }, + { + label: 'Tracking', + icon: mdiChartBellCurve, + menu: [ + { + href: '/observations/observations-list', + label: 'Observations', + icon: mdiChartBellCurve, + permissions: 'READ_OBSERVATIONS' + }, + { + href: '/observation_events/observation_events-list', + label: 'Observation Events', + icon: mdiClipboardText, + permissions: 'READ_OBSERVATION_EVENTS' + }, + { + href: '/traits/traits-list', + label: 'Traits', + icon: mdiRuler, + permissions: 'READ_TRAITS' + }, + { + href: '/media_assets/media_assets-list', + label: 'Media Assets', + icon: mdiImageMultiple, + permissions: 'READ_MEDIA_ASSETS' + }, + ] + }, + { + href: '/users/users-list', + label: 'Users', + icon: mdiAccountGroup, + permissions: 'READ_USERS' + }, + { + label: 'Reports', + icon: mdiFileChart, + menu: [ + { + href: '/reports/reports-list', + label: 'Reports List', + icon: mdiFileChart, + permissions: 'READ_REPORTS' + }, + { + href: '/report_templates/report_templates-list', + label: 'Report Templates', + icon: mdiFileDocumentOutline, + permissions: 'READ_REPORT_TEMPLATES' + }, + ] + }, + { + label: 'Settings', + icon: mdiCog, + menu: [ + { + href: '/roles/roles-list', + label: 'Roles', + icon: mdiShieldAccountVariantOutline, + permissions: 'READ_ROLES' + }, + { + href: '/permissions/permissions-list', + label: 'Permissions', + icon: mdiShieldAccountOutline, + permissions: 'READ_PERMISSIONS' + }, + { + href: '/api_clients/api_clients-list', + label: 'API Clients', + icon: mdiApi, + permissions: 'READ_API_CLIENTS' + }, + { + href: '/profile', + label: 'Profile', + icon: mdiAccountCircle, + }, + { + href: '/api-docs', + target: '_blank', + label: 'Swagger API', + icon: mdiFileCode, + permissions: 'READ_API_DOCS' + }, + ] + }, { isCurrentUser: true, menu: [ @@ -50,4 +226,4 @@ export const webPagesNavBar = [ ]; -export default menuNavBar +export default menuNavBar \ No newline at end of file diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index a93f3ea..9668aca 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -1,694 +1,87 @@ -import * as icon from '@mdi/js'; +import { mdiChartTimelineVariant } from '@mdi/js' import Head from 'next/head' -import React from 'react' -import axios from 'axios'; -import type { ReactElement } from 'react' +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 BaseIcon from "../components/BaseIcon"; import { getPageTitle } from '../config' -import Link from "next/link"; +import ActivityFeed from '../components/ActivityFeed' +import { useAppSelector } from '../stores/hooks' -import { hasPermission } from "../helpers/userPermissions"; -import { fetchWidgets } from '../stores/roles/rolesSlice'; -import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'; -import { SmartWidget } from '../components/SmartWidget/SmartWidget'; - -import { useAppDispatch, useAppSelector } from '../stores/hooks'; const Dashboard = () => { - const dispatch = useAppDispatch(); - const iconsColor = useAppSelector((state) => state.style.iconsColor); - const corners = useAppSelector((state) => state.style.corners); - const cardsStyle = useAppSelector((state) => state.style.cardsStyle); + const { currentUser } = useAppSelector((state) => state.auth) - const loadingMessage = 'Loading...'; - - - const [users, setUsers] = React.useState(loadingMessage); - const [roles, setRoles] = React.useState(loadingMessage); - const [permissions, setPermissions] = React.useState(loadingMessage); - const [organizations, setOrganizations] = React.useState(loadingMessage); - const [tenants, setTenants] = React.useState(loadingMessage); - const [farms, setFarms] = React.useState(loadingMessage); - const [trials, setTrials] = React.useState(loadingMessage); - const [trial_sites, setTrial_sites] = React.useState(loadingMessage); - const [varieties, setVarieties] = React.useState(loadingMessage); - const [trial_entries, setTrial_entries] = React.useState(loadingMessage); - const [plots, setPlots] = React.useState(loadingMessage); - const [plants, setPlants] = React.useState(loadingMessage); - const [observation_events, setObservation_events] = React.useState(loadingMessage); - const [traits, setTraits] = React.useState(loadingMessage); - const [observations, setObservations] = React.useState(loadingMessage); - const [media_assets, setMedia_assets] = React.useState(loadingMessage); - const [report_templates, setReport_templates] = React.useState(loadingMessage); - const [reports, setReports] = React.useState(loadingMessage); - const [api_clients, setApi_clients] = React.useState(loadingMessage); - - - const [widgetsRole, setWidgetsRole] = React.useState({ - role: { value: '', label: '' }, - }); - const { currentUser } = useAppSelector((state) => state.auth); - const { isFetchingQuery } = useAppSelector((state) => state.openAi); - - const { rolesWidgets, loading } = useAppSelector((state) => state.roles); - - - const organizationId = currentUser?.organizations?.id; - - async function loadData() { - const entities = ['users','roles','permissions','organizations','tenants','farms','trials','trial_sites','varieties','trial_entries','plots','plants','observation_events','traits','observations','media_assets','report_templates','reports','api_clients',]; - const fns = [setUsers,setRoles,setPermissions,setOrganizations,setTenants,setFarms,setTrials,setTrial_sites,setVarieties,setTrial_entries,setPlots,setPlants,setObservation_events,setTraits,setObservations,setMedia_assets,setReport_templates,setReports,setApi_clients,]; - - const requests = entities.map((entity, index) => { - - if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) { - return axios.get(`/${entity.toLowerCase()}/count`); - } else { - fns[index](null); - return Promise.resolve({data: {count: null}}); - } - - }); - - Promise.allSettled(requests).then((results) => { - results.forEach((result, i) => { - if (result.status === 'fulfilled') { - fns[i](result.value.data.count); - } else { - fns[i](result.reason.message); - } - }); - }); - } - - async function getWidgets(roleId) { - await dispatch(fetchWidgets(roleId)); - } - React.useEffect(() => { - if (!currentUser) return; - loadData().then(); - setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } }); - }, [currentUser]); - - React.useEffect(() => { - if (!currentUser || !widgetsRole?.role?.value) return; - getWidgets(widgetsRole?.role?.value || '').then(); - }, [widgetsRole?.role?.value]); - return ( <> - - {getPageTitle('Overview')} - + {getPageTitle('Trial Tracker')} - + {''} - - {hasPermission(currentUser, 'CREATE_ROLES') && } - {!!rolesWidgets.length && - hasPermission(currentUser, 'CREATE_ROLES') && ( -

- {`${widgetsRole?.role?.label || 'Users'}'s widgets`} -

- )} -
- {(isFetchingQuery || loading) && ( -
- {' '} - Loading widgets... -
- )} +
+ +
+
+

Total Projects

+

12

+
+
+ + + +
+
+

+2 since last month

+
- { rolesWidgets && - rolesWidgets.map((widget) => ( - - ))} + +
+
+

Active Trials

+

48

+
+
+ + + +
+
+

+12 from last week

+
+ + +
+
+

Completed Trials

+

156

+
+
+ + + +
+
+

98% success rate

+
- {!!rolesWidgets.length &&
} - -
- - - {hasPermission(currentUser, 'READ_USERS') && -
-
-
-
- Users -
-
- {users} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_ROLES') && -
-
-
-
- Roles -
-
- {roles} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_PERMISSIONS') && -
-
-
-
- Permissions -
-
- {permissions} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_ORGANIZATIONS') && -
-
-
-
- Organizations -
-
- {organizations} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_TENANTS') && -
-
-
-
- Tenants -
-
- {tenants} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_FARMS') && -
-
-
-
- Farms -
-
- {farms} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_TRIALS') && -
-
-
-
- Trials -
-
- {trials} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_TRIAL_SITES') && -
-
-
-
- Trial sites -
-
- {trial_sites} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_VARIETIES') && -
-
-
-
- Varieties -
-
- {varieties} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_TRIAL_ENTRIES') && -
-
-
-
- Trial entries -
-
- {trial_entries} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_PLOTS') && -
-
-
-
- Plots -
-
- {plots} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_PLANTS') && -
-
-
-
- Plants -
-
- {plants} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_OBSERVATION_EVENTS') && -
-
-
-
- Observation events -
-
- {observation_events} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_TRAITS') && -
-
-
-
- Traits -
-
- {traits} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_OBSERVATIONS') && -
-
-
-
- Observations -
-
- {observations} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_MEDIA_ASSETS') && -
-
-
-
- Media assets -
-
- {media_assets} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_REPORT_TEMPLATES') && -
-
-
-
- Report templates -
-
- {report_templates} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_REPORTS') && -
-
-
-
- Reports -
-
- {reports} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_API_CLIENTS') && -
-
-
-
- Api clients -
-
- {api_clients} -
-
-
- -
-
-
- } - - +
+
+

Recent Activities

+
+ Filter by: + +
+
+
@@ -699,4 +92,4 @@ Dashboard.getLayout = function getLayout(page: ReactElement) { return {page} } -export default Dashboard +export default Dashboard \ No newline at end of file diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index c3bc2a0..8f5b735 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,30 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; -import Head from 'next/head'; -import Link from 'next/link'; -import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; -import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; -import { getPageTitle } from '../config'; +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +import Head from 'next/head'; +import { getPageTitle } from '../config'; +export default function IndexPage() { + const { currentUser } = useAppSelector((state) => state.auth); + const router = useRouter(); -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('right'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'Greenhouse Trials Tracker' - - // Fetch Pexels image/video useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); + if (currentUser) { + router.push('/dashboard'); + } else { + router.push('/login'); } - fetchData(); - }, []); + }, [currentUser, router]); - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - + return ( +
+ + {getPageTitle('Loading...')} + +
+
+

Redirecting to system...

); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; - - return ( -
- - {getPageTitle('Starter Page')} - - - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - - - -
-
-
-
-
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
- -
- ); } - -Starter.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index a4f7f56..ff89cea 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -1,12 +1,10 @@ - - import React, { useEffect, useState } from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; import BaseButton from '../components/BaseButton'; import CardBox from '../components/CardBox'; import BaseIcon from "../components/BaseIcon"; -import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js'; +import { mdiInformation, mdiEye, mdiEyeOff, mdiViewDashboard } from '@mdi/js'; import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; import { Field, Form, Formik } from 'formik'; @@ -20,7 +18,6 @@ import { findMe, loginUser, resetAction } from '../stores/authSlice'; import { useAppDispatch, useAppSelector } from '../stores/hooks'; import Link from 'next/link'; import {toast, ToastContainer} from "react-toastify"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels' export default function Login() { const router = useRouter(); @@ -28,15 +25,7 @@ export default function Login() { const textColor = useAppSelector((state) => state.style.linkColor); const iconsColor = useAppSelector((state) => state.style.iconsColor); const notify = (type, msg) => toast(msg, { type }); - const [ illustrationImage, setIllustrationImage ] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('right'); - const [showPassword, setShowPassword] = useState(false); + const [showPassword, setShowPassword] = useState(false); const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector( (state) => state.auth, ); @@ -44,37 +33,29 @@ export default function Login() { password: 'db9589f7', remember: true }) - const title = 'Greenhouse Trials Tracker' + const title = 'TrialTracker ERP' - // Fetch Pexels image/video - useEffect( () => { - async function fetchData() { - const image = await getPexelsImage() - const video = await getPexelsVideo() - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); // Fetch user data useEffect(() => { if (token) { dispatch(findMe()); } }, [token, dispatch]); + // Redirect to dashboard if user is logged in useEffect(() => { if (currentUser?.id) { router.push('/dashboard'); } }, [currentUser?.id, router]); + // Show error message if there is one useEffect(() => { if (errorMessage){ notify('error', errorMessage) } - }, [errorMessage]) + // Show notification if there is one useEffect(() => { if (notifyState?.showNotification) { @@ -100,178 +81,95 @@ export default function Login() { })); }; - const imageBlock = (image) => ( - - ) - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; - return ( -
+
{getPageTitle('Login')} - -
- {contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null} - {contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null} -
- - - -

{title}

- -
-
- -

Use{' '} - setLogin(e.target)}>super_admin@flatlogic.com{' / '} - db9589f7{' / '} - to login as Super Admin

- -

Use{' '} - setLogin(e.target)}>admin@flatlogic.com{' / '} - db9589f7{' / '} - to login as Admin

-

Use setLogin(e.target)}>client@hello.com{' / '} - 259e6bfdf548{' / '} - to login as User

-
-
- -
+
+
+
+
+
- - - +

{title}

+

Enterprise Breeding & Trial Management

+
+ + handleSubmit(values)} > - + - + label='Email Address' + labelColor="text-gray-700" + help='Enter your registered enterprise email'> +
- + labelColor="text-gray-700" + help='Enter your secure password'> +
-
- +
+ - + Forgot password?
- - - +
- -
-

- Don’t have an account yet?{' '} - - New Account - -

+
+ +
+
+ +
+

Quick Access for Review:

+
+

Admin: setLogin(e.target)} data-password="db9589f7">admin@flatlogic.com

+

Staff: setLogin(e.target)} data-password="259e6bfdf548">client@hello.com

+
+
+
+
+ +
+

© 2026 {title}. Confidential & Proprietary.

+
-
- -
-

© 2026 {title}. © All rights reserved

- - Privacy Policy -
@@ -280,4 +178,4 @@ export default function Login() { Login.getLayout = function getLayout(page: ReactElement) { return {page}; -}; +}; \ No newline at end of file diff --git a/frontend/src/pages/projects/[id].tsx b/frontend/src/pages/projects/[id].tsx new file mode 100644 index 0000000..9910bd8 --- /dev/null +++ b/frontend/src/pages/projects/[id].tsx @@ -0,0 +1,194 @@ +import { mdiChartTimelineVariant, mdiArrowLeft } from '@mdi/js' +import Head from 'next/head' +import React, { ReactElement, useEffect, 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 { 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 FormFilePicker from '../../components/FormFilePicker' +import { SelectField } from '../../components/SelectField' +import { SelectFieldMany } from "../../components/SelectFieldMany"; +import { RichTextField } from "../../components/RichTextField"; +import { create, update, fetch } from '../../stores/projects/projectsSlice' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { useRouter } from 'next/router' +import moment from 'moment'; +import { hasPermission } from "../../helpers/userPermissions"; + +const ProjectsForm = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const { id } = router.query + const isNew = id === 'new' + + const { projects: projectData, loading } = useAppSelector((state) => state.projects) + const [initialValues, setInitialValues] = useState({ + name: '', + status: 'active', + description: '', + startDate: '', + endDate: '', + trials: [], + documentation: [], + tenant: '', // Optional/Tenant logic + organizations: '', // Optional/Org logic + }) + + useEffect(() => { + if (id && !isNew) { + dispatch(fetch({ id })) + } + }, [id, isNew, dispatch]) + + useEffect(() => { + if (!isNew && projectData && projectData.id === id) { + setInitialValues({ + name: projectData.name || '', + status: projectData.status || 'active', + description: projectData.description || '', + startDate: projectData.startDate ? moment(projectData.startDate).format('YYYY-MM-DDTHH:mm') : '', + endDate: projectData.endDate ? moment(projectData.endDate).format('YYYY-MM-DDTHH:mm') : '', + trials: projectData.trials || [], + documentation: projectData.documentation || [], + tenant: projectData.tenant?.id || '', + organizations: projectData.organizations?.id || '', + }) + } + }, [projectData, id, isNew]) + + const handleSubmit = async (values) => { + try { + if (isNew) { + await dispatch(create(values)).unwrap() + } else { + await dispatch(update({ id, data: values })).unwrap() + } + await router.push('/projects/projects-list') + } catch (error) { + console.error('Failed to save project:', error) + } + } + + const title = isNew ? 'New Project' : 'Edit Project' + + return ( + <> + + {getPageTitle(title)} + + + + + + + handleSubmit(values)} + > + {({ values, setFieldValue }) => ( +
+ + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + router.push('/projects/projects-list')}/> + + + )} +
+
+
+ + ) +} + +ProjectsForm.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default ProjectsForm diff --git a/frontend/src/pages/projects/index.tsx b/frontend/src/pages/projects/index.tsx new file mode 100644 index 0000000..a703214 --- /dev/null +++ b/frontend/src/pages/projects/index.tsx @@ -0,0 +1,14 @@ +import React, { useEffect } from 'react'; +import { useRouter } from 'next/router'; + +const ProjectsIndex = () => { + const router = useRouter(); + + useEffect(() => { + router.replace('/projects/projects-list'); + }, [router]); + + return null; +}; + +export default ProjectsIndex; diff --git a/frontend/src/pages/projects/projects-list.tsx b/frontend/src/pages/projects/projects-list.tsx new file mode 100644 index 0000000..8668098 --- /dev/null +++ b/frontend/src/pages/projects/projects-list.tsx @@ -0,0 +1,149 @@ +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 TableProjects from '../../components/Projects/TableProjects' +import BaseButton from '../../components/BaseButton' +import axios from "axios"; +import {useAppDispatch, useAppSelector} from "../../stores/hooks"; +import CardBoxModal from "../../components/CardBoxModal"; +import DragDropFilePicker from "../../components/DragDropFilePicker"; +import {setRefetch} from '../../stores/projects/projectsSlice'; // Assuming uploadCsv exists or we skip CSV for now +import {hasPermission} from "../../helpers/userPermissions"; + +const ProjectsListPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + {label: 'Name', title: 'name'}, + {label: 'Status', title: 'status', type: 'enum', options: ['active','completed','archived']}, + {label: 'Start Date', title: 'startDate', date: 'true'}, + {label: 'Finish Date', title: 'endDate', date: 'true'}, + ]); + + const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_PROJECTS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getProjectsCSV = async () => { + const response = await axios({url: '/projects?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 = 'projects.csv' + link.click() + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + // await dispatch(uploadCsv(csvFile)); // Not implemented in slice yet, skipping + // dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Projects')} + + + + {''} + + + + {hasCreatePermission && } + + + + + {/* CSV Upload skipped for now until slice supports it */} + {/* + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + */} + +
+
+
+ +
+ + + + + +
+ + + + + ) +} + +ProjectsListPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default ProjectsListPage diff --git a/frontend/src/pages/search.tsx b/frontend/src/pages/search.tsx index 00f5168..64bf121 100644 --- a/frontend/src/pages/search.tsx +++ b/frontend/src/pages/search.tsx @@ -1,9 +1,7 @@ import React, { ReactElement, useEffect, useState } from 'react'; import Head from 'next/head'; import 'react-datepicker/dist/react-datepicker.css'; -import { useAppDispatch } from '../stores/hooks'; - -import { useAppSelector } from '../stores/hooks'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useRouter } from 'next/router'; import LayoutAuthenticated from '../layouts/Authenticated'; @@ -93,4 +91,4 @@ SearchView.getLayout = function getLayout(page: ReactElement) { ); }; -export default SearchView; +export default SearchView; \ No newline at end of file diff --git a/frontend/src/stores/projects/projectsSlice.ts b/frontend/src/stores/projects/projectsSlice.ts new file mode 100644 index 0000000..5b031c5 --- /dev/null +++ b/frontend/src/stores/projects/projectsSlice.ts @@ -0,0 +1,185 @@ + +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' +import axios from 'axios' +import {fulfilledNotify, rejectNotify, resetNotify} from "../../helpers/notifyStateHandler"; + +interface MainState { + projects: any + loading: boolean + count: number + refetch: boolean; + notify: { + showNotification: boolean + textNotification: string + typeNotification: string + } +} + +const initialState: MainState = { + projects: [], + loading: false, + count: 0, + refetch: false, + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +} + +export const fetch = createAsyncThunk('projects/fetch', async (data: any) => { + const { id, query } = data + const result = await axios.get( + `projects${ + query || (id ? `/${id}` : '') + }` + ) + return id ? result.data : {rows: result.data.rows, count: result.data.count}; +}) + +export const deleteItemsByIds = createAsyncThunk( + 'projects/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('projects/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk('projects/deleteItem', async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`projects/${id}`) + } catch (error) { + if (!error.response) { + throw error; + } + return rejectWithValue(error.response.data); + } +}) + +export const create = createAsyncThunk('projects/create', async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post( + 'projects', + { data } + ) + return result.data + } catch (error) { + if (!error.response) { + throw error; + } + return rejectWithValue(error.response.data); + } +}) + +export const update = createAsyncThunk('projects/update', async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put( + `projects/${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 projectsSlice = createSlice({ + name: 'projects', + 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.projects = action.payload.rows; + state.count = action.payload.count; + } else { + state.projects = 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, 'Projects 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, `Project 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, `Project created`); + }) + + builder.addCase(update.pending, (state) => { + state.loading = true + resetNotify(state); + }) + builder.addCase(update.fulfilled, (state) => { + state.loading = false + fulfilledNotify(state, `Project updated`); + }) + builder.addCase(update.rejected, (state, action) => { + state.loading = false + rejectNotify(state, action); + }) + }, +}) + +export const { setRefetch } = projectsSlice.actions + +export default projectsSlice.reducer diff --git a/frontend/src/stores/store.ts b/frontend/src/stores/store.ts index 1623aea..87e5aa1 100644 --- a/frontend/src/stores/store.ts +++ b/frontend/src/stores/store.ts @@ -11,6 +11,7 @@ import organizationsSlice from "./organizations/organizationsSlice"; import tenantsSlice from "./tenants/tenantsSlice"; import farmsSlice from "./farms/farmsSlice"; import trialsSlice from "./trials/trialsSlice"; +import projectsSlice from "./projects/projectsSlice"; import trial_sitesSlice from "./trial_sites/trial_sitesSlice"; import varietiesSlice from "./varieties/varietiesSlice"; import trial_entriesSlice from "./trial_entries/trial_entriesSlice"; @@ -38,6 +39,7 @@ organizations: organizationsSlice, tenants: tenantsSlice, farms: farmsSlice, trials: trialsSlice, +projects: projectsSlice, trial_sites: trial_sitesSlice, varieties: varietiesSlice, trial_entries: trial_entriesSlice, @@ -56,4 +58,4 @@ api_clients: api_clientsSlice, // Infer the `RootState` and `AppDispatch` types from the store itself export type RootState = ReturnType // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} -export type AppDispatch = typeof store.dispatch +export type AppDispatch = typeof store.dispatch \ No newline at end of file diff --git a/frontend/src/styles.ts b/frontend/src/styles.ts index a969b60..0936e14 100644 --- a/frontend/src/styles.ts +++ b/frontend/src/styles.ts @@ -25,27 +25,27 @@ interface StyleObject { } export const white: StyleObject = { - aside: 'bg-white dark:text-white', + aside: 'bg-white dark:text-white border-r border-gray-200', asideScrollbars: 'aside-scrollbars-light', - asideBrand: '', - asideMenuItem: 'text-gray-700 hover:bg-gray-100/70 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800', - asideMenuItemActive: 'font-bold text-black dark:text-white', - asideMenuDropdown: 'bg-gray-100/75', - navBarItemLabel: 'text-blue-600', - navBarItemLabelHover: 'hover:text-black', - navBarItemLabelActiveColor: 'text-black', + asideBrand: 'border-b border-gray-200', + asideMenuItem: 'text-gray-600 hover:bg-blue-50 hover:text-blue-700 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800', + asideMenuItemActive: 'font-bold text-blue-700 bg-blue-50/50 dark:text-white', + asideMenuDropdown: 'bg-gray-50', + navBarItemLabel: 'text-gray-600', + navBarItemLabelHover: 'hover:text-blue-700', + navBarItemLabelActiveColor: 'text-blue-700', overlay: 'from-white via-gray-100 to-white', - activeLinkColor: 'bg-gray-100/70', - bgLayoutColor: 'bg-gray-50', - iconsColor: 'text-blue-500', + activeLinkColor: 'bg-blue-50/50', + bgLayoutColor: 'bg-gray-100/50', + iconsColor: 'text-blue-600', cardsColor: 'bg-white', - focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none border-gray-300 dark:focus:ring-blue-600 dark:focus:border-blue-600', - corners: 'rounded', - cardsStyle: 'bg-white border border-pavitra-400', + focusRingColor: 'focus:ring-2 focus:ring-blue-600/20 focus:border-blue-600 focus:outline-none border-gray-300 dark:focus:ring-blue-600 dark:focus:border-blue-600', + corners: 'rounded-lg', + cardsStyle: 'bg-white border border-gray-200 shadow-sm', linkColor: 'text-blue-600', websiteHeder: 'border-b border-gray-200', borders: 'border-gray-200', - shadow: '', + shadow: 'shadow-sm', websiteSectionStyle: '', textSecondary: 'text-gray-500', } @@ -57,51 +57,50 @@ export const white: StyleObject = { export const dataGridStyles = { '& .MuiDataGrid-cell': { paddingX: 3, - border: 'none', + borderBottom: '1px solid #f3f4f6', }, '& .MuiDataGrid-columnHeader': { paddingX: 3, + backgroundColor: '#f9fafb', + fontWeight: 'bold', }, '& .MuiDataGrid-columnHeaderCheckbox': { paddingX: 0, }, '& .MuiDataGrid-columnHeaders': { - paddingY: 4, - borderStartStartRadius: 7, - borderStartEndRadius: 7, + borderBottom: '2px solid #e5e7eb', }, '& .MuiDataGrid-footerContainer': { - paddingY: 0.5, - borderEndStartRadius: 7, - borderEndEndRadius: 7, + borderTop: '1px solid #e5e7eb', }, '& .MuiDataGrid-root': { - border: 'none', + border: '1px solid #e5e7eb', + borderRadius: '8px', }, }; export const basic: StyleObject = { - aside: 'bg-gray-800', + aside: 'bg-slate-900', asideScrollbars: 'aside-scrollbars-gray', - asideBrand: 'bg-gray-900 text-white', - asideMenuItem: 'text-gray-300 hover:text-white', - asideMenuItemActive: 'font-bold text-white', - asideMenuDropdown: 'bg-gray-700/50', - navBarItemLabel: 'text-black', - navBarItemLabelHover: 'hover:text-blue-500', + asideBrand: 'bg-slate-900 text-white border-b border-slate-800', + asideMenuItem: 'text-slate-400 hover:text-white hover:bg-slate-800/50', + asideMenuItemActive: 'font-bold text-white bg-blue-600/10', + asideMenuDropdown: 'bg-slate-800/40', + navBarItemLabel: 'text-slate-600', + navBarItemLabelHover: 'hover:text-blue-600', navBarItemLabelActiveColor: 'text-blue-600', - overlay: 'from-gray-700 via-gray-900 to-gray-700', - activeLinkColor: 'bg-gray-100/70', - bgLayoutColor: 'bg-gray-50', - iconsColor: 'text-blue-500', + overlay: 'from-slate-700 via-slate-900 to-slate-700', + activeLinkColor: 'bg-blue-600/10', + bgLayoutColor: 'bg-slate-50', + iconsColor: 'text-blue-600', cardsColor: 'bg-white', - focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none dark:focus:ring-blue-600 border-gray-300 dark:focus:border-blue-600', - corners: 'rounded', - cardsStyle: 'bg-white border border-pavitra-400', - linkColor: 'text-black', - websiteHeder: '', - borders: '', - shadow: '', + focusRingColor: 'focus:ring-2 focus:ring-blue-600/20 focus:border-blue-600 focus:outline-none dark:focus:ring-blue-600 border-gray-200 dark:focus:border-blue-600', + corners: 'rounded-lg', + cardsStyle: 'bg-white border border-slate-200 shadow-sm', + linkColor: 'text-blue-600', + websiteHeder: 'border-b border-slate-200', + borders: 'border-slate-200', + shadow: 'shadow-sm', websiteSectionStyle: '', - textSecondary: '', -} + textSecondary: 'text-slate-500', +} \ No newline at end of file