From d3bcb13f6357fb14dc9f512e00ee6662b66bf366 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 1 Oct 2025 12:22:55 +0000 Subject: [PATCH] 1.0 --- .gitignore | 5 + app-shell/src/_schema.json | 7 +- backend/src/db/api/category.js | 267 +++++++++++ backend/src/db/api/contact.js | 336 +++++++++++++ backend/src/db/api/folder.js | 267 +++++++++++ backend/src/db/migrations/1759320240500.js | 91 ++++ backend/src/db/migrations/1759320255622.js | 54 +++ backend/src/db/migrations/1759320269093.js | 54 +++ backend/src/db/migrations/1759320277416.js | 54 +++ backend/src/db/migrations/1759320286358.js | 54 +++ backend/src/db/migrations/1759320294013.js | 91 ++++ backend/src/db/migrations/1759320299347.js | 91 ++++ backend/src/db/migrations/1759320312257.js | 59 +++ backend/src/db/migrations/1759320322321.js | 59 +++ backend/src/db/migrations/1759320328321.js | 38 ++ backend/src/db/migrations/1759320339014.js | 54 +++ backend/src/db/migrations/1759320347228.js | 54 +++ backend/src/db/models/category.js | 48 ++ backend/src/db/models/contact.js | 63 +++ backend/src/db/models/folder.js | 48 ++ backend/src/index.js | 16 +- backend/src/routes/category.js | 410 ++++++++++++++++ backend/src/routes/contact.js | 419 +++++++++++++++++ backend/src/routes/folder.js | 410 ++++++++++++++++ backend/src/services/category.js | 131 ++++++ backend/src/services/contact.js | 131 ++++++ backend/src/services/folder.js | 131 ++++++ backend/src/services/search.js | 24 + frontend/json/runtimeError.json | 1 + .../src/components/Category/CardCategory.tsx | 93 ++++ .../src/components/Category/ListCategory.tsx | 72 +++ .../src/components/Category/TableCategory.tsx | 441 ++++++++++++++++++ .../Category/configureCategoryCols.tsx | 65 +++ .../src/components/Contact/CardContact.tsx | 129 +++++ .../src/components/Contact/ListContact.tsx | 92 ++++ .../src/components/Contact/TableContact.tsx | 441 ++++++++++++++++++ .../Contact/configureContactCols.tsx | 124 +++++ frontend/src/components/Folder/CardFolder.tsx | 93 ++++ frontend/src/components/Folder/ListFolder.tsx | 72 +++ .../src/components/Folder/TableFolder.tsx | 441 ++++++++++++++++++ .../components/Folder/configureFolderCols.tsx | 65 +++ frontend/src/components/Leads/CardLeads.tsx | 18 + frontend/src/components/Leads/ListLeads.tsx | 10 + .../components/Leads/configureLeadsCols.tsx | 40 ++ frontend/src/helpers/dataFormatter.js | 57 +++ frontend/src/menuAside.ts | 24 + frontend/src/pages/category/[categoryId].tsx | 131 ++++++ frontend/src/pages/category/category-edit.tsx | 129 +++++ frontend/src/pages/category/category-list.tsx | 128 +++++ frontend/src/pages/category/category-new.tsx | 95 ++++ .../src/pages/category/category-table.tsx | 129 +++++ frontend/src/pages/category/category-view.tsx | 83 ++++ frontend/src/pages/contact/[contactId].tsx | 164 +++++++ frontend/src/pages/contact/contact-edit.tsx | 162 +++++++ frontend/src/pages/contact/contact-list.tsx | 128 +++++ frontend/src/pages/contact/contact-new.tsx | 128 +++++ frontend/src/pages/contact/contact-table.tsx | 129 +++++ frontend/src/pages/contact/contact-view.tsx | 143 ++++++ frontend/src/pages/dashboard.tsx | 91 +++- frontend/src/pages/folder/[folderId].tsx | 131 ++++++ frontend/src/pages/folder/folder-edit.tsx | 129 +++++ frontend/src/pages/folder/folder-list.tsx | 128 +++++ frontend/src/pages/folder/folder-new.tsx | 95 ++++ frontend/src/pages/folder/folder-table.tsx | 129 +++++ frontend/src/pages/folder/folder-view.tsx | 128 +++++ frontend/src/pages/leads/[leadsId].tsx | 30 ++ frontend/src/pages/leads/leads-edit.tsx | 30 ++ frontend/src/pages/leads/leads-new.tsx | 12 + frontend/src/pages/leads/leads-view.tsx | 14 + frontend/src/stores/category/categorySlice.ts | 229 +++++++++ frontend/src/stores/contact/contactSlice.ts | 229 +++++++++ frontend/src/stores/folder/folderSlice.ts | 229 +++++++++ frontend/src/stores/store.ts | 6 + 73 files changed, 8895 insertions(+), 8 deletions(-) create mode 100644 backend/src/db/api/category.js create mode 100644 backend/src/db/api/contact.js create mode 100644 backend/src/db/api/folder.js create mode 100644 backend/src/db/migrations/1759320240500.js create mode 100644 backend/src/db/migrations/1759320255622.js create mode 100644 backend/src/db/migrations/1759320269093.js create mode 100644 backend/src/db/migrations/1759320277416.js create mode 100644 backend/src/db/migrations/1759320286358.js create mode 100644 backend/src/db/migrations/1759320294013.js create mode 100644 backend/src/db/migrations/1759320299347.js create mode 100644 backend/src/db/migrations/1759320312257.js create mode 100644 backend/src/db/migrations/1759320322321.js create mode 100644 backend/src/db/migrations/1759320328321.js create mode 100644 backend/src/db/migrations/1759320339014.js create mode 100644 backend/src/db/migrations/1759320347228.js create mode 100644 backend/src/db/models/category.js create mode 100644 backend/src/db/models/contact.js create mode 100644 backend/src/db/models/folder.js create mode 100644 backend/src/routes/category.js create mode 100644 backend/src/routes/contact.js create mode 100644 backend/src/routes/folder.js create mode 100644 backend/src/services/category.js create mode 100644 backend/src/services/contact.js create mode 100644 backend/src/services/folder.js create mode 100644 frontend/json/runtimeError.json create mode 100644 frontend/src/components/Category/CardCategory.tsx create mode 100644 frontend/src/components/Category/ListCategory.tsx create mode 100644 frontend/src/components/Category/TableCategory.tsx create mode 100644 frontend/src/components/Category/configureCategoryCols.tsx create mode 100644 frontend/src/components/Contact/CardContact.tsx create mode 100644 frontend/src/components/Contact/ListContact.tsx create mode 100644 frontend/src/components/Contact/TableContact.tsx create mode 100644 frontend/src/components/Contact/configureContactCols.tsx create mode 100644 frontend/src/components/Folder/CardFolder.tsx create mode 100644 frontend/src/components/Folder/ListFolder.tsx create mode 100644 frontend/src/components/Folder/TableFolder.tsx create mode 100644 frontend/src/components/Folder/configureFolderCols.tsx create mode 100644 frontend/src/pages/category/[categoryId].tsx create mode 100644 frontend/src/pages/category/category-edit.tsx create mode 100644 frontend/src/pages/category/category-list.tsx create mode 100644 frontend/src/pages/category/category-new.tsx create mode 100644 frontend/src/pages/category/category-table.tsx create mode 100644 frontend/src/pages/category/category-view.tsx create mode 100644 frontend/src/pages/contact/[contactId].tsx create mode 100644 frontend/src/pages/contact/contact-edit.tsx create mode 100644 frontend/src/pages/contact/contact-list.tsx create mode 100644 frontend/src/pages/contact/contact-new.tsx create mode 100644 frontend/src/pages/contact/contact-table.tsx create mode 100644 frontend/src/pages/contact/contact-view.tsx create mode 100644 frontend/src/pages/folder/[folderId].tsx create mode 100644 frontend/src/pages/folder/folder-edit.tsx create mode 100644 frontend/src/pages/folder/folder-list.tsx create mode 100644 frontend/src/pages/folder/folder-new.tsx create mode 100644 frontend/src/pages/folder/folder-table.tsx create mode 100644 frontend/src/pages/folder/folder-view.tsx create mode 100644 frontend/src/stores/category/categorySlice.ts create mode 100644 frontend/src/stores/contact/contactSlice.ts create mode 100644 frontend/src/stores/folder/folderSlice.ts diff --git a/.gitignore b/.gitignore index e427ff3..d0eb167 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ node_modules/ */node_modules/ */build/ + +**/node_modules/ +**/build/ +.DS_Store +.env \ No newline at end of file diff --git a/app-shell/src/_schema.json b/app-shell/src/_schema.json index 0bae8b9..d2f8985 100644 --- a/app-shell/src/_schema.json +++ b/app-shell/src/_schema.json @@ -1,5 +1,4 @@ - - { - "Initial version": "{\"iv\":\"ygja548vtTjviYYK\",\"encryptedData\":\"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\"}" -} + "Initial version": "{\"iv\":\"ygja548vtTjviYYK\",\"encryptedData\":\"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\"}", + "1.0": "{\"iv\":\"eSFXRIkL4Pz+bqMH\",\"encryptedData\":\"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\"}" +} \ No newline at end of file diff --git a/backend/src/db/api/category.js b/backend/src/db/api/category.js new file mode 100644 index 0000000..4643d26 --- /dev/null +++ b/backend/src/db/api/category.js @@ -0,0 +1,267 @@ + +const db = require('../models'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class CategoryDBApi { + + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const category = await db.category.create( + { + id: data.id || undefined, + + name: data.name + || + null + , + + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return category; + } + + 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 categoryData = 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 category = await db.category.bulkCreate(categoryData, { transaction }); + + return category; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || {id: null}; + const transaction = (options && options.transaction) || undefined; + + const category = await db.category.findByPk(id, {}, {transaction}); + + const updatePayload = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + + updatePayload.updatedById = currentUser.id; + + await category.update(updatePayload, {transaction}); + + return category; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const category = await db.category.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of category) { + await record.update( + {deletedBy: currentUser.id}, + {transaction} + ); + } + for (const record of category) { + await record.destroy({transaction}); + } + }); + + return category; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || {id: null}; + const transaction = (options && options.transaction) || undefined; + + const category = await db.category.findByPk(id, options); + + await category.update({ + deletedBy: currentUser.id + }, { + transaction, + }); + + await category.destroy({ + transaction + }); + + return category; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const category = await db.category.findOne( + { where }, + { transaction }, + ); + + if (!category) { + return category; + } + + const output = category.get({plain: true}); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = []; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'category', + 'name', + filter.name, + ), + }; + } + + if (filter.active !== undefined) { + where = { + ...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, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + 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.category.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) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike( + 'category', + 'name', + query, + ), + ], + }; + } + + const records = await db.category.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, + })); + } + +}; + diff --git a/backend/src/db/api/contact.js b/backend/src/db/api/contact.js new file mode 100644 index 0000000..acaf11d --- /dev/null +++ b/backend/src/db/api/contact.js @@ -0,0 +1,336 @@ + +const db = require('../models'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class ContactDBApi { + + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const contact = await db.contact.create( + { + id: data.id || undefined, + + firstname: data.firstname + || + null + , + + lastname: data.lastname + || + null + , + + email: data.email + || + null + , + + phonenumber: data.phonenumber + || + null + , + + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return contact; + } + + 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 contactData = data.map((item, index) => ({ + id: item.id || undefined, + + firstname: item.firstname + || + null + , + + lastname: item.lastname + || + null + , + + email: item.email + || + null + , + + phonenumber: item.phonenumber + || + null + , + + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const contact = await db.contact.bulkCreate(contactData, { transaction }); + + return contact; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || {id: null}; + const transaction = (options && options.transaction) || undefined; + + const contact = await db.contact.findByPk(id, {}, {transaction}); + + const updatePayload = {}; + + if (data.firstname !== undefined) updatePayload.firstname = data.firstname; + + if (data.lastname !== undefined) updatePayload.lastname = data.lastname; + + if (data.email !== undefined) updatePayload.email = data.email; + + if (data.phonenumber !== undefined) updatePayload.phonenumber = data.phonenumber; + + updatePayload.updatedById = currentUser.id; + + await contact.update(updatePayload, {transaction}); + + return contact; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const contact = await db.contact.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of contact) { + await record.update( + {deletedBy: currentUser.id}, + {transaction} + ); + } + for (const record of contact) { + await record.destroy({transaction}); + } + }); + + return contact; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || {id: null}; + const transaction = (options && options.transaction) || undefined; + + const contact = await db.contact.findByPk(id, options); + + await contact.update({ + deletedBy: currentUser.id + }, { + transaction, + }); + + await contact.destroy({ + transaction + }); + + return contact; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const contact = await db.contact.findOne( + { where }, + { transaction }, + ); + + if (!contact) { + return contact; + } + + const output = contact.get({plain: true}); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = []; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.firstname) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'contact', + 'firstname', + filter.firstname, + ), + }; + } + + if (filter.lastname) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'contact', + 'lastname', + filter.lastname, + ), + }; + } + + if (filter.email) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'contact', + 'email', + filter.email, + ), + }; + } + + if (filter.phonenumber) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'contact', + 'phonenumber', + filter.phonenumber, + ), + }; + } + + if (filter.active !== undefined) { + where = { + ...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, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + 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.contact.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) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike( + 'contact', + 'lastname', + query, + ), + ], + }; + } + + const records = await db.contact.findAll({ + attributes: [ 'id', 'lastname' ], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['lastname', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.lastname, + })); + } + +}; + diff --git a/backend/src/db/api/folder.js b/backend/src/db/api/folder.js new file mode 100644 index 0000000..b2044be --- /dev/null +++ b/backend/src/db/api/folder.js @@ -0,0 +1,267 @@ + +const db = require('../models'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class FolderDBApi { + + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const folder = await db.folder.create( + { + id: data.id || undefined, + + name: data.name + || + null + , + + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return folder; + } + + 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 folderData = 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 folder = await db.folder.bulkCreate(folderData, { transaction }); + + return folder; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || {id: null}; + const transaction = (options && options.transaction) || undefined; + + const folder = await db.folder.findByPk(id, {}, {transaction}); + + const updatePayload = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + + updatePayload.updatedById = currentUser.id; + + await folder.update(updatePayload, {transaction}); + + return folder; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const folder = await db.folder.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of folder) { + await record.update( + {deletedBy: currentUser.id}, + {transaction} + ); + } + for (const record of folder) { + await record.destroy({transaction}); + } + }); + + return folder; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || {id: null}; + const transaction = (options && options.transaction) || undefined; + + const folder = await db.folder.findByPk(id, options); + + await folder.update({ + deletedBy: currentUser.id + }, { + transaction, + }); + + await folder.destroy({ + transaction + }); + + return folder; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const folder = await db.folder.findOne( + { where }, + { transaction }, + ); + + if (!folder) { + return folder; + } + + const output = folder.get({plain: true}); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + const user = (options && options.currentUser) || null; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = []; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'folder', + 'name', + filter.name, + ), + }; + } + + if (filter.active !== undefined) { + where = { + ...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, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + 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.folder.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) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike( + 'folder', + 'name', + query, + ), + ], + }; + } + + const records = await db.folder.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, + })); + } + +}; + diff --git a/backend/src/db/migrations/1759320240500.js b/backend/src/db/migrations/1759320240500.js new file mode 100644 index 0000000..1e7594a --- /dev/null +++ b/backend/src/db/migrations/1759320240500.js @@ -0,0 +1,91 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.createTable('contact', { + 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( + 'contact', + 'RealEstateAgencyId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'RealEstateAgency', + key: 'id', + }, + + }, + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.removeColumn( + 'contact', + 'RealEstateAgencyId', + { transaction } + ); + + await queryInterface.dropTable('contact', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/migrations/1759320255622.js b/backend/src/db/migrations/1759320255622.js new file mode 100644 index 0000000..b6ae4b4 --- /dev/null +++ b/backend/src/db/migrations/1759320255622.js @@ -0,0 +1,54 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.addColumn( + 'contact', + 'firstname', + { + type: Sequelize.DataTypes.TEXT, + + }, + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.removeColumn( + 'contact', + 'firstname', + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/migrations/1759320269093.js b/backend/src/db/migrations/1759320269093.js new file mode 100644 index 0000000..fa6b714 --- /dev/null +++ b/backend/src/db/migrations/1759320269093.js @@ -0,0 +1,54 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.addColumn( + 'contact', + 'lastname', + { + type: Sequelize.DataTypes.TEXT, + + }, + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.removeColumn( + 'contact', + 'lastname', + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/migrations/1759320277416.js b/backend/src/db/migrations/1759320277416.js new file mode 100644 index 0000000..facde90 --- /dev/null +++ b/backend/src/db/migrations/1759320277416.js @@ -0,0 +1,54 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.addColumn( + 'contact', + 'email', + { + type: Sequelize.DataTypes.TEXT, + + }, + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.removeColumn( + 'contact', + 'email', + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/migrations/1759320286358.js b/backend/src/db/migrations/1759320286358.js new file mode 100644 index 0000000..24b8ed1 --- /dev/null +++ b/backend/src/db/migrations/1759320286358.js @@ -0,0 +1,54 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.addColumn( + 'contact', + 'phonenumber', + { + type: Sequelize.DataTypes.TEXT, + + }, + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.removeColumn( + 'contact', + 'phonenumber', + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/migrations/1759320294013.js b/backend/src/db/migrations/1759320294013.js new file mode 100644 index 0000000..8ab8739 --- /dev/null +++ b/backend/src/db/migrations/1759320294013.js @@ -0,0 +1,91 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.createTable('folder', { + 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( + 'folder', + 'RealEstateAgencyId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'RealEstateAgency', + key: 'id', + }, + + }, + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.removeColumn( + 'folder', + 'RealEstateAgencyId', + { transaction } + ); + + await queryInterface.dropTable('folder', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/migrations/1759320299347.js b/backend/src/db/migrations/1759320299347.js new file mode 100644 index 0000000..033f86b --- /dev/null +++ b/backend/src/db/migrations/1759320299347.js @@ -0,0 +1,91 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.createTable('category', { + 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( + 'category', + 'RealEstateAgencyId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'RealEstateAgency', + key: 'id', + }, + + }, + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.removeColumn( + 'category', + 'RealEstateAgencyId', + { transaction } + ); + + await queryInterface.dropTable('category', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/migrations/1759320312257.js b/backend/src/db/migrations/1759320312257.js new file mode 100644 index 0000000..67f3d08 --- /dev/null +++ b/backend/src/db/migrations/1759320312257.js @@ -0,0 +1,59 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.addColumn( + 'leads', + 'contactId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'contact', + key: 'id', + }, + + }, + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.removeColumn( + 'leads', + 'contactId', + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/migrations/1759320322321.js b/backend/src/db/migrations/1759320322321.js new file mode 100644 index 0000000..6146202 --- /dev/null +++ b/backend/src/db/migrations/1759320322321.js @@ -0,0 +1,59 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.addColumn( + 'leads', + 'folderId', + { + type: Sequelize.DataTypes.UUID, + + references: { + model: 'folder', + key: 'id', + }, + + }, + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.removeColumn( + 'leads', + 'folderId', + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/migrations/1759320328321.js b/backend/src/db/migrations/1759320328321.js new file mode 100644 index 0000000..a1c6034 --- /dev/null +++ b/backend/src/db/migrations/1759320328321.js @@ -0,0 +1,38 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + 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} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/migrations/1759320339014.js b/backend/src/db/migrations/1759320339014.js new file mode 100644 index 0000000..6cb5df5 --- /dev/null +++ b/backend/src/db/migrations/1759320339014.js @@ -0,0 +1,54 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.addColumn( + 'folder', + 'name', + { + type: Sequelize.DataTypes.TEXT, + + }, + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.removeColumn( + 'folder', + 'name', + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/migrations/1759320347228.js b/backend/src/db/migrations/1759320347228.js new file mode 100644 index 0000000..f0f45d8 --- /dev/null +++ b/backend/src/db/migrations/1759320347228.js @@ -0,0 +1,54 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.addColumn( + 'category', + 'name', + { + type: Sequelize.DataTypes.TEXT, + + }, + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + + await queryInterface.removeColumn( + 'category', + 'name', + { transaction } + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/models/category.js b/backend/src/db/models/category.js new file mode 100644 index 0000000..ada4857 --- /dev/null +++ b/backend/src/db/models/category.js @@ -0,0 +1,48 @@ +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 category = sequelize.define( + 'category', + { + 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, + }, + ); + + category.associate = (db) => { + + db.category.belongsTo(db.users, { + as: 'createdBy', + }); + + db.category.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return category; +}; + diff --git a/backend/src/db/models/contact.js b/backend/src/db/models/contact.js new file mode 100644 index 0000000..0b5fd33 --- /dev/null +++ b/backend/src/db/models/contact.js @@ -0,0 +1,63 @@ +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 contact = sequelize.define( + 'contact', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + +firstname: { + type: DataTypes.TEXT, + + }, + +lastname: { + type: DataTypes.TEXT, + + }, + +email: { + type: DataTypes.TEXT, + + }, + +phonenumber: { + type: DataTypes.TEXT, + + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + contact.associate = (db) => { + + db.contact.belongsTo(db.users, { + as: 'createdBy', + }); + + db.contact.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return contact; +}; + diff --git a/backend/src/db/models/folder.js b/backend/src/db/models/folder.js new file mode 100644 index 0000000..7a38a87 --- /dev/null +++ b/backend/src/db/models/folder.js @@ -0,0 +1,48 @@ +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 folder = sequelize.define( + 'folder', + { + 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, + }, + ); + + folder.associate = (db) => { + + db.folder.belongsTo(db.users, { + as: 'createdBy', + }); + + db.folder.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return folder; +}; + diff --git a/backend/src/index.js b/backend/src/index.js index 77e9427..6ed7b89 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -28,6 +28,12 @@ const property_listingsRoutes = require('./routes/property_listings'); const real_estate_agenciesRoutes = require('./routes/real_estate_agencies'); +const contactRoutes = require('./routes/contact'); + +const folderRoutes = require('./routes/folder'); + +const categoryRoutes = require('./routes/category'); + const getBaseUrl = (url) => { if (!url) return ''; return url.endsWith('/api') ? url.slice(0, -4) : url; @@ -38,8 +44,8 @@ const options = { openapi: "3.0.0", info: { version: "1.0.0", - title: "Malkiyat-CRM", - description: "Malkiyat-CRM Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.", + title: "Malkiyat-CRM ", + description: "Malkiyat-CRM Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.", }, servers: [ { @@ -94,6 +100,12 @@ app.use('/api/property_listings', passport.authenticate('jwt', {session: false}) app.use('/api/real_estate_agencies', passport.authenticate('jwt', {session: false}), real_estate_agenciesRoutes); +app.use('/api/contact', passport.authenticate('jwt', {session: false}), contactRoutes); + +app.use('/api/folder', passport.authenticate('jwt', {session: false}), folderRoutes); + +app.use('/api/category', passport.authenticate('jwt', {session: false}), categoryRoutes); + app.use('/api/contact-form', contactFormRoutes); app.use( diff --git a/backend/src/routes/category.js b/backend/src/routes/category.js new file mode 100644 index 0000000..fd9aaab --- /dev/null +++ b/backend/src/routes/category.js @@ -0,0 +1,410 @@ + +const express = require('express'); + +const CategoryService = require('../services/category'); +const CategoryDBApi = require('../db/api/category'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +/** + * @swagger + * components: + * schemas: + * Category: + * type: object + * properties: + + * name: + * type: string + * default: name + + */ + +/** + * @swagger + * tags: + * name: Category + * description: The Category managing API + */ + +/** +* @swagger +* /api/category: +* post: +* security: +* - bearerAuth: [] +* tags: [Category] +* 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/Category" +* responses: +* 200: +* description: The item was successfully added +* content: +* application/json: +* schema: +* $ref: "#/components/schemas/Category" +* 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 CategoryService.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: [Category] + * 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/Category" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Category" + * 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 CategoryService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/category/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Category] + * 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/Category" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Category" + * 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 CategoryService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/category/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Category] + * 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/Category" + * 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 CategoryService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/category/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Category] + * 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/Category" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post('/deleteByIds', wrapAsync(async (req, res) => { + await CategoryService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + })); + +/** + * @swagger + * /api/category: + * get: + * security: + * - bearerAuth: [] + * tags: [Category] + * summary: Get all category + * description: Get all category + * responses: + * 200: + * description: Category list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Category" + * 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 currentUser = req.currentUser; + const payload = await CategoryDBApi.findAll( + req.query, { 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/category/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Category] + * summary: Count all category + * description: Count all category + * responses: + * 200: + * description: Category count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Category" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/count', wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await CategoryDBApi.findAll( + req.query, + null, + { countOnly: true, currentUser } + ); + + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/category/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Category] + * summary: Find all category that match search criteria + * description: Find all category that match search criteria + * responses: + * 200: + * description: Category list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Category" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await CategoryDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/category/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Category] + * 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/Category" + * 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 CategoryDBApi.findBy( + { id: req.params.id }, + ); + + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/contact.js b/backend/src/routes/contact.js new file mode 100644 index 0000000..cc239fe --- /dev/null +++ b/backend/src/routes/contact.js @@ -0,0 +1,419 @@ + +const express = require('express'); + +const ContactService = require('../services/contact'); +const ContactDBApi = require('../db/api/contact'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +/** + * @swagger + * components: + * schemas: + * Contact: + * type: object + * properties: + + * firstname: + * type: string + * default: firstname + * lastname: + * type: string + * default: lastname + * email: + * type: string + * default: email + * phonenumber: + * type: string + * default: phonenumber + + */ + +/** + * @swagger + * tags: + * name: Contact + * description: The Contact managing API + */ + +/** +* @swagger +* /api/contact: +* post: +* security: +* - bearerAuth: [] +* tags: [Contact] +* 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/Contact" +* responses: +* 200: +* description: The item was successfully added +* content: +* application/json: +* schema: +* $ref: "#/components/schemas/Contact" +* 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 ContactService.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: [Contact] + * 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/Contact" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Contact" + * 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 ContactService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/contact/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Contact] + * 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/Contact" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Contact" + * 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 ContactService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/contact/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Contact] + * 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/Contact" + * 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 ContactService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/contact/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Contact] + * 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/Contact" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post('/deleteByIds', wrapAsync(async (req, res) => { + await ContactService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + })); + +/** + * @swagger + * /api/contact: + * get: + * security: + * - bearerAuth: [] + * tags: [Contact] + * summary: Get all contact + * description: Get all contact + * responses: + * 200: + * description: Contact list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Contact" + * 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 currentUser = req.currentUser; + const payload = await ContactDBApi.findAll( + req.query, { currentUser } + ); + if (filetype && filetype === 'csv') { + const fields = ['id','firstname','lastname','email','phonenumber', + + ]; + 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/contact/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Contact] + * summary: Count all contact + * description: Count all contact + * responses: + * 200: + * description: Contact count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Contact" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/count', wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await ContactDBApi.findAll( + req.query, + null, + { countOnly: true, currentUser } + ); + + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/contact/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Contact] + * summary: Find all contact that match search criteria + * description: Find all contact that match search criteria + * responses: + * 200: + * description: Contact list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Contact" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await ContactDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/contact/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Contact] + * 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/Contact" + * 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 ContactDBApi.findBy( + { id: req.params.id }, + ); + + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/folder.js b/backend/src/routes/folder.js new file mode 100644 index 0000000..ddd7b0b --- /dev/null +++ b/backend/src/routes/folder.js @@ -0,0 +1,410 @@ + +const express = require('express'); + +const FolderService = require('../services/folder'); +const FolderDBApi = require('../db/api/folder'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +/** + * @swagger + * components: + * schemas: + * Folder: + * type: object + * properties: + + * name: + * type: string + * default: name + + */ + +/** + * @swagger + * tags: + * name: Folder + * description: The Folder managing API + */ + +/** +* @swagger +* /api/folder: +* post: +* security: +* - bearerAuth: [] +* tags: [Folder] +* 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/Folder" +* responses: +* 200: +* description: The item was successfully added +* content: +* application/json: +* schema: +* $ref: "#/components/schemas/Folder" +* 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 FolderService.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: [Folder] + * 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/Folder" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Folder" + * 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 FolderService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/folder/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Folder] + * 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/Folder" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Folder" + * 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 FolderService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/folder/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Folder] + * 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/Folder" + * 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 FolderService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/folder/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Folder] + * 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/Folder" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post('/deleteByIds', wrapAsync(async (req, res) => { + await FolderService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + })); + +/** + * @swagger + * /api/folder: + * get: + * security: + * - bearerAuth: [] + * tags: [Folder] + * summary: Get all folder + * description: Get all folder + * responses: + * 200: + * description: Folder list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Folder" + * 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 currentUser = req.currentUser; + const payload = await FolderDBApi.findAll( + req.query, { 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/folder/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Folder] + * summary: Count all folder + * description: Count all folder + * responses: + * 200: + * description: Folder count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Folder" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/count', wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await FolderDBApi.findAll( + req.query, + null, + { countOnly: true, currentUser } + ); + + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/folder/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Folder] + * summary: Find all folder that match search criteria + * description: Find all folder that match search criteria + * responses: + * 200: + * description: Folder list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Folder" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await FolderDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/folder/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Folder] + * 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/Folder" + * 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 FolderDBApi.findBy( + { id: req.params.id }, + ); + + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/category.js b/backend/src/services/category.js new file mode 100644 index 0000000..b42ba08 --- /dev/null +++ b/backend/src/services/category.js @@ -0,0 +1,131 @@ +const db = require('../db/models'); +const CategoryDBApi = require('../db/api/category'); +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 CategoryService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await CategoryDBApi.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 CategoryDBApi.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 category = await CategoryDBApi.findBy( + {id}, + {transaction}, + ); + + if (!category) { + throw new ValidationError( + 'categoryNotFound', + ); + } + + const updatedCategory = await CategoryDBApi.update( + id, + data, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + return updatedCategory; + + } catch (error) { + await transaction.rollback(); + throw error; + } + }; + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await CategoryDBApi.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 CategoryDBApi.remove( + id, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; + diff --git a/backend/src/services/contact.js b/backend/src/services/contact.js new file mode 100644 index 0000000..588fe57 --- /dev/null +++ b/backend/src/services/contact.js @@ -0,0 +1,131 @@ +const db = require('../db/models'); +const ContactDBApi = require('../db/api/contact'); +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 ContactService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await ContactDBApi.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 ContactDBApi.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 contact = await ContactDBApi.findBy( + {id}, + {transaction}, + ); + + if (!contact) { + throw new ValidationError( + 'contactNotFound', + ); + } + + const updatedContact = await ContactDBApi.update( + id, + data, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + return updatedContact; + + } catch (error) { + await transaction.rollback(); + throw error; + } + }; + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await ContactDBApi.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 ContactDBApi.remove( + id, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; + diff --git a/backend/src/services/folder.js b/backend/src/services/folder.js new file mode 100644 index 0000000..cdd34b5 --- /dev/null +++ b/backend/src/services/folder.js @@ -0,0 +1,131 @@ +const db = require('../db/models'); +const FolderDBApi = require('../db/api/folder'); +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 FolderService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await FolderDBApi.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 FolderDBApi.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 folder = await FolderDBApi.findBy( + {id}, + {transaction}, + ); + + if (!folder) { + throw new ValidationError( + 'folderNotFound', + ); + } + + const updatedFolder = await FolderDBApi.update( + id, + data, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + return updatedFolder; + + } catch (error) { + await transaction.rollback(); + throw error; + } + }; + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await FolderDBApi.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 FolderDBApi.remove( + id, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; + diff --git a/backend/src/services/search.js b/backend/src/services/search.js index a45e5a5..4d430aa 100644 --- a/backend/src/services/search.js +++ b/backend/src/services/search.js @@ -87,6 +87,30 @@ module.exports = class SearchService { ], + "contact": [ + + "firstname", + + "lastname", + + "email", + + "phonenumber", + + ], + + "folder": [ + + "name", + + ], + + "category": [ + + "name", + + ], + }; const columnsInt = { diff --git a/frontend/json/runtimeError.json b/frontend/json/runtimeError.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/frontend/json/runtimeError.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/frontend/src/components/Category/CardCategory.tsx b/frontend/src/components/Category/CardCategory.tsx new file mode 100644 index 0000000..29a99c3 --- /dev/null +++ b/frontend/src/components/Category/CardCategory.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from "../LoadingSpinner"; +import Link from 'next/link'; + +type Props = { + category: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardCategory = ({ + category, + 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); + + return ( +
+ {loading && } +
    + {!loading && category.map((item, index) => ( +
  • + + {item.name} + + +
    + +
    +
+
+ +
+
Name
+
+
+ { item.name } +
+
+
+ +
+ + ))} + {!loading && category.length === 0 && ( +
+

No data to display

+
+ )} + +
+ +
+ + ); +}; + +export default CardCategory; diff --git a/frontend/src/components/Category/ListCategory.tsx b/frontend/src/components/Category/ListCategory.tsx new file mode 100644 index 0000000..60f3ef6 --- /dev/null +++ b/frontend/src/components/Category/ListCategory.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import dataFormatter from '../../helpers/dataFormatter'; +import ListActionsPopover from "../ListActionsPopover"; +import {useAppSelector} from "../../stores/hooks"; +import {Pagination} from "../Pagination"; +import LoadingSpinner from "../LoadingSpinner"; +import Link from 'next/link'; + +type Props = { + category: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListCategory = ({ category, loading, onDelete, currentPage, numPages, onPageChange }: Props) => { + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
+ {loading && } + {!loading && category.map((item) => ( +
+ +
+ + +
+

Name

+

{ item.name }

+
+ + + +
+
+
+ ))} + {!loading && category.length === 0 && ( +
+

No data to display

+
+ )} +
+
+ +
+ + ) +}; + +export default ListCategory diff --git a/frontend/src/components/Category/TableCategory.tsx b/frontend/src/components/Category/TableCategory.tsx new file mode 100644 index 0000000..ab3c869 --- /dev/null +++ b/frontend/src/components/Category/TableCategory.tsx @@ -0,0 +1,441 @@ +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/category/categorySlice' +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 "./configureCategoryCols"; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter' +import {dataGridStyles} from "../../styles"; +import axios from 'axios'; + +const perPage = 10; + +const TableSampleCategory = ({ 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([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + const { category, loading, count, notify: categoryNotify, refetch } = useAppSelector((state) => state.category) + 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 (categoryNotify.showNotification) { + notify(categoryNotify.typeNotification, categoryNotify.textNotification); + } + }, [categoryNotify.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(() => { + loadColumns(handleDeleteModalAction, `category`).then((newCols) => + setColumns(newCols), + ); + }, []); + + 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 border-gray-700 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
+ `datagrid--row`} + rows={category ?? []} + 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); + }} + /> +
+ ) + + return ( + <> + {filterItems && Array.isArray( filterItems ) && filterItems.length ? + + null} + > +
+ <> + {filterItems && filterItems.map((filterItem) => { + return ( +
+
+
Filter
+ + {filters.map((selectOption) => ( + + ))} + +
+ {filters.find((filter) => + filter.title === filterItem?.fields?.selectedField + )?.type === 'enum' ? ( +
+
+ Value +
+ + + {filters.find((filter) => + filter.title === filterItem?.fields?.selectedField + )?.options?.map((option) => ( + + ))} + +
+ ) : filters.find((filter) => + filter.title === filterItem?.fields?.selectedField + )?.number ? ( +
+
+
From
+ +
+
+
To
+ +
+
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField + )?.date ? ( +
+
+
+ From +
+ +
+
+
To
+ +
+
+ ) : ( +
+
Contains
+ +
+ )} +
+
Action
+ { + deleteFilter(filterItem.id) + }} + /> +
+
+ ) + })} +
+ + +
+ +
+
+
: null + } + +

Are you sure you want to delete this item?

+
+ {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ) +} + +export default TableSampleCategory diff --git a/frontend/src/components/Category/configureCategoryCols.tsx b/frontend/src/components/Category/configureCategoryCols.tsx new file mode 100644 index 0000000..d212db7 --- /dev/null +++ b/frontend/src/components/Category/configureCategoryCols.tsx @@ -0,0 +1,65 @@ +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 dataFormatter from '../../helpers/dataFormatter' +import DataGridMultiSelect from "../DataGridMultiSelect"; +import ListActionsPopover from '../ListActionsPopover'; +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, +) => { + async function callOptionsApi(entityName: string) { + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + return [ + + { + field: 'name', + headerName: 'Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: true, + + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + + return [ +
+ +
, + ] + }, + }, + ]; +}; diff --git a/frontend/src/components/Contact/CardContact.tsx b/frontend/src/components/Contact/CardContact.tsx new file mode 100644 index 0000000..587a97f --- /dev/null +++ b/frontend/src/components/Contact/CardContact.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from "../LoadingSpinner"; +import Link from 'next/link'; + +type Props = { + contact: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardContact = ({ + contact, + 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); + + return ( +
+ {loading && } +
    + {!loading && contact.map((item, index) => ( +
  • + + {item.lastname} + + +
    + +
    +
+
+ +
+
Firstname
+
+
+ { item.firstname } +
+
+
+ +
+
Lastname
+
+
+ { item.lastname } +
+
+
+ +
+
Email
+
+
+ { item.email } +
+
+
+ +
+
Phonenumber
+
+
+ { item.phonenumber } +
+
+
+ +
+
Categories
+
+
+ { item.categories } +
+
+
+ +
+ + ))} + {!loading && contact.length === 0 && ( +
+

No data to display

+
+ )} + +
+ +
+ + ); +}; + +export default CardContact; diff --git a/frontend/src/components/Contact/ListContact.tsx b/frontend/src/components/Contact/ListContact.tsx new file mode 100644 index 0000000..66d0466 --- /dev/null +++ b/frontend/src/components/Contact/ListContact.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import dataFormatter from '../../helpers/dataFormatter'; +import ListActionsPopover from "../ListActionsPopover"; +import {useAppSelector} from "../../stores/hooks"; +import {Pagination} from "../Pagination"; +import LoadingSpinner from "../LoadingSpinner"; +import Link from 'next/link'; + +type Props = { + contact: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListContact = ({ contact, loading, onDelete, currentPage, numPages, onPageChange }: Props) => { + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
+ {loading && } + {!loading && contact.map((item) => ( +
+ +
+ + +
+

Firstname

+

{ item.firstname }

+
+ +
+

Lastname

+

{ item.lastname }

+
+ +
+

Email

+

{ item.email }

+
+ +
+

Phonenumber

+

{ item.phonenumber }

+
+ +
+

Categories

+

{ item.categories }

+
+ + + +
+
+
+ ))} + {!loading && contact.length === 0 && ( +
+

No data to display

+
+ )} +
+
+ +
+ + ) +}; + +export default ListContact diff --git a/frontend/src/components/Contact/TableContact.tsx b/frontend/src/components/Contact/TableContact.tsx new file mode 100644 index 0000000..e6b70ee --- /dev/null +++ b/frontend/src/components/Contact/TableContact.tsx @@ -0,0 +1,441 @@ +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/contact/contactSlice' +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 "./configureContactCols"; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter' +import {dataGridStyles} from "../../styles"; +import axios from 'axios'; + +const perPage = 10; + +const TableSampleContact = ({ 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([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + const { contact, loading, count, notify: contactNotify, refetch } = useAppSelector((state) => state.contact) + 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 (contactNotify.showNotification) { + notify(contactNotify.typeNotification, contactNotify.textNotification); + } + }, [contactNotify.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(() => { + loadColumns(handleDeleteModalAction, `contact`).then((newCols) => + setColumns(newCols), + ); + }, []); + + 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 border-gray-700 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
+ `datagrid--row`} + rows={contact ?? []} + 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); + }} + /> +
+ ) + + return ( + <> + {filterItems && Array.isArray( filterItems ) && filterItems.length ? + + null} + > +
+ <> + {filterItems && filterItems.map((filterItem) => { + return ( +
+
+
Filter
+ + {filters.map((selectOption) => ( + + ))} + +
+ {filters.find((filter) => + filter.title === filterItem?.fields?.selectedField + )?.type === 'enum' ? ( +
+
+ Value +
+ + + {filters.find((filter) => + filter.title === filterItem?.fields?.selectedField + )?.options?.map((option) => ( + + ))} + +
+ ) : filters.find((filter) => + filter.title === filterItem?.fields?.selectedField + )?.number ? ( +
+
+
From
+ +
+
+
To
+ +
+
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField + )?.date ? ( +
+
+
+ From +
+ +
+
+
To
+ +
+
+ ) : ( +
+
Contains
+ +
+ )} +
+
Action
+ { + deleteFilter(filterItem.id) + }} + /> +
+
+ ) + })} +
+ + +
+ +
+
+
: null + } + +

Are you sure you want to delete this item?

+
+ {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ) +} + +export default TableSampleContact diff --git a/frontend/src/components/Contact/configureContactCols.tsx b/frontend/src/components/Contact/configureContactCols.tsx new file mode 100644 index 0000000..6d455be --- /dev/null +++ b/frontend/src/components/Contact/configureContactCols.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, + GridRenderEditCellParams, +} from '@mui/x-data-grid'; +import dataFormatter from '../../helpers/dataFormatter' +import DataGridMultiSelect from "../DataGridMultiSelect"; +import ListActionsPopover from '../ListActionsPopover'; +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, +) => { + async function callOptionsApi(entityName: string) { + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + return [ + + { + field: 'firstname', + headerName: 'Firstname', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: true, + + }, + + { + field: 'lastname', + headerName: 'Lastname', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: true, + + }, + + { + field: 'email', + headerName: 'Email', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: true, + + }, + + { + field: 'phonenumber', + headerName: 'Phonenumber', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: true, + + }, + + { + field: 'categories', + headerName: 'Categories', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: true, + sortable: false, + renderCell: (params: GridValueGetterParams) => + Array.isArray(params.value) + ? (params.value as any[]).map((v) => v.label).join(', ') + : '', + renderEditCell: (params: GridRenderEditCellParams) => ( + + ), + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + + return [ +
+ +
, + ] + }, + }, + ]; +}; diff --git a/frontend/src/components/Folder/CardFolder.tsx b/frontend/src/components/Folder/CardFolder.tsx new file mode 100644 index 0000000..5f0e0bf --- /dev/null +++ b/frontend/src/components/Folder/CardFolder.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from "../LoadingSpinner"; +import Link from 'next/link'; + +type Props = { + folder: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardFolder = ({ + folder, + 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); + + return ( +
+ {loading && } +
    + {!loading && folder.map((item, index) => ( +
  • + + {item.name} + + +
    + +
    +
+
+ +
+
Name
+
+
+ { item.name } +
+
+
+ +
+ + ))} + {!loading && folder.length === 0 && ( +
+

No data to display

+
+ )} + +
+ +
+ + ); +}; + +export default CardFolder; diff --git a/frontend/src/components/Folder/ListFolder.tsx b/frontend/src/components/Folder/ListFolder.tsx new file mode 100644 index 0000000..b4b8e45 --- /dev/null +++ b/frontend/src/components/Folder/ListFolder.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import dataFormatter from '../../helpers/dataFormatter'; +import ListActionsPopover from "../ListActionsPopover"; +import {useAppSelector} from "../../stores/hooks"; +import {Pagination} from "../Pagination"; +import LoadingSpinner from "../LoadingSpinner"; +import Link from 'next/link'; + +type Props = { + folder: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListFolder = ({ folder, loading, onDelete, currentPage, numPages, onPageChange }: Props) => { + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
+ {loading && } + {!loading && folder.map((item) => ( +
+ +
+ + +
+

Name

+

{ item.name }

+
+ + + +
+
+
+ ))} + {!loading && folder.length === 0 && ( +
+

No data to display

+
+ )} +
+
+ +
+ + ) +}; + +export default ListFolder diff --git a/frontend/src/components/Folder/TableFolder.tsx b/frontend/src/components/Folder/TableFolder.tsx new file mode 100644 index 0000000..ecf6058 --- /dev/null +++ b/frontend/src/components/Folder/TableFolder.tsx @@ -0,0 +1,441 @@ +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/folder/folderSlice' +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 "./configureFolderCols"; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter' +import {dataGridStyles} from "../../styles"; +import axios from 'axios'; + +const perPage = 10; + +const TableSampleFolder = ({ 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([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + const { folder, loading, count, notify: folderNotify, refetch } = useAppSelector((state) => state.folder) + 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 (folderNotify.showNotification) { + notify(folderNotify.typeNotification, folderNotify.textNotification); + } + }, [folderNotify.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(() => { + loadColumns(handleDeleteModalAction, `folder`).then((newCols) => + setColumns(newCols), + ); + }, []); + + 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 border-gray-700 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
+ `datagrid--row`} + rows={folder ?? []} + 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); + }} + /> +
+ ) + + return ( + <> + {filterItems && Array.isArray( filterItems ) && filterItems.length ? + + null} + > +
+ <> + {filterItems && filterItems.map((filterItem) => { + return ( +
+
+
Filter
+ + {filters.map((selectOption) => ( + + ))} + +
+ {filters.find((filter) => + filter.title === filterItem?.fields?.selectedField + )?.type === 'enum' ? ( +
+
+ Value +
+ + + {filters.find((filter) => + filter.title === filterItem?.fields?.selectedField + )?.options?.map((option) => ( + + ))} + +
+ ) : filters.find((filter) => + filter.title === filterItem?.fields?.selectedField + )?.number ? ( +
+
+
From
+ +
+
+
To
+ +
+
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField + )?.date ? ( +
+
+
+ From +
+ +
+
+
To
+ +
+
+ ) : ( +
+
Contains
+ +
+ )} +
+
Action
+ { + deleteFilter(filterItem.id) + }} + /> +
+
+ ) + })} +
+ + +
+ +
+
+
: null + } + +

Are you sure you want to delete this item?

+
+ {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ) +} + +export default TableSampleFolder diff --git a/frontend/src/components/Folder/configureFolderCols.tsx b/frontend/src/components/Folder/configureFolderCols.tsx new file mode 100644 index 0000000..3fea5cd --- /dev/null +++ b/frontend/src/components/Folder/configureFolderCols.tsx @@ -0,0 +1,65 @@ +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 dataFormatter from '../../helpers/dataFormatter' +import DataGridMultiSelect from "../DataGridMultiSelect"; +import ListActionsPopover from '../ListActionsPopover'; +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, +) => { + async function callOptionsApi(entityName: string) { + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + return [ + + { + field: 'name', + headerName: 'Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: true, + + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + + return [ +
+ +
, + ] + }, + }, + ]; +}; diff --git a/frontend/src/components/Leads/CardLeads.tsx b/frontend/src/components/Leads/CardLeads.tsx index 97cad19..c528f69 100644 --- a/frontend/src/components/Leads/CardLeads.tsx +++ b/frontend/src/components/Leads/CardLeads.tsx @@ -106,6 +106,24 @@ const CardLeads = ({ +
+
Contact
+
+
+ { dataFormatter.contactOneListFormatter(item.contact) } +
+
+
+ +
+
Folder
+
+
+ { dataFormatter.folderOneListFormatter(item.folder) } +
+
+
+ ))} diff --git a/frontend/src/components/Leads/ListLeads.tsx b/frontend/src/components/Leads/ListLeads.tsx index a4c04b2..534d6c3 100644 --- a/frontend/src/components/Leads/ListLeads.tsx +++ b/frontend/src/components/Leads/ListLeads.tsx @@ -60,6 +60,16 @@ const ListLeads = ({ leads, loading, onDelete, currentPage, numPages, onPageChan

{ dataFormatter.real_estate_agenciesOneListFormatter(item.agency) }

+
+

Contact

+

{ dataFormatter.contactOneListFormatter(item.contact) }

+
+ +
+

Folder

+

{ dataFormatter.folderOneListFormatter(item.folder) }

+
+ value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('contact'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + + }, + + { + field: 'folder', + headerName: 'Folder', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: true, + sortable: false, + type: 'singleSelect', + getOptionValue: (value: any) => value?.id, + getOptionLabel: (value: any) => value?.label, + valueOptions: await callOptionsApi('folder'), + valueGetter: (params: GridValueGetterParams) => + params?.value?.id ?? params?.value, + + }, + { field: 'actions', type: 'actions', diff --git a/frontend/src/helpers/dataFormatter.js b/frontend/src/helpers/dataFormatter.js index 36a1cf1..2f62d76 100644 --- a/frontend/src/helpers/dataFormatter.js +++ b/frontend/src/helpers/dataFormatter.js @@ -115,4 +115,61 @@ export default { return {label: val.name, id: val.id} }, + contactManyListFormatter(val) { + if (!val || !val.length) return [] + return val.map((item) => item.lastname) + }, + contactOneListFormatter(val) { + if (!val) return '' + return val.lastname + }, + contactManyListFormatterEdit(val) { + if (!val || !val.length) return [] + return val.map((item) => { + return {id: item.id, label: item.lastname} + }); + }, + contactOneListFormatterEdit(val) { + if (!val) return '' + return {label: val.lastname, id: val.id} + }, + + folderManyListFormatter(val) { + if (!val || !val.length) return [] + return val.map((item) => item.name) + }, + folderOneListFormatter(val) { + if (!val) return '' + return val.name + }, + folderManyListFormatterEdit(val) { + if (!val || !val.length) return [] + return val.map((item) => { + return {id: item.id, label: item.name} + }); + }, + folderOneListFormatterEdit(val) { + if (!val) return '' + return {label: val.name, id: val.id} + }, + + categoryManyListFormatter(val) { + if (!val || !val.length) return [] + return val.map((item) => item.name) + }, + categoryOneListFormatter(val) { + if (!val) return '' + return val.name + }, + categoryManyListFormatterEdit(val) { + if (!val || !val.length) return [] + return val.map((item) => { + return {id: item.id, label: item.name} + }); + }, + categoryOneListFormatterEdit(val) { + if (!val) return '' + return {label: val.name, id: val.id} + }, + } diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 27c1ab6..25d8b04 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -56,6 +56,30 @@ const menuAside: MenuAsideItem[] = [ icon: 'mdiDomain' in icon ? icon['mdiDomain' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_REAL_ESTATE_AGENCIES' }, + { + href: '/contact/contact-list', + label: 'Contact', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_CONTACT' + }, + { + href: '/folder/folder-list', + label: 'Folder', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_FOLDER' + }, + { + href: '/category/category-list', + label: 'Category', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_CATEGORY' + }, { href: '/profile', label: 'Profile', diff --git a/frontend/src/pages/category/[categoryId].tsx b/frontend/src/pages/category/[categoryId].tsx new file mode 100644 index 0000000..24d3e99 --- /dev/null +++ b/frontend/src/pages/category/[categoryId].tsx @@ -0,0 +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 { SelectField } from "../../components/SelectField"; +import { SelectFieldMany } from "../../components/SelectFieldMany"; +import { SwitchField } from '../../components/SwitchField' +import {RichTextField} from "../../components/RichTextField"; + +import { update, fetch } from '../../stores/category/categorySlice' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { useRouter } from 'next/router' + +const EditCategory = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const initVals = { + + RealEstateAgency: null, + + 'name': '', + + } + const [initialValues, setInitialValues] = useState(initVals) + + const { category } = useAppSelector((state) => state.category) + + const { categoryId } = router.query + + useEffect(() => { + dispatch(fetch({ id: categoryId })) + }, [categoryId]) + + useEffect(() => { + if (typeof category === 'object') { + setInitialValues(category) + } + }, [category]) + + useEffect(() => { + if (typeof category === 'object') { + + const newInitialVal = {...initVals}; + + Object.keys(initVals).forEach(el => newInitialVal[el] = (category)[el]) + + setInitialValues(newInitialVal); + } + }, [category]) + + const handleSubmit = async (data) => { + await dispatch(update({ id: categoryId, data })) + await router.push('/category/category-list') + } + + return ( + <> + + {getPageTitle('Edit category')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + router.push('/category/category-list')}/> + + +
+
+
+ + ) +} + +EditCategory.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default EditCategory diff --git a/frontend/src/pages/category/category-edit.tsx b/frontend/src/pages/category/category-edit.tsx new file mode 100644 index 0000000..5b4ab53 --- /dev/null +++ b/frontend/src/pages/category/category-edit.tsx @@ -0,0 +1,129 @@ +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 { SelectField } from "../../components/SelectField"; +import { SelectFieldMany } from "../../components/SelectFieldMany"; +import { SwitchField } from '../../components/SwitchField' +import {RichTextField} from "../../components/RichTextField"; + +import { update, fetch } from '../../stores/category/categorySlice' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { useRouter } from 'next/router' +import dataFormatter from '../../helpers/dataFormatter'; + +const EditCategoryPage = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const initVals = { + + RealEstateAgency: null, + + 'name': '', + + } + const [initialValues, setInitialValues] = useState(initVals) + + const { category } = useAppSelector((state) => state.category) + + const { id } = router.query + + useEffect(() => { + dispatch(fetch({ id: id })) + }, [id]) + + useEffect(() => { + if (typeof category === 'object') { + setInitialValues(category) + } + }, [category]) + + useEffect(() => { + if (typeof category === 'object') { + const newInitialVal = {...initVals}; + Object.keys(initVals).forEach(el => newInitialVal[el] = (category)[el]) + setInitialValues(newInitialVal); + } + }, [category]) + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })) + await router.push('/category/category-list') + } + + return ( + <> + + {getPageTitle('Edit category')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + router.push('/category/category-list')}/> + + +
+
+
+ + ) +} + +EditCategoryPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default EditCategoryPage diff --git a/frontend/src/pages/category/category-list.tsx b/frontend/src/pages/category/category-list.tsx new file mode 100644 index 0000000..4455247 --- /dev/null +++ b/frontend/src/pages/category/category-list.tsx @@ -0,0 +1,128 @@ +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 TableCategory from '../../components/Category/TableCategory' +import BaseButton from '../../components/BaseButton' +import axios from "axios"; +import {useAppDispatch, useAppSelector} from "../../stores/hooks"; +import CardBoxModal from "../../components/CardBoxModal"; +import DragDropFilePicker from "../../components/DragDropFilePicker"; +import {setRefetch, uploadCsv} from '../../stores/category/categorySlice'; + +const CategoryTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{label: 'Name', title: 'name'}, + + ]); + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getCategoryCSV = async () => { + const response = await axios({url: '/category?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 = 'categoryCSV.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 ( + <> + + {getPageTitle('Category')} + + + + {''} + + + + + + setIsModalActive(true)} + /> +
+
+
+
+ + + +
+ + + + + ) +} + +CategoryTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default CategoryTablesPage diff --git a/frontend/src/pages/category/category-new.tsx b/frontend/src/pages/category/category-new.tsx new file mode 100644 index 0000000..db3d7b5 --- /dev/null +++ b/frontend/src/pages/category/category-new.tsx @@ -0,0 +1,95 @@ +import { mdiChartTimelineVariant } 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 { SwitchField } from '../../components/SwitchField' + +import { SelectField } from '../../components/SelectField' +import {RichTextField} from "../../components/RichTextField"; + +import { create } from '../../stores/category/categorySlice' +import { useAppDispatch } from '../../stores/hooks' +import { useRouter } from 'next/router' + +const initialValues = { + + RealEstateAgency: '', + + name: '', + +} + +const CategoryNew = () => { + const router = useRouter() + const dispatch = useAppDispatch() + + const handleSubmit = async (data) => { + await dispatch(create(data)) + await router.push('/category/category-list') + } + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + router.push('/category/category-list')}/> + + +
+
+
+ + ) +} + +CategoryNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default CategoryNew diff --git a/frontend/src/pages/category/category-table.tsx b/frontend/src/pages/category/category-table.tsx new file mode 100644 index 0000000..edef640 --- /dev/null +++ b/frontend/src/pages/category/category-table.tsx @@ -0,0 +1,129 @@ +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 TableCategory from '../../components/Category/TableCategory' +import BaseButton from '../../components/BaseButton' +import axios from "axios"; +import {useAppDispatch, useAppSelector} from "../../stores/hooks"; +import CardBoxModal from "../../components/CardBoxModal"; +import DragDropFilePicker from "../../components/DragDropFilePicker"; +import {setRefetch, uploadCsv} from '../../stores/category/categorySlice'; + +const CategoryTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{label: 'Name', title: 'name'}, + + ]); + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getCategoryCSV = async () => { + const response = await axios({url: '/category?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 = 'categoryCSV.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 ( + <> + + {getPageTitle('Category')} + + + + {''} + + + + + + + setIsModalActive(true)} + /> +
+
+
+
+ + + +
+ + + + + ) +} + +CategoryTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default CategoryTablesPage diff --git a/frontend/src/pages/category/category-view.tsx b/frontend/src/pages/category/category-view.tsx new file mode 100644 index 0000000..c4ea352 --- /dev/null +++ b/frontend/src/pages/category/category-view.tsx @@ -0,0 +1,83 @@ +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/category/categorySlice' +import dataFormatter from '../../helpers/dataFormatter'; +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"; + +const CategoryView = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const { category } = useAppSelector((state) => state.category) + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str,`str`) + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View category')} + + + + + + + +
+

RealEstateAgency

+ +
+ +
+

Name

+

{category?.name}

+
+ + + + router.push('/category/category-list')} + /> +
+
+ + ); +}; + +CategoryView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default CategoryView; diff --git a/frontend/src/pages/contact/[contactId].tsx b/frontend/src/pages/contact/[contactId].tsx new file mode 100644 index 0000000..6eb8496 --- /dev/null +++ b/frontend/src/pages/contact/[contactId].tsx @@ -0,0 +1,164 @@ +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 { SelectField } from "../../components/SelectField"; +import { SelectFieldMany } from "../../components/SelectFieldMany"; +import { SwitchField } from '../../components/SwitchField' +import {RichTextField} from "../../components/RichTextField"; + +import { update, fetch } from '../../stores/contact/contactSlice' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { useRouter } from 'next/router' + +const EditContact = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const initVals = { + + RealEstateAgency: null, + + 'firstname': '', + + 'lastname': '', + + 'email': '', + + 'phonenumber': '', + + } + const [initialValues, setInitialValues] = useState(initVals) + + const { contact } = useAppSelector((state) => state.contact) + + const { contactId } = router.query + + useEffect(() => { + dispatch(fetch({ id: contactId })) + }, [contactId]) + + useEffect(() => { + if (typeof contact === 'object') { + setInitialValues(contact) + } + }, [contact]) + + useEffect(() => { + if (typeof contact === 'object') { + + const newInitialVal = {...initVals}; + + Object.keys(initVals).forEach(el => newInitialVal[el] = (contact)[el]) + + setInitialValues(newInitialVal); + } + }, [contact]) + + const handleSubmit = async (data) => { + await dispatch(update({ id: contactId, data })) + await router.push('/contact/contact-list') + } + + return ( + <> + + {getPageTitle('Edit contact')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/contact/contact-list')}/> + + +
+
+
+ + ) +} + +EditContact.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default EditContact diff --git a/frontend/src/pages/contact/contact-edit.tsx b/frontend/src/pages/contact/contact-edit.tsx new file mode 100644 index 0000000..c136153 --- /dev/null +++ b/frontend/src/pages/contact/contact-edit.tsx @@ -0,0 +1,162 @@ +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 { SelectField } from "../../components/SelectField"; +import { SelectFieldMany } from "../../components/SelectFieldMany"; +import { SwitchField } from '../../components/SwitchField' +import {RichTextField} from "../../components/RichTextField"; + +import { update, fetch } from '../../stores/contact/contactSlice' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { useRouter } from 'next/router' +import dataFormatter from '../../helpers/dataFormatter'; + +const EditContactPage = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const initVals = { + + RealEstateAgency: null, + + 'firstname': '', + + 'lastname': '', + + 'email': '', + + 'phonenumber': '', + + } + const [initialValues, setInitialValues] = useState(initVals) + + const { contact } = useAppSelector((state) => state.contact) + + const { id } = router.query + + useEffect(() => { + dispatch(fetch({ id: id })) + }, [id]) + + useEffect(() => { + if (typeof contact === 'object') { + setInitialValues(contact) + } + }, [contact]) + + useEffect(() => { + if (typeof contact === 'object') { + const newInitialVal = {...initVals}; + Object.keys(initVals).forEach(el => newInitialVal[el] = (contact)[el]) + setInitialValues(newInitialVal); + } + }, [contact]) + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })) + await router.push('/contact/contact-list') + } + + return ( + <> + + {getPageTitle('Edit contact')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/contact/contact-list')}/> + + +
+
+
+ + ) +} + +EditContactPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default EditContactPage diff --git a/frontend/src/pages/contact/contact-list.tsx b/frontend/src/pages/contact/contact-list.tsx new file mode 100644 index 0000000..b139722 --- /dev/null +++ b/frontend/src/pages/contact/contact-list.tsx @@ -0,0 +1,128 @@ +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 TableContact from '../../components/Contact/TableContact' +import BaseButton from '../../components/BaseButton' +import axios from "axios"; +import {useAppDispatch, useAppSelector} from "../../stores/hooks"; +import CardBoxModal from "../../components/CardBoxModal"; +import DragDropFilePicker from "../../components/DragDropFilePicker"; +import {setRefetch, uploadCsv} from '../../stores/contact/contactSlice'; + +const ContactTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{label: 'Firstname', title: 'firstname'},{label: 'Lastname', title: 'lastname'},{label: 'Email', title: 'email'},{label: 'Phonenumber', title: 'phonenumber'}, + + ]); + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getContactCSV = async () => { + const response = await axios({url: '/contact?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 = 'contactCSV.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 ( + <> + + {getPageTitle('Contact')} + + + + {''} + + + + + + setIsModalActive(true)} + /> +
+
+
+
+ + + +
+ + + + + ) +} + +ContactTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default ContactTablesPage diff --git a/frontend/src/pages/contact/contact-new.tsx b/frontend/src/pages/contact/contact-new.tsx new file mode 100644 index 0000000..6b51d1d --- /dev/null +++ b/frontend/src/pages/contact/contact-new.tsx @@ -0,0 +1,128 @@ +import { mdiChartTimelineVariant } 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 { SwitchField } from '../../components/SwitchField' + +import { SelectField } from '../../components/SelectField' +import {RichTextField} from "../../components/RichTextField"; + +import { create } from '../../stores/contact/contactSlice' +import { useAppDispatch } from '../../stores/hooks' +import { useRouter } from 'next/router' + +const initialValues = { + + RealEstateAgency: '', + + firstname: '', + + lastname: '', + + email: '', + + phonenumber: '', + +} + +const ContactNew = () => { + const router = useRouter() + const dispatch = useAppDispatch() + + const handleSubmit = async (data) => { + await dispatch(create(data)) + await router.push('/contact/contact-list') + } + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/contact/contact-list')}/> + + +
+
+
+ + ) +} + +ContactNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default ContactNew diff --git a/frontend/src/pages/contact/contact-table.tsx b/frontend/src/pages/contact/contact-table.tsx new file mode 100644 index 0000000..9a40ebe --- /dev/null +++ b/frontend/src/pages/contact/contact-table.tsx @@ -0,0 +1,129 @@ +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 TableContact from '../../components/Contact/TableContact' +import BaseButton from '../../components/BaseButton' +import axios from "axios"; +import {useAppDispatch, useAppSelector} from "../../stores/hooks"; +import CardBoxModal from "../../components/CardBoxModal"; +import DragDropFilePicker from "../../components/DragDropFilePicker"; +import {setRefetch, uploadCsv} from '../../stores/contact/contactSlice'; + +const ContactTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{label: 'Firstname', title: 'firstname'},{label: 'Lastname', title: 'lastname'},{label: 'Email', title: 'email'},{label: 'Phonenumber', title: 'phonenumber'}, + + ]); + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getContactCSV = async () => { + const response = await axios({url: '/contact?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 = 'contactCSV.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 ( + <> + + {getPageTitle('Contact')} + + + + {''} + + + + + + + setIsModalActive(true)} + /> +
+
+
+
+ + + +
+ + + + + ) +} + +ContactTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default ContactTablesPage diff --git a/frontend/src/pages/contact/contact-view.tsx b/frontend/src/pages/contact/contact-view.tsx new file mode 100644 index 0000000..392b4c3 --- /dev/null +++ b/frontend/src/pages/contact/contact-view.tsx @@ -0,0 +1,143 @@ +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/contact/contactSlice' +import dataFormatter from '../../helpers/dataFormatter'; +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"; + +const ContactView = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const { contact } = useAppSelector((state) => state.contact) + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str,`str`) + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View contact')} + + + + + + + +
+

RealEstateAgency

+ +
+ +
+

Firstname

+

{contact?.firstname}

+
+ +
+

Lastname

+

{contact?.lastname}

+
+ +
+

Email

+

{contact?.email}

+
+ +
+

Phonenumber

+

{contact?.phonenumber}

+
+ + <> +

Leads Contact

+ +
+ + + + + + + + + + + + + + {contact.leads_contact && Array.isArray(contact.leads_contact) && + contact.leads_contact.map((item: any) => ( + router.push(`/leads/leads-view/?id=${item.id}`)}> + + + + + + + + + ))} + +
LeadNameStatusCategory
+ { item.name } + + { item.status } + + { item.category } +
+
+ {!contact?.leads_contact?.length &&
No data
} +
+ + + + + router.push('/contact/contact-list')} + /> +
+
+ + ); +}; + +ContactView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default ContactView; diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 28c641c..b9d2558 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -23,10 +23,13 @@ const Dashboard = () => { const [properties, setProperties] = React.useState(loadingMessage); const [property_listings, setProperty_listings] = React.useState(loadingMessage); const [real_estate_agencies, setReal_estate_agencies] = React.useState(loadingMessage); + const [contact, setContact] = React.useState(loadingMessage); + const [folder, setFolder] = React.useState(loadingMessage); + const [category, setCategory] = React.useState(loadingMessage); async function loadData() { - const entities = ['users','activities','leads','properties','property_listings','real_estate_agencies',]; - const fns = [setUsers,setActivities,setLeads,setProperties,setProperty_listings,setReal_estate_agencies,]; + const entities = ['users','activities','leads','properties','property_listings','real_estate_agencies','contact','folder','category',]; + const fns = [setUsers,setActivities,setLeads,setProperties,setProperty_listings,setReal_estate_agencies,setContact,setFolder,setCategory,]; const requests = entities.map((entity, index) => { return axios.get(`/${entity.toLowerCase()}/count`); @@ -232,6 +235,90 @@ const Dashboard = () => { + +
+
+
+
+ Contact +
+
+ {contact} +
+
+
+ +
+
+
+ + + +
+
+
+
+ Folder +
+
+ {folder} +
+
+
+ +
+
+
+ + + +
+
+
+
+ Category +
+
+ {category} +
+
+
+ +
+
+
+ + diff --git a/frontend/src/pages/folder/[folderId].tsx b/frontend/src/pages/folder/[folderId].tsx new file mode 100644 index 0000000..6a23d75 --- /dev/null +++ b/frontend/src/pages/folder/[folderId].tsx @@ -0,0 +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 { SelectField } from "../../components/SelectField"; +import { SelectFieldMany } from "../../components/SelectFieldMany"; +import { SwitchField } from '../../components/SwitchField' +import {RichTextField} from "../../components/RichTextField"; + +import { update, fetch } from '../../stores/folder/folderSlice' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { useRouter } from 'next/router' + +const EditFolder = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const initVals = { + + RealEstateAgency: null, + + 'name': '', + + } + const [initialValues, setInitialValues] = useState(initVals) + + const { folder } = useAppSelector((state) => state.folder) + + const { folderId } = router.query + + useEffect(() => { + dispatch(fetch({ id: folderId })) + }, [folderId]) + + useEffect(() => { + if (typeof folder === 'object') { + setInitialValues(folder) + } + }, [folder]) + + useEffect(() => { + if (typeof folder === 'object') { + + const newInitialVal = {...initVals}; + + Object.keys(initVals).forEach(el => newInitialVal[el] = (folder)[el]) + + setInitialValues(newInitialVal); + } + }, [folder]) + + const handleSubmit = async (data) => { + await dispatch(update({ id: folderId, data })) + await router.push('/folder/folder-list') + } + + return ( + <> + + {getPageTitle('Edit folder')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + router.push('/folder/folder-list')}/> + + +
+
+
+ + ) +} + +EditFolder.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default EditFolder diff --git a/frontend/src/pages/folder/folder-edit.tsx b/frontend/src/pages/folder/folder-edit.tsx new file mode 100644 index 0000000..df4e3b1 --- /dev/null +++ b/frontend/src/pages/folder/folder-edit.tsx @@ -0,0 +1,129 @@ +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 { SelectField } from "../../components/SelectField"; +import { SelectFieldMany } from "../../components/SelectFieldMany"; +import { SwitchField } from '../../components/SwitchField' +import {RichTextField} from "../../components/RichTextField"; + +import { update, fetch } from '../../stores/folder/folderSlice' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { useRouter } from 'next/router' +import dataFormatter from '../../helpers/dataFormatter'; + +const EditFolderPage = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const initVals = { + + RealEstateAgency: null, + + 'name': '', + + } + const [initialValues, setInitialValues] = useState(initVals) + + const { folder } = useAppSelector((state) => state.folder) + + const { id } = router.query + + useEffect(() => { + dispatch(fetch({ id: id })) + }, [id]) + + useEffect(() => { + if (typeof folder === 'object') { + setInitialValues(folder) + } + }, [folder]) + + useEffect(() => { + if (typeof folder === 'object') { + const newInitialVal = {...initVals}; + Object.keys(initVals).forEach(el => newInitialVal[el] = (folder)[el]) + setInitialValues(newInitialVal); + } + }, [folder]) + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })) + await router.push('/folder/folder-list') + } + + return ( + <> + + {getPageTitle('Edit folder')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + router.push('/folder/folder-list')}/> + + +
+
+
+ + ) +} + +EditFolderPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default EditFolderPage diff --git a/frontend/src/pages/folder/folder-list.tsx b/frontend/src/pages/folder/folder-list.tsx new file mode 100644 index 0000000..9ef5f31 --- /dev/null +++ b/frontend/src/pages/folder/folder-list.tsx @@ -0,0 +1,128 @@ +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 TableFolder from '../../components/Folder/TableFolder' +import BaseButton from '../../components/BaseButton' +import axios from "axios"; +import {useAppDispatch, useAppSelector} from "../../stores/hooks"; +import CardBoxModal from "../../components/CardBoxModal"; +import DragDropFilePicker from "../../components/DragDropFilePicker"; +import {setRefetch, uploadCsv} from '../../stores/folder/folderSlice'; + +const FolderTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{label: 'Name', title: 'name'}, + + ]); + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getFolderCSV = async () => { + const response = await axios({url: '/folder?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 = 'folderCSV.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 ( + <> + + {getPageTitle('Folder')} + + + + {''} + + + + + + setIsModalActive(true)} + /> +
+
+
+
+ + + +
+ + + + + ) +} + +FolderTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default FolderTablesPage diff --git a/frontend/src/pages/folder/folder-new.tsx b/frontend/src/pages/folder/folder-new.tsx new file mode 100644 index 0000000..c87a995 --- /dev/null +++ b/frontend/src/pages/folder/folder-new.tsx @@ -0,0 +1,95 @@ +import { mdiChartTimelineVariant } 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 { SwitchField } from '../../components/SwitchField' + +import { SelectField } from '../../components/SelectField' +import {RichTextField} from "../../components/RichTextField"; + +import { create } from '../../stores/folder/folderSlice' +import { useAppDispatch } from '../../stores/hooks' +import { useRouter } from 'next/router' + +const initialValues = { + + RealEstateAgency: '', + + name: '', + +} + +const FolderNew = () => { + const router = useRouter() + const dispatch = useAppDispatch() + + const handleSubmit = async (data) => { + await dispatch(create(data)) + await router.push('/folder/folder-list') + } + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + router.push('/folder/folder-list')}/> + + +
+
+
+ + ) +} + +FolderNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default FolderNew diff --git a/frontend/src/pages/folder/folder-table.tsx b/frontend/src/pages/folder/folder-table.tsx new file mode 100644 index 0000000..9ec3b2d --- /dev/null +++ b/frontend/src/pages/folder/folder-table.tsx @@ -0,0 +1,129 @@ +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 TableFolder from '../../components/Folder/TableFolder' +import BaseButton from '../../components/BaseButton' +import axios from "axios"; +import {useAppDispatch, useAppSelector} from "../../stores/hooks"; +import CardBoxModal from "../../components/CardBoxModal"; +import DragDropFilePicker from "../../components/DragDropFilePicker"; +import {setRefetch, uploadCsv} from '../../stores/folder/folderSlice'; + +const FolderTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + + const dispatch = useAppDispatch(); + + const [filters] = useState([{label: 'Name', title: 'name'}, + + ]); + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getFolderCSV = async () => { + const response = await axios({url: '/folder?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 = 'folderCSV.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 ( + <> + + {getPageTitle('Folder')} + + + + {''} + + + + + + + setIsModalActive(true)} + /> +
+
+
+
+ + + +
+ + + + + ) +} + +FolderTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default FolderTablesPage diff --git a/frontend/src/pages/folder/folder-view.tsx b/frontend/src/pages/folder/folder-view.tsx new file mode 100644 index 0000000..27553f3 --- /dev/null +++ b/frontend/src/pages/folder/folder-view.tsx @@ -0,0 +1,128 @@ +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/folder/folderSlice' +import dataFormatter from '../../helpers/dataFormatter'; +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"; + +const FolderView = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const { folder } = useAppSelector((state) => state.folder) + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str,`str`) + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View folder')} + + + + + + + +
+

RealEstateAgency

+ +
+ +
+

Name

+

{folder?.name}

+
+ + <> +

Leads Folder

+ +
+ + + + + + + + + + + + + + {folder.leads_folder && Array.isArray(folder.leads_folder) && + folder.leads_folder.map((item: any) => ( + router.push(`/leads/leads-view/?id=${item.id}`)}> + + + + + + + + + ))} + +
LeadNameStatusCategory
+ { item.name } + + { item.status } + + { item.category } +
+
+ {!folder?.leads_folder?.length &&
No data
} +
+ + + + + router.push('/folder/folder-list')} + /> +
+
+ + ); +}; + +FolderView.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default FolderView; diff --git a/frontend/src/pages/leads/[leadsId].tsx b/frontend/src/pages/leads/[leadsId].tsx index 59e1994..cfff73c 100644 --- a/frontend/src/pages/leads/[leadsId].tsx +++ b/frontend/src/pages/leads/[leadsId].tsx @@ -42,6 +42,10 @@ const EditLeads = () => { agency: null, + contact: null, + + folder: null, + } const [initialValues, setInitialValues] = useState(initVals) @@ -145,6 +149,32 @@ const EditLeads = () => { showField={'name'} + > + + + + + + + + diff --git a/frontend/src/pages/leads/leads-edit.tsx b/frontend/src/pages/leads/leads-edit.tsx index 42564dd..8cdc79d 100644 --- a/frontend/src/pages/leads/leads-edit.tsx +++ b/frontend/src/pages/leads/leads-edit.tsx @@ -43,6 +43,10 @@ const EditLeadsPage = () => { agency: null, + contact: null, + + folder: null, + } const [initialValues, setInitialValues] = useState(initVals) @@ -146,6 +150,32 @@ const EditLeadsPage = () => { > + + + + + + + + diff --git a/frontend/src/pages/leads/leads-new.tsx b/frontend/src/pages/leads/leads-new.tsx index c91f5b5..0e5393e 100644 --- a/frontend/src/pages/leads/leads-new.tsx +++ b/frontend/src/pages/leads/leads-new.tsx @@ -35,6 +35,10 @@ const initialValues = { agency: '', + contact: '', + + folder: '', + } const LeadsNew = () => { @@ -101,6 +105,14 @@ const LeadsNew = () => { + + + + + + + + diff --git a/frontend/src/pages/leads/leads-view.tsx b/frontend/src/pages/leads/leads-view.tsx index d6c0227..4b47cfe 100644 --- a/frontend/src/pages/leads/leads-view.tsx +++ b/frontend/src/pages/leads/leads-view.tsx @@ -78,6 +78,20 @@ const LeadsView = () => { +
+

Contact

+ +

{leads?.contact?.lastname ?? 'No data'}

+ +
+ +
+

Folder

+ +

{leads?.folder?.name ?? 'No data'}

+ +
+ <>

Activities Lead

{ + const { id, query } = data + const result = await axios.get( + `category${ + query || (id ? `/${id}` : '') + }` + ) + return id ? result.data : {rows: result.data.rows, count: result.data.count}; +}) + +export const deleteItemsByIds = createAsyncThunk( + 'category/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('category/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk('category/deleteCategory', async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`category/${id}`) + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } +}) + +export const create = createAsyncThunk('category/createCategory', async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post( + 'category', + { data } + ) + return result.data + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } +}) + +export const uploadCsv = createAsyncThunk( + 'category/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('category/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('category/updateCategory', async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put( + `category/${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 categorySlice = createSlice({ + name: 'category', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + 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.category = action.payload.rows; + state.count = action.payload.count; + } else { + state.category = 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, 'Category 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, `${'Category'.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, `${'Category'.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, `${'Category'.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, 'Category 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 } = categorySlice.actions + +export default categorySlice.reducer diff --git a/frontend/src/stores/contact/contactSlice.ts b/frontend/src/stores/contact/contactSlice.ts new file mode 100644 index 0000000..4ebc1bd --- /dev/null +++ b/frontend/src/stores/contact/contactSlice.ts @@ -0,0 +1,229 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' +import axios from 'axios' +import {fulfilledNotify, rejectNotify, resetNotify} from "../../helpers/notifyStateHandler"; + +interface MainState { + contact: any + loading: boolean + count: number + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean + textNotification: string + typeNotification: string + } +} + +const initialState: MainState = { + contact: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +} + +export const fetch = createAsyncThunk('contact/fetch', async (data: any) => { + const { id, query } = data + const result = await axios.get( + `contact${ + query || (id ? `/${id}` : '') + }` + ) + return id ? result.data : {rows: result.data.rows, count: result.data.count}; +}) + +export const deleteItemsByIds = createAsyncThunk( + 'contact/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('contact/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk('contact/deleteContact', async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`contact/${id}`) + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } +}) + +export const create = createAsyncThunk('contact/createContact', async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post( + 'contact', + { data } + ) + return result.data + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } +}) + +export const uploadCsv = createAsyncThunk( + 'contact/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('contact/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('contact/updateContact', async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put( + `contact/${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 contactSlice = createSlice({ + name: 'contact', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + 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.contact = action.payload.rows; + state.count = action.payload.count; + } else { + state.contact = 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, 'Contact 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, `${'Contact'.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, `${'Contact'.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, `${'Contact'.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, 'Contact 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 } = contactSlice.actions + +export default contactSlice.reducer diff --git a/frontend/src/stores/folder/folderSlice.ts b/frontend/src/stores/folder/folderSlice.ts new file mode 100644 index 0000000..c0396f2 --- /dev/null +++ b/frontend/src/stores/folder/folderSlice.ts @@ -0,0 +1,229 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' +import axios from 'axios' +import {fulfilledNotify, rejectNotify, resetNotify} from "../../helpers/notifyStateHandler"; + +interface MainState { + folder: any + loading: boolean + count: number + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean + textNotification: string + typeNotification: string + } +} + +const initialState: MainState = { + folder: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +} + +export const fetch = createAsyncThunk('folder/fetch', async (data: any) => { + const { id, query } = data + const result = await axios.get( + `folder${ + query || (id ? `/${id}` : '') + }` + ) + return id ? result.data : {rows: result.data.rows, count: result.data.count}; +}) + +export const deleteItemsByIds = createAsyncThunk( + 'folder/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('folder/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk('folder/deleteFolder', async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`folder/${id}`) + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } +}) + +export const create = createAsyncThunk('folder/createFolder', async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post( + 'folder', + { data } + ) + return result.data + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } +}) + +export const uploadCsv = createAsyncThunk( + 'folder/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('folder/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('folder/updateFolder', async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put( + `folder/${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 folderSlice = createSlice({ + name: 'folder', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + 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.folder = action.payload.rows; + state.count = action.payload.count; + } else { + state.folder = 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, 'Folder 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, `${'Folder'.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, `${'Folder'.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, `${'Folder'.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, 'Folder 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 } = folderSlice.actions + +export default folderSlice.reducer diff --git a/frontend/src/stores/store.ts b/frontend/src/stores/store.ts index c12732a..d3be2fd 100644 --- a/frontend/src/stores/store.ts +++ b/frontend/src/stores/store.ts @@ -9,6 +9,9 @@ import leadsSlice from "./leads/leadsSlice"; import propertiesSlice from "./properties/propertiesSlice"; import property_listingsSlice from "./property_listings/property_listingsSlice"; import real_estate_agenciesSlice from "./real_estate_agencies/real_estate_agenciesSlice"; +import contactSlice from "./contact/contactSlice"; +import folderSlice from "./folder/folderSlice"; +import categorySlice from "./category/categorySlice"; export const store = configureStore({ reducer: { @@ -22,6 +25,9 @@ leads: leadsSlice, properties: propertiesSlice, property_listings: property_listingsSlice, real_estate_agencies: real_estate_agenciesSlice, +contact: contactSlice, +folder: folderSlice, +category: categorySlice, }, })