Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
392654669b |
@ -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,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
132
frontend/src/components/Categories/CategoryForm.tsx
Normal file
132
frontend/src/components/Categories/CategoryForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>,
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user