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 FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
module.exports = class CategoriesDBApi {
static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const categories = await db.categories.create(
{
id: data.id || undefined,
name: data.name
||
null
,
description: data.description
||
null
,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
return categories;
}
static async bulkImport(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
// Prepare data - wrapping individual data transformations in a map() method
const categoriesData = data.map((item, index) => ({
id: item.id || undefined,
name: item.name
||
null
,
description: item.description
||
null
,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000),
}));
// Bulk create items
const categories = await db.categories.bulkCreate(categoriesData, { transaction });
// For each item created, replace relation files
return categories;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const categories = await db.categories.findByPk(id, {}, {transaction});
const updatePayload = {};
if (data.name !== undefined) updatePayload.name = data.name;
if (data.description !== undefined) updatePayload.description = data.description;
updatePayload.updatedById = currentUser.id;
await categories.update(updatePayload, {transaction});
return categories;
}
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const categories = await db.categories.findAll({
where: {
id: {
[Op.in]: ids,
},
},
transaction,
});
await db.sequelize.transaction(async (transaction) => {
for (const record of categories) {
await record.update(
{deletedBy: currentUser.id},
{transaction}
);
}
for (const record of categories) {
await record.destroy({transaction});
}
});
return categories;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const categories = await db.categories.findByPk(id, options);
await categories.update({
deletedBy: currentUser.id
}, {
transaction,
});
await categories.destroy({
transaction
});
return categories;
}
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const categories = await db.categories.findOne(
{ where },
{ transaction },
);
if (!categories) {
return categories;
}
const output = categories.get({plain: true});
return output;
}
static async findAll(
filter,
options
) {
const limit = filter.limit || 0;
let offset = 0;
let where = {};
const currentPage = +filter.page;
offset = currentPage * limit;
const orderBy = null;
static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
let include = [
const categories = await db.categories.create(
{
id: data.id || undefined,
categoryId: data.categoryId || null,
name: data.name || null,
description: data.description || null,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
return categories;
}
static async bulkImport(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
];
const categoriesData = data.map((item, index) => ({
id: item.id || undefined,
categoryId: item.categoryId || null,
name: item.name || null,
description: item.description || null,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000),
}));
if (filter) {
if (filter.id) {
where = {
...where,
['id']: Utils.uuid(filter.id),
};
}
const categories = await db.categories.bulkCreate(categoriesData, { transaction });
if (filter.name) {
where = {
...where,
[Op.and]: Utils.ilike(
'categories',
'name',
filter.name,
),
};
}
if (filter.description) {
where = {
...where,
[Op.and]: Utils.ilike(
'categories',
'description',
filter.description,
),
};
}
return categories;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const categories = await db.categories.findByPk(id, {}, { transaction });
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true'
};
}
const updatePayload = {};
if (data.categoryId !== undefined) updatePayload.categoryId = data.categoryId;
if (data.name !== undefined) updatePayload.name = data.name;
if (data.description !== undefined) updatePayload.description = data.description;
updatePayload.updatedById = currentUser.id;
await categories.update(updatePayload, { transaction });
return categories;
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.gte]: start,
},
};
}
const categories = await db.categories.findAll({
where: {
id: {
[Op.in]: ids,
},
},
transaction,
});
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.lte]: end,
},
};
}
}
}
await db.sequelize.transaction(async (nestedTransaction) => {
for (const record of categories) {
await record.update(
{ deletedBy: currentUser.id },
{ transaction: nestedTransaction },
);
}
for (const record of categories) {
await record.destroy({ transaction: nestedTransaction });
}
});
return categories;
}
const queryOptions = {
where,
include,
distinct: true,
order: filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options?.transaction,
logging: console.log
};
static async remove(id, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
if (!options?.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
const categories = await db.categories.findByPk(id, options);
try {
const { rows, count } = await db.categories.findAndCountAll(queryOptions);
await categories.update(
{
deletedBy: currentUser.id,
},
{
transaction,
},
);
return {
rows: options?.countOnly ? [] : rows,
count: count
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
await categories.destroy({
transaction,
});
return categories;
}
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const categories = await db.categories.findOne(
{ where },
{ transaction },
);
if (!categories) {
return categories;
}
static async findAllAutocomplete(query, limit, offset, ) {
let where = {};
const output = categories.get({ plain: true });
if (query) {
where = {
[Op.or]: [
{ ['id']: Utils.uuid(query) },
Utils.ilike(
'categories',
'name',
query,
),
],
};
return output;
}
static async findAll(filter, options) {
const limit = filter.limit || 0;
let offset = 0;
let where = {};
const andFilters = [];
const currentPage = +filter.page;
offset = currentPage * limit;
if (filter) {
if (filter.id) {
where.id = Utils.uuid(filter.id);
}
if (filter.categoryId) {
andFilters.push(Utils.ilike('categories', 'categoryId', filter.categoryId));
}
if (filter.name) {
andFilters.push(Utils.ilike('categories', 'name', filter.name));
}
if (filter.description) {
andFilters.push(Utils.ilike('categories', 'description', filter.description));
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
createdAt: {
...where.createdAt,
[Op.gte]: start,
},
};
}
const records = await db.categories.findAll({
attributes: [ 'id', 'name' ],
where,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
orderBy: [['name', 'ASC']],
});
return records.map((record) => ({
id: record.id,
label: record.name,
}));
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
createdAt: {
...where.createdAt,
[Op.lte]: end,
},
};
}
}
}
if (andFilters.length) {
where[Op.and] = andFilters;
}
const queryOptions = {
where,
include: [],
distinct: true,
order: filter.field && filter.sort ? [[filter.field, filter.sort]] : [['createdAt', 'desc']],
transaction: options?.transaction,
logging: console.log,
};
if (!options?.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await db.categories.findAndCountAll(queryOptions);
return {
rows: options?.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
static async findAllAutocomplete(query, limit, offset) {
let where = {};
if (query) {
where = {
[Op.or]: [
{ id: Utils.uuid(query) },
Utils.ilike('categories', 'categoryId', query),
Utils.ilike('categories', 'name', query),
],
};
}
const records = await db.categories.findAll({
attributes: ['id', 'categoryId', 'name'],
where,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
order: [['name', 'ASC']],
});
return records.map((record) => ({
id: record.id,
label: record.categoryId ? `${record.categoryId} · ${record.name}` : record.name,
}));
}
};

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');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
module.exports = function (sequelize, DataTypes) {
const categories = sequelize.define(
'categories',
{
@ -13,21 +7,20 @@ module.exports = function(sequelize, DataTypes) {
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
categoryId: {
type: DataTypes.TEXT,
allowNull: false,
unique: true,
},
description: {
name: {
type: DataTypes.TEXT,
allowNull: false,
unique: true,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
@ -42,23 +35,6 @@ description: {
);
categories.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
//end loop
db.categories.belongsTo(db.users, {
as: 'createdBy',
});
@ -68,9 +44,5 @@ description: {
});
};
return categories;
};

View File

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

View File

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

View File

@ -1,36 +1,147 @@
const db = require('../db/models');
const CategoriesDBApi = require('../db/api/categories');
const processFile = require("../middlewares/upload");
const processFile = require('../middlewares/upload');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream');
const { Op } = db.Sequelize;
function buildBadRequest(message) {
const error = new Error(message);
error.code = 400;
return error;
}
function normalizeText(value) {
if (typeof value !== 'string') {
return value;
}
return value.trim();
}
function normalizeCategoryId(value) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
return normalized
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.toUpperCase();
}
function normalizeName(value) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
return normalized.replace(/\s+/g, ' ');
}
module.exports = class CategoriesService {
static normalizePayload(data = {}) {
return {
...data,
categoryId: normalizeCategoryId(data.categoryId),
name: normalizeName(data.name),
description: normalizeText(data.description) || null,
};
}
static validatePayload(data) {
if (!data.categoryId) {
throw buildBadRequest('Category ID is required.');
}
if (!/^[A-Z0-9]+(?:-[A-Z0-9]+)*$/.test(data.categoryId)) {
throw buildBadRequest('Category ID can contain only letters, numbers, and hyphens.');
}
if (!data.name) {
throw buildBadRequest('Category name is required.');
}
if (data.name.length < 2) {
throw buildBadRequest('Category name must be at least 2 characters.');
}
if (data.description && data.description.length > 500) {
throw buildBadRequest('Description must be 500 characters or fewer.');
}
}
static async ensureUniqueFields(data, transaction, excludeId = null) {
const categoryIdWhere = {
categoryId: data.categoryId,
};
if (excludeId) {
categoryIdWhere.id = {
[Op.ne]: excludeId,
};
}
const categoryIdExists = await db.categories.findOne({
where: categoryIdWhere,
transaction,
});
if (categoryIdExists) {
throw buildBadRequest('Category ID must be unique.');
}
const nameWhere = {
[Op.and]: [
db.Sequelize.where(
db.Sequelize.fn('lower', db.Sequelize.col('name')),
data.name.toLowerCase(),
),
],
};
if (excludeId) {
nameWhere.id = {
[Op.ne]: excludeId,
};
}
const nameExists = await db.categories.findOne({
where: nameWhere,
transaction,
});
if (nameExists) {
throw buildBadRequest('Category name must be unique.');
}
}
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await CategoriesDBApi.create(
data,
{
currentUser,
transaction,
},
);
const normalizedData = this.normalizePayload(data);
this.validatePayload(normalizedData);
await this.ensureUniqueFields(normalizedData, transaction);
await CategoriesDBApi.create(normalizedData, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
};
}
static async bulkImport(req, res, sendInvitationEmails = true, host) {
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
@ -38,24 +149,21 @@ module.exports = class CategoriesService {
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
.on('data', (data) => results.push(this.normalizePayload(data)))
.on('end', resolve)
.on('error', reject);
});
await CategoriesDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser,
});
await transaction.commit();
@ -67,35 +175,33 @@ module.exports = class CategoriesService {
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let categories = await CategoriesDBApi.findBy(
{id},
{transaction},
const categories = await CategoriesDBApi.findBy(
{ id },
{ transaction },
);
if (!categories) {
throw new ValidationError(
'categoriesNotFound',
);
throw new ValidationError('categoriesNotFound');
}
const updatedCategories = await CategoriesDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
const normalizedData = this.normalizePayload(data);
this.validatePayload(normalizedData);
await this.ensureUniqueFields(normalizedData, transaction, id);
const updatedCategories = await CategoriesDBApi.update(id, normalizedData, {
currentUser,
transaction,
});
await transaction.commit();
return updatedCategories;
} catch (error) {
await transaction.rollback();
throw error;
}
};
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
@ -117,13 +223,10 @@ module.exports = class CategoriesService {
const transaction = await db.sequelize.transaction();
try {
await CategoriesDBApi.remove(
id,
{
currentUser,
transaction,
},
);
await CategoriesDBApi.remove(id, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
@ -131,8 +234,4 @@ module.exports = class CategoriesService {
throw error;
}
}
};

View File

@ -1,15 +1,10 @@
import React from 'react';
import ImageField from '../ImageField';
import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks';
import dataFormatter from '../../helpers/dataFormatter';
import { Pagination } from '../Pagination';
import {saveFile} from "../../helpers/fileSaver";
import LoadingSpinner from "../LoadingSpinner";
import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
import { hasPermission } from '../../helpers/userPermissions';
type Props = {
categories: any[];
@ -20,101 +15,70 @@ type Props = {
onPageChange: (page: number) => void;
};
const CardCategories = ({
categories,
loading,
onDelete,
currentPage,
numPages,
onPageChange,
}: Props) => {
const asideScrollbarsStyle = useAppSelector(
(state) => state.style.asideScrollbarsStyle,
);
const bgColor = useAppSelector((state) => state.style.cardsColor);
const darkMode = useAppSelector((state) => state.style.darkMode);
const corners = useAppSelector((state) => state.style.corners);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CATEGORIES')
const CardCategories = ({ categories, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle);
const bgColor = useAppSelector((state) => state.style.cardsColor);
const darkMode = useAppSelector((state) => state.style.darkMode);
const corners = useAppSelector((state) => state.style.corners);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CATEGORIES');
return (
<div className={'p-4'}>
<div className='p-4'>
{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'
>
{!loading && categories.map((item, index) => (
<li
key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
<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'>
{!loading &&
categories.map((item) => (
<li
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
}`}
>
<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`}>
<Link href={`/categories/categories-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
}`}
>
<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>
<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='mt-3 block text-lg font-bold leading-6 line-clamp-1'>
{item.name}
</Link>
</Link>
</div>
<div className='ml-auto '>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/categories/categories-edit/?id=${item.id}`}
pathView={`/categories/categories-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
<div className='ml-auto'>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/categories/categories-edit/?id=${item.id}`}
pathView={`/categories/categories-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div>
</div>
<dl className='divide-y divide-stone-300 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
<dl className='h-64 overflow-y-auto divide-y divide-stone-300 px-6 py-4 text-sm leading-6 dark:divide-dark-700'>
<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>
<dt className='text-gray-500 dark:text-dark-600'>Description</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-5'>{item.description || 'No description yet'}</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Description</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.description }
</div>
</dd>
<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>
</div>
</dl>
</li>
))}
</dl>
</li>
))}
{!loading && categories.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
<div className='col-span-full flex h-40 items-center justify-center'>
<p>No categories available yet.</p>
</div>
)}
</ul>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
<div className='my-6 flex items-center justify-center'>
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
</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 CardBox from '../CardBox';
import ImageField from '../ImageField';
import dataFormatter from '../../helpers/dataFormatter';
import {saveFile} from "../../helpers/fileSaver";
import ListActionsPopover from "../ListActionsPopover";
import {useAppSelector} from "../../stores/hooks";
import {Pagination} from "../Pagination";
import LoadingSpinner from "../LoadingSpinner";
import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks';
import { Pagination } from '../Pagination';
import LoadingSpinner from '../LoadingSpinner';
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
import { hasPermission } from '../../helpers/userPermissions';
type Props = {
categories: any[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;
numPages: number;
onPageChange: (page: number) => void;
categories: any[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;
numPages: number;
onPageChange: (page: number) => void;
};
const ListCategories = ({ categories, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CATEGORIES')
const corners = useAppSelector((state) => state.style.corners);
const bgColor = useAppSelector((state) => state.style.cardsColor);
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_CATEGORIES');
return (
<>
<div className='relative overflow-x-auto p-4 space-y-4'>
{loading && <LoadingSpinner />}
{!loading && categories.map((item) => (
<div key={item.id}>
<CardBox hasTable isList className={'rounded shadow-none'}>
<div className={`flex rounded dark:bg-dark-900 border border-stone-300 items-center overflow-hidden`}>
<Link
href={`/categories/categories-view/?id=${item.id}`}
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 '}>Name</p>
<p className={'line-clamp-2'}>{ item.name }</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Description</p>
<p className={'line-clamp-2'}>{ item.description }</p>
</div>
</Link>
<ListActionsPopover
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>
)}
return (
<>
<div className='relative space-y-4 p-4'>
{loading && <LoadingSpinner />}
{!loading &&
categories.map((item) => (
<div key={item.id}>
<CardBox hasTable isList className='rounded shadow-none'>
<div className='flex items-center overflow-hidden rounded border border-stone-300 dark:bg-dark-900'>
<Link
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'
>
<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'>
<p className='text-xs text-gray-500'>Name</p>
<p className='line-clamp-2'>{item.name}</p>
</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>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/categories/categories-edit/?id=${item.id}`}
pathView={`/categories/categories-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</CardBox>
</div>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div>
</>
)
))}
{!loading && categories.length === 0 && (
<div className='col-span-full flex h-40 items-center justify-center'>
<p>No categories match the current filters.</p>
</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 BaseIcon from '../BaseIcon';
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
import axios from 'axios';
import {
GridActionsCellItem,
GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid';
import ImageField from '../ImageField';
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter'
import DataGridMultiSelect from "../DataGridMultiSelect";
import { GridRowParams } from '@mui/x-data-grid';
import ListActionsPopover from '../ListActionsPopover';
import {hasPermission} from "../../helpers/userPermissions";
import { hasPermission } from '../../helpers/userPermissions';
type Params = (id: string) => void;
export const loadColumns = async (
onDelete: Params,
entityName: string,
user
) => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
console.log(error);
return [];
}
export const loadColumns = async (onDelete: Params, entityName: string, user) => {
async function callOptionsApi(entityNameValue: string) {
if (!hasPermission(user, `READ_${entityNameValue.toUpperCase()}`)) return [];
try {
const data = await axios(`/${entityNameValue}/autocomplete?limit=100`);
return data.data;
} catch (error) {
console.log(error);
return [];
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_CATEGORIES')
return [
{
field: 'name',
headerName: 'Name',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'description',
headerName: 'Description',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'actions',
type: 'actions',
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/categories/categories-edit/?id=${params?.row?.id}`}
pathView={`/categories/categories-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
]
},
},
];
}
await callOptionsApi(entityName);
const hasUpdatePermission = hasPermission(user, 'UPDATE_CATEGORIES');
return [
{
field: 'categoryId',
headerName: 'Category ID',
flex: 1,
minWidth: 160,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell font-medium',
editable: hasUpdatePermission,
},
{
field: 'name',
headerName: 'Name',
flex: 1,
minWidth: 180,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'description',
headerName: 'Description',
flex: 1.4,
minWidth: 220,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'actions',
type: 'actions',
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/categories/categories-edit/?id=${params?.row?.id}`}
pathView={`/categories/categories-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</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 { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View File

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

View File

@ -1,244 +1,131 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement, useEffect, useState } from 'react'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SelectField } from "../../components/SelectField";
import { SelectFieldMany } from "../../components/SelectFieldMany";
import { SwitchField } from '../../components/SwitchField'
import {RichTextField} from "../../components/RichTextField";
import { update, fetch } from '../../stores/categories/categoriesSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import CardBox from '../../components/CardBox';
import CategoryForm, { CategoryFormValues } from '../../components/Categories/CategoryForm';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { fetch, update } from '../../stores/categories/categoriesSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
const emptyValues: CategoryFormValues = {
categoryId: '',
name: '',
description: '',
};
const EditCategoriesPage = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const initVals = {
'name': '',
description: '',
}
const [initialValues, setInitialValues] = useState(initVals)
const { categories } = useAppSelector((state) => state.categories)
const { id } = router.query
const router = useRouter();
const dispatch = useAppDispatch();
const { categories, loading } = useAppSelector((state) => state.categories);
const [submissionError, setSubmissionError] = useState('');
const { id } = router.query;
useEffect(() => {
dispatch(fetch({ id: id }))
}, [id])
useEffect(() => {
if (typeof categories === 'object') {
setInitialValues(categories)
if (typeof id === 'string') {
dispatch(fetch({ id }));
}
}, [categories])
}, [dispatch, id]);
useEffect(() => {
if (typeof categories === 'object') {
const newInitialVal = {...initVals};
Object.keys(initVals).forEach(el => newInitialVal[el] = (categories)[el])
setInitialValues(newInitialVal);
}
}, [categories])
const category = useMemo(() => {
if (!categories || Array.isArray(categories)) {
return null;
}
const handleSubmit = async (data) => {
await dispatch(update({ id: id, data }))
await router.push('/categories/categories-list')
}
return categories;
}, [categories]);
const initialValues: CategoryFormValues = {
categoryId: category?.categoryId || '',
name: category?.name || '',
description: category?.description || '',
};
const handleSubmit = async (data: CategoryFormValues) => {
if (typeof id !== 'string') {
return;
}
setSubmissionError('');
try {
await dispatch(update({ id, data })).unwrap();
await router.push(`/categories/categories-view/?id=${id}`);
} catch (error) {
setSubmissionError(typeof error === 'string' ? error : 'Unable to save category changes right now.');
}
};
return (
<>
<Head>
<title>{getPageTitle('Edit categories')}</title>
<title>{getPageTitle('Edit category')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit categories'} main>
{''}
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Edit category' main>
{''}
</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>
<FormField
label="Name"
>
<Field
name="name"
placeholder="Name"
/>
</FormField>
{!category && loading ? (
<CardBox className='border border-gray-200 shadow-sm'>
<div className='py-16 text-center text-gray-500'>Loading category details...</div>
</CardBox>
) : (
<div className='grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]'>
<CardBox className='border border-blue-100 shadow-sm'>
<CategoryForm
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>
<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>
<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>
)}
</SectionMain>
</>
)
}
);
};
EditCategoriesPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'UPDATE_CATEGORIES'}
>
{page}
</LayoutAuthenticated>
)
}
return <LayoutAuthenticated permission='UPDATE_CATEGORIES'>{page}</LayoutAuthenticated>;
};
export default EditCategoriesPage
export default EditCategoriesPage;

View File

@ -1,162 +1,175 @@
import { mdiChartTimelineVariant } from '@mdi/js'
import Head from 'next/head'
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react'
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import TableCategories from '../../components/Categories/TableCategories'
import BaseButton from '../../components/BaseButton'
import axios from "axios";
import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import {setRefetch, uploadCsv} from '../../stores/categories/categoriesSlice';
import React, { ReactElement, useMemo, useState } from 'react';
import BaseButton from '../../components/BaseButton';
import CardBox from '../../components/CardBox';
import CardBoxModal from '../../components/CardBoxModal';
import TableCategories from '../../components/Categories/TableCategories';
import DragDropFilePicker from '../../components/DragDropFilePicker';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import axios from 'axios';
import { hasPermission } from '../../helpers/userPermissions';
import { setRefetch, uploadCsv } from '../../stores/categories/categoriesSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import {hasPermission} from "../../helpers/userPermissions";
const CategoriesTablesPage = () => {
const CategoriesPage = () => {
const [filterItems, setFilterItems] = useState([]);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [isModalActive, setIsModalActive] = useState(false);
const [showTableView, setShowTableView] = useState(false);
const { currentUser } = useAppSelector((state) => state.auth);
const dispatch = useAppDispatch();
const { currentUser } = useAppSelector((state) => state.auth);
const { count, loading } = useAppSelector((state) => state.categories);
const [filters] = useState([{label: 'Name', title: 'name'},{label: 'Description', title: 'description'},
]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_CATEGORIES');
const filters = useMemo(
() => [
{ label: 'Category ID', title: 'categoryId' },
{ label: 'Name', title: 'name' },
{ label: 'Description', title: 'description' },
],
[],
);
const addFilter = () => {
const newItem = {
id: uniqueId(),
fields: {
filterValue: '',
filterValueFrom: '',
filterValueTo: '',
selectedField: '',
},
};
newItem.fields.selectedField = filters[0].title;
setFilterItems([...filterItems, newItem]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_CATEGORIES');
const addFilter = () => {
const newItem = {
id: uniqueId(),
fields: {
filterValue: '',
filterValueFrom: '',
filterValueTo: '',
selectedField: filters[0].title,
},
};
const getCategoriesCSV = async () => {
const response = await axios({url: '/categories?filetype=csv', method: 'GET',responseType: 'blob'});
const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = 'categoriesCSV.csv'
link.click()
};
setFilterItems([...filterItems, newItem]);
};
const onModalConfirm = async () => {
if (!csvFile) return;
await dispatch(uploadCsv(csvFile));
dispatch(setRefetch(true));
setCsvFile(null);
setIsModalActive(false);
};
const getCategoriesCSV = async () => {
const response = await axios({
url: '/categories?filetype=csv',
method: 'GET',
responseType: 'blob',
});
const type = response.headers['content-type'];
const blob = new Blob([response.data], { type });
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = 'categories.csv';
link.click();
};
const onModalCancel = () => {
setCsvFile(null);
setIsModalActive(false);
};
const onModalConfirm = async () => {
if (!csvFile) return;
await dispatch(uploadCsv(csvFile));
dispatch(setRefetch(true));
setCsvFile(null);
setIsModalActive(false);
};
const onModalCancel = () => {
setCsvFile(null);
setIsModalActive(false);
};
return (
<>
<Head>
<title>{getPageTitle('Categories')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Categories" main>
{''}
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Categories' main>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/categories/categories-new'} color='info' label='New Item'/>}
<BaseButton
className={'mr-3'}
color='info'
label='Filter'
onClick={addFilter}
/>
<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>
<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'>
<div className='flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between'>
<div className='max-w-2xl'>
<p className='text-xs uppercase tracking-[0.25em] text-blue-100'>Sales CRM starter</p>
<h2 className='mt-3 text-3xl font-semibold'>Category control center</h2>
<p className='mt-4 text-sm leading-7 text-blue-50/90'>
Manage the first CRM master-data table with a proper business key, unique names, CSV import/export,
and a clean admin workflow for your team.
</p>
</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 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
filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters}
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>
</>
)
}
);
};
CategoriesTablesPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'READ_CATEGORIES'}
>
{page}
</LayoutAuthenticated>
)
}
CategoriesPage.getLayout = function getLayout(page: ReactElement) {
return <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 Head from 'next/head'
import React, { ReactElement } from 'react'
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config'
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useState } from 'react';
import CardBox from '../../components/CardBox';
import CategoryForm, { CategoryFormValues } from '../../components/Categories/CategoryForm';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { create } from '../../stores/categories/categoriesSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SwitchField } from '../../components/SwitchField'
const initialValues: CategoryFormValues = {
categoryId: '',
name: '',
description: '',
};
import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField";
const CategoryNewPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const { loading } = useAppSelector((state) => state.categories);
const [submissionError, setSubmissionError] = useState('');
import { create } from '../../stores/categories/categoriesSlice'
import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
const handleSubmit = async (data: CategoryFormValues) => {
setSubmissionError('');
const initialValues = {
name: '',
description: '',
}
try {
await dispatch(create(data)).unwrap();
await router.push('/categories/categories-list');
} catch (error) {
setSubmissionError(typeof error === 'string' ? error : 'Unable to create category right now.');
}
};
const CategoriesNew = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const handleSubmit = async (data) => {
await dispatch(create(data))
await router.push('/categories/categories-list')
}
return (
<>
<Head>
<title>{getPageTitle('New Item')}</title>
<title>{getPageTitle('Create category')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
{''}
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Create category' main>
{''}
</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
label="Name"
>
<Field
name="name"
placeholder="Name"
/>
</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>
<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>
<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>
</SectionMain>
</>
)
}
);
};
CategoriesNew.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'CREATE_CATEGORIES'}
>
{page}
</LayoutAuthenticated>
)
}
CategoryNewPage.getLayout = function getLayout(page: ReactElement) {
return <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 Head from 'next/head'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import {useRouter} from "next/router";
import { fetch } from '../../stores/categories/categoriesSlice'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
import LayoutAuthenticated from "../../layouts/Authenticated";
import {getPageTitle} from "../../config";
import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton";
import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox";
import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider";
import {mdiChartTimelineVariant} from "@mdi/js";
import {SwitchField} from "../../components/SwitchField";
import FormField from "../../components/FormField";
import { mdiEye } from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement, useEffect, useMemo } from 'react';
import BaseButton from '../../components/BaseButton';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { fetch } from '../../stores/categories/categoriesSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { hasPermission } from '../../helpers/userPermissions';
const CategoriesView = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const { categories } = useAppSelector((state) => state.categories)
const router = useRouter();
const dispatch = useAppDispatch();
const { categories, loading } = useAppSelector((state) => state.categories);
const { currentUser } = useAppSelector((state) => state.auth);
const { id } = router.query;
const { id } = router.query;
function removeLastCharacter(str) {
console.log(str,`str`)
return str.slice(0, -1);
useEffect(() => {
if (typeof id === 'string') {
dispatch(fetch({ id }));
}
}, [dispatch, id]);
const category = useMemo(() => {
if (!categories || Array.isArray(categories)) {
return null;
}
useEffect(() => {
dispatch(fetch({ id }));
}, [dispatch, id]);
return categories;
}, [categories]);
const canUpdate = currentUser ? hasPermission(currentUser, 'UPDATE_CATEGORIES') : false;
return (
<>
<Head>
<title>{getPageTitle('View categories')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View categories')} main>
<BaseButton
color='info'
label='Edit'
href={`/categories/categories-edit/?id=${id}`}
/>
</SectionTitleLineWithButton>
<CardBox>
return (
<>
<Head>
<title>{getPageTitle(category?.name || 'Category details')}</title>
</Head>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Name</p>
<p>{categories?.name}</p>
<SectionMain>
<SectionTitleLineWithButton icon={mdiEye} title='Category detail' main>
{''}
</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 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>
<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')}
/>
<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>
</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) {
return (
<LayoutAuthenticated
permission={'READ_CATEGORIES'}
>
{page}
</LayoutAuthenticated>
)
}
return <LayoutAuthenticated permission='READ_CATEGORIES'>{page}</LayoutAuthenticated>;
};
export default CategoriesView;
export default CategoriesView;

View File

@ -1,166 +1,118 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const featureCards = [
{
title: 'Category master data',
description: 'Create, edit, review, and delete clean sales categories with a dedicated business key and unique naming rules.',
},
{
title: 'Admin-ready workflow',
description: 'Use the login-protected CRM interface for your team while keeping a polished public landing page for orientation.',
},
{
title: 'Ready for expansion',
description: 'This first slice sets up the structure for future Leads, Deals, Customers, and pipeline reporting.',
},
];
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'Sales CRM Starter'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<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 (
<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>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Sales CRM')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{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>
<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'>
<section className='mx-auto flex min-h-screen max-w-7xl flex-col px-6 py-10 lg:px-10'>
<header className='flex items-center justify-between border-b border-white/60 pb-6'>
<div>
<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>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/login' color='whiteDark' outline label='Login' />
<BaseButton href='/dashboard' color='info' label='Admin Interface' />
</div>
</header>
</BaseButtons>
</CardBox>
</div>
</div>
</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 className='grid flex-1 gap-8 py-12 lg:grid-cols-[minmax(0,1.05fr)_460px] lg:items-center'>
<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'>
First MVP slice implemented: Category CRUD workflow
</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>
<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) {
return <LayoutGuest>{page}</LayoutGuest>;
};