Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
392654669b Autosave: 20260406-121339 2026-04-06 12:13:39 +00:00
17 changed files with 1364 additions and 1478 deletions

View File

@ -1,354 +1,246 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
module.exports = class CategoriesDBApi { module.exports = class CategoriesDBApi {
static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
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;
const transaction = (options && options.transaction) || undefined; 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) { const categories = await db.categories.bulkCreate(categoriesData, { transaction });
if (filter.id) {
where = {
...where,
['id']: Utils.uuid(filter.id),
};
}
return categories;
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,
),
};
}
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) { const updatePayload = {};
where = {
...where,
active: filter.active === true || filter.active === 'true'
};
}
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) { static async deleteByIds(ids, options) {
const [start, end] = filter.createdAtRange; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
if (start !== undefined && start !== null && start !== '') { const categories = await db.categories.findAll({
where = { where: {
...where, id: {
['createdAt']: { [Op.in]: ids,
...where.createdAt, },
[Op.gte]: start, },
}, transaction,
}; });
}
if (end !== undefined && end !== null && end !== '') { await db.sequelize.transaction(async (nestedTransaction) => {
where = { for (const record of categories) {
...where, await record.update(
['createdAt']: { { deletedBy: currentUser.id },
...where.createdAt, { transaction: nestedTransaction },
[Op.lte]: end, );
}, }
}; for (const record of categories) {
} await record.destroy({ transaction: nestedTransaction });
} }
} });
return categories;
}
const queryOptions = { static async remove(id, options) {
where, const currentUser = (options && options.currentUser) || { id: null };
include, const transaction = (options && options.transaction) || undefined;
distinct: true,
order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options?.transaction,
logging: console.log
};
if (!options?.countOnly) { const categories = await db.categories.findByPk(id, options);
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try { await categories.update(
const { rows, count } = await db.categories.findAndCountAll(queryOptions); {
deletedBy: currentUser.id,
},
{
transaction,
},
);
return { await categories.destroy({
rows: options?.countOnly ? [] : rows, transaction,
count: count });
};
} catch (error) { return categories;
console.error('Error executing query:', error); }
throw error;
} 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, ) { const output = categories.get({ plain: true });
let where = {};
if (query) { return output;
where = { }
[Op.or]: [
{ ['id']: Utils.uuid(query) }, static async findAll(filter, options) {
Utils.ilike( const limit = filter.limit || 0;
'categories', let offset = 0;
'name', let where = {};
query, 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({ if (end !== undefined && end !== null && end !== '') {
attributes: [ 'id', 'name' ], where = {
where, ...where,
limit: limit ? Number(limit) : undefined, createdAt: {
offset: offset ? Number(offset) : undefined, ...where.createdAt,
orderBy: [['name', 'ASC']], [Op.lte]: end,
}); },
};
return records.map((record) => ({ }
id: record.id, }
label: record.name,
}));
} }
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,
}));
}
}; };

View File

@ -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;
}
},
};

View File

@ -1,10 +1,4 @@
const config = require('../../config'); module.exports = function (sequelize, DataTypes) {
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const categories = sequelize.define( const categories = sequelize.define(
'categories', 'categories',
{ {
@ -13,21 +7,20 @@ module.exports = function(sequelize, DataTypes) {
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
primaryKey: true, primaryKey: true,
}, },
categoryId: {
name: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false,
unique: true,
}, },
name: {
description: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false,
unique: true,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
}, },
importHash: { importHash: {
type: DataTypes.STRING(255), type: DataTypes.STRING(255),
allowNull: true, allowNull: true,
@ -42,23 +35,6 @@ description: {
); );
categories.associate = (db) => { 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, { db.categories.belongsTo(db.users, {
as: 'createdBy', as: 'createdBy',
}); });
@ -68,9 +44,5 @@ description: {
}); });
}; };
return categories; return categories;
}; };

View File

@ -21,12 +21,6 @@
const db = require('../models'); const db = require('../models');
const Users = db.users;
const Categories = db.categories; const Categories = db.categories;
@ -43,6 +37,12 @@ const CategoriesData = [
"categoryId": "CAT-INBOUND",
"name": "Inbound Leads", "name": "Inbound Leads",
@ -61,6 +61,12 @@ const CategoriesData = [
"categoryId": "CAT-OUTBOUND",
"name": "Outbound Prospects", "name": "Outbound Prospects",
@ -79,6 +85,12 @@ const CategoriesData = [
"categoryId": "CAT-ENTERPRISE",
"name": "Enterprise Accounts", "name": "Enterprise Accounts",
@ -145,7 +157,7 @@ const CategoriesData = [
module.exports = { module.exports = {
up: async (queryInterface, Sequelize) => { up: async () => {
@ -206,7 +218,7 @@ module.exports = {
}, },
down: async (queryInterface, Sequelize) => { down: async (queryInterface) => {

View File

@ -26,9 +26,12 @@ router.use(checkCrudPermissions('categories'));
* type: object * type: object
* properties: * properties:
* categoryId:
* type: string
* default: CAT-INBOUND
* name: * name:
* type: string * type: string
* default: name * default: Inbound Leads
* description: * description:
* type: string * type: string
* default: description * default: description
@ -291,7 +294,7 @@ router.get('/', wrapAsync(async (req, res) => {
req.query, { currentUser } req.query, { currentUser }
); );
if (filetype && filetype === 'csv') { if (filetype && filetype === 'csv') {
const fields = ['id','name','description', const fields = ['id','categoryId','name','description',

View File

@ -1,36 +1,147 @@
const db = require('../db/models'); const db = require('../db/models');
const CategoriesDBApi = require('../db/api/categories'); const CategoriesDBApi = require('../db/api/categories');
const processFile = require("../middlewares/upload"); const processFile = require('../middlewares/upload');
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream'); 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 { 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) { static async create(data, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await CategoriesDBApi.create( const normalizedData = this.normalizePayload(data);
data, this.validatePayload(normalizedData);
{ await this.ensureUniqueFields(normalizedData, transaction);
currentUser,
transaction, await CategoriesDBApi.create(normalizedData, {
}, currentUser,
); transaction,
});
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async bulkImport(req, res, sendInvitationEmails = true, host) { static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
@ -38,24 +149,21 @@ module.exports = class CategoriesService {
const bufferStream = new stream.PassThrough(); const bufferStream = new stream.PassThrough();
const results = []; 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) => { await new Promise((resolve, reject) => {
bufferStream bufferStream
.pipe(csv()) .pipe(csv())
.on('data', (data) => results.push(data)) .on('data', (data) => results.push(this.normalizePayload(data)))
.on('end', async () => { .on('end', resolve)
console.log('CSV results', results); .on('error', reject);
resolve(); });
})
.on('error', (error) => reject(error));
})
await CategoriesDBApi.bulkImport(results, { await CategoriesDBApi.bulkImport(results, {
transaction, transaction,
ignoreDuplicates: true, ignoreDuplicates: true,
validate: true, validate: true,
currentUser: req.currentUser currentUser: req.currentUser,
}); });
await transaction.commit(); await transaction.commit();
@ -67,35 +175,33 @@ module.exports = class CategoriesService {
static async update(data, id, currentUser) { static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
let categories = await CategoriesDBApi.findBy( const categories = await CategoriesDBApi.findBy(
{id}, { id },
{transaction}, { transaction },
); );
if (!categories) { if (!categories) {
throw new ValidationError( throw new ValidationError('categoriesNotFound');
'categoriesNotFound',
);
} }
const updatedCategories = await CategoriesDBApi.update( const normalizedData = this.normalizePayload(data);
id, this.validatePayload(normalizedData);
data, await this.ensureUniqueFields(normalizedData, transaction, id);
{
currentUser, const updatedCategories = await CategoriesDBApi.update(id, normalizedData, {
transaction, currentUser,
}, transaction,
); });
await transaction.commit(); await transaction.commit();
return updatedCategories; return updatedCategories;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async deleteByIds(ids, currentUser) { static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
@ -117,13 +223,10 @@ module.exports = class CategoriesService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await CategoriesDBApi.remove( await CategoriesDBApi.remove(id, {
id, currentUser,
{ transaction,
currentUser, });
transaction,
},
);
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
@ -131,8 +234,4 @@ module.exports = class CategoriesService {
throw error; throw error;
} }
} }
}; };

View File

@ -1,15 +1,10 @@
import React from 'react'; import React from 'react';
import ImageField from '../ImageField';
import ListActionsPopover from '../ListActionsPopover'; import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks'; import { useAppSelector } from '../../stores/hooks';
import dataFormatter from '../../helpers/dataFormatter';
import { Pagination } from '../Pagination'; import { Pagination } from '../Pagination';
import {saveFile} from "../../helpers/fileSaver"; import LoadingSpinner from '../LoadingSpinner';
import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link'; import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import {hasPermission} from "../../helpers/userPermissions";
type Props = { type Props = {
categories: any[]; categories: any[];
@ -20,101 +15,70 @@ type Props = {
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
}; };
const CardCategories = ({ const CardCategories = ({ categories, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
categories, const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle);
loading, const bgColor = useAppSelector((state) => state.style.cardsColor);
onDelete, const darkMode = useAppSelector((state) => state.style.darkMode);
currentPage, const corners = useAppSelector((state) => state.style.corners);
numPages, const focusRing = useAppSelector((state) => state.style.focusRingColor);
onPageChange, const currentUser = useAppSelector((state) => state.auth.currentUser);
}: Props) => { const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CATEGORIES');
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 ( return (
<div className={'p-4'}> <div className='p-4'>
{loading && <LoadingSpinner />} {loading && <LoadingSpinner />}
<ul <ul role='list' className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'>
role='list' {!loading &&
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8' categories.map((item) => (
> <li
{!loading && categories.map((item, index) => ( key={item.id}
<li className={`overflow-hidden ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`} }`}
> >
<div className={`relative flex items-center gap-x-4 border-b border-gray-900/5 bg-gray-50 p-6 ${bgColor} dark:bg-dark-800`}>
<div className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}> <div>
<p className='text-xs uppercase tracking-[0.2em] text-gray-500'>Category ID</p>
<Link href={`/categories/categories-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'> <p className='mt-1 text-sm font-semibold text-blue-600 dark:text-blue-400'>{item.categoryId}</p>
<Link href={`/categories/categories-view/?id=${item.id}`} className='mt-3 block text-lg font-bold leading-6 line-clamp-1'>
{item.name} {item.name}
</Link> </Link>
</div>
<div className='ml-auto '> <div className='ml-auto'>
<ListActionsPopover <ListActionsPopover
onDelete={onDelete} onDelete={onDelete}
itemId={item.id} itemId={item.id}
pathEdit={`/categories/categories-edit/?id=${item.id}`} pathEdit={`/categories/categories-edit/?id=${item.id}`}
pathView={`/categories/categories-view/?id=${item.id}`} pathView={`/categories/categories-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
hasUpdatePermission={hasUpdatePermission} />
</div>
/>
</div> </div>
</div> <dl className='h-64 overflow-y-auto divide-y divide-stone-300 px-6 py-4 text-sm leading-6 dark:divide-dark-700'>
<dl className='divide-y divide-stone-300 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Name</dt> <dt className='text-gray-500 dark:text-dark-600'>Description</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-5'>{item.description || 'No description yet'}</div>
{ item.name } </dd>
</div>
</dd>
</div> </div>
<div className='flex justify-between gap-x-4 py-3'> <div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Description</dt> <dt className='text-gray-500 dark:text-dark-600'>Record ID</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='break-all font-mono text-xs'>{item.id}</div>
{ item.description } </dd>
</div>
</dd>
</div> </div>
</dl>
</li>
))}
</dl>
</li>
))}
{!loading && categories.length === 0 && ( {!loading && categories.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'> <div className='col-span-full flex h-40 items-center justify-center'>
<p className=''>No data to display</p> <p>No categories available yet.</p>
</div> </div>
)} )}
</ul> </ul>
<div className={'flex items-center justify-center my-6'}> <div className='my-6 flex items-center justify-center'>
<Pagination <Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div> </div>
</div> </div>
); );

View File

@ -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<void>;
};
const validate = (values: CategoryFormValues) => {
const errors: Partial<Record<keyof CategoryFormValues, string>> = {};
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 (
<Formik
enableReinitialize
initialValues={initialValues}
validate={validate}
onSubmit={async (values) => {
await onSubmit({
categoryId: values.categoryId.trim().toUpperCase(),
name: values.name.trim(),
description: values.description.trim(),
});
}}
>
{({ errors, touched }) => (
<Form>
<div className='grid gap-6 lg:grid-cols-2'>
<div>
<FormField
label='Category ID'
help='Stable CRM key used by your team, for example CAT-ENTERPRISE.'
>
<Field name='categoryId' placeholder='CAT-ENTERPRISE' />
</FormField>
{touched.categoryId && errors.categoryId ? (
<div className={errorClassName}>{errors.categoryId}</div>
) : null}
</div>
<div>
<FormField
label='Name'
help='Unique display name visible in CRM lists, filters, and future lead/deal records.'
>
<Field name='name' placeholder='Enterprise Accounts' />
</FormField>
{touched.name && errors.name ? <div className={errorClassName}>{errors.name}</div> : null}
</div>
</div>
<div className='mt-2'>
<FormField
label='Description'
hasTextareaHeight
help='Optional context for the team. Keep it short and explain when to use this category.'
>
<Field name='description' as='textarea' placeholder='Describe the purpose of this category...' />
</FormField>
{touched.description && errors.description ? (
<div className={errorClassName}>{errors.description}</div>
) : null}
</div>
{submissionError ? (
<div className='mt-2 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700'>
{submissionError}
</div>
) : null}
<BaseDivider />
<BaseButtons>
<BaseButton
type='submit'
color='info'
label={loading ? (mode === 'create' ? 'Creating...' : 'Saving...') : mode === 'create' ? 'Create Category' : 'Save Changes'}
disabled={loading}
/>
<BaseButton type='reset' color='info' outline label='Reset' disabled={loading} />
<BaseButton type='button' color='danger' outline label='Cancel' onClick={onCancel} disabled={loading} />
</BaseButtons>
</Form>
)}
</Formik>
);
}

View File

@ -1,96 +1,73 @@
import React from 'react'; import React from 'react';
import CardBox from '../CardBox'; import CardBox from '../CardBox';
import ImageField from '../ImageField'; import ListActionsPopover from '../ListActionsPopover';
import dataFormatter from '../../helpers/dataFormatter'; import { useAppSelector } from '../../stores/hooks';
import {saveFile} from "../../helpers/fileSaver"; import { Pagination } from '../Pagination';
import ListActionsPopover from "../ListActionsPopover"; import LoadingSpinner from '../LoadingSpinner';
import {useAppSelector} from "../../stores/hooks";
import {Pagination} from "../Pagination";
import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link'; import Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import {hasPermission} from "../../helpers/userPermissions";
type Props = { type Props = {
categories: any[]; categories: any[];
loading: boolean; loading: boolean;
onDelete: (id: string) => void; onDelete: (id: string) => void;
currentPage: number; currentPage: number;
numPages: number; numPages: number;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
}; };
const ListCategories = ({ categories, loading, onDelete, currentPage, numPages, onPageChange }: Props) => { const ListCategories = ({ categories, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
const currentUser = useAppSelector((state) => state.auth.currentUser);
const currentUser = useAppSelector((state) => state.auth.currentUser); const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CATEGORIES');
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CATEGORIES')
const corners = useAppSelector((state) => state.style.corners);
const bgColor = useAppSelector((state) => state.style.cardsColor);
return (
return ( <>
<> <div className='relative space-y-4 p-4'>
<div className='relative overflow-x-auto p-4 space-y-4'> {loading && <LoadingSpinner />}
{loading && <LoadingSpinner />} {!loading &&
{!loading && categories.map((item) => ( categories.map((item) => (
<div key={item.id}> <div key={item.id}>
<CardBox hasTable isList className={'rounded shadow-none'}> <CardBox hasTable isList className='rounded shadow-none'>
<div className={`flex rounded dark:bg-dark-900 border border-stone-300 items-center overflow-hidden`}> <div className='flex items-center overflow-hidden rounded border border-stone-300 dark:bg-dark-900'>
<Link
<Link href={`/categories/categories-view/?id=${item.id}`}
href={`/categories/categories-view/?id=${item.id}`} className='flex h-auto flex-1 flex-col gap-4 overflow-hidden px-4 py-5 md:flex-row md:items-center md:divide-x-2 md:divide-stone-300 md:overflow-x-auto dark:divide-dark-700'
className={ >
'flex-1 px-4 py-6 h-24 flex divide-x-2 divide-stone-300 items-center overflow-hidden`}> dark:divide-dark-700 overflow-x-auto' <div className='flex-1 px-3'>
} <p className='text-xs text-gray-500'>Category ID</p>
> <p className='line-clamp-2 font-semibold'>{item.categoryId}</p>
</div>
<div className='flex-1 px-3'>
<div className={'flex-1 px-3'}> <p className='text-xs text-gray-500'>Name</p>
<p className={'text-xs text-gray-500 '}>Name</p> <p className='line-clamp-2'>{item.name}</p>
<p className={'line-clamp-2'}>{ item.name }</p> </div>
</div> <div className='flex-1 px-3'>
<p className='text-xs text-gray-500'>Description</p>
<p className='line-clamp-2'>{item.description || 'No description yet'}</p>
</div>
</Link>
<div className={'flex-1 px-3'}> <ListActionsPopover
<p className={'text-xs text-gray-500 '}>Description</p> onDelete={onDelete}
<p className={'line-clamp-2'}>{ item.description }</p> itemId={item.id}
</div> pathEdit={`/categories/categories-edit/?id=${item.id}`}
pathView={`/categories/categories-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</Link> </div>
<ListActionsPopover </CardBox>
onDelete={onDelete}
itemId={item.id}
pathEdit={`/categories/categories-edit/?id=${item.id}`}
pathView={`/categories/categories-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</CardBox>
</div>
))}
{!loading && categories.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
</div>
)}
</div> </div>
<div className={'flex items-center justify-center my-6'}> ))}
<Pagination {!loading && categories.length === 0 && (
currentPage={currentPage} <div className='col-span-full flex h-40 items-center justify-center'>
numPages={numPages} <p>No categories match the current filters.</p>
setCurrentPage={onPageChange} </div>
/> )}
</div> </div>
</> <div className='my-6 flex items-center justify-center'>
) <Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
</div>
</>
);
}; };
export default ListCategories export default ListCategories;

View File

@ -1,98 +1,76 @@
import React from 'react'; import React from 'react';
import BaseIcon from '../BaseIcon';
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
import axios from 'axios'; import axios from 'axios';
import { import { GridRowParams } from '@mui/x-data-grid';
GridActionsCellItem,
GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid';
import ImageField from '../ImageField';
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter'
import DataGridMultiSelect from "../DataGridMultiSelect";
import ListActionsPopover from '../ListActionsPopover'; import ListActionsPopover from '../ListActionsPopover';
import { hasPermission } from '../../helpers/userPermissions';
import {hasPermission} from "../../helpers/userPermissions";
type Params = (id: string) => void; type Params = (id: string) => void;
export const loadColumns = async ( export const loadColumns = async (onDelete: Params, entityName: string, user) => {
onDelete: Params, async function callOptionsApi(entityNameValue: string) {
entityName: string, if (!hasPermission(user, `READ_${entityNameValue.toUpperCase()}`)) return [];
user try {
const data = await axios(`/${entityNameValue}/autocomplete?limit=100`);
) => { return data.data;
async function callOptionsApi(entityName: string) { } catch (error) {
console.log(error);
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
console.log(error);
return [];
}
} }
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_CATEGORIES')
await callOptionsApi(entityName);
return [
const hasUpdatePermission = hasPermission(user, 'UPDATE_CATEGORIES');
{
field: 'name', return [
headerName: 'Name', {
flex: 1, field: 'categoryId',
minWidth: 120, headerName: 'Category ID',
filterable: false, flex: 1,
headerClassName: 'datagrid--header', minWidth: 160,
cellClassName: 'datagrid--cell', filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell font-medium',
editable: hasUpdatePermission, editable: hasUpdatePermission,
},
{
}, field: 'name',
headerName: 'Name',
{ flex: 1,
field: 'description', minWidth: 180,
headerName: 'Description', filterable: false,
flex: 1, headerClassName: 'datagrid--header',
minWidth: 120, cellClassName: 'datagrid--cell',
filterable: false, editable: hasUpdatePermission,
headerClassName: 'datagrid--header', },
cellClassName: 'datagrid--cell', {
field: 'description',
headerName: 'Description',
editable: hasUpdatePermission, flex: 1.4,
minWidth: 220,
filterable: false,
}, headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
{ editable: hasUpdatePermission,
field: 'actions', },
type: 'actions', {
minWidth: 30, field: 'actions',
headerClassName: 'datagrid--header', type: 'actions',
cellClassName: 'datagrid--cell', minWidth: 30,
getActions: (params: GridRowParams) => { headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
return [ getActions: (params: GridRowParams) => [
<div key={params?.row?.id}> <div key={params?.row?.id}>
<ListActionsPopover <ListActionsPopover
onDelete={onDelete} onDelete={onDelete}
itemId={params?.row?.id} itemId={params?.row?.id}
pathEdit={`/categories/categories-edit/?id=${params?.row?.id}`} pathEdit={`/categories/categories-edit/?id=${params?.row?.id}`}
pathView={`/categories/categories-view/?id=${params?.row?.id}`} pathView={`/categories/categories-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
hasUpdatePermission={hasUpdatePermission} />
</div>,
/> ],
</div>, },
] ];
},
},
];
}; };

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react' import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider' import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react' import React, { ReactNode, useEffect, useState } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside' import menuAside from '../menuAside'

View File

@ -1,244 +1,131 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js' import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head' import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react' import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import DatePicker from "react-datepicker"; import CardBox from '../../components/CardBox';
import "react-datepicker/dist/react-datepicker.css"; import CategoryForm, { CategoryFormValues } from '../../components/Categories/CategoryForm';
import dayjs from "dayjs"; import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import CardBox from '../../components/CardBox' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import LayoutAuthenticated from '../../layouts/Authenticated' import { getPageTitle } from '../../config';
import SectionMain from '../../components/SectionMain' import { fetch, update } from '../../stores/categories/categoriesSlice';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { getPageTitle } from '../../config' 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 { SelectField } from "../../components/SelectField";
import { SelectFieldMany } from "../../components/SelectFieldMany";
import { SwitchField } from '../../components/SwitchField'
import {RichTextField} from "../../components/RichTextField";
import { update, fetch } from '../../stores/categories/categoriesSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
const emptyValues: CategoryFormValues = {
categoryId: '',
name: '',
description: '',
};
const EditCategoriesPage = () => { const EditCategoriesPage = () => {
const router = useRouter() const router = useRouter();
const dispatch = useAppDispatch() const dispatch = useAppDispatch();
const initVals = { const { categories, loading } = useAppSelector((state) => state.categories);
const [submissionError, setSubmissionError] = useState('');
const { id } = router.query;
'name': '',
description: '',
}
const [initialValues, setInitialValues] = useState(initVals)
const { categories } = useAppSelector((state) => state.categories)
const { id } = router.query
useEffect(() => { useEffect(() => {
dispatch(fetch({ id: id })) if (typeof id === 'string') {
}, [id]) dispatch(fetch({ id }));
useEffect(() => {
if (typeof categories === 'object') {
setInitialValues(categories)
} }
}, [categories]) }, [dispatch, id]);
useEffect(() => { const category = useMemo(() => {
if (typeof categories === 'object') { if (!categories || Array.isArray(categories)) {
const newInitialVal = {...initVals}; return null;
Object.keys(initVals).forEach(el => newInitialVal[el] = (categories)[el]) }
setInitialValues(newInitialVal);
}
}, [categories])
const handleSubmit = async (data) => { return categories;
await dispatch(update({ id: id, data })) }, [categories]);
await router.push('/categories/categories-list')
} 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 ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Edit categories')}</title> <title>{getPageTitle('Edit category')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit categories'} main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Edit category' main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<div className='mb-6 max-w-3xl'>
<p className='text-sm uppercase tracking-[0.2em] text-blue-600'>Category maintenance</p>
<p className='mt-3 text-base text-gray-600'>
Update the business key, name, or description to keep category data clean before your CRM grows into leads, deals, and accounts.
</p>
</div>
{!category && loading ? (
<FormField <CardBox className='border border-gray-200 shadow-sm'>
label="Name" <div className='py-16 text-center text-gray-500'>Loading category details...</div>
> </CardBox>
<Field ) : (
name="name" <div className='grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]'>
placeholder="Name" <CardBox className='border border-blue-100 shadow-sm'>
/> <CategoryForm
</FormField> initialValues={category ? initialValues : emptyValues}
mode='edit'
loading={loading}
submissionError={submissionError}
onSubmit={handleSubmit}
onCancel={() => router.push(`/categories/categories-view/?id=${id}`)}
/>
</CardBox>
<CardBox className='border border-gray-200 bg-white shadow-sm'>
<div className='space-y-5'>
<div>
<p className='text-xs uppercase tracking-[0.2em] text-blue-600'>Current record</p>
<h2 className='mt-2 text-2xl font-semibold text-gray-900'>{category?.name || 'Category'}</h2>
</div>
<div className='space-y-3 text-sm text-gray-600'>
<div className='rounded-xl border border-gray-200 bg-slate-50 p-4'>
<p className='font-semibold text-gray-900'>Category ID</p>
<p className='mt-2'>{category?.categoryId || '—'}</p>
</div>
<div className='rounded-xl border border-gray-200 bg-slate-50 p-4'>
<p className='font-semibold text-gray-900'>Unique name</p>
<p className='mt-2'>{category?.name || '—'}</p>
</div>
<div className='rounded-xl border border-gray-200 bg-slate-50 p-4'>
<p className='font-semibold text-gray-900'>Description status</p>
<p className='mt-2'>{category?.description ? 'Description provided' : 'No description yet'}</p>
</div>
</div>
</div>
</CardBox>
</div>
)}
<FormField label="Description" hasTextareaHeight>
<Field name="description" as="textarea" placeholder="Description" />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/categories/categories-list')}/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain> </SectionMain>
</> </>
) );
} };
EditCategoriesPage.getLayout = function getLayout(page: ReactElement) { EditCategoriesPage.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated permission='UPDATE_CATEGORIES'>{page}</LayoutAuthenticated>;
<LayoutAuthenticated };
permission={'UPDATE_CATEGORIES'}
>
{page}
</LayoutAuthenticated>
)
}
export default EditCategoriesPage export default EditCategoriesPage;

View File

@ -1,162 +1,175 @@
import { mdiChartTimelineVariant } from '@mdi/js' import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head' import Head from 'next/head';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react' import React, { ReactElement, useMemo, useState } from 'react';
import CardBox from '../../components/CardBox' import BaseButton from '../../components/BaseButton';
import LayoutAuthenticated from '../../layouts/Authenticated' import CardBox from '../../components/CardBox';
import SectionMain from '../../components/SectionMain' import CardBoxModal from '../../components/CardBoxModal';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import TableCategories from '../../components/Categories/TableCategories';
import { getPageTitle } from '../../config' import DragDropFilePicker from '../../components/DragDropFilePicker';
import TableCategories from '../../components/Categories/TableCategories' import LayoutAuthenticated from '../../layouts/Authenticated';
import BaseButton from '../../components/BaseButton' import SectionMain from '../../components/SectionMain';
import axios from "axios"; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import Link from "next/link"; import { getPageTitle } from '../../config';
import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import axios from 'axios';
import CardBoxModal from "../../components/CardBoxModal"; import { hasPermission } from '../../helpers/userPermissions';
import DragDropFilePicker from "../../components/DragDropFilePicker"; import { setRefetch, uploadCsv } from '../../stores/categories/categoriesSlice';
import {setRefetch, uploadCsv} from '../../stores/categories/categoriesSlice'; import { useAppDispatch, useAppSelector } from '../../stores/hooks';
const CategoriesPage = () => {
import {hasPermission} from "../../helpers/userPermissions";
const CategoriesTablesPage = () => {
const [filterItems, setFilterItems] = useState([]); const [filterItems, setFilterItems] = useState([]);
const [csvFile, setCsvFile] = useState<File | null>(null); const [csvFile, setCsvFile] = useState<File | null>(null);
const [isModalActive, setIsModalActive] = useState(false); const [isModalActive, setIsModalActive] = useState(false);
const [showTableView, setShowTableView] = useState(false);
const { currentUser } = useAppSelector((state) => state.auth);
const dispatch = useAppDispatch(); 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 filters = useMemo(
() => [
{ label: 'Category ID', title: 'categoryId' },
{ label: 'Name', title: 'name' },
{ label: 'Description', title: 'description' },
],
[],
]); );
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_CATEGORIES');
const addFilter = () => { const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_CATEGORIES');
const newItem = {
id: uniqueId(), const addFilter = () => {
fields: { const newItem = {
filterValue: '', id: uniqueId(),
filterValueFrom: '', fields: {
filterValueTo: '', filterValue: '',
selectedField: '', filterValueFrom: '',
}, filterValueTo: '',
}; selectedField: filters[0].title,
newItem.fields.selectedField = filters[0].title; },
setFilterItems([...filterItems, newItem]);
}; };
const getCategoriesCSV = async () => { setFilterItems([...filterItems, newItem]);
const response = await axios({url: '/categories?filetype=csv', method: 'GET',responseType: 'blob'}); };
const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = 'categoriesCSV.csv'
link.click()
};
const onModalConfirm = async () => { const getCategoriesCSV = async () => {
if (!csvFile) return; const response = await axios({
await dispatch(uploadCsv(csvFile)); url: '/categories?filetype=csv',
dispatch(setRefetch(true)); method: 'GET',
setCsvFile(null); responseType: 'blob',
setIsModalActive(false); });
}; 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 = () => { const onModalConfirm = async () => {
setCsvFile(null); if (!csvFile) return;
setIsModalActive(false); await dispatch(uploadCsv(csvFile));
}; dispatch(setRefetch(true));
setCsvFile(null);
setIsModalActive(false);
};
const onModalCancel = () => {
setCsvFile(null);
setIsModalActive(false);
};
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Categories')}</title> <title>{getPageTitle('Categories')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Categories" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Categories' main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
<CardBox className='mb-6 overflow-hidden border border-blue-100 bg-gradient-to-r from-slate-900 via-blue-900 to-blue-700 text-white shadow-sm'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/categories/categories-new'} color='info' label='New Item'/>} <div className='flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between'>
<div className='max-w-2xl'>
<BaseButton <p className='text-xs uppercase tracking-[0.25em] text-blue-100'>Sales CRM starter</p>
className={'mr-3'} <h2 className='mt-3 text-3xl font-semibold'>Category control center</h2>
color='info' <p className='mt-4 text-sm leading-7 text-blue-50/90'>
label='Filter' Manage the first CRM master-data table with a proper business key, unique names, CSV import/export,
onClick={addFilter} and a clean admin workflow for your team.
/> </p>
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getCategoriesCSV} /> </div>
{hasCreatePermission && ( <div className='grid gap-3 sm:grid-cols-2'>
<BaseButton <div className='rounded-2xl border border-white/15 bg-white/10 p-4'>
color='info' <p className='text-sm text-blue-100'>Active categories</p>
label='Upload CSV' <p className='mt-2 text-3xl font-semibold'>{loading ? '…' : count}</p>
onClick={() => setIsModalActive(true)} </div>
/> <div className='rounded-2xl border border-white/15 bg-white/10 p-4'>
)} <p className='text-sm text-blue-100'>Data rules</p>
<p className='mt-2 text-sm font-medium text-white'>Unique Category ID + Unique Name</p>
<div className='md:inline-flex items-center ms-auto'> </div>
<div id='delete-rows-button'></div> </div>
</div> </div>
</CardBox> </CardBox>
<CardBox className="mb-6" hasTable> <div className='mb-6 grid gap-4 xl:grid-cols-[minmax(0,1fr)_280px]'>
<CardBox className='border border-gray-200 shadow-sm' cardBoxClassName='flex flex-wrap gap-3'>
{hasCreatePermission ? <BaseButton href='/categories/categories-new' color='info' label='New Category' /> : null}
<BaseButton color='whiteDark' outline label='Add Filter' onClick={addFilter} />
<BaseButton color='whiteDark' outline label='Download CSV' onClick={getCategoriesCSV} />
{hasCreatePermission ? (
<BaseButton color='success' label='Upload CSV' onClick={() => setIsModalActive(true)} />
) : null}
</CardBox>
<CardBox className='border border-gray-200 shadow-sm'>
<div className='space-y-2 text-sm text-gray-600'>
<p className='font-semibold text-gray-900'>Workflow</p>
<p>Create a category, confirm it on the detail page, then reuse it later for leads and deals.</p>
</div>
</CardBox>
</div>
{!loading && count === 0 ? (
<CardBox className='mb-6 border border-dashed border-blue-200 bg-blue-50 shadow-sm'>
<div className='flex flex-col gap-4 py-6 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-lg font-semibold text-gray-900'>No categories yet</p>
<p className='mt-2 text-sm text-gray-600'>
Start your CRM foundation with the first category record. You can always import more from CSV later.
</p>
</div>
{hasCreatePermission ? <BaseButton href='/categories/categories-new' color='info' label='Create First Category' /> : null}
</div>
</CardBox>
) : null}
<CardBox className='mb-6 border border-gray-200 shadow-sm' hasTable>
<TableCategories <TableCategories
filterItems={filterItems} filterItems={filterItems}
setFilterItems={setFilterItems} setFilterItems={setFilterItems}
filters={filters} filters={filters}
showGrid={false} showGrid={false}
/>
</CardBox>
</SectionMain>
<CardBoxModal
title='Upload CSV'
buttonColor='info'
buttonLabel={'Confirm'}
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
isActive={isModalActive}
onConfirm={onModalConfirm}
onCancel={onModalCancel}
>
<DragDropFilePicker
file={csvFile}
setFile={setCsvFile}
formats={'.csv'}
/> />
</CardBox>
</SectionMain>
<CardBoxModal
title='Upload category CSV'
buttonColor='info'
buttonLabel='Confirm'
isActive={isModalActive}
onConfirm={onModalConfirm}
onCancel={onModalCancel}
>
<DragDropFilePicker file={csvFile} setFile={setCsvFile} formats={'.csv'} />
</CardBoxModal> </CardBoxModal>
</> </>
) );
} };
CategoriesTablesPage.getLayout = function getLayout(page: ReactElement) { CategoriesPage.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated permission='READ_CATEGORIES'>{page}</LayoutAuthenticated>;
<LayoutAuthenticated };
permission={'READ_CATEGORIES'}
>
{page}
</LayoutAuthenticated>
)
}
export default CategoriesTablesPage export default CategoriesPage;

View File

@ -1,189 +1,101 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head' import Head from 'next/head';
import React, { ReactElement } from 'react' import React, { ReactElement, useState } from 'react';
import CardBox from '../../components/CardBox' import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated' import CategoryForm, { CategoryFormValues } from '../../components/Categories/CategoryForm';
import SectionMain from '../../components/SectionMain' import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import SectionMain from '../../components/SectionMain';
import { getPageTitle } from '../../config' 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' const initialValues: CategoryFormValues = {
import FormField from '../../components/FormField' categoryId: '',
import BaseDivider from '../../components/BaseDivider' name: '',
import BaseButtons from '../../components/BaseButtons' description: '',
import BaseButton from '../../components/BaseButton' };
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField' const CategoryNewPage = () => {
import { SelectFieldMany } from "../../components/SelectFieldMany"; const router = useRouter();
import {RichTextField} from "../../components/RichTextField"; const dispatch = useAppDispatch();
const { loading } = useAppSelector((state) => state.categories);
const [submissionError, setSubmissionError] = useState('');
import { create } from '../../stores/categories/categoriesSlice' const handleSubmit = async (data: CategoryFormValues) => {
import { useAppDispatch } from '../../stores/hooks' setSubmissionError('');
import { useRouter } from 'next/router'
import moment from 'moment';
const initialValues = { try {
await dispatch(create(data)).unwrap();
await router.push('/categories/categories-list');
name: '', } catch (error) {
setSubmissionError(typeof error === 'string' ? error : 'Unable to create category right now.');
}
};
description: '',
}
const CategoriesNew = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const handleSubmit = async (data) => {
await dispatch(create(data))
await router.push('/categories/categories-list')
}
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('New Item')}</title> <title>{getPageTitle('Create category')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Create category' main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox>
<Formik
initialValues={
initialValues
}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<div className='mb-6 max-w-3xl'>
<p className='text-sm uppercase tracking-[0.2em] text-blue-600'>Sales CRM master data</p>
<p className='mt-3 text-base text-gray-600'>
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.
</p>
</div>
<div className='grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]'>
<CardBox className='border border-blue-100 shadow-sm'>
<CategoryForm
initialValues={initialValues}
mode='create'
loading={loading}
submissionError={submissionError}
onSubmit={handleSubmit}
onCancel={() => router.push('/categories/categories-list')}
/>
</CardBox>
<FormField <CardBox className='border border-gray-200 bg-gradient-to-br from-slate-900 via-slate-800 to-blue-900 text-white shadow-sm'>
label="Name" <div className='space-y-5'>
> <div>
<Field <p className='text-xs uppercase tracking-[0.2em] text-blue-200'>Recommended setup</p>
name="name" <h2 className='mt-2 text-2xl font-semibold'>Keep categories consistent</h2>
placeholder="Name" </div>
/>
</FormField>
<div className='space-y-4 text-sm text-blue-50/90'>
<div className='rounded-xl border border-white/10 bg-white/5 p-4'>
<p className='font-semibold text-white'>Example Category ID</p>
<p className='mt-2'>CAT-INBOUND, CAT-ENTERPRISE, or CAT-RENEWAL</p>
</div>
<div className='rounded-xl border border-white/10 bg-white/5 p-4'>
<p className='font-semibold text-white'>Example description</p>
<p className='mt-2'>Use for large accounts with longer cycles and multiple decision makers.</p>
</div>
<div className='rounded-xl border border-white/10 bg-white/5 p-4'>
<p className='font-semibold text-white'>Why this matters</p>
<p className='mt-2'>A good category structure keeps future CRM reporting, filtering, and automation reliable.</p>
</div>
</div>
</div>
</CardBox>
</div>
<FormField label="Description" hasTextareaHeight>
<Field name="description" as="textarea" placeholder="Description" />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/categories/categories-list')}/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain> </SectionMain>
</> </>
) );
} };
CategoriesNew.getLayout = function getLayout(page: ReactElement) { CategoryNewPage.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated permission='CREATE_CATEGORIES'>{page}</LayoutAuthenticated>;
<LayoutAuthenticated };
permission={'CREATE_CATEGORIES'}
>
{page}
</LayoutAuthenticated>
)
}
export default CategoriesNew export default CategoryNewPage;

View File

@ -1,152 +1,140 @@
import React, { ReactElement, useEffect } from 'react'; import { mdiEye } from '@mdi/js';
import Head from 'next/head' import Head from 'next/head';
import DatePicker from "react-datepicker"; import React, { ReactElement, useEffect, useMemo } from 'react';
import "react-datepicker/dist/react-datepicker.css"; import BaseButton from '../../components/BaseButton';
import dayjs from "dayjs"; import CardBox from '../../components/CardBox';
import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import LayoutAuthenticated from '../../layouts/Authenticated';
import {useRouter} from "next/router"; import SectionMain from '../../components/SectionMain';
import { fetch } from '../../stores/categories/categoriesSlice' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import {saveFile} from "../../helpers/fileSaver"; import { getPageTitle } from '../../config';
import dataFormatter from '../../helpers/dataFormatter'; import { fetch } from '../../stores/categories/categoriesSlice';
import ImageField from "../../components/ImageField"; import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import LayoutAuthenticated from "../../layouts/Authenticated"; import { useRouter } from 'next/router';
import {getPageTitle} from "../../config"; import { hasPermission } from '../../helpers/userPermissions';
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox";
import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider";
import {mdiChartTimelineVariant} from "@mdi/js";
import {SwitchField} from "../../components/SwitchField";
import FormField from "../../components/FormField";
const CategoriesView = () => { const CategoriesView = () => {
const router = useRouter() const router = useRouter();
const dispatch = useAppDispatch() const dispatch = useAppDispatch();
const { categories } = useAppSelector((state) => state.categories) const { categories, loading } = useAppSelector((state) => state.categories);
const { currentUser } = useAppSelector((state) => state.auth);
const { id } = router.query;
const { id } = router.query; useEffect(() => {
if (typeof id === 'string') {
function removeLastCharacter(str) { dispatch(fetch({ id }));
console.log(str,`str`) }
return str.slice(0, -1); }, [dispatch, id]);
const category = useMemo(() => {
if (!categories || Array.isArray(categories)) {
return null;
} }
useEffect(() => { return categories;
dispatch(fetch({ id })); }, [categories]);
}, [dispatch, id]);
const canUpdate = currentUser ? hasPermission(currentUser, 'UPDATE_CATEGORIES') : false;
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('View categories')}</title> <title>{getPageTitle(category?.name || 'Category details')}</title>
</Head> </Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View categories')} main>
<BaseButton
color='info'
label='Edit'
href={`/categories/categories-edit/?id=${id}`}
/>
</SectionTitleLineWithButton>
<CardBox>
<SectionMain>
<div className={'mb-4'}> <SectionTitleLineWithButton icon={mdiEye} title='Category detail' main>
<p className={'block font-bold mb-2'}>Name</p> {''}
<p>{categories?.name}</p> </SectionTitleLineWithButton>
{!category && loading ? (
<CardBox className='border border-gray-200 shadow-sm'>
<div className='py-16 text-center text-gray-500'>Loading category record...</div>
</CardBox>
) : (
<div className='space-y-6'>
<CardBox className='overflow-hidden border border-blue-100 bg-gradient-to-r from-slate-900 via-blue-900 to-blue-700 text-white shadow-sm'>
<div className='flex flex-col gap-6 p-1 lg:flex-row lg:items-end lg:justify-between'>
<div className='space-y-4'>
<p className='text-xs uppercase tracking-[0.25em] text-blue-100'>Category master record</p>
<div>
<div className='inline-flex rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs font-semibold tracking-wide text-blue-50'>
{category?.categoryId || '—'}
</div>
<h1 className='mt-4 text-3xl font-semibold'>{category?.name || 'Category not found'}</h1>
</div>
<p className='max-w-2xl text-sm leading-7 text-blue-50/90'>
{category?.description || 'No description has been added yet. Add context so the sales team knows exactly when to use this category.'}
</p>
</div> </div>
<div className='flex flex-wrap gap-3'>
{canUpdate ? (
<BaseButton color='info' label='Edit Category' href={`/categories/categories-edit/?id=${id}`} />
) : null}
<BaseButton color='whiteDark' outline label='Back to Categories' href='/categories/categories-list' />
</div>
</div>
</CardBox>
<div className='grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]'>
<CardBox className='border border-gray-200 shadow-sm'>
<div className='grid gap-6 md:grid-cols-2'>
<div className='rounded-xl border border-gray-200 bg-slate-50 p-5'>
<p className='text-sm font-semibold text-gray-500'>Category ID</p>
<p className='mt-3 text-lg font-semibold text-gray-900'>{category?.categoryId || '—'}</p>
<p className='mt-2 text-sm text-gray-500'>Stable internal identifier for filters, reporting, and future CRM automation.</p>
</div>
<div className='rounded-xl border border-gray-200 bg-slate-50 p-5'>
<p className='text-sm font-semibold text-gray-500'>Category Name</p>
<p className='mt-3 text-lg font-semibold text-gray-900'>{category?.name || '—'}</p>
<p className='mt-2 text-sm text-gray-500'>This name is unique so sales teams always see a single clear option.</p>
</div>
<div className='rounded-xl border border-gray-200 bg-slate-50 p-5 md:col-span-2'>
<p className='text-sm font-semibold text-gray-500'>Description</p>
<p className='mt-3 whitespace-pre-wrap text-gray-700'>
{category?.description || 'No description available for this category yet.'}
</p>
</div>
<div className='rounded-xl border border-gray-200 bg-slate-50 p-5 md:col-span-2'>
<p className='text-sm font-semibold text-gray-500'>System record ID</p>
<p className='mt-3 break-all font-mono text-sm text-gray-700'>{category?.id || '—'}</p>
</div>
</div>
<FormField label='Multi Text' hasTextareaHeight>
<textarea className={'w-full'} disabled value={categories?.description} />
</FormField>
<BaseDivider />
<BaseButton
color='info'
label='Back'
onClick={() => router.push('/categories/categories-list')}
/>
</CardBox> </CardBox>
</SectionMain>
</> <CardBox className='border border-gray-200 bg-white shadow-sm'>
); <div className='space-y-4'>
<div>
<p className='text-xs uppercase tracking-[0.2em] text-blue-600'>How this helps</p>
<h2 className='mt-2 text-2xl font-semibold text-gray-900'>Ready for the next CRM tables</h2>
</div>
<div className='space-y-3 text-sm text-gray-600'>
<div className='rounded-xl border border-gray-200 bg-slate-50 p-4'>
Leads can inherit category defaults later.
</div>
<div className='rounded-xl border border-gray-200 bg-slate-50 p-4'>
Deal pipelines can filter by category for cleaner forecasting.
</div>
<div className='rounded-xl border border-gray-200 bg-slate-50 p-4'>
Reporting becomes more trustworthy when names stay unique.
</div>
</div>
</div>
</CardBox>
</div>
</div>
)}
</SectionMain>
</>
);
}; };
CategoriesView.getLayout = function getLayout(page: ReactElement) { CategoriesView.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated permission='READ_CATEGORIES'>{page}</LayoutAuthenticated>;
<LayoutAuthenticated };
permission={'READ_CATEGORIES'}
>
{page}
</LayoutAuthenticated>
)
}
export default CategoriesView; export default CategoriesView;

View File

@ -1,166 +1,118 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox'; import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config'; 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() { 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) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return ( return (
<div <>
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<Head> <Head>
<title>{getPageTitle('Starter Page')}</title> <title>{getPageTitle('Sales CRM')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> <main className='min-h-screen bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.16),_transparent_35%),linear-gradient(180deg,_#f8fbff_0%,_#eef4ff_50%,_#ffffff_100%)] text-gray-900'>
<div <section className='mx-auto flex min-h-screen max-w-7xl flex-col px-6 py-10 lg:px-10'>
className={`flex ${ <header className='flex items-center justify-between border-b border-white/60 pb-6'>
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row' <div>
} min-h-screen w-full`} <p className='text-xs uppercase tracking-[0.25em] text-blue-600'>Sales CRM Starter</p>
> <h1 className='mt-2 text-xl font-semibold'>Internal admin CRM</h1>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Sales CRM Starter app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div> </div>
<div className='flex flex-wrap gap-3'>
<BaseButtons> <BaseButton href='/login' color='whiteDark' outline label='Login' />
<BaseButton <BaseButton href='/dashboard' color='info' label='Admin Interface' />
href='/login' </div>
label='Login' </header>
color='info'
className='w-full'
/>
</BaseButtons> <div className='grid flex-1 gap-8 py-12 lg:grid-cols-[minmax(0,1.05fr)_460px] lg:items-center'>
</CardBox> <div>
</div> <div className='inline-flex rounded-full border border-blue-100 bg-white/90 px-4 py-2 text-sm font-medium text-blue-700 shadow-sm'>
</div> First MVP slice implemented: Category CRUD workflow
</SectionFullScreen> </div>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'> <h2 className='mt-6 max-w-3xl text-5xl font-semibold leading-tight tracking-tight text-slate-950'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p> Build your Sales CRM from a clean category foundation.
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'> </h2>
Privacy Policy <p className='mt-6 max-w-2xl text-lg leading-8 text-slate-600'>
</Link> Start with the master data your team will rely on daily. Categories now have a business-friendly Category ID,
</div> unique names, admin CRUD pages, CSV tools, and a cleaner workflow designed for future CRM growth.
</p>
</div> <div className='mt-8 flex flex-wrap gap-4'>
<BaseButton href='/dashboard' color='info' label='Open Admin Interface' />
<BaseButton href='/login' color='whiteDark' outline label='Go to Login' />
</div>
<div className='mt-10 grid gap-4 md:grid-cols-3'>
<div className='rounded-2xl border border-white/70 bg-white/80 p-5 shadow-sm backdrop-blur'>
<p className='text-sm text-slate-500'>Workflow</p>
<p className='mt-2 text-2xl font-semibold'>Create Review Manage</p>
</div>
<div className='rounded-2xl border border-white/70 bg-white/80 p-5 shadow-sm backdrop-blur'>
<p className='text-sm text-slate-500'>Validation</p>
<p className='mt-2 text-2xl font-semibold'>Unique key + name</p>
</div>
<div className='rounded-2xl border border-white/70 bg-white/80 p-5 shadow-sm backdrop-blur'>
<p className='text-sm text-slate-500'>Next up</p>
<p className='mt-2 text-2xl font-semibold'>Leads & Deals</p>
</div>
</div>
</div>
<CardBox className='border border-white/80 bg-white/85 shadow-xl backdrop-blur'>
<div className='space-y-6'>
<div>
<p className='text-xs uppercase tracking-[0.25em] text-blue-600'>Current first win</p>
<h3 className='mt-2 text-3xl font-semibold text-slate-950'>Category control center</h3>
</div>
<div className='space-y-4'>
{featureCards.map((card) => (
<div key={card.title} className='rounded-2xl border border-slate-200 bg-slate-50 p-5'>
<p className='text-lg font-semibold text-slate-950'>{card.title}</p>
<p className='mt-2 text-sm leading-7 text-slate-600'>{card.description}</p>
</div>
))}
</div>
<div className='rounded-2xl border border-blue-100 bg-blue-50 p-5'>
<p className='text-sm font-semibold text-slate-900'>Default next action</p>
<p className='mt-2 text-sm leading-7 text-slate-600'>
Log in, open Categories from the sidebar, and create the first category your sales team will use.
</p>
<div className='mt-4 flex flex-wrap gap-3'>
<BaseButton href='/login' color='info' label='Login to CRM' />
<Link href='/dashboard' className='inline-flex items-center text-sm font-medium text-blue-700 hover:text-blue-800'>
or jump to the admin interface
</Link>
</div>
</div>
</div>
</CardBox>
</div>
</section>
</main>
</>
); );
} }
Starter.getLayout = function getLayout(page: ReactElement) { Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>;
}; };