adding tags entity

This commit is contained in:
Flatlogic Bot 2025-04-05 11:34:42 +00:00
parent b473ee6362
commit 26c535c419
41 changed files with 3595 additions and 147 deletions

View File

@ -152,6 +152,10 @@ module.exports = class OrganizationsDBApi {
transaction,
});
output.tags_organizations = await organizations.getTags_organizations({
transaction,
});
return output;
}

302
backend/src/db/api/tags.js Normal file
View File

@ -0,0 +1,302 @@
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 TagsDBApi {
static async create(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const tags = await db.tags.create(
{
id: data.id || undefined,
name: data.name || null,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await tags.setOrganizations(data.organizations || null, {
transaction,
});
return tags;
}
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 tagsData = data.map((item, index) => ({
id: item.id || undefined,
name: item.name || null,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000),
}));
// Bulk create items
const tags = await db.tags.bulkCreate(tagsData, { transaction });
// For each item created, replace relation files
return tags;
}
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const globalAccess = currentUser.app_role?.globalAccess;
const tags = await db.tags.findByPk(id, {}, { transaction });
const updatePayload = {};
if (data.name !== undefined) updatePayload.name = data.name;
updatePayload.updatedById = currentUser.id;
await tags.update(updatePayload, { transaction });
if (data.organizations !== undefined) {
await tags.setOrganizations(
data.organizations,
{ transaction },
);
}
return tags;
}
static async deleteByIds(ids, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const tags = await db.tags.findAll({
where: {
id: {
[Op.in]: ids,
},
},
transaction,
});
await db.sequelize.transaction(async (transaction) => {
for (const record of tags) {
await record.update({ deletedBy: currentUser.id }, { transaction });
}
for (const record of tags) {
await record.destroy({ transaction });
}
});
return tags;
}
static async remove(id, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const tags = await db.tags.findByPk(id, options);
await tags.update(
{
deletedBy: currentUser.id,
},
{
transaction,
},
);
await tags.destroy({
transaction,
});
return tags;
}
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const tags = await db.tags.findOne({ where }, { transaction });
if (!tags) {
return tags;
}
const output = tags.get({ plain: true });
output.organizations = await tags.getOrganizations({
transaction,
});
return output;
}
static async findAll(filter, globalAccess, options) {
const limit = filter.limit || 0;
let offset = 0;
let where = {};
const currentPage = +filter.page;
const user = (options && options.currentUser) || null;
const userOrganizations = (user && user.organizations?.id) || null;
if (userOrganizations) {
if (options?.currentUser?.organizationsId) {
where.organizationsId = options.currentUser.organizationsId;
}
}
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [
{
model: db.organizations,
as: 'organizations',
},
];
if (filter) {
if (filter.id) {
where = {
...where,
['id']: Utils.uuid(filter.id),
};
}
if (filter.name) {
where = {
...where,
[Op.and]: Utils.ilike('tags', 'name', filter.name),
};
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true',
};
}
if (filter.organizations) {
const listItems = filter.organizations.split('|').map((item) => {
return Utils.uuid(item);
});
where = {
...where,
organizationsId: { [Op.or]: listItems },
};
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.lte]: end,
},
};
}
}
}
if (globalAccess) {
delete where.organizationsId;
}
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.tags.findAndCountAll(queryOptions);
return {
rows: options?.countOnly ? [] : rows,
count: count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
static async findAllAutocomplete(
query,
limit,
offset,
globalAccess,
organizationId,
) {
let where = {};
if (!globalAccess && organizationId) {
where.organizationId = organizationId;
}
if (query) {
where = {
[Op.or]: [
{ ['id']: Utils.uuid(query) },
Utils.ilike('tags', 'name', query),
],
};
}
const records = await db.tags.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,
}));
}
};

View File

@ -43,6 +43,10 @@ module.exports = class TasksDBApi {
transaction,
});
await tasks.setTags(data.tags || [], {
transaction,
});
return tasks;
}
@ -130,6 +134,10 @@ module.exports = class TasksDBApi {
);
}
if (data.tags !== undefined) {
await tasks.setTags(data.tags, { transaction });
}
return tasks;
}
@ -207,6 +215,10 @@ module.exports = class TasksDBApi {
transaction,
});
output.tags = await tasks.getTags({
transaction,
});
return output;
}
@ -314,6 +326,12 @@ module.exports = class TasksDBApi {
model: db.organizations,
as: 'organizations',
},
{
model: db.tags,
as: 'tags',
required: false,
},
];
if (filter) {
@ -411,6 +429,38 @@ module.exports = class TasksDBApi {
};
}
if (filter.tags) {
const searchTerms = filter.tags.split('|');
include = [
{
model: db.tags,
as: 'tags_filter',
required: searchTerms.length > 0,
where:
searchTerms.length > 0
? {
[Op.or]: [
{
id: {
[Op.in]: searchTerms.map((term) => Utils.uuid(term)),
},
},
{
name: {
[Op.or]: searchTerms.map((term) => ({
[Op.iLike]: `%${term}%`,
})),
},
},
],
}
: undefined,
},
...include,
];
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;

View File

@ -0,0 +1,90 @@
module.exports = {
/**
* @param {QueryInterface} queryInterface
* @param {Sequelize} Sequelize
* @returns {Promise<void>}
*/
async up(queryInterface, Sequelize) {
/**
* @type {Transaction}
*/
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.createTable(
'tags',
{
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
createdById: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'users',
},
},
updatedById: {
type: Sequelize.DataTypes.UUID,
references: {
key: 'id',
model: 'users',
},
},
createdAt: { type: Sequelize.DataTypes.DATE },
updatedAt: { type: Sequelize.DataTypes.DATE },
deletedAt: { type: Sequelize.DataTypes.DATE },
importHash: {
type: Sequelize.DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{ transaction },
);
await queryInterface.addColumn(
'tags',
'organizationsId',
{
type: Sequelize.DataTypes.UUID,
references: {
model: 'organizations',
key: 'id',
},
},
{ transaction },
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
/**
* @param {QueryInterface} queryInterface
* @param {Sequelize} Sequelize
* @returns {Promise<void>}
*/
async down(queryInterface, Sequelize) {
/**
* @type {Transaction}
*/
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.removeColumn('tags', 'organizationsId', {
transaction,
});
await queryInterface.dropTable('tags', { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
};

View File

@ -0,0 +1,47 @@
module.exports = {
/**
* @param {QueryInterface} queryInterface
* @param {Sequelize} Sequelize
* @returns {Promise<void>}
*/
async up(queryInterface, Sequelize) {
/**
* @type {Transaction}
*/
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.addColumn(
'tags',
'name',
{
type: Sequelize.DataTypes.TEXT,
},
{ transaction },
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
/**
* @param {QueryInterface} queryInterface
* @param {Sequelize} Sequelize
* @returns {Promise<void>}
*/
async down(queryInterface, Sequelize) {
/**
* @type {Transaction}
*/
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.removeColumn('tags', 'name', { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
};

View File

@ -0,0 +1,36 @@
module.exports = {
/**
* @param {QueryInterface} queryInterface
* @param {Sequelize} Sequelize
* @returns {Promise<void>}
*/
async up(queryInterface, Sequelize) {
/**
* @type {Transaction}
*/
const transaction = await queryInterface.sequelize.transaction();
try {
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
/**
* @param {QueryInterface} queryInterface
* @param {Sequelize} Sequelize
* @returns {Promise<void>}
*/
async down(queryInterface, Sequelize) {
/**
* @type {Transaction}
*/
const transaction = await queryInterface.sequelize.transaction();
try {
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
};

View File

@ -66,6 +66,14 @@ module.exports = function (sequelize, DataTypes) {
constraints: false,
});
db.organizations.hasMany(db.tags, {
as: 'tags_organizations',
foreignKey: {
name: 'organizationsId',
},
constraints: false,
});
//end loop
db.organizations.belongsTo(db.users, {

View File

@ -0,0 +1,57 @@
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) {
const tags = sequelize.define(
'tags',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
name: {
type: DataTypes.TEXT,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
tags.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.tags.belongsTo(db.organizations, {
as: 'organizations',
foreignKey: {
name: 'organizationsId',
},
constraints: false,
});
db.tags.belongsTo(db.users, {
as: 'createdBy',
});
db.tags.belongsTo(db.users, {
as: 'updatedBy',
});
};
return tags;
};

View File

@ -50,6 +50,24 @@ module.exports = function (sequelize, DataTypes) {
);
tasks.associate = (db) => {
db.tasks.belongsToMany(db.tags, {
as: 'tags',
foreignKey: {
name: 'tasks_tagsId',
},
constraints: false,
through: 'tasksTagsTags',
});
db.tasks.belongsToMany(db.tags, {
as: 'tags_filter',
foreignKey: {
name: 'tasks_tagsId',
},
constraints: false,
through: 'tasksTagsTags',
});
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
//end loop

View File

@ -83,6 +83,7 @@ module.exports = {
'roles',
'permissions',
'organizations',
'tags',
,
];
await queryInterface.bulkInsert(
@ -223,6 +224,31 @@ primary key ("roles_permissionsId", "permissionId")
permissionId: getId('DELETE_TASKS'),
},
{
createdAt,
updatedAt,
roles_permissionsId: getId('User'),
permissionId: getId('CREATE_TAGS'),
},
{
createdAt,
updatedAt,
roles_permissionsId: getId('User'),
permissionId: getId('READ_TAGS'),
},
{
createdAt,
updatedAt,
roles_permissionsId: getId('User'),
permissionId: getId('UPDATE_TAGS'),
},
{
createdAt,
updatedAt,
roles_permissionsId: getId('User'),
permissionId: getId('DELETE_TAGS'),
},
{
createdAt,
updatedAt,
@ -330,6 +356,31 @@ primary key ("roles_permissionsId", "permissionId")
permissionId: getId('DELETE_TASKS'),
},
{
createdAt,
updatedAt,
roles_permissionsId: getId('Administrator'),
permissionId: getId('CREATE_TAGS'),
},
{
createdAt,
updatedAt,
roles_permissionsId: getId('Administrator'),
permissionId: getId('READ_TAGS'),
},
{
createdAt,
updatedAt,
roles_permissionsId: getId('Administrator'),
permissionId: getId('UPDATE_TAGS'),
},
{
createdAt,
updatedAt,
roles_permissionsId: getId('Administrator'),
permissionId: getId('DELETE_TAGS'),
},
{
createdAt,
updatedAt,
@ -505,6 +556,31 @@ primary key ("roles_permissionsId", "permissionId")
permissionId: getId('DELETE_ORGANIZATIONS'),
},
{
createdAt,
updatedAt,
roles_permissionsId: getId('SuperAdmin'),
permissionId: getId('CREATE_TAGS'),
},
{
createdAt,
updatedAt,
roles_permissionsId: getId('SuperAdmin'),
permissionId: getId('READ_TAGS'),
},
{
createdAt,
updatedAt,
roles_permissionsId: getId('SuperAdmin'),
permissionId: getId('UPDATE_TAGS'),
},
{
createdAt,
updatedAt,
roles_permissionsId: getId('SuperAdmin'),
permissionId: getId('DELETE_TAGS'),
},
{
createdAt,
updatedAt,

View File

@ -9,7 +9,41 @@ const Tasks = db.tasks;
const Organizations = db.organizations;
const Tags = db.tags;
const CategoriesData = [
{
name: 'Jonas Salk',
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Francis Crick',
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Pierre Simon de Laplace',
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Andreas Vesalius',
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Justus Liebig',
@ -17,44 +51,63 @@ const CategoriesData = [
// type code here for "relation_one" field
},
{
name: 'John Bardeen',
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Jean Piaget',
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Sheldon Glashow',
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Linus Pauling',
// type code here for "relation_many" field
// type code here for "relation_one" field
},
];
const ProjectsData = [
{
name: 'Archimedes',
description:
'Like fire across the galaxy the Clone Wars spread. In league with the wicked Count Dooku, more and more planets slip. Against this threat, upon the Jedi Knights falls the duty to lead the newly formed army of the Republic. And as the heat of war grows, so, to, grows the prowess of one most gifted student of the Force.',
// type code here for "relation_many" field
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Claude Levi-Strauss',
description:
'Strong is Vader. Mind what you have learned. Save you it can.',
// type code here for "relation_many" field
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Francis Galton',
description:
'Soon will I rest, yes, forever sleep. Earned it I have. Twilight is upon me, soon night must fall.',
// type code here for "relation_many" field
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Charles Sherrington',
description: 'To answer power with power, the Jedi way this is',
// type code here for "relation_many" field
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Ludwig Boltzmann',
description:
'Death is a natural part of life. Rejoice for those around you who transform into the Force. Mourn them do not. Miss them do not. Attachment leads to jealously. The shadow of greed, that is.',
@ -64,81 +117,14 @@ const ProjectsData = [
// type code here for "relation_one" field
},
{
name: 'Max von Laue',
description: 'You will find only what you bring in.',
// type code here for "relation_many" field
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Gertrude Belle Elion',
description: 'Already know you that which you need.',
// type code here for "relation_many" field
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Emil Kraepelin',
description: 'Good relations with the Wookiees, I have.',
// type code here for "relation_many" field
// type code here for "relation_many" field
// type code here for "relation_one" field
},
{
name: 'Ernst Haeckel',
description: 'Truly wonderful, the mind of a child is.',
// type code here for "relation_many" field
// type code here for "relation_many" field
// type code here for "relation_one" field
},
];
const TasksData = [
{
title: 'I want my damn cart back',
title: 'No one tells me shit',
description: 'At an end your rule is, and not short enough it was!',
status: 'InProgress',
// type code here for "relation_one" field
// type code here for "relation_one" field
// type code here for "relation_one" field
start_date: new Date('2024-05-30'),
end_date: new Date('2024-12-14'),
// type code here for "relation_one" field
},
{
title: 'Yup',
description: 'Younglings, younglings gather round.',
description:
'Through the Force, things you will see. Other places. The future - the past. Old friends long gone.',
status: 'ToDo',
@ -148,20 +134,44 @@ const TasksData = [
// type code here for "relation_one" field
start_date: new Date('2024-09-14'),
start_date: new Date('2024-08-22'),
end_date: new Date('2024-11-06'),
end_date: new Date('2024-04-20'),
// type code here for "relation_one" field
// type code here for "relation_many" field
},
{
title: 'Turd gone wrong',
title: 'No one tells me shit',
description: 'That is why you fail.',
status: 'InProgress',
// type code here for "relation_one" field
// type code here for "relation_one" field
// type code here for "relation_one" field
start_date: new Date('2024-07-25'),
end_date: new Date('2025-01-16'),
// type code here for "relation_one" field
// type code here for "relation_many" field
},
{
title: 'Let me tell ya',
description:
'Much to learn you still have my old padawan. ... This is just the beginning!',
status: 'InProgress',
status: 'ToDo',
// type code here for "relation_one" field
@ -169,37 +179,19 @@ const TasksData = [
// type code here for "relation_one" field
start_date: new Date('2024-11-17'),
start_date: new Date('2024-10-08'),
end_date: new Date('2024-06-18'),
end_date: new Date('2024-05-10'),
// type code here for "relation_one" field
// type code here for "relation_many" field
},
{
title: 'I want my damn cart back',
title: "How 'bout them Cowboys",
description: 'Ow, ow, OW! On my ear you are!',
status: 'InProgress',
// type code here for "relation_one" field
// type code here for "relation_one" field
// type code here for "relation_one" field
start_date: new Date('2025-02-28'),
end_date: new Date('2024-04-05'),
// type code here for "relation_one" field
},
{
title: 'My buddy Harlen',
description: 'Around the survivors a perimeter create.',
description: 'Luminous beings are we - not this crude matter.',
status: 'ToDo',
@ -209,34 +201,90 @@ const TasksData = [
// type code here for "relation_one" field
start_date: new Date('2024-10-19'),
start_date: new Date('2024-12-06'),
end_date: new Date('2024-06-01'),
end_date: new Date('2025-03-25'),
// type code here for "relation_one" field
// type code here for "relation_many" field
},
{
title: 'I want my 5$ back',
description: 'Not if anything to say about it I have',
status: 'ToDo',
// type code here for "relation_one" field
// type code here for "relation_one" field
// type code here for "relation_one" field
start_date: new Date('2024-07-05'),
end_date: new Date('2024-06-13'),
// type code here for "relation_one" field
// type code here for "relation_many" field
},
];
const OrganizationsData = [
{
name: 'Alfred Kinsey',
name: 'Pierre Simon de Laplace',
},
{
name: 'Jean Piaget',
},
{
name: 'Rudolf Virchow',
},
{
name: 'Werner Heisenberg',
name: 'Isaac Newton',
},
{
name: 'Enrico Fermi',
},
{
name: 'Isaac Newton',
},
{
name: 'Ernst Haeckel',
},
];
const TagsData = [
{
// type code here for "relation_one" field
name: 'Enrico Fermi',
},
{
// type code here for "relation_one" field
name: 'Francis Galton',
},
{
// type code here for "relation_one" field
name: 'Anton van Leeuwenhoek',
},
{
// type code here for "relation_one" field
name: 'John von Neumann',
},
{
// type code here for "relation_one" field
name: 'Alfred Binet',
},
];
// Similar logic for "relation_many"
@ -646,6 +694,65 @@ async function associateTaskWithOrganization() {
}
}
// Similar logic for "relation_many"
async function associateTagWithOrganization() {
const relatedOrganization0 = await Organizations.findOne({
offset: Math.floor(Math.random() * (await Organizations.count())),
});
const Tag0 = await Tags.findOne({
order: [['id', 'ASC']],
offset: 0,
});
if (Tag0?.setOrganization) {
await Tag0.setOrganization(relatedOrganization0);
}
const relatedOrganization1 = await Organizations.findOne({
offset: Math.floor(Math.random() * (await Organizations.count())),
});
const Tag1 = await Tags.findOne({
order: [['id', 'ASC']],
offset: 1,
});
if (Tag1?.setOrganization) {
await Tag1.setOrganization(relatedOrganization1);
}
const relatedOrganization2 = await Organizations.findOne({
offset: Math.floor(Math.random() * (await Organizations.count())),
});
const Tag2 = await Tags.findOne({
order: [['id', 'ASC']],
offset: 2,
});
if (Tag2?.setOrganization) {
await Tag2.setOrganization(relatedOrganization2);
}
const relatedOrganization3 = await Organizations.findOne({
offset: Math.floor(Math.random() * (await Organizations.count())),
});
const Tag3 = await Tags.findOne({
order: [['id', 'ASC']],
offset: 3,
});
if (Tag3?.setOrganization) {
await Tag3.setOrganization(relatedOrganization3);
}
const relatedOrganization4 = await Organizations.findOne({
offset: Math.floor(Math.random() * (await Organizations.count())),
});
const Tag4 = await Tags.findOne({
order: [['id', 'ASC']],
offset: 4,
});
if (Tag4?.setOrganization) {
await Tag4.setOrganization(relatedOrganization4);
}
}
module.exports = {
up: async (queryInterface, Sequelize) => {
await Categories.bulkCreate(CategoriesData);
@ -656,6 +763,8 @@ module.exports = {
await Organizations.bulkCreate(OrganizationsData);
await Tags.bulkCreate(TagsData);
await Promise.all([
// Similar logic for "relation_many"
@ -678,6 +787,10 @@ module.exports = {
await associateTaskWithCategory(),
await associateTaskWithOrganization(),
// Similar logic for "relation_many"
await associateTagWithOrganization(),
]);
},
@ -689,5 +802,7 @@ module.exports = {
await queryInterface.bulkDelete('tasks', null, {});
await queryInterface.bulkDelete('organizations', null, {});
await queryInterface.bulkDelete('tags', null, {});
},
};

View File

@ -0,0 +1,87 @@
const { v4: uuid } = require('uuid');
const db = require('../models');
const Sequelize = require('sequelize');
const config = require('../../config');
module.exports = {
/**
* @param{import("sequelize").QueryInterface} queryInterface
* @return {Promise<void>}
*/
async up(queryInterface) {
const createdAt = new Date();
const updatedAt = new Date();
/** @type {Map<string, string>} */
const idMap = new Map();
/**
* @param {string} key
* @return {string}
*/
function getId(key) {
if (idMap.has(key)) {
return idMap.get(key);
}
const id = uuid();
idMap.set(key, id);
return id;
}
/**
* @param {string} name
*/
function createPermissions(name) {
return [
{
id: getId(`CREATE_${name.toUpperCase()}`),
createdAt,
updatedAt,
name: `CREATE_${name.toUpperCase()}`,
},
{
id: getId(`READ_${name.toUpperCase()}`),
createdAt,
updatedAt,
name: `READ_${name.toUpperCase()}`,
},
{
id: getId(`UPDATE_${name.toUpperCase()}`),
createdAt,
updatedAt,
name: `UPDATE_${name.toUpperCase()}`,
},
{
id: getId(`DELETE_${name.toUpperCase()}`),
createdAt,
updatedAt,
name: `DELETE_${name.toUpperCase()}`,
},
];
}
const entities = ['tags'];
const createdPermissions = entities.flatMap(createPermissions);
// Add permissions to database
await queryInterface.bulkInsert('permissions', createdPermissions);
// Get permissions ids
const permissionsIds = createdPermissions.map((p) => p.id);
// Get admin role
const adminRole = await db.roles.findOne({
where: { name: config.roles.super_admin },
});
if (adminRole) {
// Add permissions to admin role if it exists
await adminRole.addPermissions(permissionsIds);
}
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete(
'permissions',
entities.flatMap(createPermissions),
);
},
};

View File

@ -33,6 +33,8 @@ const permissionsRoutes = require('./routes/permissions');
const organizationsRoutes = require('./routes/organizations');
const tagsRoutes = require('./routes/tags');
const getBaseUrl = (url) => {
if (!url) return '';
return url.endsWith('/api') ? url.slice(0, -4) : url;
@ -140,6 +142,12 @@ app.use(
organizationsRoutes,
);
app.use(
'/api/tags',
passport.authenticate('jwt', { session: false }),
tagsRoutes,
);
app.use(
'/api/openai',
passport.authenticate('jwt', { session: false }),

447
backend/src/routes/tags.js Normal file
View File

@ -0,0 +1,447 @@
const express = require('express');
const TagsService = require('../services/tags');
const TagsDBApi = require('../db/api/tags');
const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router();
const { parse } = require('json2csv');
const { checkCrudPermissions } = require('../middlewares/check-permissions');
router.use(checkCrudPermissions('tags'));
/**
* @swagger
* components:
* schemas:
* Tags:
* type: object
* properties:
* name:
* type: string
* default: name
*/
/**
* @swagger
* tags:
* name: Tags
* description: The Tags managing API
*/
/**
* @swagger
* /api/tags:
* post:
* security:
* - bearerAuth: []
* tags: [Tags]
* summary: Add new item
* description: Add new item
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* data:
* description: Data of the updated item
* type: object
* $ref: "#/components/schemas/Tags"
* responses:
* 200:
* description: The item was successfully added
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Tags"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 405:
* description: Invalid input data
* 500:
* description: Some server error
*/
router.post(
'/',
wrapAsync(async (req, res) => {
const referer =
req.headers.referer ||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await TagsService.create(req.body.data, req.currentUser, true, link.host);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/budgets/bulk-import:
* post:
* security:
* - bearerAuth: []
* tags: [Tags]
* summary: Bulk import items
* description: Bulk import items
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* data:
* description: Data of the updated items
* type: array
* items:
* $ref: "#/components/schemas/Tags"
* responses:
* 200:
* description: The items were successfully imported
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Tags"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 405:
* description: Invalid input data
* 500:
* description: Some server error
*
*/
router.post(
'/bulk-import',
wrapAsync(async (req, res) => {
const referer =
req.headers.referer ||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await TagsService.bulkImport(req, res, true, link.host);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/tags/{id}:
* put:
* security:
* - bearerAuth: []
* tags: [Tags]
* summary: Update the data of the selected item
* description: Update the data of the selected item
* parameters:
* - in: path
* name: id
* description: Item ID to update
* required: true
* schema:
* type: string
* requestBody:
* description: Set new item data
* required: true
* content:
* application/json:
* schema:
* properties:
* id:
* description: ID of the updated item
* type: string
* data:
* description: Data of the updated item
* type: object
* $ref: "#/components/schemas/Tags"
* required:
* - id
* responses:
* 200:
* description: The item data was successfully updated
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Tags"
* 400:
* description: Invalid ID supplied
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
*/
router.put(
'/:id',
wrapAsync(async (req, res) => {
await TagsService.update(req.body.data, req.body.id, req.currentUser);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/tags/{id}:
* delete:
* security:
* - bearerAuth: []
* tags: [Tags]
* summary: Delete the selected item
* description: Delete the selected item
* parameters:
* - in: path
* name: id
* description: Item ID to delete
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The item was successfully deleted
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Tags"
* 400:
* description: Invalid ID supplied
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
*/
router.delete(
'/:id',
wrapAsync(async (req, res) => {
await TagsService.remove(req.params.id, req.currentUser);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/tags/deleteByIds:
* post:
* security:
* - bearerAuth: []
* tags: [Tags]
* summary: Delete the selected item list
* description: Delete the selected item list
* requestBody:
* required: true
* content:
* application/json:
* schema:
* properties:
* ids:
* description: IDs of the updated items
* type: array
* responses:
* 200:
* description: The items was successfully deleted
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Tags"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Items not found
* 500:
* description: Some server error
*/
router.post(
'/deleteByIds',
wrapAsync(async (req, res) => {
await TagsService.deleteByIds(req.body.data, req.currentUser);
const payload = true;
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/tags:
* get:
* security:
* - bearerAuth: []
* tags: [Tags]
* summary: Get all tags
* description: Get all tags
* responses:
* 200:
* description: Tags list successfully received
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: "#/components/schemas/Tags"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Data not found
* 500:
* description: Some server error
*/
router.get(
'/',
wrapAsync(async (req, res) => {
const filetype = req.query.filetype;
const globalAccess = req.currentUser.app_role.globalAccess;
const currentUser = req.currentUser;
const payload = await TagsDBApi.findAll(req.query, globalAccess, {
currentUser,
});
if (filetype && filetype === 'csv') {
const fields = ['id', 'name'];
const opts = { fields };
try {
const csv = parse(payload.rows, opts);
res.status(200).attachment(csv);
res.send(csv);
} catch (err) {
console.error(err);
}
} else {
res.status(200).send(payload);
}
}),
);
/**
* @swagger
* /api/tags/count:
* get:
* security:
* - bearerAuth: []
* tags: [Tags]
* summary: Count all tags
* description: Count all tags
* responses:
* 200:
* description: Tags count successfully received
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: "#/components/schemas/Tags"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Data not found
* 500:
* description: Some server error
*/
router.get(
'/count',
wrapAsync(async (req, res) => {
const globalAccess = req.currentUser.app_role.globalAccess;
const currentUser = req.currentUser;
const payload = await TagsDBApi.findAll(req.query, globalAccess, {
countOnly: true,
currentUser,
});
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/tags/autocomplete:
* get:
* security:
* - bearerAuth: []
* tags: [Tags]
* summary: Find all tags that match search criteria
* description: Find all tags that match search criteria
* responses:
* 200:
* description: Tags list successfully received
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: "#/components/schemas/Tags"
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Data not found
* 500:
* description: Some server error
*/
router.get('/autocomplete', async (req, res) => {
const globalAccess = req.currentUser.app_role.globalAccess;
const organizationId = req.currentUser.organization?.id;
const payload = await TagsDBApi.findAllAutocomplete(
req.query.query,
req.query.limit,
req.query.offset,
globalAccess,
organizationId,
);
res.status(200).send(payload);
});
/**
* @swagger
* /api/tags/{id}:
* get:
* security:
* - bearerAuth: []
* tags: [Tags]
* summary: Get selected item
* description: Get selected item
* parameters:
* - in: path
* name: id
* description: ID of item to get
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Selected item successfully received
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/Tags"
* 400:
* description: Invalid ID supplied
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 404:
* description: Item not found
* 500:
* description: Some server error
*/
router.get(
'/:id',
wrapAsync(async (req, res) => {
const payload = await TagsDBApi.findBy({ id: req.params.id });
res.status(200).send(payload);
}),
);
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;

View File

@ -50,6 +50,8 @@ module.exports = class SearchService {
tasks: ['title', 'description'],
organizations: ['name'],
tags: ['name'],
};
const columnsInt = {};

View File

@ -0,0 +1,114 @@
const db = require('../db/models');
const TagsDBApi = require('../db/api/tags');
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');
module.exports = class TagsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await TagsDBApi.create(data, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream
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));
});
await TagsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let tags = await TagsDBApi.findBy({ id }, { transaction });
if (!tags) {
throw new ValidationError('tagsNotFound');
}
const updatedTags = await TagsDBApi.update(id, data, {
currentUser,
transaction,
});
await transaction.commit();
return updatedTags;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await TagsDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await TagsDBApi.remove(id, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
};

View File

@ -0,0 +1,105 @@
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 Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
type Props = {
tags: any[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;
numPages: number;
onPageChange: (page: number) => void;
};
const CardTags = ({
tags,
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_TAGS');
return (
<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 &&
tags.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 ${
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={`/tags/tags-view/?id=${item.id}`}
className='text-lg font-bold leading-6 line-clamp-1'
>
{item.name}
</Link>
<div className='ml-auto '>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/tags/tags-edit/?id=${item.id}`}
pathView={`/tags/tags-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</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'>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Name</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>{item.name}</div>
</dd>
</div>
</dl>
</li>
))}
{!loading && tags.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
</div>
)}
</ul>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div>
</div>
);
};
export default CardTags;

View File

@ -0,0 +1,90 @@
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 Link from 'next/link';
import { hasPermission } from '../../helpers/userPermissions';
type Props = {
tags: any[];
loading: boolean;
onDelete: (id: string) => void;
currentPage: number;
numPages: number;
onPageChange: (page: number) => void;
};
const ListTags = ({
tags,
loading,
onDelete,
currentPage,
numPages,
onPageChange,
}: Props) => {
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TAGS');
const corners = useAppSelector((state) => state.style.corners);
const bgColor = useAppSelector((state) => state.style.cardsColor);
return (
<>
<div className='relative overflow-x-auto p-4 space-y-4'>
{loading && <LoadingSpinner />}
{!loading &&
tags.map((item) => (
<CardBox
hasTable
isList
key={item.id}
className={'rounded shadow-none'}
>
<div
className={`flex rounded dark:bg-dark-900 border border-stone-300 items-center overflow-hidden`}
>
<Link
href={`/tags/tags-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>
</Link>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/tags/tags-edit/?id=${item.id}`}
pathView={`/tags/tags-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</CardBox>
))}
{!loading && tags.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
</div>
)}
</div>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div>
</>
);
};
export default ListTags;

View File

@ -0,0 +1,481 @@
import React, { useEffect, useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { ToastContainer, toast } from 'react-toastify';
import BaseButton from '../BaseButton';
import CardBoxModal from '../CardBoxModal';
import CardBox from '../CardBox';
import {
fetch,
update,
deleteItem,
setRefetch,
deleteItemsByIds,
} from '../../stores/tags/tagsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import { Field, Form, Formik } from 'formik';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { loadColumns } from './configureTagsCols';
import _ from 'lodash';
import dataFormatter from '../../helpers/dataFormatter';
import { dataGridStyles } from '../../styles';
const perPage = 10;
const TableSampleTags = ({
filterItems,
setFilterItems,
filters,
showGrid,
}) => {
const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' });
const dispatch = useAppDispatch();
const router = useRouter();
const pagesList = [];
const [id, setId] = useState(null);
const [currentPage, setCurrentPage] = useState(0);
const [filterRequest, setFilterRequest] = React.useState('');
const [columns, setColumns] = useState<GridColDef[]>([]);
const [selectedRows, setSelectedRows] = useState([]);
const [sortModel, setSortModel] = useState([
{
field: '',
sort: 'desc',
},
]);
const {
tags,
loading,
count,
notify: tagsNotify,
refetch,
} = useAppSelector((state) => state.tags);
const { currentUser } = useAppSelector((state) => state.auth);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
const corners = useAppSelector((state) => state.style.corners);
const numPages =
Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage);
for (let i = 0; i < numPages; i++) {
pagesList.push(i);
}
const loadData = async (page = currentPage, request = filterRequest) => {
if (page !== currentPage) setCurrentPage(page);
if (request !== filterRequest) setFilterRequest(request);
const { sort, field } = sortModel[0];
const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`;
dispatch(fetch({ limit: perPage, page, query }));
};
useEffect(() => {
if (tagsNotify.showNotification) {
notify(tagsNotify.typeNotification, tagsNotify.textNotification);
}
}, [tagsNotify.showNotification]);
useEffect(() => {
if (!currentUser) return;
loadData();
}, [sortModel, currentUser]);
useEffect(() => {
if (refetch) {
loadData(0);
dispatch(setRefetch(false));
}
}, [refetch, dispatch]);
const [isModalInfoActive, setIsModalInfoActive] = useState(false);
const [isModalTrashActive, setIsModalTrashActive] = useState(false);
const handleModalAction = () => {
setIsModalInfoActive(false);
setIsModalTrashActive(false);
};
const handleDeleteModalAction = (id: string) => {
setId(id);
setIsModalTrashActive(true);
};
const handleDeleteAction = async () => {
if (id) {
await dispatch(deleteItem(id));
await loadData(0);
setIsModalTrashActive(false);
}
};
const generateFilterRequests = useMemo(() => {
let request = '&';
filterItems.forEach((item) => {
const isRangeFilter = filters.find(
(filter) =>
filter.title === item.fields.selectedField &&
(filter.number || filter.date),
);
if (isRangeFilter) {
const from = item.fields.filterValueFrom;
const to = item.fields.filterValueTo;
if (from) {
request += `${item.fields.selectedField}Range=${from}&`;
}
if (to) {
request += `${item.fields.selectedField}Range=${to}&`;
}
} else {
const value = item.fields.filterValue;
if (value) {
request += `${item.fields.selectedField}=${value}&`;
}
}
});
return request;
}, [filterItems, filters]);
const deleteFilter = (value) => {
const newItems = filterItems.filter((item) => item.id !== value);
if (newItems.length) {
setFilterItems(newItems);
} else {
loadData(0, '');
setFilterItems(newItems);
}
};
const handleSubmit = () => {
loadData(0, generateFilterRequests);
};
const handleChange = (id) => (e) => {
const value = e.target.value;
const name = e.target.name;
setFilterItems(
filterItems.map((item) => {
if (item.id !== id) return item;
if (name === 'selectedField') return { id, fields: { [name]: value } };
return { id, fields: { ...item.fields, [name]: value } };
}),
);
};
const handleReset = () => {
setFilterItems([]);
loadData(0, '');
};
const onPageChange = (page: number) => {
loadData(page);
setCurrentPage(page);
};
useEffect(() => {
if (!currentUser) return;
loadColumns(handleDeleteModalAction, `tags`, currentUser).then((newCols) =>
setColumns(newCols),
);
}, [currentUser]);
const handleTableSubmit = async (id: string, data) => {
if (!_.isEmpty(data)) {
await dispatch(update({ id, data }))
.unwrap()
.then((res) => res)
.catch((err) => {
throw new Error(err);
});
}
};
const onDeleteRows = async (selectedRows) => {
await dispatch(deleteItemsByIds(selectedRows));
await loadData(0);
};
const controlClasses =
'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' +
` ${bgColor} ${focusRing} ${corners} ` +
'dark:bg-slate-800 border';
const dataGrid = (
<div className='relative overflow-x-auto'>
<DataGrid
autoHeight
rowHeight={64}
sx={dataGridStyles}
className={'datagrid--table'}
getRowClassName={() => `datagrid--row`}
rows={tags ?? []}
columns={columns}
initialState={{
pagination: {
paginationModel: {
pageSize: 10,
},
},
}}
disableRowSelectionOnClick
onProcessRowUpdateError={(params) => {
console.log('Error', params);
}}
processRowUpdate={async (newRow, oldRow) => {
const data = dataFormatter.dataGridEditFormatter(newRow);
try {
await handleTableSubmit(newRow.id, data);
return newRow;
} catch {
return oldRow;
}
}}
sortingMode={'server'}
checkboxSelection
onRowSelectionModelChange={(ids) => {
setSelectedRows(ids);
}}
onSortModelChange={(params) => {
params.length
? setSortModel(params)
: setSortModel([{ field: '', sort: 'desc' }]);
}}
rowCount={count}
pageSizeOptions={[10]}
paginationMode={'server'}
loading={loading}
onPaginationModelChange={(params) => {
onPageChange(params.page);
}}
/>
</div>
);
return (
<>
{filterItems && Array.isArray(filterItems) && filterItems.length ? (
<CardBox>
<Formik
initialValues={{
checkboxes: ['lorem'],
switches: ['lorem'],
radio: 'lorem',
}}
onSubmit={() => null}
>
<Form>
<>
{filterItems &&
filterItems.map((filterItem) => {
return (
<div key={filterItem.id} className='flex mb-4'>
<div className='flex flex-col w-full mr-3'>
<div className=' text-gray-500 font-bold'>
Filter
</div>
<Field
className={controlClasses}
name='selectedField'
id='selectedField'
component='select'
value={filterItem?.fields?.selectedField || ''}
onChange={handleChange(filterItem.id)}
>
{filters.map((selectOption) => (
<option
key={selectOption.title}
value={`${selectOption.title}`}
>
{selectOption.label}
</option>
))}
</Field>
</div>
{filters.find(
(filter) =>
filter.title === filterItem?.fields?.selectedField,
)?.type === 'enum' ? (
<div className='flex flex-col w-full mr-3'>
<div className='text-gray-500 font-bold'>Value</div>
<Field
className={controlClasses}
name='filterValue'
id='filterValue'
component='select'
value={filterItem?.fields?.filterValue || ''}
onChange={handleChange(filterItem.id)}
>
<option value=''>Select Value</option>
{filters
.find(
(filter) =>
filter.title ===
filterItem?.fields?.selectedField,
)
?.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Field>
</div>
) : filters.find(
(filter) =>
filter.title ===
filterItem?.fields?.selectedField,
)?.number ? (
<div className='flex flex-row w-full mr-3'>
<div className='flex flex-col w-full mr-3'>
<div className=' text-gray-500 font-bold'>
From
</div>
<Field
className={controlClasses}
name='filterValueFrom'
placeholder='From'
id='filterValueFrom'
value={
filterItem?.fields?.filterValueFrom || ''
}
onChange={handleChange(filterItem.id)}
/>
</div>
<div className='flex flex-col w-full'>
<div className=' text-gray-500 font-bold'>
To
</div>
<Field
className={controlClasses}
name='filterValueTo'
placeholder='to'
id='filterValueTo'
value={filterItem?.fields?.filterValueTo || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
</div>
) : filters.find(
(filter) =>
filter.title ===
filterItem?.fields?.selectedField,
)?.date ? (
<div className='flex flex-row w-full mr-3'>
<div className='flex flex-col w-full mr-3'>
<div className=' text-gray-500 font-bold'>
From
</div>
<Field
className={controlClasses}
name='filterValueFrom'
placeholder='From'
id='filterValueFrom'
type='datetime-local'
value={
filterItem?.fields?.filterValueFrom || ''
}
onChange={handleChange(filterItem.id)}
/>
</div>
<div className='flex flex-col w-full'>
<div className=' text-gray-500 font-bold'>
To
</div>
<Field
className={controlClasses}
name='filterValueTo'
placeholder='to'
id='filterValueTo'
type='datetime-local'
value={filterItem?.fields?.filterValueTo || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
</div>
) : (
<div className='flex flex-col w-full mr-3'>
<div className=' text-gray-500 font-bold'>
Contains
</div>
<Field
className={controlClasses}
name='filterValue'
placeholder='Contained'
id='filterValue'
value={filterItem?.fields?.filterValue || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
)}
<div className='flex flex-col'>
<div className=' text-gray-500 font-bold'>
Action
</div>
<BaseButton
className='my-2'
type='reset'
color='danger'
label='Delete'
onClick={() => {
deleteFilter(filterItem.id);
}}
/>
</div>
</div>
);
})}
<div className='flex'>
<BaseButton
className='my-2 mr-3'
color='success'
label='Apply'
onClick={handleSubmit}
/>
<BaseButton
className='my-2'
color='info'
label='Cancel'
onClick={handleReset}
/>
</div>
</>
</Form>
</Formik>
</CardBox>
) : null}
<CardBoxModal
title='Please confirm'
buttonColor='info'
buttonLabel={loading ? 'Deleting...' : 'Confirm'}
isActive={isModalTrashActive}
onConfirm={handleDeleteAction}
onCancel={handleModalAction}
>
<p>Are you sure you want to delete this item?</p>
</CardBoxModal>
{dataGrid}
{selectedRows.length > 0 &&
createPortal(
<BaseButton
className='me-4'
color='danger'
label={`Delete ${selectedRows.length === 1 ? 'Row' : 'Rows'}`}
onClick={() => onDeleteRows(selectedRows)}
/>,
document.getElementById('delete-rows-button'),
)}
<ToastContainer />
</>
);
};
export default TableSampleTags;

View File

@ -0,0 +1,73 @@
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 ListActionsPopover from '../ListActionsPopover';
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 [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_TAGS');
return [
{
field: 'name',
headerName: 'Name',
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 [
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/tags/tags-edit/?id=${params?.row?.id}`}
pathView={`/tags/tags-view/?id=${params?.row?.id}`}
key={1}
hasUpdatePermission={hasUpdatePermission}
/>,
];
},
},
];
};

View File

@ -161,6 +161,17 @@ const CardTasks = ({
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Tags</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{dataFormatter
.tagsManyListFormatter(item.tags)
.join(', ')}
</div>
</dd>
</div>
</dl>
</li>
))}

View File

@ -104,6 +104,15 @@ const ListTasks = ({
{dataFormatter.dateTimeFormatter(item.end_date)}
</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Tags</p>
<p className={'line-clamp-2'}>
{dataFormatter
.tagsManyListFormatter(item.tags)
.join(', ')}
</p>
</div>
</Link>
<ListActionsPopover
onDelete={onDelete}

View File

@ -166,6 +166,25 @@ export const loadColumns = async (
new Date(params.row.end_date),
},
{
field: 'tags',
headerName: 'Tags',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: false,
sortable: false,
type: 'singleSelect',
valueFormatter: ({ value }) =>
dataFormatter.tagsManyListFormatter(value).join(', '),
renderEditCell: (params) => (
<DataGridMultiSelect {...params} entityName={'tags'} />
),
},
{
field: 'actions',
type: 'actions',

View File

@ -171,4 +171,23 @@ export default {
if (!val) return '';
return { label: val.name, id: val.id };
},
tagsManyListFormatter(val) {
if (!val || !val.length) return [];
return val.map((item) => item.name);
},
tagsOneListFormatter(val) {
if (!val) return '';
return val.name;
},
tagsManyListFormatterEdit(val) {
if (!val || !val.length) return [];
return val.map((item) => {
return { id: item.id, label: item.name };
});
},
tagsOneListFormatterEdit(val) {
if (!val) return '';
return { label: val.name, id: val.id };
},
};

View File

@ -68,6 +68,14 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiTable ? icon.mdiTable : icon.mdiTable,
permissions: 'READ_ORGANIZATIONS',
},
{
href: '/tags/tags-list',
label: 'Tags',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiTable ? icon.mdiTable : icon.mdiTable,
permissions: 'READ_TAGS',
},
{
href: '/profile',
label: 'Profile',
@ -87,11 +95,6 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS',
},
{
label: 'Alert Hello',
icon: icon.mdiBellOutline,
onClick: () => alert("Hello!"),
},
];
export default menuAside;

View File

@ -29,6 +29,7 @@ const Dashboard = () => {
const [roles, setRoles] = React.useState('Loading...');
const [permissions, setPermissions] = React.useState('Loading...');
const [organizations, setOrganizations] = React.useState('Loading...');
const [tags, setTags] = React.useState('Loading...');
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
@ -49,6 +50,7 @@ const Dashboard = () => {
'roles',
'permissions',
'organizations',
'tags',
];
const fns = [
setUsers,
@ -58,6 +60,7 @@ const Dashboard = () => {
setRoles,
setPermissions,
setOrganizations,
setTags,
];
const requests = entities.map((entity, index) => {
@ -389,6 +392,38 @@ const Dashboard = () => {
</div>
</Link>
)}
{hasPermission(currentUser, 'READ_TAGS') && (
<Link href={'/tags/tags-list'}>
<div
className={`${
corners !== 'rounded-full' ? corners : 'rounded-3xl'
} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='flex justify-between align-center'>
<div>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Tags
</div>
<div className='text-3xl leading-tight font-semibold'>
{tags}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w='w-16'
h='h-16'
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>
)}
</div>
</SectionMain>
</>

View File

@ -247,6 +247,41 @@ const OrganizationsView = () => {
</CardBox>
</>
<>
<p className={'block font-bold mb-2'}>Tags organizations</p>
<CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden'
hasTable
>
<div className='overflow-x-auto'>
<table>
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
{organizations.tags_organizations &&
Array.isArray(organizations.tags_organizations) &&
organizations.tags_organizations.map((item: any) => (
<tr
key={item.id}
onClick={() =>
router.push(`/tags/tags-view/?id=${item.id}`)
}
>
<td data-label='name'>{item.name}</td>
</tr>
))}
</tbody>
</table>
</div>
{!organizations?.tags_organizations?.length && (
<div className={'text-center py-4'}>No data</div>
)}
</CardBox>
</>
<BaseDivider />
<BaseButton

View File

@ -0,0 +1,139 @@
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/tags/tagsSlice';
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 { hasPermission } from '../../helpers/userPermissions';
const EditTags = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const initVals = {
organizations: null,
name: '',
};
const [initialValues, setInitialValues] = useState(initVals);
const { tags } = useAppSelector((state) => state.tags);
const { currentUser } = useAppSelector((state) => state.auth);
const { tagsId } = router.query;
useEffect(() => {
dispatch(fetch({ id: tagsId }));
}, [tagsId]);
useEffect(() => {
if (typeof tags === 'object') {
setInitialValues(tags);
}
}, [tags]);
useEffect(() => {
if (typeof tags === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((el) => (newInitialVal[el] = tags[el]));
setInitialValues(newInitialVal);
}
}, [tags]);
const handleSubmit = async (data) => {
await dispatch(update({ id: tagsId, data }));
await router.push('/tags/tags-list');
};
return (
<>
<Head>
<title>{getPageTitle('Edit tags')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title={'Edit tags'}
main
>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='organizations' labelFor='organizations'>
<Field
name='organizations'
id='organizations'
component={SelectField}
options={initialValues.organizations}
itemRef={'organizations'}
showField={'name'}
></Field>
</FormField>
<FormField label='Name'>
<Field name='name' placeholder='Name' />
</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('/tags/tags-list')}
/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
</>
);
};
EditTags.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'UPDATE_TAGS'}>{page}</LayoutAuthenticated>
);
};
export default EditTags;

View File

@ -0,0 +1,137 @@
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/tags/tagsSlice';
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 { hasPermission } from '../../helpers/userPermissions';
const EditTagsPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const initVals = {
organizations: null,
name: '',
};
const [initialValues, setInitialValues] = useState(initVals);
const { tags } = useAppSelector((state) => state.tags);
const { currentUser } = useAppSelector((state) => state.auth);
const { id } = router.query;
useEffect(() => {
dispatch(fetch({ id: id }));
}, [id]);
useEffect(() => {
if (typeof tags === 'object') {
setInitialValues(tags);
}
}, [tags]);
useEffect(() => {
if (typeof tags === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((el) => (newInitialVal[el] = tags[el]));
setInitialValues(newInitialVal);
}
}, [tags]);
const handleSubmit = async (data) => {
await dispatch(update({ id: id, data }));
await router.push('/tags/tags-list');
};
return (
<>
<Head>
<title>{getPageTitle('Edit tags')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title={'Edit tags'}
main
>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='organizations' labelFor='organizations'>
<Field
name='organizations'
id='organizations'
component={SelectField}
options={initialValues.organizations}
itemRef={'organizations'}
showField={'name'}
></Field>
</FormField>
<FormField label='Name'>
<Field name='name' placeholder='Name' />
</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('/tags/tags-list')}
/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
</>
);
};
EditTagsPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'UPDATE_TAGS'}>{page}</LayoutAuthenticated>
);
};
export default EditTagsPage;

View File

@ -0,0 +1,160 @@
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 TableTags from '../../components/Tags/TableTags';
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/tags/tagsSlice';
import { hasPermission } from '../../helpers/userPermissions';
const TagsTablesPage = () => {
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 [filters] = useState([{ label: 'Name', title: 'name' }]);
const hasCreatePermission =
currentUser && hasPermission(currentUser, 'CREATE_TAGS');
const addFilter = () => {
const newItem = {
id: uniqueId(),
fields: {
filterValue: '',
filterValueFrom: '',
filterValueTo: '',
selectedField: '',
},
};
newItem.fields.selectedField = filters[0].title;
setFilterItems([...filterItems, newItem]);
};
const getTagsCSV = async () => {
const response = await axios({
url: '/tags?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 = 'tagsCSV.csv';
link.click();
};
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('Tags')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title='Tags'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && (
<BaseButton
className={'mr-3'}
href={'/tags/tags-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={getTagsCSV}
/>
{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>
<CardBox className='mb-6' hasTable>
<TableTags
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'}
/>
</CardBoxModal>
</>
);
};
TagsTablesPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'READ_TAGS'}>{page}</LayoutAuthenticated>
);
};
export default TagsTablesPage;

View File

@ -0,0 +1,108 @@
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 { 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';
import { SelectField } from '../../components/SelectField';
import { SelectFieldMany } from '../../components/SelectFieldMany';
import { RichTextField } from '../../components/RichTextField';
import { create } from '../../stores/tags/tagsSlice';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router';
import moment from 'moment';
const initialValues = {
organizations: '',
name: '',
};
const TagsNew = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const handleSubmit = async (data) => {
await dispatch(create(data));
await router.push('/tags/tags-list');
};
return (
<>
<Head>
<title>{getPageTitle('New Item')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title='New Item'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label='organizations' labelFor='organizations'>
<Field
name='organizations'
id='organizations'
component={SelectField}
options={[]}
itemRef={'organizations'}
></Field>
</FormField>
<FormField label='Name'>
<Field name='name' placeholder='Name' />
</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('/tags/tags-list')}
/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
</>
);
};
TagsNew.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'CREATE_TAGS'}>{page}</LayoutAuthenticated>
);
};
export default TagsNew;

View File

@ -0,0 +1,159 @@
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 TableTags from '../../components/Tags/TableTags';
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/tags/tagsSlice';
import { hasPermission } from '../../helpers/userPermissions';
const TagsTablesPage = () => {
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 [filters] = useState([{ label: 'Name', title: 'name' }]);
const hasCreatePermission =
currentUser && hasPermission(currentUser, 'CREATE_TAGS');
const addFilter = () => {
const newItem = {
id: uniqueId(),
fields: {
filterValue: '',
filterValueFrom: '',
filterValueTo: '',
selectedField: '',
},
};
newItem.fields.selectedField = filters[0].title;
setFilterItems([...filterItems, newItem]);
};
const getTagsCSV = async () => {
const response = await axios({
url: '/tags?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 = 'tagsCSV.csv';
link.click();
};
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('Tags')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title='Tags'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && (
<BaseButton
className={'mr-3'}
href={'/tags/tags-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={getTagsCSV}
/>
{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>
<CardBox className='mb-6' hasTable>
<TableTags
filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters}
showGrid={true}
/>
</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'}
/>
</CardBoxModal>
</>
);
};
TagsTablesPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'READ_TAGS'}>{page}</LayoutAuthenticated>
);
};
export default TagsTablesPage;

View File

@ -0,0 +1,91 @@
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/tags/tagsSlice';
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 { hasPermission } from '../../helpers/userPermissions';
const TagsView = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const { tags } = useAppSelector((state) => state.tags);
const { currentUser } = useAppSelector((state) => state.auth);
const { id } = router.query;
function removeLastCharacter(str) {
console.log(str, `str`);
return str.slice(0, -1);
}
useEffect(() => {
dispatch(fetch({ id }));
}, [dispatch, id]);
return (
<>
<Head>
<title>{getPageTitle('View tags')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title={removeLastCharacter('View tags')}
main
>
<BaseButton
color='info'
label='Edit'
href={`/tags/tags-edit/?id=${id}`}
/>
</SectionTitleLineWithButton>
<CardBox>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>organizations</p>
<p>{tags?.organizations?.name ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Name</p>
<p>{tags?.name}</p>
</div>
<BaseDivider />
<BaseButton
color='info'
label='Back'
onClick={() => router.push('/tags/tags-list')}
/>
</CardBox>
</SectionMain>
</>
);
};
TagsView.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'READ_TAGS'}>{page}</LayoutAuthenticated>
);
};
export default TagsView;

View File

@ -55,6 +55,8 @@ const EditTasks = () => {
end_date: new Date(),
organizations: null,
tags: [],
};
const [initialValues, setInitialValues] = useState(initVals);
@ -213,6 +215,17 @@ const EditTasks = () => {
></Field>
</FormField>
<FormField label='Tags' labelFor='tags'>
<Field
name='tags'
id='tags'
component={SelectFieldMany}
options={initialValues.tags}
itemRef={'tags'}
showField={'name'}
></Field>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />

View File

@ -55,6 +55,8 @@ const EditTasksPage = () => {
end_date: new Date(),
organizations: null,
tags: [],
};
const [initialValues, setInitialValues] = useState(initVals);
@ -211,6 +213,17 @@ const EditTasksPage = () => {
></Field>
</FormField>
<FormField label='Tags' labelFor='tags'>
<Field
name='tags'
id='tags'
component={SelectFieldMany}
options={initialValues.tags}
itemRef={'tags'}
showField={'name'}
></Field>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />

View File

@ -41,6 +41,7 @@ const TasksTablesPage = () => {
{ label: 'Category', title: 'category' },
{ label: 'Tags', title: 'tags' },
{
label: 'Status',
title: 'status',

View File

@ -50,6 +50,8 @@ const initialValues = {
end_date: '',
organizations: '',
tags: [],
};
const TasksNew = () => {
@ -157,6 +159,16 @@ const TasksNew = () => {
></Field>
</FormField>
<FormField label='Tags' labelFor='tags'>
<Field
name='tags'
id='tags'
itemRef={'tags'}
options={[]}
component={SelectFieldMany}
></Field>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />

View File

@ -41,6 +41,7 @@ const TasksTablesPage = () => {
{ label: 'Category', title: 'category' },
{ label: 'Tags', title: 'tags' },
{
label: 'Status',
title: 'status',

View File

@ -137,6 +137,41 @@ const TasksView = () => {
<p>{tasks?.organizations?.name ?? 'No data'}</p>
</div>
<>
<p className={'block font-bold mb-2'}>Tags</p>
<CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden'
hasTable
>
<div className='overflow-x-auto'>
<table>
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
{tasks.tags &&
Array.isArray(tasks.tags) &&
tasks.tags.map((item: any) => (
<tr
key={item.id}
onClick={() =>
router.push(`/tags/tags-view/?id=${item.id}`)
}
>
<td data-label='name'>{item.name}</td>
</tr>
))}
</tbody>
</table>
</div>
{!tasks?.tags?.length && (
<div className={'text-center py-4'}>No data</div>
)}
</CardBox>
</>
<BaseDivider />
<BaseButton

View File

@ -11,6 +11,7 @@ import tasksSlice from './tasks/tasksSlice';
import rolesSlice from './roles/rolesSlice';
import permissionsSlice from './permissions/permissionsSlice';
import organizationsSlice from './organizations/organizationsSlice';
import tagsSlice from './tags/tagsSlice';
export const store = configureStore({
reducer: {
@ -26,6 +27,7 @@ export const store = configureStore({
roles: rolesSlice,
permissions: permissionsSlice,
organizations: organizationsSlice,
tags: tagsSlice,
},
});

View File

@ -0,0 +1,236 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from 'axios';
import {
fulfilledNotify,
rejectNotify,
resetNotify,
} from '../../helpers/notifyStateHandler';
interface MainState {
tags: any;
loading: boolean;
count: number;
refetch: boolean;
rolesWidgets: any[];
notify: {
showNotification: boolean;
textNotification: string;
typeNotification: string;
};
}
const initialState: MainState = {
tags: [],
loading: false,
count: 0,
refetch: false,
rolesWidgets: [],
notify: {
showNotification: false,
textNotification: '',
typeNotification: 'warn',
},
};
export const fetch = createAsyncThunk('tags/fetch', async (data: any) => {
const { id, query } = data;
const result = await axios.get(`tags${query || (id ? `/${id}` : '')}`);
return id
? result.data
: { rows: result.data.rows, count: result.data.count };
});
export const deleteItemsByIds = createAsyncThunk(
'tags/deleteByIds',
async (data: any, { rejectWithValue }) => {
try {
await axios.post('tags/deleteByIds', { data });
} catch (error) {
if (!error.response) {
throw error;
}
return rejectWithValue(error.response.data);
}
},
);
export const deleteItem = createAsyncThunk(
'tags/deleteTags',
async (id: string, { rejectWithValue }) => {
try {
await axios.delete(`tags/${id}`);
} catch (error) {
if (!error.response) {
throw error;
}
return rejectWithValue(error.response.data);
}
},
);
export const create = createAsyncThunk(
'tags/createTags',
async (data: any, { rejectWithValue }) => {
try {
const result = await axios.post('tags', { data });
return result.data;
} catch (error) {
if (!error.response) {
throw error;
}
return rejectWithValue(error.response.data);
}
},
);
export const uploadCsv = createAsyncThunk(
'tags/uploadCsv',
async (file: File, { rejectWithValue }) => {
try {
const data = new FormData();
data.append('file', file);
data.append('filename', file.name);
const result = await axios.post('tags/bulk-import', data, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return result.data;
} catch (error) {
if (!error.response) {
throw error;
}
return rejectWithValue(error.response.data);
}
},
);
export const update = createAsyncThunk(
'tags/updateTags',
async (payload: any, { rejectWithValue }) => {
try {
const result = await axios.put(`tags/${payload.id}`, {
id: payload.id,
data: payload.data,
});
return result.data;
} catch (error) {
if (!error.response) {
throw error;
}
return rejectWithValue(error.response.data);
}
},
);
export const tagsSlice = createSlice({
name: 'tags',
initialState,
reducers: {
setRefetch: (state, action: PayloadAction<boolean>) => {
state.refetch = action.payload;
},
},
extraReducers: (builder) => {
builder.addCase(fetch.pending, (state) => {
state.loading = true;
resetNotify(state);
});
builder.addCase(fetch.rejected, (state, action) => {
state.loading = false;
rejectNotify(state, action);
});
builder.addCase(fetch.fulfilled, (state, action) => {
if (action.payload.rows && action.payload.count >= 0) {
state.tags = action.payload.rows;
state.count = action.payload.count;
} else {
state.tags = action.payload;
}
state.loading = false;
});
builder.addCase(deleteItemsByIds.pending, (state) => {
state.loading = true;
resetNotify(state);
});
builder.addCase(deleteItemsByIds.fulfilled, (state) => {
state.loading = false;
fulfilledNotify(state, 'Tags has been deleted');
});
builder.addCase(deleteItemsByIds.rejected, (state, action) => {
state.loading = false;
rejectNotify(state, action);
});
builder.addCase(deleteItem.pending, (state) => {
state.loading = true;
resetNotify(state);
});
builder.addCase(deleteItem.fulfilled, (state) => {
state.loading = false;
fulfilledNotify(state, `${'Tags'.slice(0, -1)} has been deleted`);
});
builder.addCase(deleteItem.rejected, (state, action) => {
state.loading = false;
rejectNotify(state, action);
});
builder.addCase(create.pending, (state) => {
state.loading = true;
resetNotify(state);
});
builder.addCase(create.rejected, (state, action) => {
state.loading = false;
rejectNotify(state, action);
});
builder.addCase(create.fulfilled, (state) => {
state.loading = false;
fulfilledNotify(state, `${'Tags'.slice(0, -1)} has been created`);
});
builder.addCase(update.pending, (state) => {
state.loading = true;
resetNotify(state);
});
builder.addCase(update.fulfilled, (state) => {
state.loading = false;
fulfilledNotify(state, `${'Tags'.slice(0, -1)} has been updated`);
});
builder.addCase(update.rejected, (state, action) => {
state.loading = false;
rejectNotify(state, action);
});
builder.addCase(uploadCsv.pending, (state) => {
state.loading = true;
resetNotify(state);
});
builder.addCase(uploadCsv.fulfilled, (state) => {
state.loading = false;
fulfilledNotify(state, 'Tags has been uploaded');
});
builder.addCase(uploadCsv.rejected, (state, action) => {
state.loading = false;
rejectNotify(state, action);
});
},
});
// Action creators are generated for each case reducer function
export const { setRefetch } = tagsSlice.actions;
export default tagsSlice.reducer;