From 392654669b74381054e909b3331a3b47743dc1ac Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 6 Apr 2026 12:13:39 +0000 Subject: [PATCH] Autosave: 20260406-121339 --- backend/src/db/api/categories.js | 526 +++++++----------- ...00100-add-category-code-and-unique-name.js | 107 ++++ backend/src/db/models/categories.js | 50 +- .../db/seeders/20231127130745-sample-data.js | 28 +- backend/src/routes/categories.js | 7 +- backend/src/services/categories.js | 201 +++++-- .../components/Categories/CardCategories.tsx | 138 ++--- .../components/Categories/CategoryForm.tsx | 132 +++++ .../components/Categories/ListCategories.tsx | 145 ++--- .../Categories/configureCategoriesCols.tsx | 158 +++--- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- .../src/pages/categories/categories-edit.tsx | 327 ++++------- .../src/pages/categories/categories-list.tsx | 273 ++++----- .../src/pages/categories/categories-new.tsx | 252 +++------ .../src/pages/categories/categories-view.tsx | 254 ++++----- frontend/src/pages/index.tsx | 238 ++++---- 17 files changed, 1364 insertions(+), 1478 deletions(-) create mode 100644 backend/src/db/migrations/20260405000100-add-category-code-and-unique-name.js create mode 100644 frontend/src/components/Categories/CategoryForm.tsx diff --git a/backend/src/db/api/categories.js b/backend/src/db/api/categories.js index 6f95312..69344c2 100644 --- a/backend/src/db/api/categories.js +++ b/backend/src/db/api/categories.js @@ -1,354 +1,246 @@ - 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 - , - - description: data.description - || - 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 - , - - description: item.description - || - 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; - - - if (data.description !== undefined) updatePayload.description = data.description; - - - 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; - + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; const transaction = (options && options.transaction) || undefined; - let include = [ + const categories = await db.categories.create( + { + id: data.id || undefined, + categoryId: data.categoryId || null, + name: data.name || null, + description: data.description || 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; - ]; + const categoriesData = data.map((item, index) => ({ + id: item.id || undefined, + categoryId: item.categoryId || null, + name: item.name || null, + description: item.description || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); - if (filter) { - if (filter.id) { - where = { - ...where, - ['id']: Utils.uuid(filter.id), - }; - } + const categories = await db.categories.bulkCreate(categoriesData, { transaction }); - - if (filter.name) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'categories', - 'name', - filter.name, - ), - }; - } - - if (filter.description) { - where = { - ...where, - [Op.and]: Utils.ilike( - 'categories', - 'description', - filter.description, - ), - }; - } - + 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 }); - if (filter.active !== undefined) { - where = { - ...where, - active: filter.active === true || filter.active === 'true' - }; - } + const updatePayload = {}; - + if (data.categoryId !== undefined) updatePayload.categoryId = data.categoryId; + if (data.name !== undefined) updatePayload.name = data.name; + if (data.description !== undefined) updatePayload.description = data.description; + updatePayload.updatedById = currentUser.id; + await categories.update(updatePayload, { transaction }); + return categories; + } - if (filter.createdAtRange) { - const [start, end] = filter.createdAtRange; + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; - if (start !== undefined && start !== null && start !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.gte]: start, - }, - }; - } + const categories = await db.categories.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); - if (end !== undefined && end !== null && end !== '') { - where = { - ...where, - ['createdAt']: { - ...where.createdAt, - [Op.lte]: end, - }, - }; - } - } - } - + await db.sequelize.transaction(async (nestedTransaction) => { + for (const record of categories) { + await record.update( + { deletedBy: currentUser.id }, + { transaction: nestedTransaction }, + ); + } + for (const record of categories) { + await record.destroy({ transaction: nestedTransaction }); + } + }); - + return categories; + } - const queryOptions = { - where, - include, - distinct: true, - order: filter.field && filter.sort - ? [[filter.field, filter.sort]] - : [['createdAt', 'desc']], - transaction: options?.transaction, - logging: console.log - }; + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; - if (!options?.countOnly) { - queryOptions.limit = limit ? Number(limit) : undefined; - queryOptions.offset = offset ? Number(offset) : undefined; - } + const categories = await db.categories.findByPk(id, options); - try { - const { rows, count } = await db.categories.findAndCountAll(queryOptions); + await categories.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); - return { - rows: options?.countOnly ? [] : rows, - count: count - }; - } catch (error) { - console.error('Error executing query:', error); - throw error; - } + 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; } - static async findAllAutocomplete(query, limit, offset, ) { - let where = {}; - - + const output = categories.get({ plain: true }); - if (query) { - where = { - [Op.or]: [ - { ['id']: Utils.uuid(query) }, - Utils.ilike( - 'categories', - 'name', - query, - ), - ], - }; + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const andFilters = []; + const currentPage = +filter.page; + + offset = currentPage * limit; + + if (filter) { + if (filter.id) { + where.id = Utils.uuid(filter.id); + } + + if (filter.categoryId) { + andFilters.push(Utils.ilike('categories', 'categoryId', filter.categoryId)); + } + + if (filter.name) { + andFilters.push(Utils.ilike('categories', 'name', filter.name)); + } + + if (filter.description) { + andFilters.push(Utils.ilike('categories', 'description', filter.description)); + } + + if (filter.active !== undefined) { + 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, + }, + }; } - 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, - })); + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + createdAt: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } } - + if (andFilters.length) { + where[Op.and] = andFilters; + } + + 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, + }; + } 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', 'categoryId', query), + Utils.ilike('categories', 'name', query), + ], + }; + } + + const records = await db.categories.findAll({ + attributes: ['id', 'categoryId', 'name'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + order: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.categoryId ? `${record.categoryId} · ${record.name}` : record.name, + })); + } }; - diff --git a/backend/src/db/migrations/20260405000100-add-category-code-and-unique-name.js b/backend/src/db/migrations/20260405000100-add-category-code-and-unique-name.js new file mode 100644 index 0000000..109352b --- /dev/null +++ b/backend/src/db/migrations/20260405000100-add-category-code-and-unique-name.js @@ -0,0 +1,107 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await queryInterface.addColumn( + 'categories', + 'categoryId', + { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + { transaction }, + ); + + const [rows] = await queryInterface.sequelize.query( + 'SELECT id FROM "categories" ORDER BY "createdAt" ASC NULLS LAST, id ASC', + { transaction }, + ); + + for (let index = 0; index < rows.length; index += 1) { + const categoryId = `CAT-${String(index + 1).padStart(3, '0')}`; + + await queryInterface.sequelize.query( + 'UPDATE "categories" SET "categoryId" = :categoryId WHERE id = :id', + { + replacements: { + categoryId, + id: rows[index].id, + }, + transaction, + }, + ); + } + + await queryInterface.changeColumn( + 'categories', + 'categoryId', + { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + { transaction }, + ); + + await queryInterface.changeColumn( + 'categories', + 'name', + { + type: Sequelize.DataTypes.TEXT, + allowNull: false, + }, + { transaction }, + ); + + await queryInterface.addConstraint('categories', { + fields: ['categoryId'], + type: 'unique', + name: 'categories_categoryId_key', + transaction, + }); + + await queryInterface.addConstraint('categories', { + fields: ['name'], + type: 'unique', + name: 'categories_name_key', + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await queryInterface.removeConstraint('categories', 'categories_name_key', { + transaction, + }); + + await queryInterface.removeConstraint('categories', 'categories_categoryId_key', { + transaction, + }); + + await queryInterface.changeColumn( + 'categories', + 'name', + { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + { transaction }, + ); + + await queryInterface.removeColumn('categories', 'categoryId', { transaction }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/models/categories.js b/backend/src/db/models/categories.js index 2f3d784..29da1c1 100644 --- a/backend/src/db/models/categories.js +++ b/backend/src/db/models/categories.js @@ -1,10 +1,4 @@ -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) { +module.exports = function (sequelize, DataTypes) { const categories = sequelize.define( 'categories', { @@ -13,21 +7,20 @@ module.exports = function(sequelize, DataTypes) { defaultValue: DataTypes.UUIDV4, primaryKey: true, }, - -name: { + categoryId: { type: DataTypes.TEXT, - - - + allowNull: false, + unique: true, }, - -description: { + name: { type: DataTypes.TEXT, - - - + allowNull: false, + unique: true, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, }, - importHash: { type: DataTypes.STRING(255), allowNull: true, @@ -42,23 +35,6 @@ description: { ); 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', }); @@ -68,9 +44,5 @@ description: { }); }; - - return categories; }; - - diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index 7bd83c1..98330d5 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -21,12 +21,6 @@ const db = require('../models'); -const Users = db.users; - - - - - const Categories = db.categories; @@ -43,6 +37,12 @@ const CategoriesData = [ + "categoryId": "CAT-INBOUND", + + + + + "name": "Inbound Leads", @@ -61,6 +61,12 @@ const CategoriesData = [ + "categoryId": "CAT-OUTBOUND", + + + + + "name": "Outbound Prospects", @@ -79,6 +85,12 @@ const CategoriesData = [ + "categoryId": "CAT-ENTERPRISE", + + + + + "name": "Enterprise Accounts", @@ -145,7 +157,7 @@ const CategoriesData = [ module.exports = { - up: async (queryInterface, Sequelize) => { + up: async () => { @@ -206,7 +218,7 @@ module.exports = { }, - down: async (queryInterface, Sequelize) => { + down: async (queryInterface) => { diff --git a/backend/src/routes/categories.js b/backend/src/routes/categories.js index 95b2f47..b79d388 100644 --- a/backend/src/routes/categories.js +++ b/backend/src/routes/categories.js @@ -26,9 +26,12 @@ router.use(checkCrudPermissions('categories')); * type: object * properties: + * categoryId: + * type: string + * default: CAT-INBOUND * name: * type: string - * default: name + * default: Inbound Leads * description: * type: string * default: description @@ -291,7 +294,7 @@ router.get('/', wrapAsync(async (req, res) => { req.query, { currentUser } ); if (filetype && filetype === 'csv') { - const fields = ['id','name','description', + const fields = ['id','categoryId','name','description', diff --git a/backend/src/services/categories.js b/backend/src/services/categories.js index 90fbeae..f04664d 100644 --- a/backend/src/services/categories.js +++ b/backend/src/services/categories.js @@ -1,36 +1,147 @@ const db = require('../db/models'); const CategoriesDBApi = require('../db/api/categories'); -const processFile = require("../middlewares/upload"); +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'); +const { Op } = db.Sequelize; +function buildBadRequest(message) { + const error = new Error(message); + error.code = 400; + return error; +} +function normalizeText(value) { + if (typeof value !== 'string') { + return value; + } + return value.trim(); +} + +function normalizeCategoryId(value) { + const normalized = normalizeText(value); + + if (!normalized) { + return ''; + } + + return normalized + .replace(/[^a-zA-Z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .toUpperCase(); +} + +function normalizeName(value) { + const normalized = normalizeText(value); + + if (!normalized) { + return ''; + } + + return normalized.replace(/\s+/g, ' '); +} module.exports = class CategoriesService { + static normalizePayload(data = {}) { + return { + ...data, + categoryId: normalizeCategoryId(data.categoryId), + name: normalizeName(data.name), + description: normalizeText(data.description) || null, + }; + } + + static validatePayload(data) { + if (!data.categoryId) { + throw buildBadRequest('Category ID is required.'); + } + + if (!/^[A-Z0-9]+(?:-[A-Z0-9]+)*$/.test(data.categoryId)) { + throw buildBadRequest('Category ID can contain only letters, numbers, and hyphens.'); + } + + if (!data.name) { + throw buildBadRequest('Category name is required.'); + } + + if (data.name.length < 2) { + throw buildBadRequest('Category name must be at least 2 characters.'); + } + + if (data.description && data.description.length > 500) { + throw buildBadRequest('Description must be 500 characters or fewer.'); + } + } + + static async ensureUniqueFields(data, transaction, excludeId = null) { + const categoryIdWhere = { + categoryId: data.categoryId, + }; + + if (excludeId) { + categoryIdWhere.id = { + [Op.ne]: excludeId, + }; + } + + const categoryIdExists = await db.categories.findOne({ + where: categoryIdWhere, + transaction, + }); + + if (categoryIdExists) { + throw buildBadRequest('Category ID must be unique.'); + } + + const nameWhere = { + [Op.and]: [ + db.Sequelize.where( + db.Sequelize.fn('lower', db.Sequelize.col('name')), + data.name.toLowerCase(), + ), + ], + }; + + if (excludeId) { + nameWhere.id = { + [Op.ne]: excludeId, + }; + } + + const nameExists = await db.categories.findOne({ + where: nameWhere, + transaction, + }); + + if (nameExists) { + throw buildBadRequest('Category name must be unique.'); + } + } + static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); + try { - await CategoriesDBApi.create( - data, - { - currentUser, - transaction, - }, - ); + const normalizedData = this.normalizePayload(data); + this.validatePayload(normalizedData); + await this.ensureUniqueFields(normalizedData, transaction); + + await CategoriesDBApi.create(normalizedData, { + currentUser, + transaction, + }); await transaction.commit(); } catch (error) { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { @@ -38,24 +149,21 @@ module.exports = class CategoriesService { const bufferStream = new stream.PassThrough(); const results = []; - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); await new Promise((resolve, reject) => { bufferStream .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', async () => { - console.log('CSV results', results); - resolve(); - }) - .on('error', (error) => reject(error)); - }) + .on('data', (data) => results.push(this.normalizePayload(data))) + .on('end', resolve) + .on('error', reject); + }); await CategoriesDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, }); await transaction.commit(); @@ -67,35 +175,33 @@ module.exports = class CategoriesService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); + try { - let categories = await CategoriesDBApi.findBy( - {id}, - {transaction}, + const categories = await CategoriesDBApi.findBy( + { id }, + { transaction }, ); if (!categories) { - throw new ValidationError( - 'categoriesNotFound', - ); + throw new ValidationError('categoriesNotFound'); } - const updatedCategories = await CategoriesDBApi.update( - id, - data, - { - currentUser, - transaction, - }, - ); + const normalizedData = this.normalizePayload(data); + this.validatePayload(normalizedData); + await this.ensureUniqueFields(normalizedData, transaction, id); + + const updatedCategories = await CategoriesDBApi.update(id, normalizedData, { + 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(); @@ -117,13 +223,10 @@ module.exports = class CategoriesService { const transaction = await db.sequelize.transaction(); try { - await CategoriesDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); + await CategoriesDBApi.remove(id, { + currentUser, + transaction, + }); await transaction.commit(); } catch (error) { @@ -131,8 +234,4 @@ module.exports = class CategoriesService { throw error; } } - - }; - - diff --git a/frontend/src/components/Categories/CardCategories.tsx b/frontend/src/components/Categories/CardCategories.tsx index ccdde4f..2b93ba3 100644 --- a/frontend/src/components/Categories/CardCategories.tsx +++ b/frontend/src/components/Categories/CardCategories.tsx @@ -1,15 +1,10 @@ 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 LoadingSpinner from '../LoadingSpinner'; import Link from 'next/link'; - -import {hasPermission} from "../../helpers/userPermissions"; - +import { hasPermission } from '../../helpers/userPermissions'; type Props = { categories: any[]; @@ -20,101 +15,70 @@ type Props = { 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') - +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) => ( -
  • + {!loading && + categories.map((item) => ( +
  • - -
    - - + }`} + > +
    +
    +

    Category ID

    +

    {item.categoryId}

    + {item.name} - - + +
    -
    - +
    + +
    -
    -
    - - +
    -
    Name
    -
    -
    - { item.name } -
    -
    +
    Description
    +
    +
    {item.description || 'No description yet'}
    +
    - - - -
    -
    Description
    -
    -
    - { item.description } -
    -
    +
    Record ID
    +
    +
    {item.id}
    +
    - - - -
    -
  • - ))} + + + ))} {!loading && categories.length === 0 && ( -
    -

    No data to display

    +
    +

    No categories available yet.

    )}
-
- +
+
); diff --git a/frontend/src/components/Categories/CategoryForm.tsx b/frontend/src/components/Categories/CategoryForm.tsx new file mode 100644 index 0000000..198ffef --- /dev/null +++ b/frontend/src/components/Categories/CategoryForm.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { Field, Form, Formik } from 'formik'; +import BaseButton from '../BaseButton'; +import BaseButtons from '../BaseButtons'; +import BaseDivider from '../BaseDivider'; +import FormField from '../FormField'; + +export type CategoryFormValues = { + categoryId: string; + name: string; + description: string; +}; + +type CategoryFormProps = { + initialValues: CategoryFormValues; + loading?: boolean; + mode: 'create' | 'edit'; + submissionError?: string; + onCancel: () => void; + onSubmit: (values: CategoryFormValues) => Promise; +}; + +const validate = (values: CategoryFormValues) => { + const errors: Partial> = {}; + const categoryId = values.categoryId.trim().toUpperCase(); + const name = values.name.trim(); + const description = values.description.trim(); + + if (!categoryId) { + errors.categoryId = 'Category ID is required.'; + } else if (!/^[A-Z0-9]+(?:-[A-Z0-9]+)*$/.test(categoryId)) { + errors.categoryId = 'Use uppercase letters, numbers, and hyphens only.'; + } + + if (!name) { + errors.name = 'Category name is required.'; + } else if (name.length < 2) { + errors.name = 'Category name must be at least 2 characters.'; + } + + if (description.length > 500) { + errors.description = 'Description must be 500 characters or fewer.'; + } + + return errors; +}; + +const errorClassName = 'mt-2 text-sm text-red-600'; + +export default function CategoryForm({ + initialValues, + loading = false, + mode, + submissionError, + onCancel, + onSubmit, +}: CategoryFormProps) { + return ( + { + await onSubmit({ + categoryId: values.categoryId.trim().toUpperCase(), + name: values.name.trim(), + description: values.description.trim(), + }); + }} + > + {({ errors, touched }) => ( +
+
+
+ + + + {touched.categoryId && errors.categoryId ? ( +
{errors.categoryId}
+ ) : null} +
+ +
+ + + + {touched.name && errors.name ?
{errors.name}
: null} +
+
+ +
+ + + + {touched.description && errors.description ? ( +
{errors.description}
+ ) : null} +
+ + {submissionError ? ( +
+ {submissionError} +
+ ) : null} + + + + + + + + + + )} +
+ ); +} diff --git a/frontend/src/components/Categories/ListCategories.tsx b/frontend/src/components/Categories/ListCategories.tsx index 1663b0a..6ae624d 100644 --- a/frontend/src/components/Categories/ListCategories.tsx +++ b/frontend/src/components/Categories/ListCategories.tsx @@ -1,96 +1,73 @@ 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 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"; - +import { hasPermission } from '../../helpers/userPermissions'; type Props = { - categories: any[]; - loading: boolean; - onDelete: (id: string) => void; - currentPage: number; - numPages: number; - onPageChange: (page: number) => void; + 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); + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CATEGORIES'); - - return ( - <> -
- {loading && } - {!loading && categories.map((item) => ( -
- -
- - dark:divide-dark-700 overflow-x-auto' - } - > - - -
-

Name

-

{ item.name }

-
- - - - -
-

Description

-

{ item.description }

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

No data to display

-
- )} + return ( + <> +
+ {loading && } + {!loading && + categories.map((item) => ( +
+ +
+ +
+

Category ID

+

{item.categoryId}

+
+
+

Name

+

{item.name}

+
+
+

Description

+

{item.description || 'No description yet'}

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

No categories match the current filters.

+
+ )} +
+
+ +
+ + ); }; -export default ListCategories \ No newline at end of file +export default ListCategories; diff --git a/frontend/src/components/Categories/configureCategoriesCols.tsx b/frontend/src/components/Categories/configureCategoriesCols.tsx index e0fa94b..54f4a9c 100644 --- a/frontend/src/components/Categories/configureCategoriesCols.tsx +++ b/frontend/src/components/Categories/configureCategoriesCols.tsx @@ -1,98 +1,76 @@ 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 { GridRowParams } from '@mui/x-data-grid'; import ListActionsPopover from '../ListActionsPopover'; - -import {hasPermission} from "../../helpers/userPermissions"; +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 []; - } +export const loadColumns = async (onDelete: Params, entityName: string, user) => { + async function callOptionsApi(entityNameValue: string) { + if (!hasPermission(user, `READ_${entityNameValue.toUpperCase()}`)) return []; + + try { + const data = await axios(`/${entityNameValue}/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: 'description', - headerName: 'Description', - 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 [ -
- -
, - ] - }, - }, - ]; + } + + await callOptionsApi(entityName); + + const hasUpdatePermission = hasPermission(user, 'UPDATE_CATEGORIES'); + + return [ + { + field: 'categoryId', + headerName: 'Category ID', + flex: 1, + minWidth: 160, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell font-medium', + editable: hasUpdatePermission, + }, + { + field: 'name', + headerName: 'Name', + flex: 1, + minWidth: 180, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + }, + { + field: 'description', + headerName: 'Description', + flex: 1.4, + minWidth: 220, + 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) => [ +
+ +
, + ], + }, + ]; }; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/pages/categories/categories-edit.tsx b/frontend/src/pages/categories/categories-edit.tsx index 4927f1d..ad0f821 100644 --- a/frontend/src/pages/categories/categories-edit.tsx +++ b/frontend/src/pages/categories/categories-edit.tsx @@ -1,244 +1,131 @@ -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"; - +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import CategoryForm, { CategoryFormValues } from '../../components/Categories/CategoryForm'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import { fetch, update } from '../../stores/categories/categoriesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +const emptyValues: CategoryFormValues = { + categoryId: '', + name: '', + description: '', +}; const EditCategoriesPage = () => { - const router = useRouter() - const dispatch = useAppDispatch() - const initVals = { - - - 'name': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - description: '', - - - - - - - - - - - - - - - - - - - - - - - - - - } - const [initialValues, setInitialValues] = useState(initVals) - - const { categories } = useAppSelector((state) => state.categories) - - - const { id } = router.query + const router = useRouter(); + const dispatch = useAppDispatch(); + const { categories, loading } = useAppSelector((state) => state.categories); + const [submissionError, setSubmissionError] = useState(''); + const { id } = router.query; useEffect(() => { - dispatch(fetch({ id: id })) - }, [id]) - - useEffect(() => { - if (typeof categories === 'object') { - setInitialValues(categories) + if (typeof id === 'string') { + dispatch(fetch({ id })); } - }, [categories]) + }, [dispatch, id]); - useEffect(() => { - if (typeof categories === 'object') { - const newInitialVal = {...initVals}; - Object.keys(initVals).forEach(el => newInitialVal[el] = (categories)[el]) - setInitialValues(newInitialVal); - } - }, [categories]) + const category = useMemo(() => { + if (!categories || Array.isArray(categories)) { + return null; + } - const handleSubmit = async (data) => { - await dispatch(update({ id: id, data })) - await router.push('/categories/categories-list') - } + return categories; + }, [categories]); + + const initialValues: CategoryFormValues = { + categoryId: category?.categoryId || '', + name: category?.name || '', + description: category?.description || '', + }; + + const handleSubmit = async (data: CategoryFormValues) => { + if (typeof id !== 'string') { + return; + } + + setSubmissionError(''); + + try { + await dispatch(update({ id, data })).unwrap(); + await router.push(`/categories/categories-view/?id=${id}`); + } catch (error) { + setSubmissionError(typeof error === 'string' ? error : 'Unable to save category changes right now.'); + } + }; return ( <> - {getPageTitle('Edit categories')} + {getPageTitle('Edit category')} + - - {''} + + {''} - - handleSubmit(values)} - > -
+
+

Category maintenance

+

+ Update the business key, name, or description to keep category data clean before your CRM grows into leads, deals, and accounts. +

+
- - - - - + {!category && loading ? ( + +
Loading category details...
+
+ ) : ( +
+ + router.push(`/categories/categories-view/?id=${id}`)} + /> + - + +
+
+

Current record

+

{category?.name || 'Category'}

+
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - router.push('/categories/categories-list')}/> - - - - +
+
+

Category ID

+

{category?.categoryId || '—'}

+
+
+

Unique name

+

{category?.name || '—'}

+
+
+

Description status

+

{category?.description ? 'Description provided' : 'No description yet'}

+
+
+
+
+
+ )}
- ) -} + ); +}; EditCategoriesPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) -} + return {page}; +}; -export default EditCategoriesPage +export default EditCategoriesPage; diff --git a/frontend/src/pages/categories/categories-list.tsx b/frontend/src/pages/categories/categories-list.tsx index f6530ca..eb413c4 100644 --- a/frontend/src/pages/categories/categories-list.tsx +++ b/frontend/src/pages/categories/categories-list.tsx @@ -1,162 +1,175 @@ -import { mdiChartTimelineVariant } from '@mdi/js' -import Head from 'next/head' +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 React, { ReactElement, useMemo, useState } from 'react'; +import BaseButton from '../../components/BaseButton'; +import CardBox from '../../components/CardBox'; +import CardBoxModal from '../../components/CardBoxModal'; +import TableCategories from '../../components/Categories/TableCategories'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import axios from 'axios'; +import { hasPermission } from '../../helpers/userPermissions'; +import { setRefetch, uploadCsv } from '../../stores/categories/categoriesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; - -import {hasPermission} from "../../helpers/userPermissions"; - - - -const CategoriesTablesPage = () => { +const CategoriesPage = () => { 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 { currentUser } = useAppSelector((state) => state.auth); + const { count, loading } = useAppSelector((state) => state.categories); - const [filters] = useState([{label: 'Name', title: 'name'},{label: 'Description', title: 'description'}, - - - - - - - ]); - - const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_CATEGORIES'); - + const filters = useMemo( + () => [ + { label: 'Category ID', title: 'categoryId' }, + { label: 'Name', title: 'name' }, + { label: 'Description', title: 'description' }, + ], + [], + ); - const addFilter = () => { - const newItem = { - id: uniqueId(), - fields: { - filterValue: '', - filterValueFrom: '', - filterValueTo: '', - selectedField: '', - }, - }; - newItem.fields.selectedField = filters[0].title; - setFilterItems([...filterItems, newItem]); + const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_CATEGORIES'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: filters[0].title, + }, }; - 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() - }; + setFilterItems([...filterItems, newItem]); + }; - const onModalConfirm = async () => { - if (!csvFile) return; - await dispatch(uploadCsv(csvFile)); - dispatch(setRefetch(true)); - setCsvFile(null); - setIsModalActive(false); - }; + 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 }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'categories.csv'; + link.click(); + }; - const onModalCancel = () => { - setCsvFile(null); - setIsModalActive(false); - }; + 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)} - /> - )} - -
-
-
- + + +
+
+

Sales CRM starter

+

Category control center

+

+ Manage the first CRM master-data table with a proper business key, unique names, CSV import/export, + and a clean admin workflow for your team. +

+
+ +
+
+

Active categories

+

{loading ? '…' : count}

+
+
+

Data rules

+

Unique Category ID + Unique Name

+
+
+
- - + +
+ + {hasCreatePermission ? : null} + + + {hasCreatePermission ? ( + setIsModalActive(true)} /> + ) : null} + + + +
+

Workflow

+

Create a category, confirm it on the detail page, then reuse it later for leads and deals.

+
+
+
+ + {!loading && count === 0 ? ( + +
+
+

No categories yet

+

+ Start your CRM foundation with the first category record. You can always import more from CSV later. +

+
+ {hasCreatePermission ? : null} +
+
+ ) : null} + + - - -
- - + + + + + - ) -} + ); +}; -CategoriesTablesPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) -} +CategoriesPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; -export default CategoriesTablesPage +export default CategoriesPage; diff --git a/frontend/src/pages/categories/categories-new.tsx b/frontend/src/pages/categories/categories-new.tsx index aaa02b3..7bd97c1 100644 --- a/frontend/src/pages/categories/categories-new.tsx +++ b/frontend/src/pages/categories/categories-new.tsx @@ -1,189 +1,101 @@ -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 { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import CategoryForm, { CategoryFormValues } from '../../components/Categories/CategoryForm'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import { create } from '../../stores/categories/categoriesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; -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' +const initialValues: CategoryFormValues = { + categoryId: '', + name: '', + description: '', +}; -import { SelectField } from '../../components/SelectField' -import { SelectFieldMany } from "../../components/SelectFieldMany"; -import {RichTextField} from "../../components/RichTextField"; +const CategoryNewPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { loading } = useAppSelector((state) => state.categories); + const [submissionError, setSubmissionError] = useState(''); -import { create } from '../../stores/categories/categoriesSlice' -import { useAppDispatch } from '../../stores/hooks' -import { useRouter } from 'next/router' -import moment from 'moment'; + const handleSubmit = async (data: CategoryFormValues) => { + setSubmissionError(''); -const initialValues = { - - - name: '', - - - - - - - - - - - - - - - - - description: '', - - - - - - - - - - - - - -} + try { + await dispatch(create(data)).unwrap(); + await router.push('/categories/categories-list'); + } catch (error) { + setSubmissionError(typeof error === 'string' ? error : 'Unable to create category right now.'); + } + }; - -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')} + {getPageTitle('Create category')} + - - {''} + + {''} - - handleSubmit(values)} - > -
+
+

Sales CRM master data

+

+ Create a clean category your team can reuse across lead, deal, and customer workflows later. + Start with a stable business key, a unique name, and a short usage note. +

+
+
+ + router.push('/categories/categories-list')} + /> + - - - + +
+
+

Recommended setup

+

Keep categories consistent

+
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - router.push('/categories/categories-list')}/> - - - - +
+
+

Example Category ID

+

CAT-INBOUND, CAT-ENTERPRISE, or CAT-RENEWAL

+
+
+

Example description

+

Use for large accounts with longer cycles and multiple decision makers.

+
+
+

Why this matters

+

A good category structure keeps future CRM reporting, filtering, and automation reliable.

+
+
+
+
+
- ) -} + ); +}; -CategoriesNew.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) -} +CategoryNewPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; -export default CategoriesNew +export default CategoryNewPage; diff --git a/frontend/src/pages/categories/categories-view.tsx b/frontend/src/pages/categories/categories-view.tsx index 19498b0..ddccca9 100644 --- a/frontend/src/pages/categories/categories-view.tsx +++ b/frontend/src/pages/categories/categories-view.tsx @@ -1,152 +1,140 @@ -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"; - +import { mdiEye } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useMemo } from 'react'; +import BaseButton from '../../components/BaseButton'; +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 { fetch } from '../../stores/categories/categoriesSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { hasPermission } from '../../helpers/userPermissions'; const CategoriesView = () => { - const router = useRouter() - const dispatch = useAppDispatch() - const { categories } = useAppSelector((state) => state.categories) - + const router = useRouter(); + const dispatch = useAppDispatch(); + const { categories, loading } = useAppSelector((state) => state.categories); + const { currentUser } = useAppSelector((state) => state.auth); + const { id } = router.query; - const { id } = router.query; - - function removeLastCharacter(str) { - console.log(str,`str`) - return str.slice(0, -1); + useEffect(() => { + if (typeof id === 'string') { + dispatch(fetch({ id })); + } + }, [dispatch, id]); + + const category = useMemo(() => { + if (!categories || Array.isArray(categories)) { + return null; } - useEffect(() => { - dispatch(fetch({ id })); - }, [dispatch, id]); + return categories; + }, [categories]); + const canUpdate = currentUser ? hasPermission(currentUser, 'UPDATE_CATEGORIES') : false; - return ( - <> - - {getPageTitle('View categories')} - - - - - - - + return ( + <> + + {getPageTitle(category?.name || 'Category details')} + - -
-

Name

-

{categories?.name}

+ + + {''} + + + {!category && loading ? ( + +
Loading category record...
+
+ ) : ( +
+ +
+
+

Category master record

+
+
+ {category?.categoryId || '—'} +
+

{category?.name || 'Category not found'}

+
+

+ {category?.description || 'No description has been added yet. Add context so the sales team knows exactly when to use this category.'} +

- - +
+ {canUpdate ? ( + + ) : null} + +
+
+
- +
+ +
+
+

Category ID

+

{category?.categoryId || '—'}

+

Stable internal identifier for filters, reporting, and future CRM automation.

+
- +
+

Category Name

+

{category?.name || '—'}

+

This name is unique so sales teams always see a single clear option.

+
- +
+

Description

+

+ {category?.description || 'No description available for this category yet.'} +

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