Compare commits

..

No commits in common. "ai-dev" and "master" have entirely different histories.

17 changed files with 1477 additions and 1363 deletions

View File

@ -1,10 +1,18 @@
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) { static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null }; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
@ -12,9 +20,17 @@ module.exports = class CategoriesDBApi {
const categories = await db.categories.create( const categories = await db.categories.create(
{ {
id: data.id || undefined, id: data.id || undefined,
categoryId: data.categoryId || null,
name: data.name || null, name: data.name
description: data.description || null, ||
null
,
description: data.description
||
null
,
importHash: data.importHash || null, importHash: data.importHash || null,
createdById: currentUser.id, createdById: currentUser.id,
updatedById: currentUser.id, updatedById: currentUser.id,
@ -22,26 +38,46 @@ module.exports = class CategoriesDBApi {
{ transaction }, { transaction },
); );
return categories; return categories;
} }
static async bulkImport(data, options) { static async bulkImport(data, options) {
const currentUser = (options && options.currentUser) || { id: null }; const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
// Prepare data - wrapping individual data transformations in a map() method
const categoriesData = data.map((item, index) => ({ const categoriesData = data.map((item, index) => ({
id: item.id || undefined, id: item.id || undefined,
categoryId: item.categoryId || null,
name: item.name || null, name: item.name
description: item.description || null, ||
null
,
description: item.description
||
null
,
importHash: item.importHash || null, importHash: item.importHash || null,
createdById: currentUser.id, createdById: currentUser.id,
updatedById: currentUser.id, updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000), createdAt: new Date(Date.now() + index * 1000),
})); }));
// Bulk create items
const categories = await db.categories.bulkCreate(categoriesData, { transaction }); const categories = await db.categories.bulkCreate(categoriesData, { transaction });
// For each item created, replace relation files
return categories; return categories;
} }
@ -49,18 +85,32 @@ module.exports = class CategoriesDBApi {
const currentUser = (options && options.currentUser) || {id: null}; const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const categories = await db.categories.findByPk(id, {}, {transaction}); const categories = await db.categories.findByPk(id, {}, {transaction});
const updatePayload = {}; const updatePayload = {};
if (data.categoryId !== undefined) updatePayload.categoryId = data.categoryId;
if (data.name !== undefined) updatePayload.name = data.name; if (data.name !== undefined) updatePayload.name = data.name;
if (data.description !== undefined) updatePayload.description = data.description; if (data.description !== undefined) updatePayload.description = data.description;
updatePayload.updatedById = currentUser.id; updatePayload.updatedById = currentUser.id;
await categories.update(updatePayload, {transaction}); await categories.update(updatePayload, {transaction});
return categories; return categories;
} }
@ -77,18 +127,19 @@ module.exports = class CategoriesDBApi {
transaction, transaction,
}); });
await db.sequelize.transaction(async (nestedTransaction) => { await db.sequelize.transaction(async (transaction) => {
for (const record of categories) { for (const record of categories) {
await record.update( await record.update(
{deletedBy: currentUser.id}, {deletedBy: currentUser.id},
{ transaction: nestedTransaction }, {transaction}
); );
} }
for (const record of categories) { for (const record of categories) {
await record.destroy({ transaction: nestedTransaction }); await record.destroy({transaction});
} }
}); });
return categories; return categories;
} }
@ -98,17 +149,14 @@ module.exports = class CategoriesDBApi {
const categories = await db.categories.findByPk(id, options); const categories = await db.categories.findByPk(id, options);
await categories.update( await categories.update({
{ deletedBy: currentUser.id
deletedBy: currentUser.id, }, {
},
{
transaction, transaction,
}, });
);
await categories.destroy({ await categories.destroy({
transaction, transaction
}); });
return categories; return categories;
@ -128,46 +176,97 @@ module.exports = class CategoriesDBApi {
const output = categories.get({plain: true}); const output = categories.get({plain: true});
return output; return output;
} }
static async findAll(filter, options) { static async findAll(
filter,
options
) {
const limit = filter.limit || 0; const limit = filter.limit || 0;
let offset = 0; let offset = 0;
let where = {}; let where = {};
const andFilters = [];
const currentPage = +filter.page; const currentPage = +filter.page;
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [
];
if (filter) { if (filter) {
if (filter.id) { if (filter.id) {
where.id = Utils.uuid(filter.id); where = {
...where,
['id']: Utils.uuid(filter.id),
};
} }
if (filter.categoryId) {
andFilters.push(Utils.ilike('categories', 'categoryId', filter.categoryId));
}
if (filter.name) { if (filter.name) {
andFilters.push(Utils.ilike('categories', 'name', filter.name)); where = {
...where,
[Op.and]: Utils.ilike(
'categories',
'name',
filter.name,
),
};
} }
if (filter.description) { if (filter.description) {
andFilters.push(Utils.ilike('categories', 'description', filter.description)); where = {
...where,
[Op.and]: Utils.ilike(
'categories',
'description',
filter.description,
),
};
} }
if (filter.active !== undefined) { if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true'; where = {
...where,
active: filter.active === true || filter.active === 'true'
};
} }
if (filter.createdAtRange) { if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange; const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') { if (start !== undefined && start !== null && start !== '') {
where = { where = {
...where, ...where,
createdAt: { ['createdAt']: {
...where.createdAt, ...where.createdAt,
[Op.gte]: start, [Op.gte]: start,
}, },
@ -177,7 +276,7 @@ module.exports = class CategoriesDBApi {
if (end !== undefined && end !== null && end !== '') { if (end !== undefined && end !== null && end !== '') {
where = { where = {
...where, ...where,
createdAt: { ['createdAt']: {
...where.createdAt, ...where.createdAt,
[Op.lte]: end, [Op.lte]: end,
}, },
@ -186,17 +285,18 @@ module.exports = class CategoriesDBApi {
} }
} }
if (andFilters.length) {
where[Op.and] = andFilters;
}
const queryOptions = { const queryOptions = {
where, where,
include: [], include,
distinct: true, distinct: true,
order: filter.field && filter.sort ? [[filter.field, filter.sort]] : [['createdAt', 'desc']], order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options?.transaction, transaction: options?.transaction,
logging: console.log, logging: console.log
}; };
if (!options?.countOnly) { if (!options?.countOnly) {
@ -209,7 +309,7 @@ module.exports = class CategoriesDBApi {
return { return {
rows: options?.countOnly ? [] : rows, rows: options?.countOnly ? [] : rows,
count, count: count
}; };
} catch (error) { } catch (error) {
console.error('Error executing query:', error); console.error('Error executing query:', error);
@ -217,30 +317,38 @@ module.exports = class CategoriesDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset) { static async findAllAutocomplete(query, limit, offset, ) {
let where = {}; let where = {};
if (query) { if (query) {
where = { where = {
[Op.or]: [ [Op.or]: [
{ id: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike('categories', 'categoryId', query), Utils.ilike(
Utils.ilike('categories', 'name', query), 'categories',
'name',
query,
),
], ],
}; };
} }
const records = await db.categories.findAll({ const records = await db.categories.findAll({
attributes: ['id', 'categoryId', 'name'], attributes: [ 'id', 'name' ],
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
order: [['name', 'ASC']], orderBy: [['name', 'ASC']],
}); });
return records.map((record) => ({ return records.map((record) => ({
id: record.id, id: record.id,
label: record.categoryId ? `${record.categoryId} · ${record.name}` : record.name, label: record.name,
})); }));
} }
}; };

View File

@ -1,107 +0,0 @@
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,3 +1,9 @@
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( const categories = sequelize.define(
'categories', 'categories',
@ -7,20 +13,21 @@ module.exports = function (sequelize, DataTypes) {
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
primaryKey: true, primaryKey: true,
}, },
categoryId: {
type: DataTypes.TEXT,
allowNull: false,
unique: true,
},
name: { name: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false,
unique: true,
}, },
description: { description: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: true,
}, },
importHash: { importHash: {
type: DataTypes.STRING(255), type: DataTypes.STRING(255),
allowNull: true, allowNull: true,
@ -35,6 +42,23 @@ module.exports = function (sequelize, DataTypes) {
); );
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',
}); });
@ -44,5 +68,9 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return categories; return categories;
}; };

View File

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

View File

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

View File

@ -1,147 +1,36 @@
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 {
const normalizedData = this.normalizePayload(data); await CategoriesDBApi.create(
this.validatePayload(normalizedData); data,
await this.ensureUniqueFields(normalizedData, transaction); {
await CategoriesDBApi.create(normalizedData, {
currentUser, currentUser,
transaction, 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) { static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
@ -149,21 +38,24 @@ 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')); await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
bufferStream bufferStream
.pipe(csv()) .pipe(csv())
.on('data', (data) => results.push(this.normalizePayload(data))) .on('data', (data) => results.push(data))
.on('end', resolve) .on('end', async () => {
.on('error', reject); console.log('CSV results', results);
}); 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();
@ -175,33 +67,35 @@ 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 {
const categories = await CategoriesDBApi.findBy( let categories = await CategoriesDBApi.findBy(
{id}, {id},
{transaction}, {transaction},
); );
if (!categories) { if (!categories) {
throw new ValidationError('categoriesNotFound'); throw new ValidationError(
'categoriesNotFound',
);
} }
const normalizedData = this.normalizePayload(data); const updatedCategories = await CategoriesDBApi.update(
this.validatePayload(normalizedData); id,
await this.ensureUniqueFields(normalizedData, transaction, id); data,
{
const updatedCategories = await CategoriesDBApi.update(id, normalizedData, {
currentUser, currentUser,
transaction, 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();
@ -223,10 +117,13 @@ module.exports = class CategoriesService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await CategoriesDBApi.remove(id, { await CategoriesDBApi.remove(
id,
{
currentUser, currentUser,
transaction, transaction,
}); },
);
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
@ -234,4 +131,8 @@ module.exports = class CategoriesService {
throw error; throw error;
} }
} }
}; };

View File

@ -1,10 +1,15 @@
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 LoadingSpinner from '../LoadingSpinner'; import {saveFile} from "../../helpers/fileSaver";
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[];
@ -15,35 +20,47 @@ type Props = {
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
}; };
const CardCategories = ({ categories, loading, onDelete, currentPage, numPages, onPageChange }: Props) => { const CardCategories = ({
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle); categories,
loading,
onDelete,
currentPage,
numPages,
onPageChange,
}: Props) => {
const asideScrollbarsStyle = useAppSelector(
(state) => state.style.asideScrollbarsStyle,
);
const bgColor = useAppSelector((state) => state.style.cardsColor); const bgColor = useAppSelector((state) => state.style.cardsColor);
const darkMode = useAppSelector((state) => state.style.darkMode); const darkMode = useAppSelector((state) => state.style.darkMode);
const corners = useAppSelector((state) => state.style.corners); const corners = useAppSelector((state) => state.style.corners);
const focusRing = useAppSelector((state) => state.style.focusRingColor); const focusRing = useAppSelector((state) => state.style.focusRingColor);
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')
return ( return (
<div className='p-4'> <div className={'p-4'}>
{loading && <LoadingSpinner />} {loading && <LoadingSpinner />}
<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'> <ul
{!loading && role='list'
categories.map((item) => ( className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
>
{!loading && categories.map((item, index) => (
<li <li
key={item.id} key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${ 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> <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`}>
<p className='text-xs uppercase tracking-[0.2em] text-gray-500'>Category ID</p>
<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='text-lg font-bold leading-6 line-clamp-1'>
<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
@ -51,34 +68,53 @@ const CardCategories = ({ categories, loading, onDelete, currentPage, numPages,
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'>
<dt className=' text-gray-500 dark:text-dark-600'>Name</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.name }
</div>
</dd>
</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'>Description</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-5'>{item.description || 'No description yet'}</div> <div className='font-medium line-clamp-4'>
</dd> { item.description }
</div> </div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className='text-gray-500 dark:text-dark-600'>Record ID</dt>
<dd className='flex items-start gap-x-2'>
<div className='break-all font-mono text-xs'>{item.id}</div>
</dd> </dd>
</div> </div>
</dl> </dl>
</li> </li>
))} ))}
{!loading && categories.length === 0 && ( {!loading && categories.length === 0 && (
<div className='col-span-full flex h-40 items-center justify-center'> <div className='col-span-full flex items-center justify-center h-40'>
<p>No categories available yet.</p> <p className=''>No data to display</p>
</div> </div>
)} )}
</ul> </ul>
<div className='my-6 flex items-center justify-center'> <div className={'flex items-center justify-center my-6'}>
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} /> <Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div> </div>
</div> </div>
); );

View File

@ -1,132 +0,0 @@
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,11 +1,16 @@
import React from 'react'; import React from 'react';
import CardBox from '../CardBox'; import CardBox from '../CardBox';
import ListActionsPopover from '../ListActionsPopover'; import ImageField from '../ImageField';
import { useAppSelector } from '../../stores/hooks'; import dataFormatter from '../../helpers/dataFormatter';
import { Pagination } from '../Pagination'; import {saveFile} from "../../helpers/fileSaver";
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 Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
import {hasPermission} from "../../helpers/userPermissions";
type Props = { type Props = {
categories: any[]; categories: any[];
@ -17,57 +22,75 @@ type Props = {
}; };
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 items-center overflow-hidden rounded border border-stone-300 dark:bg-dark-900'> <div className={`flex rounded dark:bg-dark-900 border border-stone-300 items-center overflow-hidden`}>
<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 className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>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'>Name</p>
<p className='line-clamp-2'>{item.name}</p>
</div>
<div className='flex-1 px-3'> <div className={'flex-1 px-3'}>
<p className='text-xs text-gray-500'>Description</p> <p className={'text-xs text-gray-500 '}>Description</p>
<p className='line-clamp-2'>{item.description || 'No description yet'}</p> <p className={'line-clamp-2'}>{ item.description }</p>
</div> </div>
</Link> </Link>
<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>
</CardBox> </CardBox>
</div> </div>
))} ))}
{!loading && categories.length === 0 && ( {!loading && categories.length === 0 && (
<div className='col-span-full flex h-40 items-center justify-center'> <div className='col-span-full flex items-center justify-center h-40'>
<p>No categories match the current filters.</p> <p className=''>No data to display</p>
</div> </div>
)} )}
</div> </div>
<div className='my-6 flex items-center justify-center'> <div className={'flex items-center justify-center my-6'}>
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} /> <Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div> </div>
</> </>
); )
}; };
export default ListCategories; export default ListCategories

View File

@ -1,17 +1,35 @@
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 { GridRowParams } from '@mui/x-data-grid'; import {
GridActionsCellItem,
GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid';
import ImageField from '../ImageField';
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter'
import DataGridMultiSelect from "../DataGridMultiSelect";
import ListActionsPopover from '../ListActionsPopover'; import 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 (onDelete: Params, entityName: string, user) => { export const loadColumns = async (
async function callOptionsApi(entityNameValue: string) { onDelete: Params,
if (!hasPermission(user, `READ_${entityNameValue.toUpperCase()}`)) return []; entityName: string,
user
) => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try { try {
const data = await axios(`/${entityNameValue}/autocomplete?limit=100`); const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data; return data.data;
} catch (error) { } catch (error) {
console.log(error); console.log(error);
@ -19,58 +37,62 @@ export const loadColumns = async (onDelete: Params, entityName: string, user) =>
} }
} }
await callOptionsApi(entityName); const hasUpdatePermission = hasPermission(user, 'UPDATE_CATEGORIES')
const hasUpdatePermission = hasPermission(user, 'UPDATE_CATEGORIES');
return [ return [
{
field: 'categoryId',
headerName: 'Category ID',
flex: 1,
minWidth: 160,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell font-medium',
editable: hasUpdatePermission,
},
{ {
field: 'name', field: 'name',
headerName: 'Name', headerName: 'Name',
flex: 1, flex: 1,
minWidth: 180, minWidth: 120,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission, editable: hasUpdatePermission,
}, },
{ {
field: 'description', field: 'description',
headerName: 'Description', headerName: 'Description',
flex: 1.4, flex: 1,
minWidth: 220, minWidth: 120,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
editable: hasUpdatePermission, editable: hasUpdatePermission,
}, },
{ {
field: 'actions', field: 'actions',
type: 'actions', type: 'actions',
minWidth: 30, minWidth: 30,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => [ getActions: (params: GridRowParams) => {
return [
<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,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react' import React, {useEffect, useRef} 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,4 +1,5 @@
import React, { ReactNode, useEffect, useState } from 'react' import React, { ReactNode, useEffect } 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,131 +1,244 @@
import { mdiChartTimelineVariant } from '@mdi/js'; import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
import Head from 'next/head'; import Head from 'next/head'
import React, { ReactElement, useEffect, useMemo, useState } from 'react'; import React, { ReactElement, useEffect, useState } from 'react'
import CardBox from '../../components/CardBox'; import DatePicker from "react-datepicker";
import CategoryForm, { CategoryFormValues } from '../../components/Categories/CategoryForm'; import "react-datepicker/dist/react-datepicker.css";
import LayoutAuthenticated from '../../layouts/Authenticated'; import dayjs from "dayjs";
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; import CardBox from '../../components/CardBox'
import { getPageTitle } from '../../config'; import LayoutAuthenticated from '../../layouts/Authenticated'
import { fetch, update } from '../../stores/categories/categoriesSlice'; import SectionMain from '../../components/SectionMain'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { useRouter } from 'next/router'; import { getPageTitle } from '../../config'
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SelectField } from "../../components/SelectField";
import { SelectFieldMany } from "../../components/SelectFieldMany";
import { SwitchField } from '../../components/SwitchField'
import {RichTextField} from "../../components/RichTextField";
import { update, fetch } from '../../stores/categories/categoriesSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
const emptyValues: CategoryFormValues = {
categoryId: '',
name: '',
description: '',
};
const EditCategoriesPage = () => { const EditCategoriesPage = () => {
const router = useRouter(); const router = useRouter()
const dispatch = useAppDispatch(); const dispatch = useAppDispatch()
const { categories, loading } = useAppSelector((state) => state.categories); const initVals = {
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(() => {
if (typeof id === 'string') { dispatch(fetch({ id: id }))
dispatch(fetch({ id })); }, [id])
useEffect(() => {
if (typeof categories === 'object') {
setInitialValues(categories)
} }
}, [dispatch, id]); }, [categories])
const category = useMemo(() => { useEffect(() => {
if (!categories || Array.isArray(categories)) { if (typeof categories === 'object') {
return null; const newInitialVal = {...initVals};
Object.keys(initVals).forEach(el => newInitialVal[el] = (categories)[el])
setInitialValues(newInitialVal);
} }
}, [categories])
return categories; const handleSubmit = async (data) => {
}, [categories]); await dispatch(update({ id: id, data }))
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 category')}</title> <title>{getPageTitle('Edit categories')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Edit category' main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit categories'} 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 ? (
<CardBox className='border border-gray-200 shadow-sm'> <FormField
<div className='py-16 text-center text-gray-500'>Loading category details...</div> label="Name"
</CardBox> >
) : ( <Field
<div className='grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]'> name="name"
<CardBox className='border border-blue-100 shadow-sm'> placeholder="Name"
<CategoryForm
initialValues={category ? initialValues : emptyValues}
mode='edit'
loading={loading}
submissionError={submissionError}
onSubmit={handleSubmit}
onCancel={() => router.push(`/categories/categories-view/?id=${id}`)}
/> />
</CardBox> </FormField>
<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>
<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> </CardBox>
</div>
)}
</SectionMain> </SectionMain>
</> </>
); )
}; }
EditCategoriesPage.getLayout = function getLayout(page: ReactElement) { EditCategoriesPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission='UPDATE_CATEGORIES'>{page}</LayoutAuthenticated>; return (
}; <LayoutAuthenticated
export default EditCategoriesPage; permission={'UPDATE_CATEGORIES'}
>
{page}
</LayoutAuthenticated>
)
}
export default EditCategoriesPage

View File

@ -1,41 +1,51 @@
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, useMemo, useState } from 'react'; import React, { ReactElement, useState } from 'react'
import BaseButton from '../../components/BaseButton'; import CardBox from '../../components/CardBox'
import CardBox from '../../components/CardBox'; import LayoutAuthenticated from '../../layouts/Authenticated'
import CardBoxModal from '../../components/CardBoxModal'; import SectionMain from '../../components/SectionMain'
import TableCategories from '../../components/Categories/TableCategories'; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import DragDropFilePicker from '../../components/DragDropFilePicker'; import { getPageTitle } from '../../config'
import LayoutAuthenticated from '../../layouts/Authenticated'; import TableCategories from '../../components/Categories/TableCategories'
import SectionMain from '../../components/SectionMain'; import BaseButton from '../../components/BaseButton'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; import axios from "axios";
import { getPageTitle } from '../../config'; import Link from "next/link";
import axios from 'axios'; import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import { hasPermission } from '../../helpers/userPermissions'; import CardBoxModal from "../../components/CardBoxModal";
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 dispatch = useAppDispatch(); const [showTableView, setShowTableView] = useState(false);
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const { count, loading } = useAppSelector((state) => state.categories);
const filters = useMemo(
() => [ const dispatch = useAppDispatch();
{ label: 'Category ID', title: 'categoryId' },
{ label: 'Name', title: 'name' },
{ label: 'Description', title: 'description' }, const [filters] = useState([{label: 'Name', title: 'name'},{label: 'Description', title: 'description'},
],
[],
);
]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_CATEGORIES'); const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_CATEGORIES');
const addFilter = () => { const addFilter = () => {
const newItem = { const newItem = {
id: uniqueId(), id: uniqueId(),
@ -43,25 +53,21 @@ const CategoriesPage = () => {
filterValue: '', filterValue: '',
filterValueFrom: '', filterValueFrom: '',
filterValueTo: '', filterValueTo: '',
selectedField: filters[0].title, selectedField: '',
}, },
}; };
newItem.fields.selectedField = filters[0].title;
setFilterItems([...filterItems, newItem]); setFilterItems([...filterItems, newItem]);
}; };
const getCategoriesCSV = async () => { const getCategoriesCSV = async () => {
const response = await axios({ const response = await axios({url: '/categories?filetype=csv', method: 'GET',responseType: 'blob'});
url: '/categories?filetype=csv', const type = response.headers['content-type']
method: 'GET', const blob = new Blob([response.data], { type: type })
responseType: 'blob', const link = document.createElement('a')
}); link.href = window.URL.createObjectURL(blob)
const type = response.headers['content-type']; link.download = 'categoriesCSV.csv'
const blob = new Blob([response.data], { type }); link.click()
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = 'categories.csv';
link.click();
}; };
const onModalConfirm = async () => { const onModalConfirm = async () => {
@ -82,69 +88,37 @@ const CategoriesPage = () => {
<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} />
{hasCreatePermission && (
<BaseButton
color='info'
label='Upload CSV'
onClick={() => setIsModalActive(true)}
/>
)}
<div className='md:inline-flex items-center ms-auto'>
<div id='delete-rows-button'></div>
</div> </div>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-2xl border border-white/15 bg-white/10 p-4'>
<p className='text-sm text-blue-100'>Active categories</p>
<p className='mt-2 text-3xl font-semibold'>{loading ? '…' : count}</p>
</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>
</div>
</div>
</CardBox> </CardBox>
<div className='mb-6 grid gap-4 xl:grid-cols-[minmax(0,1fr)_280px]'> <CardBox className="mb-6" hasTable>
<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}
@ -152,24 +126,37 @@ const CategoriesPage = () => {
showGrid={false} showGrid={false}
/> />
</CardBox> </CardBox>
</SectionMain>
</SectionMain>
<CardBoxModal <CardBoxModal
title='Upload category CSV' title='Upload CSV'
buttonColor='info' buttonColor='info'
buttonLabel='Confirm' buttonLabel={'Confirm'}
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
isActive={isModalActive} isActive={isModalActive}
onConfirm={onModalConfirm} onConfirm={onModalConfirm}
onCancel={onModalCancel} onCancel={onModalCancel}
> >
<DragDropFilePicker file={csvFile} setFile={setCsvFile} formats={'.csv'} /> <DragDropFilePicker
file={csvFile}
setFile={setCsvFile}
formats={'.csv'}
/>
</CardBoxModal> </CardBoxModal>
</> </>
); )
}; }
CategoriesPage.getLayout = function getLayout(page: ReactElement) { CategoriesTablesPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission='READ_CATEGORIES'>{page}</LayoutAuthenticated>; return (
}; <LayoutAuthenticated
export default CategoriesPage; permission={'READ_CATEGORIES'}
>
{page}
</LayoutAuthenticated>
)
}
export default CategoriesTablesPage

View File

@ -1,101 +1,189 @@
import { mdiChartTimelineVariant } from '@mdi/js'; import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
import Head from 'next/head'; import Head from 'next/head'
import React, { ReactElement, useState } from 'react'; import React, { ReactElement } from 'react'
import CardBox from '../../components/CardBox'; import CardBox from '../../components/CardBox'
import CategoryForm, { CategoryFormValues } from '../../components/Categories/CategoryForm'; import LayoutAuthenticated from '../../layouts/Authenticated'
import LayoutAuthenticated from '../../layouts/Authenticated'; import SectionMain from '../../components/SectionMain'
import SectionMain from '../../components/SectionMain'; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; import { getPageTitle } from '../../config'
import { getPageTitle } from '../../config';
import { create } from '../../stores/categories/categoriesSlice'; import { Field, Form, Formik } from 'formik'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'; import FormField from '../../components/FormField'
import { useRouter } from 'next/router'; import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField";
import { create } from '../../stores/categories/categoriesSlice'
import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
const initialValues = {
const initialValues: CategoryFormValues = {
categoryId: '',
name: '', name: '',
description: '', description: '',
};
const CategoryNewPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const { loading } = useAppSelector((state) => state.categories);
const [submissionError, setSubmissionError] = useState('');
const handleSubmit = async (data: CategoryFormValues) => {
setSubmissionError('');
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 ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Create category')}</title> <title>{getPageTitle('New Item')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Create category' main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox>
<Formik
initialValues={
<div className='mb-6 max-w-3xl'> initialValues
<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'> onSubmit={(values) => handleSubmit(values)}
<CategoryForm >
initialValues={initialValues} <Form>
mode='create'
loading={loading}
submissionError={submissionError}
onSubmit={handleSubmit} <FormField
onCancel={() => router.push('/categories/categories-list')} label="Name"
>
<Field
name="name"
placeholder="Name"
/> />
</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'>
<div className='space-y-5'>
<div>
<p className='text-xs uppercase tracking-[0.2em] text-blue-200'>Recommended setup</p>
<h2 className='mt-2 text-2xl font-semibold'>Keep categories consistent</h2>
</div>
<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>
<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> </CardBox>
</div>
</SectionMain> </SectionMain>
</> </>
); )
}; }
CategoryNewPage.getLayout = function getLayout(page: ReactElement) { CategoriesNew.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission='CREATE_CATEGORIES'>{page}</LayoutAuthenticated>; return (
}; <LayoutAuthenticated
export default CategoryNewPage; permission={'CREATE_CATEGORIES'}
>
{page}
</LayoutAuthenticated>
)
}
export default CategoriesNew

View File

@ -1,140 +1,152 @@
import { mdiEye } from '@mdi/js'; import React, { ReactElement, useEffect } from 'react';
import Head from 'next/head'; import Head from 'next/head'
import React, { ReactElement, useEffect, useMemo } from 'react'; import DatePicker from "react-datepicker";
import BaseButton from '../../components/BaseButton'; import "react-datepicker/dist/react-datepicker.css";
import CardBox from '../../components/CardBox'; import dayjs from "dayjs";
import LayoutAuthenticated from '../../layouts/Authenticated'; import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import SectionMain from '../../components/SectionMain'; import {useRouter} from "next/router";
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; import { fetch } from '../../stores/categories/categoriesSlice'
import { getPageTitle } from '../../config'; import {saveFile} from "../../helpers/fileSaver";
import { fetch } from '../../stores/categories/categoriesSlice'; import dataFormatter from '../../helpers/dataFormatter';
import { useAppDispatch, useAppSelector } from '../../stores/hooks'; import ImageField from "../../components/ImageField";
import { useRouter } from 'next/router'; import LayoutAuthenticated from "../../layouts/Authenticated";
import { hasPermission } from '../../helpers/userPermissions'; import {getPageTitle} from "../../config";
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox";
import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider";
import {mdiChartTimelineVariant} from "@mdi/js";
import {SwitchField} from "../../components/SwitchField";
import FormField from "../../components/FormField";
const CategoriesView = () => { const CategoriesView = () => {
const router = useRouter(); const router = useRouter()
const dispatch = useAppDispatch(); const dispatch = useAppDispatch()
const { categories, loading } = useAppSelector((state) => state.categories); const { categories } = useAppSelector((state) => state.categories)
const { currentUser } = useAppSelector((state) => state.auth);
const { id } = router.query; const { id } = router.query;
useEffect(() => { function removeLastCharacter(str) {
if (typeof id === 'string') { console.log(str,`str`)
dispatch(fetch({ id })); return str.slice(0, -1);
} }
useEffect(() => {
dispatch(fetch({ id }));
}, [dispatch, id]); }, [dispatch, id]);
const category = useMemo(() => {
if (!categories || Array.isArray(categories)) {
return null;
}
return categories;
}, [categories]);
const canUpdate = currentUser ? hasPermission(currentUser, 'UPDATE_CATEGORIES') : false;
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle(category?.name || 'Category details')}</title> <title>{getPageTitle('View categories')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiEye} title='Category detail' main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View categories')} main>
{''} <BaseButton
color='info'
label='Edit'
href={`/categories/categories-edit/?id=${id}`}
/>
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox>
{!category && loading ? (
<CardBox className='border border-gray-200 shadow-sm'>
<div className='py-16 text-center text-gray-500'>Loading category record...</div> <div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Name</p>
<p>{categories?.name}</p>
</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>
) : (
<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 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>
</CardBox>
<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> </SectionMain>
</> </>
); );
}; };
CategoriesView.getLayout = function getLayout(page: ReactElement) { CategoriesView.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission='READ_CATEGORIES'>{page}</LayoutAuthenticated>; return (
}; <LayoutAuthenticated
permission={'READ_CATEGORIES'}
>
{page}
</LayoutAuthenticated>
)
}
export default CategoriesView; export default CategoriesView;

View File

@ -1,118 +1,166 @@
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 ( 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 (
<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('Sales CRM')}</title> <title>{getPageTitle('Starter Page')}</title>
</Head> </Head>
<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'> <SectionFullScreen bg='violet'>
<section className='mx-auto flex min-h-screen max-w-7xl flex-col px-6 py-10 lg:px-10'> <div
<header className='flex items-center justify-between border-b border-white/60 pb-6'> className={`flex ${
<div> contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
<p className='text-xs uppercase tracking-[0.25em] text-blue-600'>Sales CRM Starter</p> } min-h-screen w-full`}
<h1 className='mt-2 text-xl font-semibold'>Internal admin CRM</h1> >
</div> {contentType === 'image' && contentPosition !== 'background'
<div className='flex flex-wrap gap-3'> ? imageBlock(illustrationImage)
<BaseButton href='/login' color='whiteDark' outline label='Login' /> : null}
<BaseButton href='/dashboard' color='info' label='Admin Interface' /> {contentType === 'video' && contentPosition !== 'background'
</div> ? videoBlock(illustrationVideo)
</header> : 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='grid flex-1 gap-8 py-12 lg:grid-cols-[minmax(0,1.05fr)_460px] lg:items-center'> <div className="space-y-3">
<div> <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>
<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'> <p className='text-center text-gray-500'>For guides and documentation please check
First MVP slice implemented: Category CRUD workflow your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<h2 className='mt-6 max-w-3xl text-5xl font-semibold leading-tight tracking-tight text-slate-950'>
Build your Sales CRM from a clean category foundation.
</h2>
<p className='mt-6 max-w-2xl text-lg leading-8 text-slate-600'>
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.
</p>
<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>
<div className='mt-10 grid gap-4 md:grid-cols-3'> <BaseButtons>
<div className='rounded-2xl border border-white/70 bg-white/80 p-5 shadow-sm backdrop-blur'> <BaseButton
<p className='text-sm text-slate-500'>Workflow</p> href='/login'
<p className='mt-2 text-2xl font-semibold'>Create Review Manage</p> label='Login'
</div> color='info'
<div className='rounded-2xl border border-white/70 bg-white/80 p-5 shadow-sm backdrop-blur'> className='w-full'
<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'> </BaseButtons>
<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> </CardBox>
</div> </div>
</section> </div>
</main> </SectionFullScreen>
</> <div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
); );
} }
Starter.getLayout = function getLayout(page: ReactElement) { Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>; return <LayoutGuest>{page}</LayoutGuest>;
}; };