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 }) => (
+
+ )}
+
+ );
+}
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'
- }
- >
-
-
-
-
-
-
-
-
-
Description
-
{ item.description }
-
-
-
-
-
-
-
-
-
- ))}
- {!loading && categories.length === 0 && (
-
- )}
+ return (
+ <>
+
+ {loading &&
}
+ {!loading &&
+ categories.map((item) => (
+
+
+
+
+
+
Category ID
+
{item.categoryId}
+
+
+
+
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)}
- >
-
>
- )
-}
+ );
+};
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)}
- >
-
>
- )
-}
+ );
+};
-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.'}
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
router.push('/categories/categories-list')}
- />
+
+
System record ID
+
{category?.id || '—'}
+
+
-
- >
- );
+
+
+
+
+
How this helps
+
Ready for the next CRM tables
+
+
+
+
+ Leads can inherit category defaults later.
+
+
+ Deal pipelines can filter by category for cleaner forecasting.
+
+
+ Reporting becomes more trustworthy when names stay unique.
+
+
+
+
+
+
+ )}
+
+ >
+ );
};
CategoriesView.getLayout = function getLayout(page: ReactElement) {
- return (
-
- {page}
-
- )
-}
+ return
{page} ;
+};
-export default CategoriesView;
\ No newline at end of file
+export default CategoriesView;
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index ccc6b6d..106dd6a 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -1,166 +1,118 @@
-
-import React, { useEffect, useState } from 'react';
+import React from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
-import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
-import BaseDivider from '../components/BaseDivider';
-import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
-import { useAppSelector } from '../stores/hooks';
-import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
-import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
+const featureCards = [
+ {
+ title: 'Category master data',
+ description: 'Create, edit, review, and delete clean sales categories with a dedicated business key and unique naming rules.',
+ },
+ {
+ title: 'Admin-ready workflow',
+ description: 'Use the login-protected CRM interface for your team while keeping a polished public landing page for orientation.',
+ },
+ {
+ title: 'Ready for expansion',
+ description: 'This first slice sets up the structure for future Leads, Deals, Customers, and pipeline reporting.',
+ },
+];
export default function Starter() {
- const [illustrationImage, setIllustrationImage] = useState({
- src: undefined,
- photographer: undefined,
- photographer_url: undefined,
- })
- const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
- const [contentType, setContentType] = useState('video');
- const [contentPosition, setContentPosition] = useState('left');
- const textColor = useAppSelector((state) => state.style.linkColor);
-
- const title = 'Sales CRM Starter'
-
- // Fetch Pexels image/video
- useEffect(() => {
- async function fetchData() {
- const image = await getPexelsImage();
- const video = await getPexelsVideo();
- setIllustrationImage(image);
- setIllustrationVideo(video);
- }
- fetchData();
- }, []);
-
- const imageBlock = (image) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
-
return (
-
+ <>
-
{getPageTitle('Starter Page')}
+
{getPageTitle('Sales CRM')}
-
-
- {contentType === 'image' && contentPosition !== 'background'
- ? imageBlock(illustrationImage)
- : null}
- {contentType === 'video' && contentPosition !== 'background'
- ? videoBlock(illustrationVideo)
- : null}
-
-
-
-
© 2026 {title} . All rights reserved
-
- Privacy Policy
-
-
+
+
+
+ First MVP slice implemented: Category CRUD workflow
+
+
+ Build your Sales CRM from a clean category foundation.
+
+
+ Start with the master data your team will rely on daily. Categories now have a business-friendly Category ID,
+ unique names, admin CRUD pages, CSV tools, and a cleaner workflow designed for future CRM growth.
+
-
+
+
+
+
+
+
+
+
Workflow
+
Create → Review → Manage
+
+
+
Validation
+
Unique key + name
+
+
+
Next up
+
Leads & Deals
+
+
+
+
+
+
+
+
Current first win
+
Category control center
+
+
+
+ {featureCards.map((card) => (
+
+
{card.title}
+
{card.description}
+
+ ))}
+
+
+
+
Default next action
+
+ Log in, open Categories from the sidebar, and create the first category your sales team will use.
+
+
+
+
+ or jump to the admin interface
+
+
+
+
+
+
+
+
+ >
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
return {page} ;
};
-