From a654215c598b2ee26c4bda219c8ef6aaac7b8f81 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 2 Apr 2025 10:00:23 +0000 Subject: [PATCH 1/4] wtf #1 --- .gitignore | 5 + backend/src/db/api/categories.js | 248 +++++++++ backend/src/db/migrations/1743587820040.js | 72 +++ backend/src/db/migrations/1743587851692.js | 47 ++ backend/src/db/models/categories.js | 49 ++ .../db/seeders/20200430130760-user-roles.js | 26 + .../db/seeders/20231127130745-sample-data.js | 24 +- backend/src/db/seeders/20250402095700.js | 87 ++++ backend/src/index.js | 8 + backend/src/routes/categories.js | 438 ++++++++++++++++ backend/src/services/categories.js | 114 +++++ backend/src/services/search.js | 2 + frontend/json/runtimeError.json | 1 + .../components/Categories/CardCategories.tsx | 105 ++++ .../components/Categories/ListCategories.tsx | 90 ++++ .../components/Categories/TableCategories.tsx | 484 ++++++++++++++++++ .../Categories/configureCategoriesCols.tsx | 73 +++ .../components/WebPageComponents/Footer.tsx | 2 +- frontend/src/menuAside.ts | 8 + .../src/pages/categories/[categoriesId].tsx | 126 +++++ .../src/pages/categories/categories-edit.tsx | 124 +++++ .../src/pages/categories/categories-list.tsx | 162 ++++++ .../src/pages/categories/categories-new.tsx | 98 ++++ .../src/pages/categories/categories-table.tsx | 161 ++++++ .../src/pages/categories/categories-view.tsx | 83 +++ frontend/src/pages/dashboard.tsx | 35 ++ frontend/src/pages/index.tsx | 2 +- frontend/src/pages/web_pages/services.tsx | 2 +- .../src/stores/categories/categoriesSlice.ts | 236 +++++++++ frontend/src/stores/store.ts | 2 + 30 files changed, 2909 insertions(+), 5 deletions(-) create mode 100644 backend/src/db/api/categories.js create mode 100644 backend/src/db/migrations/1743587820040.js create mode 100644 backend/src/db/migrations/1743587851692.js create mode 100644 backend/src/db/models/categories.js create mode 100644 backend/src/db/seeders/20250402095700.js create mode 100644 backend/src/routes/categories.js create mode 100644 backend/src/services/categories.js create mode 100644 frontend/json/runtimeError.json create mode 100644 frontend/src/components/Categories/CardCategories.tsx create mode 100644 frontend/src/components/Categories/ListCategories.tsx create mode 100644 frontend/src/components/Categories/TableCategories.tsx create mode 100644 frontend/src/components/Categories/configureCategoriesCols.tsx create mode 100644 frontend/src/pages/categories/[categoriesId].tsx create mode 100644 frontend/src/pages/categories/categories-edit.tsx create mode 100644 frontend/src/pages/categories/categories-list.tsx create mode 100644 frontend/src/pages/categories/categories-new.tsx create mode 100644 frontend/src/pages/categories/categories-table.tsx create mode 100644 frontend/src/pages/categories/categories-view.tsx create mode 100644 frontend/src/stores/categories/categoriesSlice.ts diff --git a/.gitignore b/.gitignore index e427ff3..d0eb167 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ node_modules/ */node_modules/ */build/ + +**/node_modules/ +**/build/ +.DS_Store +.env \ No newline at end of file diff --git a/backend/src/db/api/categories.js b/backend/src/db/api/categories.js new file mode 100644 index 0000000..2c750d9 --- /dev/null +++ b/backend/src/db/api/categories.js @@ -0,0 +1,248 @@ +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 CategoriesDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const categories = await db.categories.create( + { + id: data.id || undefined, + + name: data.name || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return categories; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const categoriesData = data.map((item, index) => ({ + id: item.id || undefined, + + name: item.name || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const categories = await db.categories.bulkCreate(categoriesData, { + transaction, + }); + + // For each item created, replace relation files + + return categories; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const categories = await db.categories.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + + updatePayload.updatedById = currentUser.id; + + await categories.update(updatePayload, { transaction }); + + return categories; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const categories = await db.categories.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of categories) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of categories) { + await record.destroy({ transaction }); + } + }); + + return categories; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const categories = await db.categories.findByPk(id, options); + + await categories.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await categories.destroy({ + transaction, + }); + + return categories; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const categories = await db.categories.findOne({ where }, { transaction }); + + if (!categories) { + return categories; + } + + const output = categories.get({ plain: true }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = []; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('categories', 'name', filter.name), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.categories.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('categories', 'name', query), + ], + }; + } + + const records = await db.categories.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +}; diff --git a/backend/src/db/migrations/1743587820040.js b/backend/src/db/migrations/1743587820040.js new file mode 100644 index 0000000..6bedca1 --- /dev/null +++ b/backend/src/db/migrations/1743587820040.js @@ -0,0 +1,72 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.createTable( + 'categories', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.dropTable('categories', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1743587851692.js b/backend/src/db/migrations/1743587851692.js new file mode 100644 index 0000000..bd2d80a --- /dev/null +++ b/backend/src/db/migrations/1743587851692.js @@ -0,0 +1,47 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'categories', + 'name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('categories', 'name', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/models/categories.js b/backend/src/db/models/categories.js new file mode 100644 index 0000000..d0fb8ae --- /dev/null +++ b/backend/src/db/models/categories.js @@ -0,0 +1,49 @@ +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 categories = sequelize.define( + 'categories', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + name: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + categories.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.categories.belongsTo(db.users, { + as: 'createdBy', + }); + + db.categories.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return categories; +}; diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.js index 6692f2d..080bd3d 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.js +++ b/backend/src/db/seeders/20200430130760-user-roles.js @@ -111,6 +111,7 @@ module.exports = { 'students', 'roles', 'permissions', + 'categories', , ]; await queryInterface.bulkInsert( @@ -965,6 +966,31 @@ primary key ("roles_permissionsId", "permissionId") permissionId: getId('DELETE_PERMISSIONS'), }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_CATEGORIES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_CATEGORIES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_CATEGORIES'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_CATEGORIES'), + }, + { createdAt, updatedAt, diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index ecbbf8f..72f7b27 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -13,6 +13,8 @@ const Instructors = db.instructors; const Students = db.students; +const Categories = db.categories; + const AnalyticsData = [ { // type code here for "relation_many" field @@ -143,7 +145,7 @@ const EnrollmentsData = [ // type code here for "relation_one" field - payment_status: 'Pending', + payment_status: 'Completed', enrollment_date: new Date('2023-02-20T11:30:00Z'), }, @@ -153,7 +155,7 @@ const EnrollmentsData = [ // type code here for "relation_one" field - payment_status: 'Failed', + payment_status: 'Pending', enrollment_date: new Date('2023-03-10T09:45:00Z'), }, @@ -223,6 +225,20 @@ const StudentsData = [ }, ]; +const CategoriesData = [ + { + name: 'Robert Koch', + }, + + { + name: 'Edwin Hubble', + }, + + { + name: 'Robert Koch', + }, +]; + // Similar logic for "relation_many" // Similar logic for "relation_many" @@ -434,6 +450,8 @@ module.exports = { await Students.bulkCreate(StudentsData); + await Categories.bulkCreate(CategoriesData); + await Promise.all([ // Similar logic for "relation_many" @@ -481,5 +499,7 @@ module.exports = { await queryInterface.bulkDelete('instructors', null, {}); await queryInterface.bulkDelete('students', null, {}); + + await queryInterface.bulkDelete('categories', null, {}); }, }; diff --git a/backend/src/db/seeders/20250402095700.js b/backend/src/db/seeders/20250402095700.js new file mode 100644 index 0000000..17f6705 --- /dev/null +++ b/backend/src/db/seeders/20250402095700.js @@ -0,0 +1,87 @@ +const { v4: uuid } = require('uuid'); +const db = require('../models'); +const Sequelize = require('sequelize'); +const config = require('../../config'); + +module.exports = { + /** + * @param{import("sequelize").QueryInterface} queryInterface + * @return {Promise} + */ + async up(queryInterface) { + const createdAt = new Date(); + const updatedAt = new Date(); + + /** @type {Map} */ + const idMap = new Map(); + + /** + * @param {string} key + * @return {string} + */ + function getId(key) { + if (idMap.has(key)) { + return idMap.get(key); + } + const id = uuid(); + idMap.set(key, id); + return id; + } + + /** + * @param {string} name + */ + function createPermissions(name) { + return [ + { + id: getId(`CREATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `CREATE_${name.toUpperCase()}`, + }, + { + id: getId(`READ_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `READ_${name.toUpperCase()}`, + }, + { + id: getId(`UPDATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `UPDATE_${name.toUpperCase()}`, + }, + { + id: getId(`DELETE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `DELETE_${name.toUpperCase()}`, + }, + ]; + } + + const entities = ['categories']; + + const createdPermissions = entities.flatMap(createPermissions); + + // Add permissions to database + await queryInterface.bulkInsert('permissions', createdPermissions); + // Get permissions ids + const permissionsIds = createdPermissions.map((p) => p.id); + // Get admin role + const adminRole = await db.roles.findOne({ + where: { name: config.roles.admin }, + }); + + if (adminRole) { + // Add permissions to admin role if it exists + await adminRole.addPermissions(permissionsIds); + } + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete( + 'permissions', + entities.flatMap(createPermissions), + ); + }, +}; diff --git a/backend/src/index.js b/backend/src/index.js index 40be529..4c01276 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -37,6 +37,8 @@ const rolesRoutes = require('./routes/roles'); const permissionsRoutes = require('./routes/permissions'); +const categoriesRoutes = require('./routes/categories'); + const getBaseUrl = (url) => { if (!url) return ''; return url.endsWith('/api') ? url.slice(0, -4) : url; @@ -156,6 +158,12 @@ app.use( permissionsRoutes, ); +app.use( + '/api/categories', + passport.authenticate('jwt', { session: false }), + categoriesRoutes, +); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/categories.js b/backend/src/routes/categories.js new file mode 100644 index 0000000..1b5ad7a --- /dev/null +++ b/backend/src/routes/categories.js @@ -0,0 +1,438 @@ +const express = require('express'); + +const CategoriesService = require('../services/categories'); +const CategoriesDBApi = require('../db/api/categories'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('categories')); + +/** + * @swagger + * components: + * schemas: + * Categories: + * type: object + * properties: + + * name: + * type: string + * default: name + + */ + +/** + * @swagger + * tags: + * name: Categories + * description: The Categories managing API + */ + +/** + * @swagger + * /api/categories: + * post: + * security: + * - bearerAuth: [] + * tags: [Categories] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Categories" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Categories" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await CategoriesService.create( + req.body.data, + req.currentUser, + true, + link.host, + ); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Categories] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Categories" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Categories" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await CategoriesService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/categories/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Categories] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Categories" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Categories" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await CategoriesService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/categories/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Categories] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Categories" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await CategoriesService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/categories/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Categories] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Categories" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await CategoriesService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/categories: + * get: + * security: + * - bearerAuth: [] + * tags: [Categories] + * summary: Get all categories + * description: Get all categories + * responses: + * 200: + * description: Categories list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Categories" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await CategoriesDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'name']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/categories/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Categories] + * summary: Count all categories + * description: Count all categories + * responses: + * 200: + * description: Categories count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Categories" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await CategoriesDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/categories/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Categories] + * summary: Find all categories that match search criteria + * description: Find all categories that match search criteria + * responses: + * 200: + * description: Categories list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Categories" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await CategoriesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/categories/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Categories] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Categories" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await CategoriesDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/categories.js b/backend/src/services/categories.js new file mode 100644 index 0000000..a30fcd4 --- /dev/null +++ b/backend/src/services/categories.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const CategoriesDBApi = require('../db/api/categories'); +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 CategoriesService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await CategoriesDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await CategoriesDBApi.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 categories = await CategoriesDBApi.findBy({ id }, { transaction }); + + if (!categories) { + throw new ValidationError('categoriesNotFound'); + } + + const updatedCategories = await CategoriesDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedCategories; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await CategoriesDBApi.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 CategoriesDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/search.js b/backend/src/services/search.js index 946abc2..2cd4b22 100644 --- a/backend/src/services/search.js +++ b/backend/src/services/search.js @@ -48,6 +48,8 @@ module.exports = class SearchService { discussion_boards: ['topic'], instructors: ['qualifications'], + + categories: ['name'], }; const columnsInt = { analytics: [ diff --git a/frontend/json/runtimeError.json b/frontend/json/runtimeError.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/frontend/json/runtimeError.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/frontend/src/components/Categories/CardCategories.tsx b/frontend/src/components/Categories/CardCategories.tsx new file mode 100644 index 0000000..c2a6a19 --- /dev/null +++ b/frontend/src/components/Categories/CardCategories.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + categories: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardCategories = ({ + categories, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CATEGORIES'); + + return ( +
+ {loading && } +
    + {!loading && + categories.map((item, index) => ( +
  • +
    + + {item.name} + + +
    + +
    +
    +
    +
    +
    Name
    +
    +
    {item.name}
    +
    +
    +
    +
  • + ))} + {!loading && categories.length === 0 && ( +
    +

    No data to display

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

Name

+

{item.name}

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

No data to display

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

Are you sure you want to delete this item?

+
+ + {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleCategories; diff --git a/frontend/src/components/Categories/configureCategoriesCols.tsx b/frontend/src/components/Categories/configureCategoriesCols.tsx new file mode 100644 index 0000000..a0ff001 --- /dev/null +++ b/frontend/src/components/Categories/configureCategoriesCols.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_CATEGORIES'); + + return [ + { + field: 'name', + headerName: 'Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/WebPageComponents/Footer.tsx b/frontend/src/components/WebPageComponents/Footer.tsx index 081158b..78d5af7 100644 --- a/frontend/src/components/WebPageComponents/Footer.tsx +++ b/frontend/src/components/WebPageComponents/Footer.tsx @@ -18,7 +18,7 @@ export default function WebSiteFooter({ const borders = useAppSelector((state) => state.style.borders); const websiteHeder = useAppSelector((state) => state.style.websiteHeder); - const style = FooterStyle.WITH_PROJECT_NAME; + const style = FooterStyle.WITH_PAGES; const design = FooterDesigns.DESIGN_DIVERSITY; diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index c5ac6a4..1546223 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -86,6 +86,14 @@ const menuAside: MenuAsideItem[] = [ : icon.mdiTable, permissions: 'READ_PERMISSIONS', }, + { + href: '/categories/categories-list', + label: 'Categories', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiTable ? icon.mdiTable : icon.mdiTable, + permissions: 'READ_CATEGORIES', + }, { href: '/profile', label: 'Profile', diff --git a/frontend/src/pages/categories/[categoriesId].tsx b/frontend/src/pages/categories/[categoriesId].tsx new file mode 100644 index 0000000..3e6238e --- /dev/null +++ b/frontend/src/pages/categories/[categoriesId].tsx @@ -0,0 +1,126 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/categories/categoriesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditCategories = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + name: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { categories } = useAppSelector((state) => state.categories); + + const { categoriesId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: categoriesId })); + }, [categoriesId]); + + useEffect(() => { + if (typeof categories === 'object') { + setInitialValues(categories); + } + }, [categories]); + + useEffect(() => { + if (typeof categories === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = categories[el]), + ); + + setInitialValues(newInitialVal); + } + }, [categories]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: categoriesId, data })); + await router.push('/categories/categories-list'); + }; + + return ( + <> + + {getPageTitle('Edit categories')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + router.push('/categories/categories-list')} + /> + + +
+
+
+ + ); +}; + +EditCategories.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditCategories; diff --git a/frontend/src/pages/categories/categories-edit.tsx b/frontend/src/pages/categories/categories-edit.tsx new file mode 100644 index 0000000..d311e63 --- /dev/null +++ b/frontend/src/pages/categories/categories-edit.tsx @@ -0,0 +1,124 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/categories/categoriesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditCategoriesPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + name: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { categories } = useAppSelector((state) => state.categories); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof categories === 'object') { + setInitialValues(categories); + } + }, [categories]); + + useEffect(() => { + if (typeof categories === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach( + (el) => (newInitialVal[el] = categories[el]), + ); + setInitialValues(newInitialVal); + } + }, [categories]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/categories/categories-list'); + }; + + return ( + <> + + {getPageTitle('Edit categories')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + router.push('/categories/categories-list')} + /> + + +
+
+
+ + ); +}; + +EditCategoriesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditCategoriesPage; diff --git a/frontend/src/pages/categories/categories-list.tsx b/frontend/src/pages/categories/categories-list.tsx new file mode 100644 index 0000000..31d2c0b --- /dev/null +++ b/frontend/src/pages/categories/categories-list.tsx @@ -0,0 +1,162 @@ +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 TableCategories from '../../components/Categories/TableCategories'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/categories/categoriesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const CategoriesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{ label: 'Name', title: 'name' }]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_CATEGORIES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getCategoriesCSV = async () => { + const response = await axios({ + url: '/categories?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 = 'categoriesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Categories')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+
+ + + + +
+ + + + + ); +}; + +CategoriesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default CategoriesTablesPage; diff --git a/frontend/src/pages/categories/categories-new.tsx b/frontend/src/pages/categories/categories-new.tsx new file mode 100644 index 0000000..591deb2 --- /dev/null +++ b/frontend/src/pages/categories/categories-new.tsx @@ -0,0 +1,98 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/categories/categoriesSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + name: '', +}; + +const CategoriesNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/categories/categories-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + router.push('/categories/categories-list')} + /> + + +
+
+
+ + ); +}; + +CategoriesNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default CategoriesNew; diff --git a/frontend/src/pages/categories/categories-table.tsx b/frontend/src/pages/categories/categories-table.tsx new file mode 100644 index 0000000..772a0fd --- /dev/null +++ b/frontend/src/pages/categories/categories-table.tsx @@ -0,0 +1,161 @@ +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 TableCategories from '../../components/Categories/TableCategories'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/categories/categoriesSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const CategoriesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{ label: 'Name', title: 'name' }]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_CATEGORIES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getCategoriesCSV = async () => { + const response = await axios({ + url: '/categories?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 = 'categoriesCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Categories')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+
+ + + +
+ + + + + ); +}; + +CategoriesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default CategoriesTablesPage; diff --git a/frontend/src/pages/categories/categories-view.tsx b/frontend/src/pages/categories/categories-view.tsx new file mode 100644 index 0000000..ad4bb7e --- /dev/null +++ b/frontend/src/pages/categories/categories-view.tsx @@ -0,0 +1,83 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/categories/categoriesSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const CategoriesView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { categories } = useAppSelector((state) => state.categories); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View categories')} + + + + + + +
+

Name

+

{categories?.name}

+
+ + + + router.push('/categories/categories-list')} + /> +
+
+ + ); +}; + +CategoriesView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default CategoriesView; diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 74c0aef..286faf0 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -32,6 +32,7 @@ const Dashboard = () => { const [students, setStudents] = React.useState('Loading...'); const [roles, setRoles] = React.useState('Loading...'); const [permissions, setPermissions] = React.useState('Loading...'); + const [categories, setCategories] = React.useState('Loading...'); const [widgetsRole, setWidgetsRole] = React.useState({ role: { value: '', label: '' }, @@ -52,6 +53,7 @@ const Dashboard = () => { 'students', 'roles', 'permissions', + 'categories', ]; const fns = [ setUsers, @@ -63,6 +65,7 @@ const Dashboard = () => { setStudents, setRoles, setPermissions, + setCategories, ]; const requests = entities.map((entity, index) => { @@ -458,6 +461,38 @@ const Dashboard = () => { )} + + {hasPermission(currentUser, 'READ_CATEGORIES') && ( + +
+
+
+
+ Categories +
+
+ {categories} +
+
+
+ +
+
+
+ + )} diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index ecc432f..75dd66c 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -139,7 +139,7 @@ export default function WebSite() { { + const { id, query } = data; + const result = await axios.get(`categories${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'categories/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('categories/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'categories/deleteCategories', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`categories/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'categories/createCategories', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('categories', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'categories/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('categories/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'categories/updateCategories', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`categories/${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 categoriesSlice = createSlice({ + name: 'categories', + 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.categories = action.payload.rows; + state.count = action.payload.count; + } else { + state.categories = 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, 'Categories has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Categories'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Categories'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Categories'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Categories has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = categoriesSlice.actions; + +export default categoriesSlice.reducer; diff --git a/frontend/src/stores/store.ts b/frontend/src/stores/store.ts index aaf78cc..741e87c 100644 --- a/frontend/src/stores/store.ts +++ b/frontend/src/stores/store.ts @@ -13,6 +13,7 @@ import instructorsSlice from './instructors/instructorsSlice'; import studentsSlice from './students/studentsSlice'; import rolesSlice from './roles/rolesSlice'; import permissionsSlice from './permissions/permissionsSlice'; +import categoriesSlice from './categories/categoriesSlice'; export const store = configureStore({ reducer: { @@ -30,6 +31,7 @@ export const store = configureStore({ students: studentsSlice, roles: rolesSlice, permissions: permissionsSlice, + categories: categoriesSlice, }, }); From 0296244a80115df0044b423e00c5baba6fa275e2 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 2 Apr 2025 10:04:26 +0000 Subject: [PATCH 2/4] ver #2 --- backend/src/db/api/categories.js | 4 + backend/src/db/api/tasks.js | 288 +++++++++++ backend/src/db/migrations/1743588081293.js | 72 +++ backend/src/db/migrations/1743588145846.js | 47 ++ backend/src/db/migrations/1743588190191.js | 52 ++ backend/src/db/models/categories.js | 8 + backend/src/db/models/tasks.js | 57 +++ .../db/seeders/20200430130760-user-roles.js | 26 + .../db/seeders/20231127130745-sample-data.js | 359 ++++++++++++- backend/src/db/seeders/20250402100121.js | 87 ++++ backend/src/index.js | 8 + backend/src/routes/tasks.js | 433 ++++++++++++++++ backend/src/services/search.js | 2 + backend/src/services/tasks.js | 114 +++++ frontend/src/components/Tasks/CardTasks.tsx | 116 +++++ frontend/src/components/Tasks/ListTasks.tsx | 97 ++++ frontend/src/components/Tasks/TableTasks.tsx | 481 ++++++++++++++++++ .../components/Tasks/configureTasksCols.tsx | 93 ++++ .../components/WebPageComponents/Footer.tsx | 2 +- frontend/src/helpers/dataFormatter.js | 19 + frontend/src/menuAside.ts | 8 + .../src/pages/categories/categories-view.tsx | 35 ++ frontend/src/pages/dashboard.tsx | 35 ++ frontend/src/pages/index.tsx | 2 +- frontend/src/pages/tasks/[tasksId].tsx | 137 +++++ frontend/src/pages/tasks/tasks-edit.tsx | 135 +++++ frontend/src/pages/tasks/tasks-list.tsx | 164 ++++++ frontend/src/pages/tasks/tasks-new.tsx | 110 ++++ frontend/src/pages/tasks/tasks-table.tsx | 163 ++++++ frontend/src/pages/tasks/tasks-view.tsx | 87 ++++ frontend/src/pages/web_pages/home.tsx | 2 +- frontend/src/pages/web_pages/services.tsx | 2 +- frontend/src/stores/store.ts | 2 + frontend/src/stores/tasks/tasksSlice.ts | 236 +++++++++ 34 files changed, 3473 insertions(+), 10 deletions(-) create mode 100644 backend/src/db/api/tasks.js create mode 100644 backend/src/db/migrations/1743588081293.js create mode 100644 backend/src/db/migrations/1743588145846.js create mode 100644 backend/src/db/migrations/1743588190191.js create mode 100644 backend/src/db/models/tasks.js create mode 100644 backend/src/db/seeders/20250402100121.js create mode 100644 backend/src/routes/tasks.js create mode 100644 backend/src/services/tasks.js create mode 100644 frontend/src/components/Tasks/CardTasks.tsx create mode 100644 frontend/src/components/Tasks/ListTasks.tsx create mode 100644 frontend/src/components/Tasks/TableTasks.tsx create mode 100644 frontend/src/components/Tasks/configureTasksCols.tsx create mode 100644 frontend/src/pages/tasks/[tasksId].tsx create mode 100644 frontend/src/pages/tasks/tasks-edit.tsx create mode 100644 frontend/src/pages/tasks/tasks-list.tsx create mode 100644 frontend/src/pages/tasks/tasks-new.tsx create mode 100644 frontend/src/pages/tasks/tasks-table.tsx create mode 100644 frontend/src/pages/tasks/tasks-view.tsx create mode 100644 frontend/src/stores/tasks/tasksSlice.ts diff --git a/backend/src/db/api/categories.js b/backend/src/db/api/categories.js index 2c750d9..004025a 100644 --- a/backend/src/db/api/categories.js +++ b/backend/src/db/api/categories.js @@ -126,6 +126,10 @@ module.exports = class CategoriesDBApi { const output = categories.get({ plain: true }); + output.tasks_category = await categories.getTasks_category({ + transaction, + }); + return output; } diff --git a/backend/src/db/api/tasks.js b/backend/src/db/api/tasks.js new file mode 100644 index 0000000..eba01ca --- /dev/null +++ b/backend/src/db/api/tasks.js @@ -0,0 +1,288 @@ +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 TasksDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const tasks = await db.tasks.create( + { + id: data.id || undefined, + + name: data.name || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await tasks.setCategory(data.category || null, { + transaction, + }); + + return tasks; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const tasksData = data.map((item, index) => ({ + id: item.id || undefined, + + name: item.name || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const tasks = await db.tasks.bulkCreate(tasksData, { transaction }); + + // For each item created, replace relation files + + return tasks; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const tasks = await db.tasks.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + + updatePayload.updatedById = currentUser.id; + + await tasks.update(updatePayload, { transaction }); + + if (data.category !== undefined) { + await tasks.setCategory( + data.category, + + { transaction }, + ); + } + + return tasks; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const tasks = await db.tasks.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of tasks) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of tasks) { + await record.destroy({ transaction }); + } + }); + + return tasks; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const tasks = await db.tasks.findByPk(id, options); + + await tasks.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await tasks.destroy({ + transaction, + }); + + return tasks; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const tasks = await db.tasks.findOne({ where }, { transaction }); + + if (!tasks) { + return tasks; + } + + const output = tasks.get({ plain: true }); + + output.category = await tasks.getCategory({ + transaction, + }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = [ + { + model: db.categories, + as: 'category', + + where: filter.category + ? { + [Op.or]: [ + { + id: { + [Op.in]: filter.category + .split('|') + .map((term) => Utils.uuid(term)), + }, + }, + { + name: { + [Op.or]: filter.category + .split('|') + .map((term) => ({ [Op.iLike]: `%${term}%` })), + }, + }, + ], + } + : {}, + }, + ]; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('tasks', 'name', filter.name), + }; + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.tasks.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('tasks', 'name', query), + ], + }; + } + + const records = await db.tasks.findAll({ + attributes: ['id', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +}; diff --git a/backend/src/db/migrations/1743588081293.js b/backend/src/db/migrations/1743588081293.js new file mode 100644 index 0000000..f3b1303 --- /dev/null +++ b/backend/src/db/migrations/1743588081293.js @@ -0,0 +1,72 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.createTable( + 'tasks', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.dropTable('tasks', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1743588145846.js b/backend/src/db/migrations/1743588145846.js new file mode 100644 index 0000000..4c6f76a --- /dev/null +++ b/backend/src/db/migrations/1743588145846.js @@ -0,0 +1,47 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'tasks', + 'name', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('tasks', 'name', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1743588190191.js b/backend/src/db/migrations/1743588190191.js new file mode 100644 index 0000000..88c3732 --- /dev/null +++ b/backend/src/db/migrations/1743588190191.js @@ -0,0 +1,52 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'tasks', + 'categoryId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'categories', + key: 'id', + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('tasks', 'categoryId', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/models/categories.js b/backend/src/db/models/categories.js index d0fb8ae..b0f9307 100644 --- a/backend/src/db/models/categories.js +++ b/backend/src/db/models/categories.js @@ -34,6 +34,14 @@ module.exports = function (sequelize, DataTypes) { categories.associate = (db) => { /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + db.categories.hasMany(db.tasks, { + as: 'tasks_category', + foreignKey: { + name: 'categoryId', + }, + constraints: false, + }); + //end loop db.categories.belongsTo(db.users, { diff --git a/backend/src/db/models/tasks.js b/backend/src/db/models/tasks.js new file mode 100644 index 0000000..cd32262 --- /dev/null +++ b/backend/src/db/models/tasks.js @@ -0,0 +1,57 @@ +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 tasks = sequelize.define( + 'tasks', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + name: { + type: DataTypes.TEXT, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + tasks.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.tasks.belongsTo(db.categories, { + as: 'category', + foreignKey: { + name: 'categoryId', + }, + constraints: false, + }); + + db.tasks.belongsTo(db.users, { + as: 'createdBy', + }); + + db.tasks.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return tasks; +}; diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.js index 080bd3d..3908a6c 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.js +++ b/backend/src/db/seeders/20200430130760-user-roles.js @@ -112,6 +112,7 @@ module.exports = { 'roles', 'permissions', 'categories', + 'tasks', , ]; await queryInterface.bulkInsert( @@ -991,6 +992,31 @@ primary key ("roles_permissionsId", "permissionId") permissionId: getId('DELETE_CATEGORIES'), }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_TASKS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_TASKS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_TASKS'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_TASKS'), + }, + { createdAt, updatedAt, diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index 72f7b27..de06bd6 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -15,6 +15,8 @@ const Students = db.students; const Categories = db.categories; +const Tasks = db.tasks; + const AnalyticsData = [ { // type code here for "relation_many" field @@ -57,6 +59,34 @@ const AnalyticsData = [ instructor_performance: 90, }, + + { + // type code here for "relation_many" field + + // type code here for "relation_many" field + + // type code here for "relation_many" field + + engagement_rate: 81.5, + + completion_rate: 82, + + instructor_performance: 87.5, + }, + + { + // type code here for "relation_many" field + + // type code here for "relation_many" field + + // type code here for "relation_many" field + + engagement_rate: 77, + + completion_rate: 79.5, + + instructor_performance: 86, + }, ]; const CoursesData = [ @@ -101,6 +131,34 @@ const CoursesData = [ // type code here for "relation_many" field }, + + { + title: 'Graphic Design Basics', + + description: 'Introduction to graphic design principles and tools.', + + // type code here for "relation_many" field + + // type code here for "relation_many" field + + // type code here for "files" field + + // type code here for "relation_many" field + }, + + { + title: 'Data Science Essentials', + + description: 'Understand the fundamentals of data science and analytics.', + + // type code here for "relation_many" field + + // type code here for "relation_many" field + + // type code here for "files" field + + // type code here for "relation_many" field + }, ]; const DiscussionBoardsData = [ @@ -127,6 +185,22 @@ const DiscussionBoardsData = [ // type code here for "relation_many" field }, + + { + // type code here for "relation_one" field + + topic: 'Design Tools Overview', + + // type code here for "relation_many" field + }, + + { + // type code here for "relation_one" field + + topic: 'Data Analysis Techniques', + + // type code here for "relation_many" field + }, ]; const EnrollmentsData = [ @@ -135,7 +209,7 @@ const EnrollmentsData = [ // type code here for "relation_one" field - payment_status: 'Pending', + payment_status: 'Completed', enrollment_date: new Date('2023-01-15T10:00:00Z'), }, @@ -145,7 +219,7 @@ const EnrollmentsData = [ // type code here for "relation_one" field - payment_status: 'Completed', + payment_status: 'Failed', enrollment_date: new Date('2023-02-20T11:30:00Z'), }, @@ -155,10 +229,30 @@ const EnrollmentsData = [ // type code here for "relation_one" field - payment_status: 'Pending', + payment_status: 'Failed', enrollment_date: new Date('2023-03-10T09:45:00Z'), }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + payment_status: 'Completed', + + enrollment_date: new Date('2023-04-05T14:20:00Z'), + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_one" field + + payment_status: 'Pending', + + enrollment_date: new Date('2023-05-25T16:00:00Z'), + }, ]; const InstructorsData = [ @@ -189,8 +283,28 @@ const InstructorsData = [ // type code here for "relation_many" field + availability: false, + }, + + { + // type code here for "relation_one" field + + qualifications: 'MBA in Marketing', + + // type code here for "relation_many" field + availability: true, }, + + { + // type code here for "relation_one" field + + qualifications: 'MSc in Data Science', + + // type code here for "relation_many" field + + availability: false, + }, ]; const StudentsData = [ @@ -223,19 +337,79 @@ const StudentsData = [ average_grade: 88, }, + + { + // type code here for "relation_one" field + + // type code here for "relation_many" field + + // type code here for "relation_many" field + + average_grade: 92, + }, + + { + // type code here for "relation_one" field + + // type code here for "relation_many" field + + // type code here for "relation_many" field + + average_grade: 87.5, + }, ]; const CategoriesData = [ { - name: 'Robert Koch', + name: 'August Kekule', }, { - name: 'Edwin Hubble', + name: 'Sheldon Glashow', }, { - name: 'Robert Koch', + name: 'Andreas Vesalius', + }, + + { + name: 'Edward O. Wilson', + }, + + { + name: 'John Dalton', + }, +]; + +const TasksData = [ + { + name: 'Lynn Margulis', + + // type code here for "relation_one" field + }, + + { + name: 'James Clerk Maxwell', + + // type code here for "relation_one" field + }, + + { + name: 'Charles Sherrington', + + // type code here for "relation_one" field + }, + + { + name: 'Gertrude Belle Elion', + + // type code here for "relation_one" field + }, + + { + name: 'Franz Boas', + + // type code here for "relation_one" field }, ]; @@ -286,6 +460,28 @@ async function associateDiscussionBoardWithCourse() { if (DiscussionBoard2?.setCourse) { await DiscussionBoard2.setCourse(relatedCourse2); } + + const relatedCourse3 = await Courses.findOne({ + offset: Math.floor(Math.random() * (await Courses.count())), + }); + const DiscussionBoard3 = await DiscussionBoards.findOne({ + order: [['id', 'ASC']], + offset: 3, + }); + if (DiscussionBoard3?.setCourse) { + await DiscussionBoard3.setCourse(relatedCourse3); + } + + const relatedCourse4 = await Courses.findOne({ + offset: Math.floor(Math.random() * (await Courses.count())), + }); + const DiscussionBoard4 = await DiscussionBoards.findOne({ + order: [['id', 'ASC']], + offset: 4, + }); + if (DiscussionBoard4?.setCourse) { + await DiscussionBoard4.setCourse(relatedCourse4); + } } // Similar logic for "relation_many" @@ -323,6 +519,28 @@ async function associateEnrollmentWithStudent() { if (Enrollment2?.setStudent) { await Enrollment2.setStudent(relatedStudent2); } + + const relatedStudent3 = await Students.findOne({ + offset: Math.floor(Math.random() * (await Students.count())), + }); + const Enrollment3 = await Enrollments.findOne({ + order: [['id', 'ASC']], + offset: 3, + }); + if (Enrollment3?.setStudent) { + await Enrollment3.setStudent(relatedStudent3); + } + + const relatedStudent4 = await Students.findOne({ + offset: Math.floor(Math.random() * (await Students.count())), + }); + const Enrollment4 = await Enrollments.findOne({ + order: [['id', 'ASC']], + offset: 4, + }); + if (Enrollment4?.setStudent) { + await Enrollment4.setStudent(relatedStudent4); + } } async function associateEnrollmentWithCourse() { @@ -358,6 +576,28 @@ async function associateEnrollmentWithCourse() { if (Enrollment2?.setCourse) { await Enrollment2.setCourse(relatedCourse2); } + + const relatedCourse3 = await Courses.findOne({ + offset: Math.floor(Math.random() * (await Courses.count())), + }); + const Enrollment3 = await Enrollments.findOne({ + order: [['id', 'ASC']], + offset: 3, + }); + if (Enrollment3?.setCourse) { + await Enrollment3.setCourse(relatedCourse3); + } + + const relatedCourse4 = await Courses.findOne({ + offset: Math.floor(Math.random() * (await Courses.count())), + }); + const Enrollment4 = await Enrollments.findOne({ + order: [['id', 'ASC']], + offset: 4, + }); + if (Enrollment4?.setCourse) { + await Enrollment4.setCourse(relatedCourse4); + } } async function associateInstructorWithUser() { @@ -393,6 +633,28 @@ async function associateInstructorWithUser() { if (Instructor2?.setUser) { await Instructor2.setUser(relatedUser2); } + + const relatedUser3 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Instructor3 = await Instructors.findOne({ + order: [['id', 'ASC']], + offset: 3, + }); + if (Instructor3?.setUser) { + await Instructor3.setUser(relatedUser3); + } + + const relatedUser4 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Instructor4 = await Instructors.findOne({ + order: [['id', 'ASC']], + offset: 4, + }); + if (Instructor4?.setUser) { + await Instructor4.setUser(relatedUser4); + } } // Similar logic for "relation_many" @@ -430,12 +692,91 @@ async function associateStudentWithUser() { if (Student2?.setUser) { await Student2.setUser(relatedUser2); } + + const relatedUser3 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Student3 = await Students.findOne({ + order: [['id', 'ASC']], + offset: 3, + }); + if (Student3?.setUser) { + await Student3.setUser(relatedUser3); + } + + const relatedUser4 = await Users.findOne({ + offset: Math.floor(Math.random() * (await Users.count())), + }); + const Student4 = await Students.findOne({ + order: [['id', 'ASC']], + offset: 4, + }); + if (Student4?.setUser) { + await Student4.setUser(relatedUser4); + } } // Similar logic for "relation_many" // Similar logic for "relation_many" +async function associateTaskWithCategory() { + const relatedCategory0 = await Categories.findOne({ + offset: Math.floor(Math.random() * (await Categories.count())), + }); + const Task0 = await Tasks.findOne({ + order: [['id', 'ASC']], + offset: 0, + }); + if (Task0?.setCategory) { + await Task0.setCategory(relatedCategory0); + } + + const relatedCategory1 = await Categories.findOne({ + offset: Math.floor(Math.random() * (await Categories.count())), + }); + const Task1 = await Tasks.findOne({ + order: [['id', 'ASC']], + offset: 1, + }); + if (Task1?.setCategory) { + await Task1.setCategory(relatedCategory1); + } + + const relatedCategory2 = await Categories.findOne({ + offset: Math.floor(Math.random() * (await Categories.count())), + }); + const Task2 = await Tasks.findOne({ + order: [['id', 'ASC']], + offset: 2, + }); + if (Task2?.setCategory) { + await Task2.setCategory(relatedCategory2); + } + + const relatedCategory3 = await Categories.findOne({ + offset: Math.floor(Math.random() * (await Categories.count())), + }); + const Task3 = await Tasks.findOne({ + order: [['id', 'ASC']], + offset: 3, + }); + if (Task3?.setCategory) { + await Task3.setCategory(relatedCategory3); + } + + const relatedCategory4 = await Categories.findOne({ + offset: Math.floor(Math.random() * (await Categories.count())), + }); + const Task4 = await Tasks.findOne({ + order: [['id', 'ASC']], + offset: 4, + }); + if (Task4?.setCategory) { + await Task4.setCategory(relatedCategory4); + } +} + module.exports = { up: async (queryInterface, Sequelize) => { await Analytics.bulkCreate(AnalyticsData); @@ -452,6 +793,8 @@ module.exports = { await Categories.bulkCreate(CategoriesData); + await Tasks.bulkCreate(TasksData); + await Promise.all([ // Similar logic for "relation_many" @@ -484,6 +827,8 @@ module.exports = { // Similar logic for "relation_many" // Similar logic for "relation_many" + + await associateTaskWithCategory(), ]); }, @@ -501,5 +846,7 @@ module.exports = { await queryInterface.bulkDelete('students', null, {}); await queryInterface.bulkDelete('categories', null, {}); + + await queryInterface.bulkDelete('tasks', null, {}); }, }; diff --git a/backend/src/db/seeders/20250402100121.js b/backend/src/db/seeders/20250402100121.js new file mode 100644 index 0000000..ac601f7 --- /dev/null +++ b/backend/src/db/seeders/20250402100121.js @@ -0,0 +1,87 @@ +const { v4: uuid } = require('uuid'); +const db = require('../models'); +const Sequelize = require('sequelize'); +const config = require('../../config'); + +module.exports = { + /** + * @param{import("sequelize").QueryInterface} queryInterface + * @return {Promise} + */ + async up(queryInterface) { + const createdAt = new Date(); + const updatedAt = new Date(); + + /** @type {Map} */ + const idMap = new Map(); + + /** + * @param {string} key + * @return {string} + */ + function getId(key) { + if (idMap.has(key)) { + return idMap.get(key); + } + const id = uuid(); + idMap.set(key, id); + return id; + } + + /** + * @param {string} name + */ + function createPermissions(name) { + return [ + { + id: getId(`CREATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `CREATE_${name.toUpperCase()}`, + }, + { + id: getId(`READ_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `READ_${name.toUpperCase()}`, + }, + { + id: getId(`UPDATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `UPDATE_${name.toUpperCase()}`, + }, + { + id: getId(`DELETE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `DELETE_${name.toUpperCase()}`, + }, + ]; + } + + const entities = ['tasks']; + + const createdPermissions = entities.flatMap(createPermissions); + + // Add permissions to database + await queryInterface.bulkInsert('permissions', createdPermissions); + // Get permissions ids + const permissionsIds = createdPermissions.map((p) => p.id); + // Get admin role + const adminRole = await db.roles.findOne({ + where: { name: config.roles.admin }, + }); + + if (adminRole) { + // Add permissions to admin role if it exists + await adminRole.addPermissions(permissionsIds); + } + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete( + 'permissions', + entities.flatMap(createPermissions), + ); + }, +}; diff --git a/backend/src/index.js b/backend/src/index.js index 4c01276..7212801 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -39,6 +39,8 @@ const permissionsRoutes = require('./routes/permissions'); const categoriesRoutes = require('./routes/categories'); +const tasksRoutes = require('./routes/tasks'); + const getBaseUrl = (url) => { if (!url) return ''; return url.endsWith('/api') ? url.slice(0, -4) : url; @@ -164,6 +166,12 @@ app.use( categoriesRoutes, ); +app.use( + '/api/tasks', + passport.authenticate('jwt', { session: false }), + tasksRoutes, +); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js new file mode 100644 index 0000000..c7dc43d --- /dev/null +++ b/backend/src/routes/tasks.js @@ -0,0 +1,433 @@ +const express = require('express'); + +const TasksService = require('../services/tasks'); +const TasksDBApi = require('../db/api/tasks'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('tasks')); + +/** + * @swagger + * components: + * schemas: + * Tasks: + * type: object + * properties: + + * name: + * type: string + * default: name + + */ + +/** + * @swagger + * tags: + * name: Tasks + * description: The Tasks managing API + */ + +/** + * @swagger + * /api/tasks: + * post: + * security: + * - bearerAuth: [] + * tags: [Tasks] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Tasks" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tasks" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await TasksService.create(req.body.data, req.currentUser, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Tasks] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Tasks" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tasks" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await TasksService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tasks/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Tasks] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Tasks" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tasks" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await TasksService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tasks/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Tasks] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tasks" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await TasksService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tasks/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Tasks] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tasks" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await TasksService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tasks: + * get: + * security: + * - bearerAuth: [] + * tags: [Tasks] + * summary: Get all tasks + * description: Get all tasks + * responses: + * 200: + * description: Tasks list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Tasks" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await TasksDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'name']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/tasks/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Tasks] + * summary: Count all tasks + * description: Count all tasks + * responses: + * 200: + * description: Tasks count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Tasks" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await TasksDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/tasks/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Tasks] + * summary: Find all tasks that match search criteria + * description: Find all tasks that match search criteria + * responses: + * 200: + * description: Tasks list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Tasks" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await TasksDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/tasks/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Tasks] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Tasks" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await TasksDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/search.js b/backend/src/services/search.js index 2cd4b22..936ebb2 100644 --- a/backend/src/services/search.js +++ b/backend/src/services/search.js @@ -50,6 +50,8 @@ module.exports = class SearchService { instructors: ['qualifications'], categories: ['name'], + + tasks: ['name'], }; const columnsInt = { analytics: [ diff --git a/backend/src/services/tasks.js b/backend/src/services/tasks.js new file mode 100644 index 0000000..4af1d3d --- /dev/null +++ b/backend/src/services/tasks.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const TasksDBApi = require('../db/api/tasks'); +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 TasksService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await TasksDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await TasksDBApi.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 tasks = await TasksDBApi.findBy({ id }, { transaction }); + + if (!tasks) { + throw new ValidationError('tasksNotFound'); + } + + const updatedTasks = await TasksDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedTasks; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await TasksDBApi.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 TasksDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/frontend/src/components/Tasks/CardTasks.tsx b/frontend/src/components/Tasks/CardTasks.tsx new file mode 100644 index 0000000..18dd578 --- /dev/null +++ b/frontend/src/components/Tasks/CardTasks.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + tasks: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardTasks = ({ + tasks, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TASKS'); + + return ( +
+ {loading && } +
    + {!loading && + tasks.map((item, index) => ( +
  • +
    + + {item.name} + + +
    + +
    +
    +
    +
    +
    Name
    +
    +
    {item.name}
    +
    +
    + +
    +
    + Category +
    +
    +
    + {dataFormatter.categoriesOneListFormatter(item.category)} +
    +
    +
    +
    +
  • + ))} + {!loading && tasks.length === 0 && ( +
    +

    No data to display

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

Name

+

{item.name}

+
+ +
+

Category

+

+ {dataFormatter.categoriesOneListFormatter(item.category)} +

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

No data to display

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

Are you sure you want to delete this item?

+
+ + {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleTasks; diff --git a/frontend/src/components/Tasks/configureTasksCols.tsx b/frontend/src/components/Tasks/configureTasksCols.tsx new file mode 100644 index 0000000..7977f97 --- /dev/null +++ b/frontend/src/components/Tasks/configureTasksCols.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_TASKS'); + + return [ + { + field: 'name', + headerName: 'Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'category', + headerName: 'Category', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('categories'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ + , + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/WebPageComponents/Footer.tsx b/frontend/src/components/WebPageComponents/Footer.tsx index 78d5af7..081158b 100644 --- a/frontend/src/components/WebPageComponents/Footer.tsx +++ b/frontend/src/components/WebPageComponents/Footer.tsx @@ -18,7 +18,7 @@ export default function WebSiteFooter({ const borders = useAppSelector((state) => state.style.borders); const websiteHeder = useAppSelector((state) => state.style.websiteHeder); - const style = FooterStyle.WITH_PAGES; + const style = FooterStyle.WITH_PROJECT_NAME; const design = FooterDesigns.DESIGN_DIVERSITY; diff --git a/frontend/src/helpers/dataFormatter.js b/frontend/src/helpers/dataFormatter.js index d47d2f9..e29d7a6 100644 --- a/frontend/src/helpers/dataFormatter.js +++ b/frontend/src/helpers/dataFormatter.js @@ -190,4 +190,23 @@ export default { if (!val) return ''; return { label: val.name, id: val.id }; }, + + categoriesManyListFormatter(val) { + if (!val || !val.length) return []; + return val.map((item) => item.name); + }, + categoriesOneListFormatter(val) { + if (!val) return ''; + return val.name; + }, + categoriesManyListFormatterEdit(val) { + if (!val || !val.length) return []; + return val.map((item) => { + return { id: item.id, label: item.name }; + }); + }, + categoriesOneListFormatterEdit(val) { + if (!val) return ''; + return { label: val.name, id: val.id }; + }, }; diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 1546223..ebf2b70 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -94,6 +94,14 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiTable ? icon.mdiTable : icon.mdiTable, permissions: 'READ_CATEGORIES', }, + { + href: '/tasks/tasks-list', + label: 'Tasks', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiTable ? icon.mdiTable : icon.mdiTable, + permissions: 'READ_TASKS', + }, { href: '/profile', label: 'Profile', diff --git a/frontend/src/pages/categories/categories-view.tsx b/frontend/src/pages/categories/categories-view.tsx index ad4bb7e..85d6ecb 100644 --- a/frontend/src/pages/categories/categories-view.tsx +++ b/frontend/src/pages/categories/categories-view.tsx @@ -59,6 +59,41 @@ const CategoriesView = () => {

{categories?.name}

+ <> +

Tasks Category

+ +
+ + + + + + + + {categories.tasks_category && + Array.isArray(categories.tasks_category) && + categories.tasks_category.map((item: any) => ( + + router.push(`/tasks/tasks-view/?id=${item.id}`) + } + > + + + ))} + +
Name
{item.name}
+
+ {!categories?.tasks_category?.length && ( +
No data
+ )} +
+ + { const [roles, setRoles] = React.useState('Loading...'); const [permissions, setPermissions] = React.useState('Loading...'); const [categories, setCategories] = React.useState('Loading...'); + const [tasks, setTasks] = React.useState('Loading...'); const [widgetsRole, setWidgetsRole] = React.useState({ role: { value: '', label: '' }, @@ -54,6 +55,7 @@ const Dashboard = () => { 'roles', 'permissions', 'categories', + 'tasks', ]; const fns = [ setUsers, @@ -66,6 +68,7 @@ const Dashboard = () => { setRoles, setPermissions, setCategories, + setTasks, ]; const requests = entities.map((entity, index) => { @@ -493,6 +496,38 @@ const Dashboard = () => { )} + + {hasPermission(currentUser, 'READ_TASKS') && ( + +
+
+
+
+ Tasks +
+
+ {tasks} +
+
+
+ +
+
+
+ + )} diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 75dd66c..ecc432f 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -139,7 +139,7 @@ export default function WebSite() { { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + name: '', + + category: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { tasks } = useAppSelector((state) => state.tasks); + + const { tasksId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: tasksId })); + }, [tasksId]); + + useEffect(() => { + if (typeof tasks === 'object') { + setInitialValues(tasks); + } + }, [tasks]); + + useEffect(() => { + if (typeof tasks === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = tasks[el])); + + setInitialValues(newInitialVal); + } + }, [tasks]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: tasksId, data })); + await router.push('/tasks/tasks-list'); + }; + + return ( + <> + + {getPageTitle('Edit tasks')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + router.push('/tasks/tasks-list')} + /> + + +
+
+
+ + ); +}; + +EditTasks.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditTasks; diff --git a/frontend/src/pages/tasks/tasks-edit.tsx b/frontend/src/pages/tasks/tasks-edit.tsx new file mode 100644 index 0000000..c3f1845 --- /dev/null +++ b/frontend/src/pages/tasks/tasks-edit.tsx @@ -0,0 +1,135 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/tasks/tasksSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditTasksPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + name: '', + + category: null, + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { tasks } = useAppSelector((state) => state.tasks); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof tasks === 'object') { + setInitialValues(tasks); + } + }, [tasks]); + + useEffect(() => { + if (typeof tasks === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = tasks[el])); + setInitialValues(newInitialVal); + } + }, [tasks]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/tasks/tasks-list'); + }; + + return ( + <> + + {getPageTitle('Edit tasks')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + router.push('/tasks/tasks-list')} + /> + + +
+
+
+ + ); +}; + +EditTasksPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditTasksPage; diff --git a/frontend/src/pages/tasks/tasks-list.tsx b/frontend/src/pages/tasks/tasks-list.tsx new file mode 100644 index 0000000..f85385a --- /dev/null +++ b/frontend/src/pages/tasks/tasks-list.tsx @@ -0,0 +1,164 @@ +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 TableTasks from '../../components/Tasks/TableTasks'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/tasks/tasksSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const TasksTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Name', title: 'name' }, + + { label: 'Category', title: 'category' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_TASKS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getTasksCSV = async () => { + const response = await axios({ + url: '/tasks?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 = 'tasksCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Tasks')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+
+ + + + +
+ + + + + ); +}; + +TasksTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default TasksTablesPage; diff --git a/frontend/src/pages/tasks/tasks-new.tsx b/frontend/src/pages/tasks/tasks-new.tsx new file mode 100644 index 0000000..9fb0bbf --- /dev/null +++ b/frontend/src/pages/tasks/tasks-new.tsx @@ -0,0 +1,110 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/tasks/tasksSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + name: '', + + category: '', +}; + +const TasksNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/tasks/tasks-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + router.push('/tasks/tasks-list')} + /> + + +
+
+
+ + ); +}; + +TasksNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default TasksNew; diff --git a/frontend/src/pages/tasks/tasks-table.tsx b/frontend/src/pages/tasks/tasks-table.tsx new file mode 100644 index 0000000..48a958a --- /dev/null +++ b/frontend/src/pages/tasks/tasks-table.tsx @@ -0,0 +1,163 @@ +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 TableTasks from '../../components/Tasks/TableTasks'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/tasks/tasksSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const TasksTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Name', title: 'name' }, + + { label: 'Category', title: 'category' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_TASKS'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getTasksCSV = async () => { + const response = await axios({ + url: '/tasks?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 = 'tasksCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Tasks')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+
+ + + +
+ + + + + ); +}; + +TasksTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default TasksTablesPage; diff --git a/frontend/src/pages/tasks/tasks-view.tsx b/frontend/src/pages/tasks/tasks-view.tsx new file mode 100644 index 0000000..c235cec --- /dev/null +++ b/frontend/src/pages/tasks/tasks-view.tsx @@ -0,0 +1,87 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/tasks/tasksSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const TasksView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { tasks } = useAppSelector((state) => state.tasks); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View tasks')} + + + + + + +
+

Name

+

{tasks?.name}

+
+ +
+

Category

+ +

{tasks?.category?.name ?? 'No data'}

+
+ + + + router.push('/tasks/tasks-list')} + /> +
+
+ + ); +}; + +TasksView.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default TasksView; diff --git a/frontend/src/pages/web_pages/home.tsx b/frontend/src/pages/web_pages/home.tsx index 4c6d5ae..e1801c4 100644 --- a/frontend/src/pages/web_pages/home.tsx +++ b/frontend/src/pages/web_pages/home.tsx @@ -139,7 +139,7 @@ export default function WebSite() { { + const { id, query } = data; + const result = await axios.get(`tasks${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'tasks/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('tasks/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'tasks/deleteTasks', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`tasks/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'tasks/createTasks', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('tasks', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'tasks/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('tasks/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'tasks/updateTasks', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`tasks/${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 tasksSlice = createSlice({ + name: 'tasks', + 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.tasks = action.payload.rows; + state.count = action.payload.count; + } else { + state.tasks = 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, 'Tasks has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Tasks'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Tasks'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Tasks'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Tasks has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = tasksSlice.actions; + +export default tasksSlice.reducer; From 181dc2bc283db6777ebd0cc4a0aea708dbaad085 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 2 Apr 2025 10:06:39 +0000 Subject: [PATCH 3/4] add kanban --- .../db/seeders/20231127130745-sample-data.js | 166 ++---------------- frontend/src/components/Tasks/TableTasks.tsx | 40 ++++- frontend/src/pages/tasks/tasks-list.tsx | 18 +- frontend/src/pages/tasks/tasks-table.tsx | 4 + frontend/src/pages/web_pages/services.tsx | 2 +- 5 files changed, 66 insertions(+), 164 deletions(-) diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index de06bd6..6f67541 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -73,20 +73,6 @@ const AnalyticsData = [ instructor_performance: 87.5, }, - - { - // type code here for "relation_many" field - - // type code here for "relation_many" field - - // type code here for "relation_many" field - - engagement_rate: 77, - - completion_rate: 79.5, - - instructor_performance: 86, - }, ]; const CoursesData = [ @@ -145,20 +131,6 @@ const CoursesData = [ // type code here for "relation_many" field }, - - { - title: 'Data Science Essentials', - - description: 'Understand the fundamentals of data science and analytics.', - - // type code here for "relation_many" field - - // type code here for "relation_many" field - - // type code here for "files" field - - // type code here for "relation_many" field - }, ]; const DiscussionBoardsData = [ @@ -193,14 +165,6 @@ const DiscussionBoardsData = [ // type code here for "relation_many" field }, - - { - // type code here for "relation_one" field - - topic: 'Data Analysis Techniques', - - // type code here for "relation_many" field - }, ]; const EnrollmentsData = [ @@ -209,7 +173,7 @@ const EnrollmentsData = [ // type code here for "relation_one" field - payment_status: 'Completed', + payment_status: 'Failed', enrollment_date: new Date('2023-01-15T10:00:00Z'), }, @@ -219,7 +183,7 @@ const EnrollmentsData = [ // type code here for "relation_one" field - payment_status: 'Failed', + payment_status: 'Pending', enrollment_date: new Date('2023-02-20T11:30:00Z'), }, @@ -229,7 +193,7 @@ const EnrollmentsData = [ // type code here for "relation_one" field - payment_status: 'Failed', + payment_status: 'Completed', enrollment_date: new Date('2023-03-10T09:45:00Z'), }, @@ -243,16 +207,6 @@ const EnrollmentsData = [ enrollment_date: new Date('2023-04-05T14:20:00Z'), }, - - { - // type code here for "relation_one" field - - // type code here for "relation_one" field - - payment_status: 'Pending', - - enrollment_date: new Date('2023-05-25T16:00:00Z'), - }, ]; const InstructorsData = [ @@ -283,7 +237,7 @@ const InstructorsData = [ // type code here for "relation_many" field - availability: false, + availability: true, }, { @@ -295,16 +249,6 @@ const InstructorsData = [ availability: true, }, - - { - // type code here for "relation_one" field - - qualifications: 'MSc in Data Science', - - // type code here for "relation_many" field - - availability: false, - }, ]; const StudentsData = [ @@ -347,67 +291,47 @@ const StudentsData = [ average_grade: 92, }, - - { - // type code here for "relation_one" field - - // type code here for "relation_many" field - - // type code here for "relation_many" field - - average_grade: 87.5, - }, ]; const CategoriesData = [ { - name: 'August Kekule', + name: 'Rudolf Virchow', }, { - name: 'Sheldon Glashow', + name: 'J. Robert Oppenheimer', }, { - name: 'Andreas Vesalius', + name: 'Leonard Euler', }, { - name: 'Edward O. Wilson', - }, - - { - name: 'John Dalton', + name: 'Hans Bethe', }, ]; const TasksData = [ { - name: 'Lynn Margulis', + name: 'Michael Faraday', // type code here for "relation_one" field }, { - name: 'James Clerk Maxwell', + name: 'Paul Dirac', // type code here for "relation_one" field }, { - name: 'Charles Sherrington', + name: 'Neils Bohr', // type code here for "relation_one" field }, { - name: 'Gertrude Belle Elion', - - // type code here for "relation_one" field - }, - - { - name: 'Franz Boas', + name: 'Emil Fischer', // type code here for "relation_one" field }, @@ -471,17 +395,6 @@ async function associateDiscussionBoardWithCourse() { if (DiscussionBoard3?.setCourse) { await DiscussionBoard3.setCourse(relatedCourse3); } - - const relatedCourse4 = await Courses.findOne({ - offset: Math.floor(Math.random() * (await Courses.count())), - }); - const DiscussionBoard4 = await DiscussionBoards.findOne({ - order: [['id', 'ASC']], - offset: 4, - }); - if (DiscussionBoard4?.setCourse) { - await DiscussionBoard4.setCourse(relatedCourse4); - } } // Similar logic for "relation_many" @@ -530,17 +443,6 @@ async function associateEnrollmentWithStudent() { if (Enrollment3?.setStudent) { await Enrollment3.setStudent(relatedStudent3); } - - const relatedStudent4 = await Students.findOne({ - offset: Math.floor(Math.random() * (await Students.count())), - }); - const Enrollment4 = await Enrollments.findOne({ - order: [['id', 'ASC']], - offset: 4, - }); - if (Enrollment4?.setStudent) { - await Enrollment4.setStudent(relatedStudent4); - } } async function associateEnrollmentWithCourse() { @@ -587,17 +489,6 @@ async function associateEnrollmentWithCourse() { if (Enrollment3?.setCourse) { await Enrollment3.setCourse(relatedCourse3); } - - const relatedCourse4 = await Courses.findOne({ - offset: Math.floor(Math.random() * (await Courses.count())), - }); - const Enrollment4 = await Enrollments.findOne({ - order: [['id', 'ASC']], - offset: 4, - }); - if (Enrollment4?.setCourse) { - await Enrollment4.setCourse(relatedCourse4); - } } async function associateInstructorWithUser() { @@ -644,17 +535,6 @@ async function associateInstructorWithUser() { if (Instructor3?.setUser) { await Instructor3.setUser(relatedUser3); } - - const relatedUser4 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Instructor4 = await Instructors.findOne({ - order: [['id', 'ASC']], - offset: 4, - }); - if (Instructor4?.setUser) { - await Instructor4.setUser(relatedUser4); - } } // Similar logic for "relation_many" @@ -703,17 +583,6 @@ async function associateStudentWithUser() { if (Student3?.setUser) { await Student3.setUser(relatedUser3); } - - const relatedUser4 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Student4 = await Students.findOne({ - order: [['id', 'ASC']], - offset: 4, - }); - if (Student4?.setUser) { - await Student4.setUser(relatedUser4); - } } // Similar logic for "relation_many" @@ -764,17 +633,6 @@ async function associateTaskWithCategory() { if (Task3?.setCategory) { await Task3.setCategory(relatedCategory3); } - - const relatedCategory4 = await Categories.findOne({ - offset: Math.floor(Math.random() * (await Categories.count())), - }); - const Task4 = await Tasks.findOne({ - order: [['id', 'ASC']], - offset: 4, - }); - if (Task4?.setCategory) { - await Task4.setCategory(relatedCategory4); - } } module.exports = { diff --git a/frontend/src/components/Tasks/TableTasks.tsx b/frontend/src/components/Tasks/TableTasks.tsx index 01419d1..bb922d1 100644 --- a/frontend/src/components/Tasks/TableTasks.tsx +++ b/frontend/src/components/Tasks/TableTasks.tsx @@ -20,6 +20,9 @@ import _ from 'lodash'; import dataFormatter from '../../helpers/dataFormatter'; import { dataGridStyles } from '../../styles'; +import KanbanBoard from '../KanbanBoard/KanbanBoard'; +import axios from 'axios'; + const perPage = 10; const TableSampleTasks = ({ @@ -46,6 +49,12 @@ const TableSampleTasks = ({ }, ]); + const [kanbanColumns, setKanbanColumns] = useState | null>(null); + const [kanbanFilters, setKanbanFilters] = useState(''); + const { tasks, loading, @@ -98,6 +107,17 @@ const TableSampleTasks = ({ setIsModalTrashActive(false); }; + useEffect(() => { + axios + .get('/categories/autocomplete?limit=100') + .then((res) => { + setKanbanColumns(res.data); + }) + .catch((err) => { + console.error('Error fetching kanban columns:', err); + }); + }, []); + const handleDeleteModalAction = (id: string) => { setId(id); setIsModalTrashActive(true); @@ -146,12 +166,16 @@ const TableSampleTasks = ({ } else { loadData(0, ''); + setKanbanFilters(''); + setFilterItems(newItems); } }; const handleSubmit = () => { loadData(0, generateFilterRequests); + + setKanbanFilters(generateFilterRequests); }; const handleChange = (id) => (e) => { @@ -171,6 +195,8 @@ const TableSampleTasks = ({ const handleReset = () => { setFilterItems([]); loadData(0, ''); + + setKanbanFilters(''); }; const onPageChange = (page: number) => { @@ -461,7 +487,19 @@ const TableSampleTasks = ({

Are you sure you want to delete this item?

- {dataGrid} + {!showGrid && kanbanColumns && ( + + )} + + {showGrid && dataGrid} {selectedRows.length > 0 && createPortal( diff --git a/frontend/src/pages/tasks/tasks-list.tsx b/frontend/src/pages/tasks/tasks-list.tsx index f85385a..3bc3447 100644 --- a/frontend/src/pages/tasks/tasks-list.tsx +++ b/frontend/src/pages/tasks/tasks-list.tsx @@ -125,16 +125,18 @@ const TasksTablesPage = () => {
+ +
+ Switch to Table +
- - - + {
+ + + Back to kanban +
diff --git a/frontend/src/pages/web_pages/services.tsx b/frontend/src/pages/web_pages/services.tsx index 13df20d..e60495c 100644 --- a/frontend/src/pages/web_pages/services.tsx +++ b/frontend/src/pages/web_pages/services.tsx @@ -133,7 +133,7 @@ export default function WebSite() { Date: Wed, 2 Apr 2025 10:12:16 +0000 Subject: [PATCH 4/4] sidebar adjustments --- frontend/src/components/AsideMenuItem.tsx | 4 ++-- frontend/src/components/AsideMenuLayer.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/AsideMenuItem.tsx b/frontend/src/components/AsideMenuItem.tsx index c5a8b8a..65664f9 100644 --- a/frontend/src/components/AsideMenuItem.tsx +++ b/frontend/src/components/AsideMenuItem.tsx @@ -74,11 +74,11 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => { ); const componentClass = [ - 'flex cursor-pointer py-1.5 ', + 'flex cursor-pointer py-1 ', isDropdownList ? 'px-6 text-sm' : '', item.color ? getButtonColor(item.color, false, true) - : `${asideMenuItemStyle}`, + : `${asideMenuItemStyle} text-white`, isLinkActive ? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800` : '', diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 580877f..74cbab6 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -37,7 +37,7 @@ export default function AsideMenuLayer({ className={`${className} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`} >