From 9ca9df39745c5fe53a01007e2a29b891dea9e86b Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Fri, 3 Apr 2026 22:03:23 +0000 Subject: [PATCH] Autosave: 20260403-220323 --- backend/src/db/api/documents.js | 69 +- backend/src/db/api/roles.js | 27 +- backend/src/db/api/service_requests.js | 52 +- backend/src/db/api/users.js | 8 +- backend/src/routes/documents.js | 11 +- backend/src/routes/roles.js | 2 + backend/src/services/users.js | 205 +- .../TableBooking_requests.tsx | 35 +- .../configureBooking_requestsCols.tsx | 12 +- .../components/Documents/TableDocuments.tsx | 53 +- .../Documents/configureDocumentsCols.tsx | 12 +- .../Reservations/TableReservations.tsx | 35 +- .../configureReservationsCols.tsx | 12 +- frontend/src/components/SelectField.tsx | 59 +- .../TableService_requests.tsx | 53 +- .../configureService_requestsCols.tsx | 12 +- frontend/src/helpers/entityVisibility.ts | 207 ++ frontend/src/helpers/roleLanes.ts | 28 + frontend/src/layouts/Authenticated.tsx | 4 +- frontend/src/menuAside.ts | 364 +-- .../booking_requests-list.tsx | 52 +- .../booking_requests-table.tsx | 13 +- .../src/pages/documents/documents-list.tsx | 140 +- .../src/pages/documents/documents-table.tsx | 50 +- .../pages/reservations/reservations-edit.tsx | 2639 ++--------------- .../pages/reservations/reservations-list.tsx | 221 +- .../pages/reservations/reservations-new.tsx | 1496 ++-------- .../pages/reservations/reservations-table.tsx | 245 +- .../pages/reservations/reservations-view.tsx | 280 +- .../service_requests-edit.tsx | 1953 ++---------- .../service_requests-list.tsx | 271 +- .../service_requests/service_requests-new.tsx | 1176 ++------ .../service_requests-table.tsx | 232 +- .../service_requests-view.tsx | 1726 +---------- frontend/src/pages/users/users-edit.tsx | 1023 +------ frontend/src/pages/users/users-new.tsx | 629 +--- 36 files changed, 2773 insertions(+), 10633 deletions(-) create mode 100644 frontend/src/helpers/entityVisibility.ts create mode 100644 frontend/src/helpers/roleLanes.ts diff --git a/backend/src/db/api/documents.js b/backend/src/db/api/documents.js index a9506cb..51accd1 100644 --- a/backend/src/db/api/documents.js +++ b/backend/src/db/api/documents.js @@ -1,14 +1,47 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); - +const config = require('../../config'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; +function isCustomerUser(currentUser) { + return currentUser?.app_role?.name === config.roles.customer; +} + +function quoteSqlString(value) { + return `'${String(value).replace(/'/g, "''")}'`; +} + +function buildCustomerDocumentScope(currentUser) { + if (!isCustomerUser(currentUser)) { + return null; + } + + const userId = quoteSqlString(currentUser.id); + + return { + [Op.or]: [ + { uploaded_byId: currentUser.id }, + Sequelize.literal(`EXISTS (SELECT 1 FROM "booking_requests" br WHERE br."id" = "documents"."booking_requestId" AND br."requested_byId" = ${userId})`), + Sequelize.literal(`EXISTS (SELECT 1 FROM "service_requests" sr WHERE sr."id" = "documents"."service_requestId" AND sr."requested_byId" = ${userId})`), + Sequelize.literal(`EXISTS (SELECT 1 FROM "reservations" r JOIN "booking_requests" br ON br."id" = r."booking_requestId" WHERE r."id" = "documents"."reservationId" AND br."requested_byId" = ${userId})`), + ], + }; +} + +function mergeWhereWithScope(where, scope) { + if (!scope) { + return where; + } + + return { + [Op.and]: [where, scope], + }; +} + module.exports = class DocumentsDBApi { @@ -176,7 +209,10 @@ module.exports = class DocumentsDBApi { const transaction = (options && options.transaction) || undefined; const globalAccess = currentUser.app_role?.globalAccess; - const documents = await db.documents.findByPk(id, {}, {transaction}); + const documents = await db.documents.findOne({ + where: mergeWhereWithScope({ id }, buildCustomerDocumentScope(currentUser)), + transaction, + }); @@ -290,11 +326,11 @@ module.exports = class DocumentsDBApi { const transaction = (options && options.transaction) || undefined; const documents = await db.documents.findAll({ - where: { + where: mergeWhereWithScope({ id: { [Op.in]: ids, }, - }, + }, buildCustomerDocumentScope(currentUser)), transaction, }); @@ -318,7 +354,10 @@ module.exports = class DocumentsDBApi { const currentUser = (options && options.currentUser) || {id: null}; const transaction = (options && options.transaction) || undefined; - const documents = await db.documents.findByPk(id, options); + const documents = await db.documents.findOne({ + where: mergeWhereWithScope({ id }, buildCustomerDocumentScope(currentUser)), + transaction, + }); await documents.update({ deletedBy: currentUser.id @@ -335,9 +374,10 @@ module.exports = class DocumentsDBApi { static async findBy(where, options) { const transaction = (options && options.transaction) || undefined; + const currentUser = (options && options.currentUser) || null; const documents = await db.documents.findOne( - { where }, + { where: mergeWhereWithScope(where, buildCustomerDocumentScope(currentUser)) }, { transaction }, ); @@ -441,11 +481,7 @@ module.exports = class DocumentsDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ + let include = [ { model: db.tenants, @@ -734,7 +770,8 @@ module.exports = class DocumentsDBApi { if (globalAccess) { delete where.organizationsId; } - + + where = mergeWhereWithScope(where, buildCustomerDocumentScope(user)); const queryOptions = { where, @@ -765,7 +802,7 @@ module.exports = class DocumentsDBApi { } } - static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { + static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, currentUser = null) { let where = {}; @@ -787,6 +824,8 @@ module.exports = class DocumentsDBApi { }; } + where = mergeWhereWithScope(where, buildCustomerDocumentScope(currentUser)); + const records = await db.documents.findAll({ attributes: [ 'id', 'file_name' ], where, diff --git a/backend/src/db/api/roles.js b/backend/src/db/api/roles.js index 5c166c2..f88fa7b 100644 --- a/backend/src/db/api/roles.js +++ b/backend/src/db/api/roles.js @@ -16,17 +16,26 @@ const BUSINESS_ROLE_NAMES = [ config.roles.customer, ]; -function appendRoleVisibilityScope(where, globalAccess, businessOnly = false) { +const HIGH_TRUST_ROLE_NAMES = [ + config.roles.super_admin, + config.roles.admin, +]; + +function appendRoleVisibilityScope(where, globalAccess, businessOnly = false, assignableOnly = false, includeHighTrust = true) { const scopes = []; if (!globalAccess) { scopes.push({ name: { [Op.ne]: config.roles.super_admin } }); } - if (businessOnly) { + if (businessOnly || assignableOnly) { scopes.push({ name: { [Op.in]: BUSINESS_ROLE_NAMES } }); } + if (!includeHighTrust) { + scopes.push({ name: { [Op.notIn]: HIGH_TRUST_ROLE_NAMES } }); + } + if (!scopes.length) { return where; } @@ -403,8 +412,10 @@ module.exports = class RolesDBApi { } const businessOnly = filter.businessOnly === true || filter.businessOnly === 'true'; + const assignableOnly = filter.assignableOnly === true || filter.assignableOnly === 'true'; + const includeHighTrust = !(filter.includeHighTrust === false || filter.includeHighTrust === 'false'); - where = appendRoleVisibilityScope(where, globalAccess, businessOnly); + where = appendRoleVisibilityScope(where, globalAccess, businessOnly, assignableOnly, includeHighTrust); @@ -438,7 +449,7 @@ module.exports = class RolesDBApi { } } - static async findAllAutocomplete(query, limit, offset, globalAccess, businessOnly = false) { + static async findAllAutocomplete(query, limit, offset, globalAccess, businessOnly = false, assignableOnly = false, includeHighTrust = true) { let where = {}; if (query) { @@ -454,7 +465,13 @@ module.exports = class RolesDBApi { }; } - where = appendRoleVisibilityScope(where, globalAccess, businessOnly === true || businessOnly === 'true'); + where = appendRoleVisibilityScope( + where, + globalAccess, + businessOnly === true || businessOnly === 'true', + assignableOnly === true || assignableOnly === 'true', + !(includeHighTrust === false || includeHighTrust === 'false'), + ); const records = await db.roles.findAll({ attributes: [ 'id', 'name' ], diff --git a/backend/src/db/api/service_requests.js b/backend/src/db/api/service_requests.js index bbbd732..c92c350 100644 --- a/backend/src/db/api/service_requests.js +++ b/backend/src/db/api/service_requests.js @@ -21,6 +21,22 @@ function mergeWhereWithScope(where, scope) { }; } +function resolveServiceStatusForCreate(data, currentUser) { + if (isCustomerUser(currentUser)) { + return 'new'; + } + + return data.status || 'new'; +} + +function resolveRequestedAt(data, currentUser) { + if (isCustomerUser(currentUser)) { + return data.requested_at || new Date(); + } + + return data.requested_at || null; +} + module.exports = class Service_requestsDBApi { @@ -38,9 +54,7 @@ module.exports = class Service_requestsDBApi { null , - status: data.status - || - null + status: resolveServiceStatusForCreate(data, currentUser) , priority: data.priority @@ -48,9 +62,7 @@ module.exports = class Service_requestsDBApi { null , - requested_at: data.requested_at - || - null + requested_at: resolveRequestedAt(data, currentUser) , due_at: data.due_at @@ -58,7 +70,7 @@ module.exports = class Service_requestsDBApi { null , - completed_at: data.completed_at + completed_at: isCustomerUser(currentUser) ? null : data.completed_at || null , @@ -78,7 +90,7 @@ module.exports = class Service_requestsDBApi { null , - actual_cost: data.actual_cost + actual_cost: isCustomerUser(currentUser) ? null : data.actual_cost || null , @@ -96,7 +108,7 @@ module.exports = class Service_requestsDBApi { ); - await service_requests.setTenant( data.tenant || null, { + await service_requests.setTenant( isCustomerUser(currentUser) ? null : data.tenant || null, { transaction, }); @@ -108,11 +120,11 @@ module.exports = class Service_requestsDBApi { transaction, }); - await service_requests.setAssigned_to( data.assigned_to || null, { + await service_requests.setAssigned_to( isCustomerUser(currentUser) ? null : data.assigned_to || null, { transaction, }); - await service_requests.setOrganizations( data.organizations || null, { + await service_requests.setOrganizations( isCustomerUser(currentUser) ? currentUser.organization?.id || null : data.organizations || null, { transaction, }); @@ -227,7 +239,7 @@ module.exports = class Service_requestsDBApi { if (data.request_type !== undefined) updatePayload.request_type = data.request_type; - if (data.status !== undefined) updatePayload.status = data.status; + if (data.status !== undefined && !isCustomerUser(currentUser)) updatePayload.status = data.status; if (data.priority !== undefined) updatePayload.priority = data.priority; @@ -239,7 +251,7 @@ module.exports = class Service_requestsDBApi { if (data.due_at !== undefined) updatePayload.due_at = data.due_at; - if (data.completed_at !== undefined) updatePayload.completed_at = data.completed_at; + if (data.completed_at !== undefined && !isCustomerUser(currentUser)) updatePayload.completed_at = data.completed_at; if (data.summary !== undefined) updatePayload.summary = data.summary; @@ -251,7 +263,7 @@ module.exports = class Service_requestsDBApi { if (data.estimated_cost !== undefined) updatePayload.estimated_cost = data.estimated_cost; - if (data.actual_cost !== undefined) updatePayload.actual_cost = data.actual_cost; + if (data.actual_cost !== undefined && !isCustomerUser(currentUser)) updatePayload.actual_cost = data.actual_cost; if (data.currency !== undefined) updatePayload.currency = data.currency; @@ -263,11 +275,9 @@ module.exports = class Service_requestsDBApi { - if (data.tenant !== undefined) { + if (data.tenant !== undefined && !isCustomerUser(currentUser)) { await service_requests.setTenant( - data.tenant, - { transaction } ); } @@ -290,20 +300,16 @@ module.exports = class Service_requestsDBApi { ); } - if (data.assigned_to !== undefined) { + if (data.assigned_to !== undefined && !isCustomerUser(currentUser)) { await service_requests.setAssigned_to( - data.assigned_to, - { transaction } ); } - if (data.organizations !== undefined) { + if (data.organizations !== undefined && !isCustomerUser(currentUser)) { await service_requests.setOrganizations( - data.organizations, - { transaction } ); } diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index 8cd91b1..f6b1b67 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -96,7 +96,7 @@ module.exports = class UsersDBApi { if (!data.data.app_role) { const role = await db.roles.findOne({ - where: { name: 'User' }, + where: { name: config.roles?.user || 'User' }, }); if (role) { await users.setApp_role(role, { @@ -548,11 +548,7 @@ module.exports = class UsersDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - - let include = [ + let include = [ { model: db.roles, diff --git a/backend/src/routes/documents.js b/backend/src/routes/documents.js index cfe0eb9..7966170 100644 --- a/backend/src/routes/documents.js +++ b/backend/src/routes/documents.js @@ -5,9 +5,6 @@ const DocumentsService = require('../services/documents'); const DocumentsDBApi = require('../db/api/documents'); const wrapAsync = require('../helpers').wrapAsync; -const config = require('../config'); - - const router = express.Router(); const { parse } = require('json2csv'); @@ -404,6 +401,7 @@ router.get('/autocomplete', async (req, res) => { req.query.limit, req.query.offset, globalAccess, organizationId, + req.currentUser, ); res.status(200).send(payload); @@ -444,9 +442,12 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await DocumentsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); - - + + if (!payload) { + return res.status(404).send('Not found'); + } res.status(200).send(payload); })); diff --git a/backend/src/routes/roles.js b/backend/src/routes/roles.js index fc897ea..a0585d3 100644 --- a/backend/src/routes/roles.js +++ b/backend/src/routes/roles.js @@ -386,6 +386,8 @@ router.get('/autocomplete', async (req, res) => { req.query.offset, globalAccess, req.query.businessOnly, + req.query.assignableOnly, + req.query.includeHighTrust, ); res.status(200).send(payload); diff --git a/backend/src/services/users.js b/backend/src/services/users.js index 41f8220..89c4cba 100644 --- a/backend/src/services/users.js +++ b/backend/src/services/users.js @@ -3,54 +3,115 @@ const UsersDBApi = require('../db/api/users'); const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); -const axios = require('axios'); const config = require('../config'); const stream = require('stream'); - -const InvitationEmail = require('./email/list/invitation'); -const EmailSender = require('./email'); const AuthService = require('./auth'); +const BUSINESS_ASSIGNABLE_ROLE_NAMES = [ + config.roles.super_admin, + config.roles.admin, + config.roles.concierge, + config.roles.customer, +]; + +const HIGH_TRUST_ROLE_NAMES = [ + config.roles.super_admin, + config.roles.admin, +]; + +async function findRoleById(roleId, transaction) { + if (!roleId) { + return null; + } + + return db.roles.findByPk(roleId, { transaction }); +} + +async function findDefaultRole(transaction) { + return db.roles.findOne({ + where: { name: config.roles.user }, + transaction, + }); +} + +async function normalizeManagedUserPayload(data, currentUser, transaction, existingUser = null) { + const payload = { ...data }; + const isSuperAdmin = Boolean(currentUser?.app_role?.globalAccess); + const currentOrganizationId = currentUser?.organization?.id || currentUser?.organizationId || null; + + if (!payload.app_role) { + payload.app_role = existingUser?.app_role?.id || null; + } + + let selectedRole = await findRoleById(payload.app_role, transaction); + + if (!selectedRole && !existingUser) { + selectedRole = await findDefaultRole(transaction); + if (selectedRole) { + payload.app_role = selectedRole.id; + } + } + + if (selectedRole && !BUSINESS_ASSIGNABLE_ROLE_NAMES.includes(selectedRole.name)) { + throw new ValidationError('errors.forbidden.message'); + } + + if (existingUser?.app_role?.name && !isSuperAdmin && HIGH_TRUST_ROLE_NAMES.includes(existingUser.app_role.name)) { + throw new ValidationError('errors.forbidden.message'); + } + + if (selectedRole?.name && !isSuperAdmin && HIGH_TRUST_ROLE_NAMES.includes(selectedRole.name)) { + throw new ValidationError('errors.forbidden.message'); + } + + if (!isSuperAdmin) { + payload.custom_permissions = existingUser?.custom_permissions?.map((permission) => permission.id) || []; + payload.organizations = currentOrganizationId || existingUser?.organizations?.id || null; + } + + if (!payload.organizations && existingUser?.organizations?.id) { + payload.organizations = existingUser.organizations.id; + } + + return payload; +} + module.exports = class UsersService { static async create(data, currentUser, sendInvitationEmails = true, host) { - let transaction = await db.sequelize.transaction(); - - const globalAccess = currentUser.app_role.globalAccess; - + const transaction = await db.sequelize.transaction(); let email = data.email; let emailsToInvite = []; + try { - if (email) { - let user = await UsersDBApi.findBy({email}, {transaction}); - if (user) { - throw new ValidationError( - 'iam.errors.userAlreadyExists', - ); - } else { - await UsersDBApi.create( - {data}, - - globalAccess, - - { - currentUser, - transaction, - }, - ); - emailsToInvite.push(email); - } - } else { - throw new ValidationError('iam.errors.emailRequired') + if (!email) { + throw new ValidationError('iam.errors.emailRequired'); } + + const user = await UsersDBApi.findBy({ email }, { transaction }); + if (user) { + throw new ValidationError('iam.errors.userAlreadyExists'); + } + + const normalizedPayload = await normalizeManagedUserPayload(data, currentUser, transaction); + + await UsersDBApi.create( + { data: normalizedPayload }, + currentUser.app_role.globalAccess, + { + currentUser, + transaction, + }, + ); + + emailsToInvite.push(email); await transaction.commit(); } catch (error) { await transaction.rollback(); throw error; } - if (emailsToInvite && emailsToInvite.length) { - if (!sendInvitationEmails) return; + if (emailsToInvite.length && sendInvitationEmails) { AuthService.sendPasswordResetEmail(email, 'invitation', host); } } @@ -64,42 +125,36 @@ module.exports = class UsersService { const bufferStream = new stream.PassThrough(); const results = []; - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); await new Promise((resolve, reject) => { bufferStream .pipe(csv()) .on('data', (data) => results.push(data)) - .on('end', () => { - console.log('results csv', results); - resolve(); - }) - .on('error', (error) => reject(error)); + .on('end', resolve) + .on('error', reject); }); const hasAllEmails = results.every((result) => result.email); - if (!hasAllEmails) { throw new ValidationError('importer.errors.userEmailMissing'); } await UsersDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, }); emailsToInvite = results.map((result) => result.email); - await transaction.commit(); } catch (error) { await transaction.rollback(); throw error; } - if (emailsToInvite && emailsToInvite.length && !sendInvitationEmails) { - + if (emailsToInvite.length && !sendInvitationEmails) { emailsToInvite.forEach((email) => { AuthService.sendPasswordResetEmail(email, 'invitation', host); }); @@ -108,27 +163,20 @@ module.exports = class UsersService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); - - const globalAccess = currentUser.app_role.globalAccess; - - try { - let users = await UsersDBApi.findBy( - {id}, - {transaction}, - ); - if (!users) { - throw new ValidationError( - 'iam.errors.userNotFound', - ); + try { + const user = await UsersDBApi.findBy({ id }, { transaction }); + + if (!user) { + throw new ValidationError('iam.errors.userNotFound'); } + const normalizedPayload = await normalizeManagedUserPayload(data, currentUser, transaction, user); + const updatedUser = await UsersDBApi.update( id, - data, - - globalAccess, - + normalizedPayload, + currentUser.app_role.globalAccess, { currentUser, transaction, @@ -137,36 +185,37 @@ module.exports = class UsersService { await transaction.commit(); return updatedUser; - } catch (error) { await transaction.rollback(); throw error; } - }; + } static async remove(id, currentUser) { const transaction = await db.sequelize.transaction(); try { if (currentUser.id === id) { - throw new ValidationError( - 'iam.errors.deletingHimself', - ); + throw new ValidationError('iam.errors.deletingHimself'); } - if (currentUser.app_role?.name !== config.roles.admin && currentUser.app_role?.name !== config.roles.super_admin ) { - throw new ValidationError( - 'errors.forbidden.message', - ); + if (currentUser.app_role?.name !== config.roles.admin && currentUser.app_role?.name !== config.roles.super_admin) { + throw new ValidationError('errors.forbidden.message'); } - await UsersDBApi.remove( - id, - { - currentUser, - transaction, - }, - ); + const user = await UsersDBApi.findBy({ id }, { transaction }); + if (!user) { + throw new ValidationError('iam.errors.userNotFound'); + } + + if (!currentUser.app_role?.globalAccess && HIGH_TRUST_ROLE_NAMES.includes(user?.app_role?.name)) { + throw new ValidationError('errors.forbidden.message'); + } + + await UsersDBApi.remove(id, { + currentUser, + transaction, + }); await transaction.commit(); } catch (error) { @@ -175,5 +224,3 @@ module.exports = class UsersService { } } }; - - diff --git a/frontend/src/components/Booking_requests/TableBooking_requests.tsx b/frontend/src/components/Booking_requests/TableBooking_requests.tsx index 24dbcea..54da724 100644 --- a/frontend/src/components/Booking_requests/TableBooking_requests.tsx +++ b/frontend/src/components/Booking_requests/TableBooking_requests.tsx @@ -15,6 +15,7 @@ import { import {loadColumns} from "./configureBooking_requestsCols"; import _ from 'lodash'; import dataFormatter from '../../helpers/dataFormatter' +import { getIncompleteFilterHint, isFilterItemIncomplete, pruneHiddenFilterItems } from '../../helpers/entityVisibility' import {dataGridStyles} from "../../styles"; @@ -99,6 +100,14 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho } }, [refetch, dispatch]); + const validFilterItems = useMemo(() => pruneHiddenFilterItems(filterItems, filters), [filterItems, filters]); + + useEffect(() => { + if (validFilterItems !== filterItems) { + setFilterItems(validFilterItems); + } + }, [filterItems, setFilterItems, validFilterItems]); + const [isModalInfoActive, setIsModalInfoActive] = useState(false) const [isModalTrashActive, setIsModalTrashActive] = useState(false) @@ -191,7 +200,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho const generateFilterRequests = useMemo(() => { let request = '&'; - filterItems.forEach((item) => { + validFilterItems.forEach((item) => { const isRangeFilter = filters.find( (filter) => filter.title === item.fields.selectedField && @@ -215,10 +224,10 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho } }); return request; - }, [filterItems, filters]); + }, [filters, validFilterItems]); const deleteFilter = (value) => { - const newItems = filterItems.filter((item) => item.id !== value); + const newItems = validFilterItems.filter((item) => item.id !== value); if (newItems.length) { setFilterItems(newItems); @@ -243,7 +252,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho const name = e.target.name; setFilterItems( - filterItems.map((item) => { + validFilterItems.map((item) => { if (item.id !== id) return item; if (name === 'selectedField') return { id, fields: { [name]: value } }; @@ -358,7 +367,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho return ( <> - {filterItems && Array.isArray( filterItems ) && filterItems.length ? + {validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ?
<> - {filterItems && filterItems.map((filterItem) => { + {validFilterItems && validFilterItems.map((filterItem) => { + const showIncompleteHint = isFilterItemIncomplete(filterItem, filters) + const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null + return ( -
+
Filter
)} -
+
Action
+ {incompleteHint ? ( +

+ {incompleteHint} +

+ ) : null}
) diff --git a/frontend/src/components/Booking_requests/configureBooking_requestsCols.tsx b/frontend/src/components/Booking_requests/configureBooking_requestsCols.tsx index 4a15f96..beff814 100644 --- a/frontend/src/components/Booking_requests/configureBooking_requestsCols.tsx +++ b/frontend/src/components/Booking_requests/configureBooking_requestsCols.tsx @@ -14,6 +14,7 @@ import DataGridMultiSelect from "../DataGridMultiSelect"; import ListActionsPopover from '../ListActionsPopover'; import {hasPermission} from "../../helpers/userPermissions"; +import { getRoleLaneFromUser } from "../../helpers/roleLanes"; type Params = (id: string) => void; @@ -38,8 +39,15 @@ export const loadColumns = async ( } const hasUpdatePermission = hasPermission(user, 'UPDATE_BOOKING_REQUESTS') + const roleLane = getRoleLaneFromUser(user) + const hiddenFieldsByLane = { + super_admin: new Set([]), + admin: new Set(['tenant', 'organization', 'requested_by']), + concierge: new Set(['tenant', 'organization', 'requested_by', 'approval_steps', 'comments']), + customer: new Set(['tenant', 'organization', 'requested_by', 'approval_steps', 'comments', 'budget_code', 'max_budget_amount', 'currency']), + } - return [ + const columns = [ { field: 'tenant', @@ -429,4 +437,6 @@ export const loadColumns = async ( }, }, ]; + + return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field)); }; diff --git a/frontend/src/components/Documents/TableDocuments.tsx b/frontend/src/components/Documents/TableDocuments.tsx index e257be1..49dcf49 100644 --- a/frontend/src/components/Documents/TableDocuments.tsx +++ b/frontend/src/components/Documents/TableDocuments.tsx @@ -15,6 +15,7 @@ import { import {loadColumns} from "./configureDocumentsCols"; import _ from 'lodash'; import dataFormatter from '../../helpers/dataFormatter' +import { getIncompleteFilterHint, isFilterItemIncomplete, pruneHiddenFilterItems } from '../../helpers/entityVisibility' import {dataGridStyles} from "../../styles"; @@ -77,6 +78,14 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid } } }, [refetch, dispatch]); + const validFilterItems = useMemo(() => pruneHiddenFilterItems(filterItems, filters), [filterItems, filters]); + + useEffect(() => { + if (validFilterItems !== filterItems) { + setFilterItems(validFilterItems); + } + }, [filterItems, setFilterItems, validFilterItems]); + const [isModalInfoActive, setIsModalInfoActive] = useState(false) const [isModalTrashActive, setIsModalTrashActive] = useState(false) @@ -103,7 +112,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid } const generateFilterRequests = useMemo(() => { let request = '&'; - filterItems.forEach((item) => { + validFilterItems.forEach((item) => { const isRangeFilter = filters.find( (filter) => filter.title === item.fields.selectedField && @@ -127,10 +136,10 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid } } }); return request; - }, [filterItems, filters]); + }, [filters, validFilterItems]); const deleteFilter = (value) => { - const newItems = filterItems.filter((item) => item.id !== value); + const newItems = validFilterItems.filter((item) => item.id !== value); if (newItems.length) { setFilterItems(newItems); @@ -151,7 +160,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid } const name = e.target.name; setFilterItems( - filterItems.map((item) => { + validFilterItems.map((item) => { if (item.id !== id) return item; if (name === 'selectedField') return { id, fields: { [name]: value } }; @@ -261,7 +270,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid } return ( <> - {filterItems && Array.isArray( filterItems ) && filterItems.length ? + {validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ? <> - {filterItems && filterItems.map((filterItem) => { + {validFilterItems && validFilterItems.map((filterItem) => { + const showIncompleteHint = isFilterItemIncomplete(filterItem, filters) + const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null + return ( -
-
+
+
Filter
filter.title === filterItem?.fields?.selectedField )?.type === 'enum' ? ( -
+
Value
@@ -324,8 +336,8 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid } ) : filters.find((filter) => filter.title === filterItem?.fields?.selectedField )?.number ? ( -
-
+
+
From
+
From @@ -382,7 +394,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
) : ( -
+
Contains
)} -
+
Action
+ {incompleteHint ? ( +

+ {incompleteHint} +

+ ) : null}
) })} -
+
void; @@ -38,8 +39,15 @@ export const loadColumns = async ( } const hasUpdatePermission = hasPermission(user, 'UPDATE_DOCUMENTS') + const roleLane = getRoleLaneFromUser(user) + const hiddenFieldsByLane = { + super_admin: new Set([]), + admin: new Set(['tenant', 'organization', 'storage_key', 'public_url']), + concierge: new Set(['tenant', 'organization', 'storage_key', 'public_url', 'mime_type', 'file_size_bytes', 'invoice', 'is_private']), + customer: new Set(['tenant', 'organization', 'storage_key', 'public_url', 'mime_type', 'file_size_bytes', 'uploaded_by', 'invoice', 'is_private', 'notes']), + } - return [ + const columns = [ { field: 'tenant', @@ -341,4 +349,6 @@ export const loadColumns = async ( }, }, ]; + + return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field)); }; diff --git a/frontend/src/components/Reservations/TableReservations.tsx b/frontend/src/components/Reservations/TableReservations.tsx index 9bd77d5..faa1ff5 100644 --- a/frontend/src/components/Reservations/TableReservations.tsx +++ b/frontend/src/components/Reservations/TableReservations.tsx @@ -15,6 +15,7 @@ import { import {loadColumns} from "./configureReservationsCols"; import _ from 'lodash'; import dataFormatter from '../../helpers/dataFormatter' +import { getIncompleteFilterHint, isFilterItemIncomplete, pruneHiddenFilterItems } from '../../helpers/entityVisibility' import {dataGridStyles} from "../../styles"; @@ -101,6 +102,14 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri } }, [refetch, dispatch]); + const validFilterItems = useMemo(() => pruneHiddenFilterItems(filterItems, filters), [filterItems, filters]); + + useEffect(() => { + if (validFilterItems !== filterItems) { + setFilterItems(validFilterItems); + } + }, [filterItems, setFilterItems, validFilterItems]); + const [isModalInfoActive, setIsModalInfoActive] = useState(false) const [isModalTrashActive, setIsModalTrashActive] = useState(false) @@ -190,7 +199,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri const generateFilterRequests = useMemo(() => { let request = '&'; - filterItems.forEach((item) => { + validFilterItems.forEach((item) => { const isRangeFilter = filters.find( (filter) => filter.title === item.fields.selectedField && @@ -214,10 +223,10 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri } }); return request; - }, [filterItems, filters]); + }, [filters, validFilterItems]); const deleteFilter = (value) => { - const newItems = filterItems.filter((item) => item.id !== value); + const newItems = validFilterItems.filter((item) => item.id !== value); if (newItems.length) { setFilterItems(newItems); @@ -238,7 +247,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri const name = e.target.name; setFilterItems( - filterItems.map((item) => { + validFilterItems.map((item) => { if (item.id !== id) return item; if (name === 'selectedField') return { id, fields: { [name]: value } }; @@ -351,7 +360,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri return ( <> - {filterItems && Array.isArray( filterItems ) && filterItems.length ? + {validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ? <> - {filterItems && filterItems.map((filterItem) => { + {validFilterItems && validFilterItems.map((filterItem) => { + const showIncompleteHint = isFilterItemIncomplete(filterItem, filters) + const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null + return ( -
+
Filter
)} -
+
Action
+ {incompleteHint ? ( +

+ {incompleteHint} +

+ ) : null}
) diff --git a/frontend/src/components/Reservations/configureReservationsCols.tsx b/frontend/src/components/Reservations/configureReservationsCols.tsx index 4c089c0..5b5f384 100644 --- a/frontend/src/components/Reservations/configureReservationsCols.tsx +++ b/frontend/src/components/Reservations/configureReservationsCols.tsx @@ -14,6 +14,7 @@ import DataGridMultiSelect from "../DataGridMultiSelect"; import ListActionsPopover from '../ListActionsPopover'; import {hasPermission} from "../../helpers/userPermissions"; +import { getRoleLaneFromUser } from "../../helpers/roleLanes"; type Params = (id: string) => void; @@ -38,8 +39,15 @@ export const loadColumns = async ( } const hasUpdatePermission = hasPermission(user, 'UPDATE_RESERVATIONS') + const roleLane = getRoleLaneFromUser(user) + const hiddenFieldsByLane = { + super_admin: new Set([]), + admin: new Set(['tenant', 'organization']), + concierge: new Set(['tenant', 'organization', 'nightly_rate', 'monthly_rate', 'currency', 'internal_notes', 'invoices', 'comments']), + customer: new Set(['tenant', 'organization', 'booking_request', 'nightly_rate', 'monthly_rate', 'currency', 'internal_notes', 'invoices', 'comments', 'service_requests']), + } - return [ + const columns = [ { field: 'tenant', @@ -524,4 +532,6 @@ export const loadColumns = async ( }, }, ]; + + return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field)); }; diff --git a/frontend/src/components/SelectField.tsx b/frontend/src/components/SelectField.tsx index 41035ab..1a7f1b7 100644 --- a/frontend/src/components/SelectField.tsx +++ b/frontend/src/components/SelectField.tsx @@ -1,39 +1,79 @@ -import React, {useEffect, useId, useState} from 'react'; +import React, { useEffect, useId, useMemo, useState } from 'react'; import { AsyncPaginate } from 'react-select-async-paginate'; import axios from 'axios' -export const SelectField = ({ options, field, form, itemRef, showField, disabled }) => { +const buildQueryString = (itemRef, queryParams) => { + const params = new URLSearchParams(); + if (itemRef === 'roles') { + params.set('businessOnly', 'true'); + } + + if (typeof queryParams === 'string') { + const extraParams = new URLSearchParams(queryParams); + extraParams.forEach((value, key) => params.set(key, value)); + return params.toString(); + } + + if (queryParams && typeof queryParams === 'object') { + Object.entries(queryParams).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') { + return; + } + + params.set(key, String(value)); + }); + } + + return params.toString(); +}; + +export const SelectField = ({ options, field, form, itemRef, showField, disabled, queryParams }) => { const [value, setValue] = useState(null) const PAGE_SIZE = 100; + const extraQueryString = useMemo(() => buildQueryString(itemRef, queryParams), [itemRef, queryParams]); useEffect(() => { - if(options?.id && field?.value?.id) { - setValue({value: field.value?.id, label: field.value[showField]}) - form.setFieldValue(field.name, field.value?.id); - } else if (!field.value) { + if (field?.value?.id) { + setValue({ value: field.value.id, label: field.value[showField || 'name'] || field.value.label }); + form.setFieldValue(field.name, field.value.id); + } else if (options?.id) { + const label = options[showField || 'name'] || options.label || options.firstName || options.email || options.request_code || options.reservation_code || options.summary; + setValue({ value: options.id, label }); + if (field?.value !== options.id) { + form.setFieldValue(field.name, options.id); + } + } else if (!field?.value) { setValue(null); } - }, [options?.id, field?.value?.id, field?.value]) + }, [options, field?.value, form, field?.name, showField]) const mapResponseToValuesAndLabels = (data) => ({ value: data.id, label: data.label, }) + const handleChange = (option) => { form.setFieldValue(field.name, option?.value || null) setValue(option) } async function callApi(inputValue: string, loadedOptions: any[]) { - const businessOnly = itemRef === 'roles' ? '&businessOnly=true' : ''; - const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}${businessOnly}`; + const params = new URLSearchParams(extraQueryString); + params.set('limit', String(PAGE_SIZE)); + params.set('offset', String(loadedOptions.length)); + if (inputValue) { + params.set('query', inputValue); + } + + const path = `/${itemRef}/autocomplete?${params.toString()}`; const { data } = await axios(path); return { options: data.map(mapResponseToValuesAndLabels), hasMore: data.length === PAGE_SIZE, } } + return ( ) } - diff --git a/frontend/src/components/Service_requests/TableService_requests.tsx b/frontend/src/components/Service_requests/TableService_requests.tsx index a42c881..8400a96 100644 --- a/frontend/src/components/Service_requests/TableService_requests.tsx +++ b/frontend/src/components/Service_requests/TableService_requests.tsx @@ -15,6 +15,7 @@ import { import {loadColumns} from "./configureService_requestsCols"; import _ from 'lodash'; import dataFormatter from '../../helpers/dataFormatter' +import { getIncompleteFilterHint, isFilterItemIncomplete, pruneHiddenFilterItems } from '../../helpers/entityVisibility' import {dataGridStyles} from "../../styles"; @@ -83,6 +84,14 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho } }, [refetch, dispatch]); + const validFilterItems = useMemo(() => pruneHiddenFilterItems(filterItems, filters), [filterItems, filters]); + + useEffect(() => { + if (validFilterItems !== filterItems) { + setFilterItems(validFilterItems); + } + }, [filterItems, setFilterItems, validFilterItems]); + const [isModalInfoActive, setIsModalInfoActive] = useState(false) const [isModalTrashActive, setIsModalTrashActive] = useState(false) @@ -134,7 +143,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho const generateFilterRequests = useMemo(() => { let request = '&'; - filterItems.forEach((item) => { + validFilterItems.forEach((item) => { const isRangeFilter = filters.find( (filter) => filter.title === item.fields.selectedField && @@ -158,10 +167,10 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho } }); return request; - }, [filterItems, filters]); + }, [filters, validFilterItems]); const deleteFilter = (value) => { - const newItems = filterItems.filter((item) => item.id !== value); + const newItems = validFilterItems.filter((item) => item.id !== value); if (newItems.length) { setFilterItems(newItems); @@ -186,7 +195,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho const name = e.target.name; setFilterItems( - filterItems.map((item) => { + validFilterItems.map((item) => { if (item.id !== id) return item; if (name === 'selectedField') return { id, fields: { [name]: value } }; @@ -298,7 +307,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho return ( <> - {filterItems && Array.isArray( filterItems ) && filterItems.length ? + {validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ? <> - {filterItems && filterItems.map((filterItem) => { + {validFilterItems && validFilterItems.map((filterItem) => { + const showIncompleteHint = isFilterItemIncomplete(filterItem, filters) + const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null + return ( -
-
+
+
Filter
filter.title === filterItem?.fields?.selectedField )?.type === 'enum' ? ( -
+
Value
@@ -361,8 +373,8 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho ) : filters.find((filter) => filter.title === filterItem?.fields?.selectedField )?.number ? ( -
-
+
+
From
+
From @@ -419,7 +431,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
) : ( -
+
Contains
)} -
+
Action
+ {incompleteHint ? ( +

+ {incompleteHint} +

+ ) : null}
) })} -
+
void; @@ -38,8 +39,15 @@ export const loadColumns = async ( } const hasUpdatePermission = hasPermission(user, 'UPDATE_SERVICE_REQUESTS') + const roleLane = getRoleLaneFromUser(user) + const hiddenFieldsByLane = { + super_admin: new Set([]), + admin: new Set(['tenant']), + concierge: new Set(['tenant', 'estimated_cost', 'actual_cost', 'currency', 'comments']), + customer: new Set(['tenant', 'requested_by', 'assigned_to', 'estimated_cost', 'actual_cost', 'currency', 'comments']), + } - return [ + const columns = [ { field: 'tenant', @@ -369,4 +377,6 @@ export const loadColumns = async ( }, }, ]; + + return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field)); }; diff --git a/frontend/src/helpers/entityVisibility.ts b/frontend/src/helpers/entityVisibility.ts new file mode 100644 index 0000000..3005079 --- /dev/null +++ b/frontend/src/helpers/entityVisibility.ts @@ -0,0 +1,207 @@ +import { RoleLane } from './roleLanes' + +type FilterDefinition = { + title: string + label?: string + type?: string + number?: string + date?: string +} + +type FilterItemFields = { + selectedField?: string + filterValue?: string + filterValueFrom?: string + filterValueTo?: string +} + +type FilterItemLike = { + fields?: FilterItemFields +} + +const hiddenFieldsByEntityAndLane: Record>> = { + booking_requests: { + super_admin: new Set([]), + admin: new Set(['tenant', 'organization', 'requested_by']), + concierge: new Set(['tenant', 'organization', 'requested_by', 'approval_steps', 'comments']), + customer: new Set(['tenant', 'organization', 'requested_by', 'approval_steps', 'comments', 'budget_code', 'max_budget_amount', 'currency']), + }, + reservations: { + super_admin: new Set([]), + admin: new Set(['tenant', 'organization']), + concierge: new Set(['tenant', 'organization', 'nightly_rate', 'monthly_rate', 'currency', 'internal_notes', 'invoices', 'comments']), + customer: new Set(['tenant', 'organization', 'booking_request', 'nightly_rate', 'monthly_rate', 'currency', 'internal_notes', 'invoices', 'comments', 'service_requests']), + }, + service_requests: { + super_admin: new Set([]), + admin: new Set(['tenant']), + concierge: new Set(['tenant', 'estimated_cost', 'actual_cost', 'currency', 'comments']), + customer: new Set(['tenant', 'requested_by', 'assigned_to', 'estimated_cost', 'actual_cost', 'currency', 'comments']), + }, + documents: { + super_admin: new Set([]), + admin: new Set(['tenant', 'organization', 'storage_key', 'public_url']), + concierge: new Set(['tenant', 'organization', 'storage_key', 'public_url', 'mime_type', 'file_size_bytes', 'invoice', 'is_private']), + customer: new Set(['tenant', 'organization', 'storage_key', 'public_url', 'mime_type', 'file_size_bytes', 'uploaded_by', 'invoice', 'is_private', 'notes']), + }, +} + +const isRangeFilter = (filter?: FilterDefinition) => Boolean(filter?.number || filter?.date) + +const getFilterDisplayLabel = (filter?: FilterDefinition) => { + const rawLabel = `${filter?.label ?? filter?.title ?? ""}`.trim() + + if (!rawLabel) { + return 'this field' + } + + return rawLabel.charAt(0).toLowerCase() + rawLabel.slice(1) +} + +export const getHiddenFieldsForRole = (entityName: string, roleLane: RoleLane) => { + return hiddenFieldsByEntityAndLane[entityName]?.[roleLane] ?? new Set() +} + +export const filterEntityFieldsForRole = ( + entityName: string, + roleLane: RoleLane, + fields: T[], +) => { + const hiddenFields = getHiddenFieldsForRole(entityName, roleLane) + + return fields.filter((field) => !hiddenFields.has(field.title)) +} + +export const pruneHiddenFilterItems = < + TFilter extends { title: string }, + TFilterItem extends { fields?: { selectedField?: string } }, +>( + filterItems: TFilterItem[], + filters: TFilter[], +) => { + if (!Array.isArray(filterItems) || !filterItems.length) { + return filterItems + } + + const availableFields = new Set(filters.map((filter) => filter.title)) + const nextFilterItems = filterItems.filter((item) => availableFields.has(item?.fields?.selectedField || '')) + + return nextFilterItems.length === filterItems.length ? filterItems : nextFilterItems +} + +export const isFilterItemIncomplete = ( + filterItem: TFilterItem, + filters: TFilter[], +) => { + const selectedField = filterItem?.fields?.selectedField || '' + const selectedFilter = filters.find((filter) => filter.title === selectedField) + + if (!selectedFilter) { + return true + } + + if (isRangeFilter(selectedFilter)) { + return !(filterItem?.fields?.filterValueFrom || filterItem?.fields?.filterValueTo) + } + + return !filterItem?.fields?.filterValue +} + +export const getIncompleteFilterHint = ( + filterItem: TFilterItem, + filters: TFilter[], +) => { + const selectedField = filterItem?.fields?.selectedField || '' + const selectedFilter = filters.find((filter) => filter.title === selectedField) + const filterLabel = getFilterDisplayLabel(selectedFilter) + + if (!selectedFilter) { + return 'Choose a filter field to continue.' + } + + if (selectedFilter.number || selectedFilter.date) { + return `Enter ${filterLabel} from/to to add another filter.` + } + + if (selectedFilter.type === 'enum') { + return `Choose ${filterLabel} to add another filter.` + } + + return `Enter ${filterLabel} to add another filter.` +} + +export const getNextAvailableFilter = ( + filterItems: TFilterItem[], + filters: TFilter[], +) => { + const usedFields = new Set( + filterItems + .map((item) => item?.fields?.selectedField) + .filter((selectedField): selectedField is string => Boolean(selectedField)), + ) + + return filters.find((filter) => !usedFields.has(filter.title)) ?? null +} + +export const getAddFilterState = ( + filterItems: TFilterItem[], + filters: TFilter[], +) => { + if (!filters.length) { + return { + canAddFilter: false, + buttonLabel: 'No filters available', + helperText: 'No filters are available for this view.', + } + } + + if (filterItems.some((item) => isFilterItemIncomplete(item, filters))) { + return { + canAddFilter: false, + buttonLabel: 'Complete current filter', + helperText: 'Finish the current filter before adding another.', + } + } + + const nextFilter = getNextAvailableFilter(filterItems, filters) + + if (!nextFilter) { + return { + canAddFilter: false, + buttonLabel: 'All filters added', + helperText: 'All available filters are already in use.', + } + } + + return { + canAddFilter: true, + buttonLabel: 'Add filter', + helperText: `Next filter: ${nextFilter.label ?? nextFilter.title}`, + } +} + +export const buildNextFilterItem = ( + filterItems: FilterItemLike[], + filters: TFilter[], + createId: () => string, +) => { + if (!filters.length || filterItems.some((item) => isFilterItemIncomplete(item, filters))) { + return null + } + + const nextFilter = getNextAvailableFilter(filterItems, filters) + + if (!nextFilter) { + return null + } + + return { + id: createId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: nextFilter.title, + }, + } +} diff --git a/frontend/src/helpers/roleLanes.ts b/frontend/src/helpers/roleLanes.ts new file mode 100644 index 0000000..f51716a --- /dev/null +++ b/frontend/src/helpers/roleLanes.ts @@ -0,0 +1,28 @@ +export type RoleLane = 'super_admin' | 'admin' | 'concierge' | 'customer'; + +export const ROLE_LANES = { + superAdmin: new Set(['Super Administrator']), + admin: new Set(['Administrator', 'Platform Owner', 'Operations Director', 'Reservations Lead', 'Finance Controller']), + concierge: new Set(['Concierge Coordinator']), + customer: new Set(['Customer']), +}; + +export function getRoleLane(roleName?: string | null, hasGlobalAccess = false): RoleLane { + if (hasGlobalAccess || (roleName && ROLE_LANES.superAdmin.has(roleName))) { + return 'super_admin'; + } + + if (roleName && ROLE_LANES.admin.has(roleName)) { + return 'admin'; + } + + if (roleName && ROLE_LANES.customer.has(roleName)) { + return 'customer'; + } + + return 'concierge'; +} + +export function getRoleLaneFromUser(user?: any): RoleLane { + return getRoleLane(user?.app_role?.name, Boolean(user?.app_role?.globalAccess)); +} diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index c234639..3e03d6b 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,7 +1,7 @@ import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' -import menuAside from '../menuAside' +import buildMenuAside from '../menuAside' import menuNavBar from '../menuNavBar' import BaseIcon from '../components/BaseIcon' import NavBar from '../components/NavBar' @@ -117,7 +117,7 @@ export default function LayoutAuthenticated({ setIsAsideLgActive(false)} /> {children} diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 9067aaf..cd1295d 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -1,179 +1,205 @@ import * as icon from '@mdi/js'; import { MenuAsideItem } from './interfaces'; +import { getRoleLaneFromUser } from './helpers/roleLanes'; -const menuAside: MenuAsideItem[] = [ - { - href: '/command-center', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiShieldHomeOutline' in icon ? icon['mdiShieldHomeOutline' as keyof typeof icon] : icon.mdiViewDashboardOutline, - label: 'Command Center', - }, - { - label: 'Operations', - icon: icon.mdiClipboardTextOutline, - permissions: [ - 'READ_BOOKING_REQUESTS', - 'READ_APPROVAL_STEPS', - 'READ_RESERVATIONS', - 'READ_SERVICE_REQUESTS', - 'READ_INVOICES', - 'READ_DOCUMENTS', - ], - menu: [ - { - href: '/booking_requests/booking_requests-list', - label: 'Booking Requests', - icon: icon.mdiClipboardTextOutline, - permissions: 'READ_BOOKING_REQUESTS', - }, - { - href: '/approval_steps/approval_steps-list', - label: 'Approvals', - icon: icon.mdiCheckDecagram, - permissions: 'READ_APPROVAL_STEPS', - }, - { - href: '/reservations/reservations-list', - label: 'Reservations', - icon: icon.mdiCalendarCheck, - permissions: 'READ_RESERVATIONS', - }, - { - href: '/service_requests/service_requests-list', - label: 'Service Requests', - icon: icon.mdiRoomService, - permissions: 'READ_SERVICE_REQUESTS', - }, - { - href: '/invoices/invoices-list', - label: 'Invoices', - icon: icon.mdiFileDocument, - permissions: 'READ_INVOICES', - }, - { - href: '/documents/documents-list', - label: 'Documents', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiPaperclip' in icon ? icon['mdiPaperclip' as keyof typeof icon] : icon.mdiFileDocument, - permissions: 'READ_DOCUMENTS', - }, - ], - }, - { - label: 'Portfolio', - icon: icon.mdiHomeCity, - permissions: [ - 'READ_ORGANIZATIONS', - 'READ_PROPERTIES', - 'READ_UNITS', - 'READ_NEGOTIATED_RATES', - 'READ_UNIT_TYPES', - 'READ_AMENITIES', - ], - menu: [ - { - href: '/organizations/organizations-list', - label: 'Organizations', - icon: icon.mdiOfficeBuildingOutline, - permissions: 'READ_ORGANIZATIONS', - }, - { - href: '/properties/properties-list', - label: 'Properties', - icon: icon.mdiHomeCity, - permissions: 'READ_PROPERTIES', - }, - { - href: '/units/units-list', - label: 'Units', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiDoor' in icon ? icon['mdiDoor' as keyof typeof icon] : icon.mdiHomeCity, - permissions: 'READ_UNITS', - }, - { - href: '/negotiated_rates/negotiated_rates-list', - label: 'Negotiated Rates', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiTag' in icon ? icon['mdiTag' as keyof typeof icon] : icon.mdiFileDocument, - permissions: 'READ_NEGOTIATED_RATES', - }, - ], - }, - { - label: 'Administration', - icon: icon.mdiAccountGroup, - permissions: [ - 'READ_USERS', - 'READ_ROLES', - 'READ_PERMISSIONS', - 'READ_NOTIFICATIONS', - 'READ_AUDIT_LOGS', - 'READ_JOB_RUNS', - ], - menu: [ +export const buildMenuAside = (currentUser?: any): MenuAsideItem[] => { + const roleLane = getRoleLaneFromUser(currentUser); + const isSuperAdmin = roleLane === 'super_admin'; + const isAdmin = roleLane === 'admin'; + const isConcierge = roleLane === 'concierge'; + const isCustomer = roleLane === 'customer'; + + const operationsLabel = isCustomer ? 'My Stay' : isConcierge ? 'Stay Delivery' : 'Operations'; + + const operationsMenu: MenuAsideItem[] = [ + { + href: '/booking_requests/booking_requests-list', + label: isCustomer ? 'My Requests' : isAdmin ? 'Incoming Requests' : 'Requests', + icon: icon.mdiClipboardTextOutline, + permissions: 'READ_BOOKING_REQUESTS', + }, + { + href: '/approval_steps/approval_steps-list', + label: isCustomer ? 'Approval Status' : 'Approvals', + icon: icon.mdiCheckDecagram, + permissions: 'READ_APPROVAL_STEPS', + }, + { + href: '/reservations/reservations-list', + label: isCustomer ? 'My Stays' : isConcierge ? 'Active Stays' : 'Stays', + icon: icon.mdiCalendarCheck, + permissions: 'READ_RESERVATIONS', + }, + { + href: '/service_requests/service_requests-list', + label: isCustomer ? 'Support' : 'Service Queue', + icon: icon.mdiRoomService, + permissions: 'READ_SERVICE_REQUESTS', + }, + { + href: '/invoices/invoices-list', + label: isCustomer ? 'Billing' : 'Invoices', + icon: icon.mdiFileDocument, + permissions: 'READ_INVOICES', + }, + { + href: '/documents/documents-list', + label: isCustomer ? 'My Documents' : isConcierge ? 'Guest Documents' : 'Documents', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiPaperclip' in icon ? icon['mdiPaperclip' as keyof typeof icon] : icon.mdiFileDocument, + permissions: 'READ_DOCUMENTS', + }, + ]; + + const menuAside: MenuAsideItem[] = [ + { + href: '/command-center', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiShieldHomeOutline' in icon ? icon['mdiShieldHomeOutline' as keyof typeof icon] : icon.mdiViewDashboardOutline, + label: 'Command Center', + }, + { + label: operationsLabel, + icon: icon.mdiClipboardTextOutline, + permissions: [ + 'READ_BOOKING_REQUESTS', + 'READ_APPROVAL_STEPS', + 'READ_RESERVATIONS', + 'READ_SERVICE_REQUESTS', + 'READ_INVOICES', + 'READ_DOCUMENTS', + ], + menu: operationsMenu, + }, + ]; + + if (!isCustomer) { + menuAside.push({ + label: 'Portfolio', + icon: icon.mdiHomeCity, + permissions: [ + 'READ_ORGANIZATIONS', + 'READ_PROPERTIES', + 'READ_UNITS', + 'READ_NEGOTIATED_RATES', + 'READ_UNIT_TYPES', + 'READ_AMENITIES', + ], + menu: [ + { + href: '/organizations/organizations-list', + label: 'Organizations', + icon: icon.mdiOfficeBuildingOutline, + permissions: 'READ_ORGANIZATIONS', + }, + { + href: '/properties/properties-list', + label: 'Properties', + icon: icon.mdiHomeCity, + permissions: 'READ_PROPERTIES', + }, + { + href: '/units/units-list', + label: 'Units', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiDoor' in icon ? icon['mdiDoor' as keyof typeof icon] : icon.mdiHomeCity, + permissions: 'READ_UNITS', + }, + { + href: '/negotiated_rates/negotiated_rates-list', + label: 'Negotiated Rates', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiTag' in icon ? icon['mdiTag' as keyof typeof icon] : icon.mdiFileDocument, + permissions: 'READ_NEGOTIATED_RATES', + }, + ], + }); + } + + if (isSuperAdmin || isAdmin) { + const adminMenu: MenuAsideItem[] = [ { href: '/users/users-list', label: 'Users', icon: icon.mdiAccountGroup, permissions: 'READ_USERS', }, - { - href: '/roles/roles-list', - label: 'Roles', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiAccountGroup, - permissions: 'READ_ROLES', - }, - { - href: '/permissions/permissions-list', - label: 'Permissions', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: icon.mdiShieldAccountOutline ?? icon.mdiAccountGroup, - permissions: 'READ_PERMISSIONS', - }, - { - href: '/notifications/notifications-list', - label: 'Notifications', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiBell' in icon ? icon['mdiBell' as keyof typeof icon] : icon.mdiFileDocument, - permissions: 'READ_NOTIFICATIONS', - }, - { - href: '/audit_logs/audit_logs-list', - label: 'Audit Logs', - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - icon: 'mdiClipboardPulse' in icon ? icon['mdiClipboardPulse' as keyof typeof icon] : icon.mdiClipboardTextOutline, - permissions: 'READ_AUDIT_LOGS', - }, - { - href: '/job_runs/job_runs-list', - label: 'Job Runs', - icon: icon.mdiClockOutline, - permissions: 'READ_JOB_RUNS', - }, - ], - }, - { - href: '/profile', - label: 'Profile', - icon: icon.mdiAccountCircle, - withDevider: true, - }, - { - href: '/api-docs', - target: '_blank', - label: 'Swagger API', - icon: icon.mdiFileCode, - permissions: 'READ_API_DOCS', - }, -]; + ]; -export default menuAside; + if (isSuperAdmin) { + adminMenu.push( + { + href: '/roles/roles-list', + label: 'Roles', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiAccountGroup, + permissions: 'READ_ROLES', + }, + { + href: '/permissions/permissions-list', + label: 'Permissions', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: icon.mdiShieldAccountOutline ?? icon.mdiAccountGroup, + permissions: 'READ_PERMISSIONS', + }, + { + href: '/notifications/notifications-list', + label: 'Notifications', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiBell' in icon ? icon['mdiBell' as keyof typeof icon] : icon.mdiFileDocument, + permissions: 'READ_NOTIFICATIONS', + }, + { + href: '/audit_logs/audit_logs-list', + label: 'Audit Logs', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiClipboardPulse' in icon ? icon['mdiClipboardPulse' as keyof typeof icon] : icon.mdiClipboardTextOutline, + permissions: 'READ_AUDIT_LOGS', + }, + { + href: '/job_runs/job_runs-list', + label: 'Job Runs', + icon: icon.mdiClockOutline, + permissions: 'READ_JOB_RUNS', + }, + ); + } + + menuAside.push({ + label: 'Administration', + icon: icon.mdiAccountGroup, + permissions: isSuperAdmin + ? ['READ_USERS', 'READ_ROLES', 'READ_PERMISSIONS', 'READ_NOTIFICATIONS', 'READ_AUDIT_LOGS', 'READ_JOB_RUNS'] + : ['READ_USERS'], + menu: adminMenu, + }); + } + + menuAside.push( + { + href: '/profile', + label: 'Profile', + icon: icon.mdiAccountCircle, + withDevider: true, + }, + { + href: '/api-docs', + target: '_blank', + label: 'Swagger API', + icon: icon.mdiFileCode, + permissions: 'READ_API_DOCS', + }, + ); + + return menuAside; +}; + +export default buildMenuAside; diff --git a/frontend/src/pages/booking_requests/booking_requests-list.tsx b/frontend/src/pages/booking_requests/booking_requests-list.tsx index 3941e8c..225b088 100644 --- a/frontend/src/pages/booking_requests/booking_requests-list.tsx +++ b/frontend/src/pages/booking_requests/booking_requests-list.tsx @@ -17,8 +17,10 @@ import DragDropFilePicker from "../../components/DragDropFilePicker"; import {setRefetch, uploadCsv} from '../../stores/booking_requests/booking_requestsSlice'; -import {hasPermission} from "../../helpers/userPermissions"; +import { buildNextFilterItem, filterEntityFieldsForRole, getAddFilterState, pruneHiddenFilterItems } from "../../helpers/entityVisibility"; import { humanize } from "../../helpers/humanize"; +import { getRoleLaneFromUser } from "../../helpers/roleLanes"; +import {hasPermission} from "../../helpers/userPermissions"; @@ -30,7 +32,8 @@ const Booking_requestsTablesPage = () => { const { currentUser } = useAppSelector((state) => state.auth); const dispatch = useAppDispatch(); - const canManageInternalFields = Boolean(currentUser?.app_role?.globalAccess); + const roleLane = getRoleLaneFromUser(currentUser); + const canManageInternalFields = roleLane === 'super_admin'; const createButtonLabel = canManageInternalFields ? 'New request' : 'Request a stay'; const filters = React.useMemo(() => { const baseFilters = [ @@ -62,24 +65,24 @@ const Booking_requestsTablesPage = () => { baseFilters.splice(10, 0, { label: 'Tenant', title: 'tenant' }, { label: 'Requestedby', title: 'requested_by' }); } - return baseFilters.map((filter) => ({ ...filter, label: humanize(filter.title) })); - }, [canManageInternalFields]); + return filterEntityFieldsForRole('booking_requests', roleLane, baseFilters).map((filter) => ({ ...filter, label: humanize(filter.title) })); + }, [canManageInternalFields, roleLane]); const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BOOKING_REQUESTS'); + React.useEffect(() => { + setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters)) + }, [filters]) + + const addFilterState = React.useMemo(() => getAddFilterState(filterItems, filters), [filterItems, filters]) + const addFilter = () => { - const newItem = { - id: uniqueId(), - fields: { - filterValue: '', - filterValueFrom: '', - filterValueTo: '', - selectedField: '', - }, - }; - newItem.fields.selectedField = filters[0].title; - setFilterItems([...filterItems, newItem]); - }; + setFilterItems((currentItems) => { + const nextItem = buildNextFilterItem(currentItems, filters, () => uniqueId()) + + return nextItem ? [...currentItems, nextItem] : currentItems + }) + } const getBooking_requestsCSV = async () => { const response = await axios({url: '/booking_requests?filetype=csv', method: 'GET',responseType: 'blob'}); @@ -125,16 +128,23 @@ const Booking_requestsTablesPage = () => {

Create, filter, export, or switch to table view from one calm action strip.

-
+
{hasCreatePermission ? ( ) : null} - + {hasCreatePermission ? ( setIsModalActive(true)} /> @@ -142,7 +152,9 @@ const Booking_requestsTablesPage = () => { -
+

{addFilterState.helperText}

+ +
diff --git a/frontend/src/pages/booking_requests/booking_requests-table.tsx b/frontend/src/pages/booking_requests/booking_requests-table.tsx index 73f62ea..b43f612 100644 --- a/frontend/src/pages/booking_requests/booking_requests-table.tsx +++ b/frontend/src/pages/booking_requests/booking_requests-table.tsx @@ -11,6 +11,8 @@ import CardBoxModal from '../../components/CardBoxModal' import DragDropFilePicker from '../../components/DragDropFilePicker' import TableBooking_requests from '../../components/Booking_requests/TableBooking_requests' import { getPageTitle } from '../../config' +import { filterEntityFieldsForRole, pruneHiddenFilterItems } from '../../helpers/entityVisibility' +import { getRoleLaneFromUser } from '../../helpers/roleLanes' import { hasPermission } from '../../helpers/userPermissions' import LayoutAuthenticated from '../../layouts/Authenticated' import SectionMain from '../../components/SectionMain' @@ -26,7 +28,8 @@ const Booking_requestsTablesPage = () => { const { currentUser } = useAppSelector((state) => state.auth) const dispatch = useAppDispatch() - const canManageInternalFields = Boolean(currentUser?.app_role?.globalAccess) + const roleLane = getRoleLaneFromUser(currentUser) + const canManageInternalFields = roleLane === 'super_admin' const createButtonLabel = canManageInternalFields ? 'New item' : 'Request a stay' const filters = useMemo(() => { const baseFilters = [ @@ -58,11 +61,15 @@ const Booking_requestsTablesPage = () => { baseFilters.splice(10, 0, { label: 'Tenant', title: 'tenant' }, { label: 'Requestedby', title: 'requested_by' }) } - return baseFilters - }, [canManageInternalFields]) + return filterEntityFieldsForRole('booking_requests', roleLane, baseFilters) + }, [canManageInternalFields, roleLane]) const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BOOKING_REQUESTS') + React.useEffect(() => { + setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters)) + }, [filters]) + const addFilter = () => { const newItem = { id: uniqueId(), diff --git a/frontend/src/pages/documents/documents-list.tsx b/frontend/src/pages/documents/documents-list.tsx index e62c90a..92242fb 100644 --- a/frontend/src/pages/documents/documents-list.tsx +++ b/frontend/src/pages/documents/documents-list.tsx @@ -1,7 +1,7 @@ import { mdiChartTimelineVariant } from '@mdi/js' import Head from 'next/head' import { uniqueId } from 'lodash'; -import React, { ReactElement, useState } from 'react' +import React, { ReactElement, useMemo, useState } from 'react' import CardBox from '../../components/CardBox' import LayoutAuthenticated from '../../layouts/Authenticated' import SectionMain from '../../components/SectionMain' @@ -9,14 +9,16 @@ import SectionTitleLineWithButton from '../../components/SectionTitleLineWithBut import { getPageTitle } from '../../config' import TableDocuments from '../../components/Documents/TableDocuments' import BaseButton from '../../components/BaseButton' +import BaseButtons from '../../components/BaseButtons' import axios from "axios"; -import Link from "next/link"; import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import CardBoxModal from "../../components/CardBoxModal"; import DragDropFilePicker from "../../components/DragDropFilePicker"; import {setRefetch, uploadCsv} from '../../stores/documents/documentsSlice'; +import { buildNextFilterItem, filterEntityFieldsForRole, getAddFilterState, pruneHiddenFilterItems } from "../../helpers/entityVisibility"; +import { getRoleLaneFromUser } from "../../helpers/roleLanes"; import {hasPermission} from "../../helpers/userPermissions"; @@ -25,66 +27,39 @@ const DocumentsTablesPage = () => { const [filterItems, setFilterItems] = useState([]); const [csvFile, setCsvFile] = useState(null); const [isModalActive, setIsModalActive] = useState(false); - const [showTableView, setShowTableView] = useState(false); - const { currentUser } = useAppSelector((state) => state.auth); const dispatch = useAppDispatch(); + const roleLane = getRoleLaneFromUser(currentUser); - - const [filters] = useState([{label: 'Filename', title: 'file_name'},{label: 'MIMEtype', title: 'mime_type'},{label: 'Storagekey', title: 'storage_key'},{label: 'PublicURL', title: 'public_url'},{label: 'Notes', title: 'notes'}, + const filters = useMemo(() => filterEntityFieldsForRole('documents', roleLane, [{label: 'Filename', title: 'file_name'},{label: 'MIMEtype', title: 'mime_type'},{label: 'Storagekey', title: 'storage_key'},{label: 'PublicURL', title: 'public_url'},{label: 'Notes', title: 'notes'}, {label: 'Filesize(bytes)', title: 'file_size_bytes', number: 'true'}, - - - - - {label: 'Tenant', title: 'tenant'}, - - - - {label: 'Uploadedby', title: 'uploaded_by'}, - - - - - - {label: 'Bookingrequest', title: 'booking_request'}, - - - - {label: 'Reservation', title: 'reservation'}, - - - - {label: 'Servicerequest', title: 'service_request'}, - - - - {label: 'Invoice', title: 'invoice'}, - - - + {label: 'Tenant', title: 'tenant'}, + {label: 'Uploadedby', title: 'uploaded_by'}, + {label: 'Bookingrequest', title: 'booking_request'}, + {label: 'Reservation', title: 'reservation'}, + {label: 'Servicerequest', title: 'service_request'}, + {label: 'Invoice', title: 'invoice'}, {label: 'Documenttype', title: 'document_type', type: 'enum', options: ['invoice_pdf','guest_id','contract','approval_attachment','arrival_instructions','checklist','inspection_report','other']}, - ]); - - const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_DOCUMENTS'); + ]), [roleLane]); + React.useEffect(() => { + setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters)); + }, [filters]); - const addFilter = () => { - const newItem = { - id: uniqueId(), - fields: { - filterValue: '', - filterValueFrom: '', - filterValueTo: '', - selectedField: '', - }, - }; - newItem.fields.selectedField = filters[0].title; - setFilterItems([...filterItems, newItem]); - }; + const addFilterState = useMemo(() => getAddFilterState(filterItems, filters), [filterItems, filters]); + + const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_DOCUMENTS'); + + const addFilter = () => { + setFilterItems((currentItems) => { + const nextItem = buildNextFilterItem(currentItems, filters, () => uniqueId()); + + return nextItem ? [...currentItems, nextItem] : currentItems; + }); + }; const getDocumentsCSV = async () => { const response = await axios({url: '/documents?filetype=csv', method: 'GET',responseType: 'blob'}); @@ -118,30 +93,41 @@ const DocumentsTablesPage = () => { {''} - - - {hasCreatePermission && } - - - - - {hasCreatePermission && ( - setIsModalActive(true)} - /> - )} - -
-
-
- + +
+
+

Working view

+

Document hub

+

Collect files, filter the document list, and switch views from a mobile-friendly action strip.

+
+ +
+ + {hasCreatePermission ? : null} + + + {hasCreatePermission ? setIsModalActive(true)} /> : null} + + + +

{addFilterState.helperText}

+ +
+
+
+
+
@@ -150,7 +136,7 @@ const DocumentsTablesPage = () => { setFilterItems={setFilterItems} filters={filters} showGrid={false} - /> + /> diff --git a/frontend/src/pages/documents/documents-table.tsx b/frontend/src/pages/documents/documents-table.tsx index 46526f5..bb52190 100644 --- a/frontend/src/pages/documents/documents-table.tsx +++ b/frontend/src/pages/documents/documents-table.tsx @@ -1,7 +1,7 @@ import { mdiChartTimelineVariant } from '@mdi/js' import Head from 'next/head' import { uniqueId } from 'lodash'; -import React, { ReactElement, useState } from 'react' +import React, { ReactElement, useMemo, useState } from 'react' import CardBox from '../../components/CardBox' import LayoutAuthenticated from '../../layouts/Authenticated' import SectionMain from '../../components/SectionMain' @@ -17,6 +17,8 @@ import DragDropFilePicker from "../../components/DragDropFilePicker"; import {setRefetch, uploadCsv} from '../../stores/documents/documentsSlice'; +import { filterEntityFieldsForRole, pruneHiddenFilterItems } from "../../helpers/entityVisibility"; +import { getRoleLaneFromUser } from "../../helpers/roleLanes"; import {hasPermission} from "../../helpers/userPermissions"; @@ -32,43 +34,23 @@ const DocumentsTablesPage = () => { const dispatch = useAppDispatch(); + const roleLane = getRoleLaneFromUser(currentUser); - - const [filters] = useState([{label: 'Filename', title: 'file_name'},{label: 'MIMEtype', title: 'mime_type'},{label: 'Storagekey', title: 'storage_key'},{label: 'PublicURL', title: 'public_url'},{label: 'Notes', title: 'notes'}, + const filters = useMemo(() => filterEntityFieldsForRole('documents', roleLane, [{label: 'Filename', title: 'file_name'},{label: 'MIMEtype', title: 'mime_type'},{label: 'Storagekey', title: 'storage_key'},{label: 'PublicURL', title: 'public_url'},{label: 'Notes', title: 'notes'}, {label: 'Filesize(bytes)', title: 'file_size_bytes', number: 'true'}, - - - - - {label: 'Tenant', title: 'tenant'}, - - - - {label: 'Uploadedby', title: 'uploaded_by'}, - - - - - - {label: 'Bookingrequest', title: 'booking_request'}, - - - - {label: 'Reservation', title: 'reservation'}, - - - - {label: 'Servicerequest', title: 'service_request'}, - - - - {label: 'Invoice', title: 'invoice'}, - - - + {label: 'Tenant', title: 'tenant'}, + {label: 'Uploadedby', title: 'uploaded_by'}, + {label: 'Bookingrequest', title: 'booking_request'}, + {label: 'Reservation', title: 'reservation'}, + {label: 'Servicerequest', title: 'service_request'}, + {label: 'Invoice', title: 'invoice'}, {label: 'Documenttype', title: 'document_type', type: 'enum', options: ['invoice_pdf','guest_id','contract','approval_attachment','arrival_instructions','checklist','inspection_report','other']}, - ]); + ]), [roleLane]); + React.useEffect(() => { + setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters)); + }, [filters]); + const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_DOCUMENTS'); diff --git a/frontend/src/pages/reservations/reservations-edit.tsx b/frontend/src/pages/reservations/reservations-edit.tsx index 4b95c92..df733fe 100644 --- a/frontend/src/pages/reservations/reservations-edit.tsx +++ b/frontend/src/pages/reservations/reservations-edit.tsx @@ -1,2456 +1,255 @@ -import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js' +import { mdiCalendarCheck } from '@mdi/js' +import dayjs from 'dayjs' 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 React, { ReactElement, useEffect, useMemo } from 'react' +import { Field, Form, Formik } from 'formik' +import { useRouter } from 'next/router' +import BaseButton from '../../components/BaseButton' +import BaseButtons from '../../components/BaseButtons' +import BaseDivider from '../../components/BaseDivider' import CardBox from '../../components/CardBox' -import LayoutAuthenticated from '../../layouts/Authenticated' +import FormField from '../../components/FormField' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' -import { getPageTitle } from '../../config' - -import { Field, Form, Formik } from 'formik' -import FormField from '../../components/FormField' -import BaseDivider from '../../components/BaseDivider' -import BaseButtons from '../../components/BaseButtons' -import BaseButton from '../../components/BaseButton' -import FormCheckRadio from '../../components/FormCheckRadio' -import FormCheckRadioGroup from '../../components/FormCheckRadioGroup' -import FormFilePicker from '../../components/FormFilePicker' -import FormImagePicker from '../../components/FormImagePicker' -import { SelectField } from "../../components/SelectField"; -import { SelectFieldMany } from "../../components/SelectFieldMany"; +import { SelectField } from '../../components/SelectField' import { SwitchField } from '../../components/SwitchField' -import {RichTextField} from "../../components/RichTextField"; - -import { update, fetch } from '../../stores/reservations/reservationsSlice' +import { getPageTitle } from '../../config' +import { getRoleLaneFromUser } from '../../helpers/roleLanes' +import LayoutAuthenticated from '../../layouts/Authenticated' +import { fetch, update } from '../../stores/reservations/reservationsSlice' import { useAppDispatch, useAppSelector } from '../../stores/hooks' -import { useRouter } from 'next/router' -import {saveFile} from "../../helpers/fileSaver"; -import dataFormatter from '../../helpers/dataFormatter'; -import ImageField from "../../components/ImageField"; -import {hasPermission} from "../../helpers/userPermissions"; +const formatDateTimeLocal = (value) => (value ? dayjs(value).format('YYYY-MM-DDTHH:mm') : '') +const emptyValues = { + tenant: null, + organization: null, + booking_request: null, + reservation_code: '', + status: 'quoted', + property: null, + unit: null, + unit_type: null, + check_in_at: '', + check_out_at: '', + actual_check_in_at: '', + actual_check_out_at: '', + early_check_in: false, + late_check_out: false, + guest_count: '', + nightly_rate: '', + monthly_rate: '', + currency: 'USD', + internal_notes: '', + external_notes: '', +} +const buildInitialValues = (record) => ({ + tenant: record?.tenant || null, + organization: record?.organization || null, + booking_request: record?.booking_request || null, + reservation_code: record?.reservation_code || '', + status: record?.status || 'quoted', + property: record?.property || null, + unit: record?.unit || null, + unit_type: record?.unit_type || null, + check_in_at: formatDateTimeLocal(record?.check_in_at), + check_out_at: formatDateTimeLocal(record?.check_out_at), + actual_check_in_at: formatDateTimeLocal(record?.actual_check_in_at), + actual_check_out_at: formatDateTimeLocal(record?.actual_check_out_at), + early_check_in: Boolean(record?.early_check_in), + late_check_out: Boolean(record?.late_check_out), + guest_count: record?.guest_count ?? '', + nightly_rate: record?.nightly_rate ?? '', + monthly_rate: record?.monthly_rate ?? '', + currency: record?.currency || 'USD', + internal_notes: record?.internal_notes || '', + external_notes: record?.external_notes || '', +}) const EditReservationsPage = () => { const router = useRouter() const dispatch = useAppDispatch() - const initVals = { - - - - - - - - - - - - - - - - - - - - - - - - - tenant: null, - - - - - - - - - - - - - - - - - - - - - - - - - - - - organization: null, - - - - - - - - - - - - - - - - - - - - - - - - - - - - booking_request: null, - - - - - - 'reservation_code': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - status: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - property: null, - - - - - - - - - - - - - - - - - - - - - - - - - - - - unit: null, - - - - - - - - - - - - - - - - - - - - - - - - - - - - unit_type: null, - - - - - - - - - - - - - - - - check_in_at: new Date(), - - - - - - - - - - - - - - - - - - - - - - - - - - - - check_out_at: new Date(), - - - - - - - - - - - - - - - - - - - - - - - - - - - - actual_check_in_at: new Date(), - - - - - - - - - - - - - - - - - - - - - - - - - - - - actual_check_out_at: new Date(), - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - early_check_in: false, - - - - - - - - - - - - - - - - - - - - - - - - - - - - late_check_out: false, - - - - - - - - - - - - - - - - - - - - - - guest_count: '', - - - - - - - - - - - - - - - - - - - - - - 'nightly_rate': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'monthly_rate': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'currency': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - internal_notes: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - external_notes: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - guests: [], - - - - - - - - - - - - - - - - - - - - - - - - - - - - service_requests: [], - - - - - - - - - - - - - - - - - - - - - - - - - - - - invoices: [], - - - - - - - - - - - - - - - - - - - - - - - - - - - - documents: [], - - - - - - - - - - - - - - - - - - - - - - - - - - - - comments: [], - - - - } - const [initialValues, setInitialValues] = useState(initVals) - - const { reservations } = useAppSelector((state) => state.reservations) - - const { currentUser } = useAppSelector((state) => state.auth); - - + const { currentUser } = useAppSelector((state) => state.auth) + const { reservations, loading } = useAppSelector((state) => state.reservations) const { id } = router.query - useEffect(() => { - dispatch(fetch({ id: id })) - }, [id]) + const roleLane = getRoleLaneFromUser(currentUser) + const canManagePlatformFields = roleLane === 'super_admin' + const canManageFinancialFields = canManagePlatformFields || roleLane === 'admin' + const canManageOperations = roleLane !== 'customer' + const pageTitle = roleLane === 'customer' ? 'My Stay' : 'Update Stay' + const introCopy = canManagePlatformFields + ? 'Update routing, internal references, housing assignment, and arrival execution details.' + : canManageFinancialFields + ? 'Keep the stay aligned across sourcing, housing, pricing, and arrival execution.' + : 'Update operational stay details while system-managed routing stays protected.' useEffect(() => { - if (typeof reservations === 'object') { - setInitialValues(reservations) + if (id) { + dispatch(fetch({ id })) } + }, [dispatch, id]) + + const initialValues = useMemo(() => { + if (reservations && typeof reservations === 'object' && !Array.isArray(reservations)) { + return buildInitialValues(reservations) + } + + return emptyValues }, [reservations]) - useEffect(() => { - if (typeof reservations === 'object') { - const newInitialVal = {...initVals}; - Object.keys(initVals).forEach(el => newInitialVal[el] = (reservations)[el]) - setInitialValues(newInitialVal); - } - }, [reservations]) + const handleSubmit = async (values) => { + const payload = { ...values } - const handleSubmit = async (data) => { - await dispatch(update({ id: id, data })) + if (!canManagePlatformFields) { + delete payload.tenant + delete payload.organization + delete payload.reservation_code + } + + if (!canManageFinancialFields) { + delete payload.nightly_rate + delete payload.monthly_rate + delete payload.internal_notes + } + + await dispatch(update({ id, data: payload })) await router.push('/reservations/reservations-list') } return ( <> - {getPageTitle('Edit Reservation')} + {getPageTitle(pageTitle)} + - - {''} + + {''} + - handleSubmit(values)} - > +
+ {introCopy} +
+ +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {hasPermission(currentUser, 'READ_ORGANIZATIONS') && - - - - } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - setInitialValues({...initialValues, 'check_in_at': date})} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - setInitialValues({...initialValues, 'check_out_at': date})} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - setInitialValues({...initialValues, 'actual_check_in_at': date})} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - setInitialValues({...initialValues, 'actual_check_out_at': date})} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {canManagePlatformFields && ( + <> + + + + + + + + + + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {canManageOperations && ( + <> + + + + + + + + + )} + + + + + + + + + + {canManageFinancialFields && ( + <> + + + + + + + + + )} + + + + + + + + + +
+ + + +
+ + {canManageFinancialFields && ( +
+ + + +
+ )}
+ - - - router.push('/reservations/reservations-list')}/> + + + router.push('/reservations/reservations-list')} />
@@ -2461,15 +260,7 @@ const EditReservationsPage = () => { } EditReservationsPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) + return {page} } export default EditReservationsPage diff --git a/frontend/src/pages/reservations/reservations-list.tsx b/frontend/src/pages/reservations/reservations-list.tsx index d5926d8..7ff3257 100644 --- a/frontend/src/pages/reservations/reservations-list.tsx +++ b/frontend/src/pages/reservations/reservations-list.tsx @@ -1,167 +1,162 @@ -import { mdiChartTimelineVariant } from '@mdi/js' +import { mdiCalendarCheck } 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 TableReservations from '../../components/Reservations/TableReservations' +import { uniqueId } from 'lodash' +import React, { ReactElement, useMemo, useState } from 'react' +import axios from 'axios' + import BaseButton from '../../components/BaseButton' import BaseButtons from '../../components/BaseButtons' -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/reservations/reservationsSlice'; - - -import {hasPermission} from "../../helpers/userPermissions"; -import { humanize } from "../../helpers/humanize"; - - +import CardBox from '../../components/CardBox' +import CardBoxModal from '../../components/CardBoxModal' +import DragDropFilePicker from '../../components/DragDropFilePicker' +import SectionMain from '../../components/SectionMain' +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' +import TableReservations from '../../components/Reservations/TableReservations' +import { getPageTitle } from '../../config' +import { buildNextFilterItem, filterEntityFieldsForRole, getAddFilterState, pruneHiddenFilterItems } from '../../helpers/entityVisibility' +import { humanize } from '../../helpers/humanize' +import { getRoleLaneFromUser } from '../../helpers/roleLanes' +import { hasPermission } from '../../helpers/userPermissions' +import LayoutAuthenticated from '../../layouts/Authenticated' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { setRefetch, uploadCsv } from '../../stores/reservations/reservationsSlice' const ReservationsTablesPage = () => { - const [filterItems, setFilterItems] = useState([]); - const [csvFile, setCsvFile] = useState(null); - const [isModalActive, setIsModalActive] = useState(false); + const [filterItems, setFilterItems] = useState([]) + const [csvFile, setCsvFile] = useState(null) + const [isModalActive, setIsModalActive] = useState(false) - const { currentUser } = useAppSelector((state) => state.auth); - const dispatch = useAppDispatch(); + const { currentUser } = useAppSelector((state) => state.auth) + const dispatch = useAppDispatch() - const [filters] = useState(([{label: 'Reservationcode', title: 'reservation_code'},{label: 'Currency', title: 'currency'},{label: 'Internalnotes', title: 'internal_notes'},{label: 'Externalnotes', title: 'external_notes'}, - {label: 'Guestcount', title: 'guest_count', number: 'true'}, - {label: 'Nightlyrate', title: 'nightly_rate', number: 'true'},{label: 'Monthlyrate', title: 'monthly_rate', number: 'true'}, - {label: 'Check-inat', title: 'check_in_at', date: 'true'},{label: 'Check-outat', title: 'check_out_at', date: 'true'},{label: 'Actualcheck-inat', title: 'actual_check_in_at', date: 'true'},{label: 'Actualcheck-outat', title: 'actual_check_out_at', date: 'true'}, - {label: 'Tenant', title: 'tenant'}, - {label: 'Bookingrequest', title: 'booking_request'}, - {label: 'Property', title: 'property'}, - {label: 'Unit', title: 'unit'}, - {label: 'Unittype', title: 'unit_type'}, - {label: 'Guests', title: 'guests'},{label: 'Servicerequests', title: 'service_requests'},{label: 'Invoices', title: 'invoices'},{label: 'Documents', title: 'documents'},{label: 'Comments', title: 'comments'}, - {label: 'Status', title: 'status', type: 'enum', options: ['quoted','confirmed','checked_in','checked_out','canceled','no_show']}, - ]).map((filter) => ({ ...filter, label: humanize(filter.title) }))); + const roleLane = getRoleLaneFromUser(currentUser) + const canManagePlatformFields = roleLane === 'super_admin' + const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_RESERVATIONS') + const createButtonLabel = roleLane === 'concierge' ? 'Create stay' : 'New stay' + const title = roleLane === 'customer' ? 'My Stays' : roleLane === 'concierge' ? 'Active Stays' : 'Stays' + const subtitle = roleLane === 'customer' + ? 'Track confirmed housing, arrivals, departures, and current stay details.' + : 'A clean operating board for arrivals, departures, and stay execution.' - const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_RESERVATIONS'); + const filters = useMemo(() => { + const baseFilters = [ + { label: 'Reservationcode', title: 'reservation_code' }, + { label: 'Currency', title: 'currency' }, + { label: 'Externalnotes', title: 'external_notes' }, + { label: 'Guestcount', title: 'guest_count', number: 'true' }, + { label: 'Check-inat', title: 'check_in_at', date: 'true' }, + { label: 'Check-outat', title: 'check_out_at', date: 'true' }, + { label: 'Actualcheck-inat', title: 'actual_check_in_at', date: 'true' }, + { label: 'Actualcheck-outat', title: 'actual_check_out_at', date: 'true' }, + { label: 'Bookingrequest', title: 'booking_request' }, + { label: 'Property', title: 'property' }, + { label: 'Unit', title: 'unit' }, + { label: 'Unittype', title: 'unit_type' }, + { label: 'Guests', title: 'guests' }, + { label: 'Servicerequests', title: 'service_requests' }, + { label: 'Documents', title: 'documents' }, + { label: 'Status', title: 'status', type: 'enum', options: ['quoted', 'confirmed', 'checked_in', 'checked_out', 'canceled', 'no_show'] }, + ] + + if (canManagePlatformFields) { + baseFilters.splice(8, 0, { label: 'Tenant', title: 'tenant' }) + } + + if (roleLane === 'admin' || roleLane === 'super_admin') { + baseFilters.splice(3, 0, { label: 'Nightlyrate', title: 'nightly_rate', number: 'true' }, { label: 'Monthlyrate', title: 'monthly_rate', number: 'true' }, { label: 'Internalnotes', title: 'internal_notes' }) + baseFilters.push({ label: 'Invoices', title: 'invoices' }) + } + + return filterEntityFieldsForRole('reservations', roleLane, baseFilters).map((filter) => ({ ...filter, label: humanize(filter.title) })) + }, [canManagePlatformFields, roleLane]) + + React.useEffect(() => { + setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters)) + }, [filters]) + + const addFilterState = useMemo(() => getAddFilterState(filterItems, filters), [filterItems, filters]) const addFilter = () => { - const newItem = { - id: uniqueId(), - fields: { - filterValue: '', - filterValueFrom: '', - filterValueTo: '', - selectedField: '', - }, - }; - newItem.fields.selectedField = filters[0].title; - setFilterItems([...filterItems, newItem]); - }; + setFilterItems((currentItems) => { + const nextItem = buildNextFilterItem(currentItems, filters, () => uniqueId()) + + return nextItem ? [...currentItems, nextItem] : currentItems + }) + } const getReservationsCSV = async () => { - const response = await axios({url: '/reservations?filetype=csv', method: 'GET',responseType: 'blob'}); + const response = await axios({ url: '/reservations?filetype=csv', method: 'GET', responseType: 'blob' }) const type = response.headers['content-type'] - const blob = new Blob([response.data], { type: type }) + const blob = new Blob([response.data], { type }) const link = document.createElement('a') link.href = window.URL.createObjectURL(blob) link.download = 'reservationsCSV.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); - }; + if (!csvFile) return + await dispatch(uploadCsv(csvFile)) + dispatch(setRefetch(true)) + setCsvFile(null) + setIsModalActive(false) + } return ( <> - {getPageTitle('Reservations')} + {getPageTitle(title)} - +

Working view

-

Operate the stay calendar

-

Move between creation, filtering, export, and the full table without leaving the calendar flow.

+

Stay calendar

+

Create, filter, export, and review stay execution from a single calm surface.

-
+
- {hasCreatePermission ? ( - - ) : null} - + {hasCreatePermission ? : null} + - {hasCreatePermission ? ( - setIsModalActive(true)} /> - ) : null} - + {hasCreatePermission ? setIsModalActive(true)} /> : null} + -
-
-
+

{addFilterState.helperText}

- - + + - - + + setIsModalActive(false)}> + ) } ReservationsTablesPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) + return {page} } export default ReservationsTablesPage diff --git a/frontend/src/pages/reservations/reservations-new.tsx b/frontend/src/pages/reservations/reservations-new.tsx index 7b52a67..68b89dd 100644 --- a/frontend/src/pages/reservations/reservations-new.tsx +++ b/frontend/src/pages/reservations/reservations-new.tsx @@ -1,1341 +1,213 @@ -import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' +import { mdiCalendarCheck } from '@mdi/js' import Head from 'next/head' import React, { ReactElement } from 'react' +import { Field, Form, Formik } from 'formik' +import { useRouter } from 'next/router' + +import BaseButton from '../../components/BaseButton' +import BaseButtons from '../../components/BaseButtons' +import BaseDivider from '../../components/BaseDivider' import CardBox from '../../components/CardBox' -import LayoutAuthenticated from '../../layouts/Authenticated' +import FormField from '../../components/FormField' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' -import { getPageTitle } from '../../config' - -import { Field, Form, Formik } from 'formik' -import FormField from '../../components/FormField' -import BaseDivider from '../../components/BaseDivider' -import BaseButtons from '../../components/BaseButtons' -import BaseButton from '../../components/BaseButton' -import FormCheckRadio from '../../components/FormCheckRadio' -import FormCheckRadioGroup from '../../components/FormCheckRadioGroup' -import FormFilePicker from '../../components/FormFilePicker' -import FormImagePicker from '../../components/FormImagePicker' -import { SwitchField } from '../../components/SwitchField' - import { SelectField } from '../../components/SelectField' -import { SelectFieldMany } from "../../components/SelectFieldMany"; -import {RichTextField} from "../../components/RichTextField"; - +import { SwitchField } from '../../components/SwitchField' +import { getPageTitle } from '../../config' +import { getRoleLaneFromUser } from '../../helpers/roleLanes' +import LayoutAuthenticated from '../../layouts/Authenticated' import { create } from '../../stores/reservations/reservationsSlice' -import { useAppDispatch } from '../../stores/hooks' -import { useRouter } from 'next/router' -import moment from 'moment'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks' const initialValues = { - - - - - - - - - - - - - - tenant: '', - - - - - - - - - - - - - - - - organization: '', - - - - - - - - - - - - - - - - booking_request: '', - - - - - reservation_code: '', - - - - - - - - - - - - - - - - - - - - - - - - - status: 'quoted', - - - - - - - - - - - - - - - - - - - property: '', - - - - - - - - - - - - - - - - unit: '', - - - - - - - - - - - - - - - - unit_type: '', - - - - - - - - - - check_in_at: '', - - - - - - - - - - - - - - - - check_out_at: '', - - - - - - - - - - - - - - - - actual_check_in_at: '', - - - - - - - - - - - - - - - - actual_check_out_at: '', - - - - - - - - - - - - - - - - - early_check_in: false, - - - - - - - - - - - - - - - - late_check_out: false, - - - - - - - - - - - - - guest_count: '', - - - - - - - - - - - - - nightly_rate: '', - - - - - - - - - - - - - - - - monthly_rate: '', - - - - - - - - - - - - - - - - currency: '', - - - - - - - - - - - - - - - - - internal_notes: '', - - - - - - - - - - - - - - - - external_notes: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - guests: [], - - - - - - - - - - - - - - - - service_requests: [], - - - - - - - - - - - - - - - - invoices: [], - - - - - - - - - - - - - - - - documents: [], - - - - - - - - - - - - - - - - comments: [], - - + tenant: null, + organization: null, + booking_request: null, + reservation_code: '', + status: 'quoted', + property: null, + unit: null, + unit_type: null, + check_in_at: '', + check_out_at: '', + actual_check_in_at: '', + actual_check_out_at: '', + early_check_in: false, + late_check_out: false, + guest_count: '', + nightly_rate: '', + monthly_rate: '', + currency: 'USD', + internal_notes: '', + external_notes: '', } - const ReservationsNew = () => { const router = useRouter() const dispatch = useAppDispatch() + const { currentUser } = useAppSelector((state) => state.auth) - - - // get from url params - const { dateRangeStart, dateRangeEnd } = router.query - + const roleLane = getRoleLaneFromUser(currentUser) + const canManagePlatformFields = roleLane === 'super_admin' + const canManageFinancialFields = canManagePlatformFields || roleLane === 'admin' + const canManageOperations = roleLane !== 'customer' + const pageTitle = roleLane === 'customer' ? 'My Stay' : 'Create Stay' + const introCopy = canManagePlatformFields + ? 'Create a stay record with full routing control, internal reference fields, and operational details.' + : canManageFinancialFields + ? 'Create the stay record that will execute an approved request, assign housing, and prepare arrival details.' + : 'Create the stay record for operations. Platform routing and internal references stay managed in the background.' - const handleSubmit = async (data) => { - await dispatch(create(data)) + const handleSubmit = async (values) => { + const payload = { ...values } + + if (!canManagePlatformFields) { + delete payload.tenant + delete payload.organization + delete payload.reservation_code + } + + if (!canManageFinancialFields) { + delete payload.nightly_rate + delete payload.monthly_rate + delete payload.internal_notes + } + + await dispatch(create(payload)) await router.push('/reservations/reservations-list') } + return ( <> - {getPageTitle('New Reservation')} + {getPageTitle(pageTitle)} + - - {''} + + {''} + - handleSubmit(values)} - > +
+ {introCopy} +
+ +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {canManagePlatformFields && ( + <> + + + + + + + + + + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {canManageOperations && ( + <> + + + + + + + + + )} + + + + + + + + + + {canManageFinancialFields && ( + <> + + + + + + + + + )} + + + + + + + + + +
+ + + +
+ + {canManageFinancialFields && ( +
+ + + +
+ )}
+ - - - router.push('/reservations/reservations-list')}/> + + + router.push('/reservations/reservations-list')} />
@@ -1346,15 +218,7 @@ const ReservationsNew = () => { } ReservationsNew.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) + return {page} } export default ReservationsNew diff --git a/frontend/src/pages/reservations/reservations-table.tsx b/frontend/src/pages/reservations/reservations-table.tsx index 4aef87e..1cb2c11 100644 --- a/frontend/src/pages/reservations/reservations-table.tsx +++ b/frontend/src/pages/reservations/reservations-table.tsx @@ -1,186 +1,127 @@ -import { mdiChartTimelineVariant } from '@mdi/js' +import { mdiCalendarCheck } from '@mdi/js' import Head from 'next/head' -import { uniqueId } from 'lodash'; -import React, { ReactElement, useState } from 'react' +import { uniqueId } from 'lodash' +import Link from 'next/link' +import React, { ReactElement, useMemo, useState } from 'react' +import axios from 'axios' + +import BaseButton from '../../components/BaseButton' import CardBox from '../../components/CardBox' -import LayoutAuthenticated from '../../layouts/Authenticated' +import CardBoxModal from '../../components/CardBoxModal' +import DragDropFilePicker from '../../components/DragDropFilePicker' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' -import { getPageTitle } from '../../config' import TableReservations from '../../components/Reservations/TableReservations' -import BaseButton from '../../components/BaseButton' -import axios from "axios"; -import Link from "next/link"; -import {useAppDispatch, useAppSelector} from "../../stores/hooks"; -import CardBoxModal from "../../components/CardBoxModal"; -import DragDropFilePicker from "../../components/DragDropFilePicker"; -import {setRefetch, uploadCsv} from '../../stores/reservations/reservationsSlice'; +import { getPageTitle } from '../../config' +import { filterEntityFieldsForRole, pruneHiddenFilterItems } from '../../helpers/entityVisibility' +import { humanize } from '../../helpers/humanize' +import { getRoleLaneFromUser } from '../../helpers/roleLanes' +import { hasPermission } from '../../helpers/userPermissions' +import LayoutAuthenticated from '../../layouts/Authenticated' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { setRefetch, uploadCsv } from '../../stores/reservations/reservationsSlice' +const ReservationsTablePage = () => { + const [filterItems, setFilterItems] = useState([]) + const [csvFile, setCsvFile] = useState(null) + const [isModalActive, setIsModalActive] = useState(false) -import {hasPermission} from "../../helpers/userPermissions"; + const { currentUser } = useAppSelector((state) => state.auth) + const dispatch = useAppDispatch() + const roleLane = getRoleLaneFromUser(currentUser) + const canManagePlatformFields = roleLane === 'super_admin' + const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_RESERVATIONS') + const filters = useMemo(() => { + const baseFilters = [ + { label: 'Reservationcode', title: 'reservation_code' }, + { label: 'Currency', title: 'currency' }, + { label: 'Externalnotes', title: 'external_notes' }, + { label: 'Guestcount', title: 'guest_count', number: 'true' }, + { label: 'Check-inat', title: 'check_in_at', date: 'true' }, + { label: 'Check-outat', title: 'check_out_at', date: 'true' }, + { label: 'Actualcheck-inat', title: 'actual_check_in_at', date: 'true' }, + { label: 'Actualcheck-outat', title: 'actual_check_out_at', date: 'true' }, + { label: 'Bookingrequest', title: 'booking_request' }, + { label: 'Property', title: 'property' }, + { label: 'Unit', title: 'unit' }, + { label: 'Unittype', title: 'unit_type' }, + { label: 'Guests', title: 'guests' }, + { label: 'Servicerequests', title: 'service_requests' }, + { label: 'Documents', title: 'documents' }, + { label: 'Status', title: 'status', type: 'enum', options: ['quoted', 'confirmed', 'checked_in', 'checked_out', 'canceled', 'no_show'] }, + ] -const ReservationsTablesPage = () => { - const [filterItems, setFilterItems] = useState([]); - const [csvFile, setCsvFile] = useState(null); - const [isModalActive, setIsModalActive] = useState(false); - const [showTableView, setShowTableView] = useState(false); + if (canManagePlatformFields) { + baseFilters.splice(8, 0, { label: 'Tenant', title: 'tenant' }) + } - - const { currentUser } = useAppSelector((state) => state.auth); - + return filterEntityFieldsForRole('reservations', roleLane, baseFilters).map((filter) => ({ ...filter, label: humanize(filter.title) })) + }, [canManagePlatformFields, roleLane]) - const dispatch = useAppDispatch(); + React.useEffect(() => { + setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters)) + }, [filters]) + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { filterValue: '', filterValueFrom: '', filterValueTo: '', selectedField: filters[0].title }, + } + setFilterItems([...filterItems, newItem]) + } - const [filters] = useState([{label: 'Reservationcode', title: 'reservation_code'},{label: 'Currency', title: 'currency'},{label: 'Internalnotes', title: 'internal_notes'},{label: 'Externalnotes', title: 'external_notes'}, - {label: 'Guestcount', title: 'guest_count', number: 'true'}, - {label: 'Nightlyrate', title: 'nightly_rate', number: 'true'},{label: 'Monthlyrate', title: 'monthly_rate', number: 'true'}, - {label: 'Check-inat', title: 'check_in_at', date: 'true'},{label: 'Check-outat', title: 'check_out_at', date: 'true'},{label: 'Actualcheck-inat', title: 'actual_check_in_at', date: 'true'},{label: 'Actualcheck-outat', title: 'actual_check_out_at', date: 'true'}, - - - {label: 'Tenant', title: 'tenant'}, - - - - - - {label: 'Bookingrequest', title: 'booking_request'}, - - - - {label: 'Property', title: 'property'}, - - - - {label: 'Unit', title: 'unit'}, - - - - {label: 'Unittype', title: 'unit_type'}, - - - {label: 'Guests', title: 'guests'},{label: 'Servicerequests', title: 'service_requests'},{label: 'Invoices', title: 'invoices'},{label: 'Documents', title: 'documents'},{label: 'Comments', title: 'comments'}, - {label: 'Status', title: 'status', type: 'enum', options: ['quoted','confirmed','checked_in','checked_out','canceled','no_show']}, - ]); - - const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_RESERVATIONS'); - + const getReservationsCSV = async () => { + const response = await axios({ url: '/reservations?filetype=csv', method: 'GET', responseType: 'blob' }) + const type = response.headers['content-type'] + const blob = new Blob([response.data], { type }) + const link = document.createElement('a') + link.href = window.URL.createObjectURL(blob) + link.download = 'reservationsCSV.csv' + link.click() + } - const addFilter = () => { - const newItem = { - id: uniqueId(), - fields: { - filterValue: '', - filterValueFrom: '', - filterValueTo: '', - selectedField: '', - }, - }; - newItem.fields.selectedField = filters[0].title; - setFilterItems([...filterItems, newItem]); - }; - - const getReservationsCSV = async () => { - const response = await axios({url: '/reservations?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 = 'reservationsCSV.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); - }; + const onModalConfirm = async () => { + if (!csvFile) return + await dispatch(uploadCsv(csvFile)) + dispatch(setRefetch(true)) + setCsvFile(null) + setIsModalActive(false) + } return ( <> - {getPageTitle('Reservations')} + {getPageTitle('Stay Table')} - - {''} + + {''} - - - {hasCreatePermission && } - - - - - {hasCreatePermission && ( - setIsModalActive(true)} - /> - )} - -
+ + {hasCreatePermission && } + + + {hasCreatePermission && setIsModalActive(true)} />} +
- - - Back to calendar - - + Back to board
- - + + - - + setIsModalActive(false)}> + ) } -ReservationsTablesPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) +ReservationsTablePage.getLayout = function getLayout(page: ReactElement) { + return {page} } -export default ReservationsTablesPage +export default ReservationsTablePage diff --git a/frontend/src/pages/reservations/reservations-view.tsx b/frontend/src/pages/reservations/reservations-view.tsx index 1c6b94c..5881600 100644 --- a/frontend/src/pages/reservations/reservations-view.tsx +++ b/frontend/src/pages/reservations/reservations-view.tsx @@ -1,231 +1,203 @@ -import React, { ReactElement, useEffect } from 'react'; -import Head from 'next/head'; -import dayjs from 'dayjs'; -import { mdiCalendarCheck } from '@mdi/js'; -import { useRouter } from 'next/router'; -import { useAppDispatch, useAppSelector } from '../../stores/hooks'; -import { fetch } from '../../stores/reservations/reservationsSlice'; -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 BaseButtons from '../../components/BaseButtons'; -import { hasPermission } from '../../helpers/userPermissions'; +import React, { ReactElement, useEffect } from 'react' +import Head from 'next/head' +import dayjs from 'dayjs' +import { mdiCalendarCheck } from '@mdi/js' +import { useRouter } from 'next/router' + +import BaseButton from '../../components/BaseButton' +import BaseButtons from '../../components/BaseButtons' +import CardBox from '../../components/CardBox' +import SectionMain from '../../components/SectionMain' +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' +import { getPageTitle } from '../../config' +import { getRoleLaneFromUser } from '../../helpers/roleLanes' +import { hasPermission } from '../../helpers/userPermissions' +import LayoutAuthenticated from '../../layouts/Authenticated' +import { fetch } from '../../stores/reservations/reservationsSlice' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' type StatProps = { - label: string; - value: string | number; - hint?: string; -}; - -type RelatedListProps = { - title: string; - items: any[]; - getLabel: (item: any) => string; - emptyLabel?: string; -}; + label: string + value: string | number + hint?: string +} const DetailStat = ({ label, value, hint }: StatProps) => ( - -
-

{label}

-

{value}

- {hint &&

{hint}

} + +
+

{label}

+

{value}

+ {hint &&

{hint}

}
-); +) const DetailRow = ({ label, value }: { label: string; value?: React.ReactNode }) => ( -
-

{label}

-
{value || 'Not set'}
+
+

{label}

+
{value || 'Not set'}
-); +) -const RelatedList = ({ title, items, getLabel, emptyLabel = 'No items yet' }: RelatedListProps) => ( -
-
-

{title}

- - {items.length} - +const RelatedList = ({ title, items, getLabel, emptyLabel = 'No items yet' }: any) => ( +
+
+

{title}

+ {items.length}
{items.length ? ( -
- {items.slice(0, 4).map((item: any) => ( -
+
+ {items.slice(0, 4).map((item) => ( +
{getLabel(item)}
))} - {items.length > 4 && ( -

+{items.length - 4} more

- )}
) : ( -

{emptyLabel}

+

{emptyLabel}

)}
-); - -const formatDate = (value?: string) => (value ? dayjs(value).format('MMM D, YYYY') : 'Not set'); +) +const formatDate = (value?: string) => (value ? dayjs(value).format('MMM D, YYYY') : 'Not set') +const formatDateTime = (value?: string) => (value ? dayjs(value).format('MMM D, YYYY h:mm A') : 'Not set') const formatCurrency = (amount?: string | number, currency?: string) => { - if (amount === undefined || amount === null || amount === '') return 'Not set'; + if (amount === undefined || amount === null || amount === '') return 'Not set' try { return new Intl.NumberFormat('en-US', { style: 'currency', currency: currency || 'USD', maximumFractionDigits: 0, - }).format(Number(amount)); + }).format(Number(amount)) } catch (error) { - console.error('Failed to format reservation rate', error); - return `${amount} ${currency || ''}`.trim(); + console.error('Failed to format reservation currency', error) + return `${amount} ${currency || ''}`.trim() } -}; +} const ReservationsView = () => { - const router = useRouter(); - const dispatch = useAppDispatch(); - const { reservations } = useAppSelector((state) => state.reservations); - const { currentUser } = useAppSelector((state) => state.auth); - const { id } = router.query; + const router = useRouter() + const dispatch = useAppDispatch() + const { reservations } = useAppSelector((state) => state.reservations) + const { currentUser } = useAppSelector((state) => state.auth) + const { id } = router.query useEffect(() => { - dispatch(fetch({ id })); - }, [dispatch, id]); + dispatch(fetch({ id })) + }, [dispatch, id]) - const canEdit = currentUser && hasPermission(currentUser, 'UPDATE_RESERVATIONS'); - const plannedStay = `${formatDate(reservations?.check_in_at)} → ${formatDate(reservations?.check_out_at)}`; - const actualStay = - reservations?.actual_check_in_at || reservations?.actual_check_out_at - ? `${formatDate(reservations?.actual_check_in_at)} → ${formatDate(reservations?.actual_check_out_at)}` - : 'Not checked in yet'; + const roleLane = getRoleLaneFromUser(currentUser) + const canManagePlatformFields = roleLane === 'super_admin' + const canManageFinancialFields = canManagePlatformFields || roleLane === 'admin' + const canEdit = currentUser && hasPermission(currentUser, 'UPDATE_RESERVATIONS') + const pageTitle = roleLane === 'customer' ? 'My Stay' : 'Stay Details' + const stayWindow = reservations?.check_in_at || reservations?.check_out_at + ? `${formatDate(reservations?.check_in_at)} → ${formatDate(reservations?.check_out_at)}` + : 'Dates not set' return ( <> - {getPageTitle('Reservation')} + {getPageTitle(pageTitle)} - + - - {canEdit && ( - - )} + + {canEdit && } -
- - - - +
+ + + +
-
-
- -
+
+
+ +
-

Overview

-

Stay summary

-

- The essential operating details for the stay, inventory assignment, and guest timing. -

+

Overview

+

Stay snapshot

+

Housing assignment, dates, and the most important execution details.

-
- - - - - - {hasPermission(currentUser, 'READ_ORGANIZATIONS') && ( - +
+ {canManagePlatformFields && ( + <> + + + )} - - - - + + + + + + + +
- -
+ +
-

Notes

-

Internal and guest-facing notes

+

Notes

+

Stay coordination

-
-
-

Internal Notes

-

- {reservations?.internal_notes || 'No internal notes recorded.'} -

-
-
-

External Notes

-

- {reservations?.external_notes || 'No guest-facing notes recorded.'} -

+
+
+

Guest-facing notes

+

{reservations?.external_notes || 'No guest-facing notes yet.'}

+ {canManageFinancialFields && ( +
+

Internal notes

+

{reservations?.internal_notes || 'No internal notes yet.'}

+
+ )}
- -
+ +
-

Activity

-

Connected operations

-

A simple view of guest, service, billing, and document activity.

+

Linked records

+

Operational context

+

Everything directly attached to this stay.

-
- item?.full_name || item?.email || item?.id} - /> - item?.request_title || item?.status || item?.id} - /> - item?.invoice_number || item?.status || item?.id} - /> - item?.name || item?.document_name || item?.id} - /> + + + + +
+ item?.full_name || item?.email || item?.id} /> + item?.summary || item?.request_type || item?.id} /> + item?.file_name || item?.document_type || item?.id} /> + item?.invoice_number || item?.status || item?.id} emptyLabel='No invoices linked yet.' />
- ); -}; + ) +} ReservationsView.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; + return {page} +} -export default ReservationsView; +export default ReservationsView diff --git a/frontend/src/pages/service_requests/service_requests-edit.tsx b/frontend/src/pages/service_requests/service_requests-edit.tsx index 7885e86..fce5501 100644 --- a/frontend/src/pages/service_requests/service_requests-edit.tsx +++ b/frontend/src/pages/service_requests/service_requests-edit.tsx @@ -1,1771 +1,242 @@ -import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js' +import { mdiRoomServiceOutline } from '@mdi/js' +import dayjs from 'dayjs' 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 React, { ReactElement, useEffect, useMemo } from 'react' +import { Field, Form, Formik } from 'formik' +import { useRouter } from 'next/router' +import BaseButton from '../../components/BaseButton' +import BaseButtons from '../../components/BaseButtons' +import BaseDivider from '../../components/BaseDivider' import CardBox from '../../components/CardBox' -import LayoutAuthenticated from '../../layouts/Authenticated' +import FormField from '../../components/FormField' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' +import { SelectField } from '../../components/SelectField' import { getPageTitle } from '../../config' - -import { Field, Form, Formik } from 'formik' -import FormField from '../../components/FormField' -import BaseDivider from '../../components/BaseDivider' -import BaseButtons from '../../components/BaseButtons' -import BaseButton from '../../components/BaseButton' -import FormCheckRadio from '../../components/FormCheckRadio' -import FormCheckRadioGroup from '../../components/FormCheckRadioGroup' -import FormFilePicker from '../../components/FormFilePicker' -import FormImagePicker from '../../components/FormImagePicker' -import { SelectField } from "../../components/SelectField"; -import { SelectFieldMany } from "../../components/SelectFieldMany"; -import { SwitchField } from '../../components/SwitchField' -import {RichTextField} from "../../components/RichTextField"; - -import { update, fetch } from '../../stores/service_requests/service_requestsSlice' +import { getRoleLaneFromUser } from '../../helpers/roleLanes' +import LayoutAuthenticated from '../../layouts/Authenticated' +import { fetch, update } from '../../stores/service_requests/service_requestsSlice' import { useAppDispatch, useAppSelector } from '../../stores/hooks' -import { useRouter } from 'next/router' -import {saveFile} from "../../helpers/fileSaver"; -import dataFormatter from '../../helpers/dataFormatter'; -import ImageField from "../../components/ImageField"; -import {hasPermission} from "../../helpers/userPermissions"; +const formatDateTimeLocal = (value) => (value ? dayjs(value).format('YYYY-MM-DDTHH:mm') : '') +const emptyValues = { + tenant: null, + reservation: null, + requested_by: null, + assigned_to: null, + organizations: null, + request_type: 'airport_pickup', + status: 'new', + priority: 'normal', + requested_at: '', + due_at: '', + completed_at: '', + summary: '', + details: '', + estimated_cost: '', + actual_cost: '', + currency: 'USD', +} +const buildInitialValues = (record) => ({ + tenant: record?.tenant || null, + reservation: record?.reservation || null, + requested_by: record?.requested_by || null, + assigned_to: record?.assigned_to || null, + organizations: record?.organizations || null, + request_type: record?.request_type || 'airport_pickup', + status: record?.status || 'new', + priority: record?.priority || 'normal', + requested_at: formatDateTimeLocal(record?.requested_at), + due_at: formatDateTimeLocal(record?.due_at), + completed_at: formatDateTimeLocal(record?.completed_at), + summary: record?.summary || '', + details: record?.details || '', + estimated_cost: record?.estimated_cost ?? '', + actual_cost: record?.actual_cost ?? '', + currency: record?.currency || 'USD', +}) -const EditService_requestsPage = () => { +const EditServiceRequestsPage = () => { const router = useRouter() const dispatch = useAppDispatch() - const initVals = { - - - - - - - - - - - - - - - - - - - - - - - - - tenant: null, - - - - - - - - - - - - - - - - - - - - - - - - - - - - reservation: null, - - - - - - - - - - - - - - - - - - - - - - - - - - - - requested_by: null, - - - - - - - - - - - - - - - - - - - - - - - - - - - - assigned_to: null, - - - - - - - - - - - - - - - - - - - - - - request_type: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - status: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - priority: '', - - - - - - - - - - - - - - - - - - - - - - requested_at: new Date(), - - - - - - - - - - - - - - - - - - - - - - - - - - - - due_at: new Date(), - - - - - - - - - - - - - - - - - - - - - - - - - - - - completed_at: new Date(), - - - - - - - - - - - - - - - - - - 'summary': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - details: '', - - - - - - - - - - - - - - - - - - - - - - - - 'estimated_cost': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'actual_cost': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'currency': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - documents: [], - - - - - - - - - - - - - - - - - - - - - - - - - - - - comments: [], - - - - - - - - - - - - - - - - - - - - - - - - - - organizations: null, - - - - - - } - const [initialValues, setInitialValues] = useState(initVals) - - const { service_requests } = useAppSelector((state) => state.service_requests) - - const { currentUser } = useAppSelector((state) => state.auth); - - + const { currentUser } = useAppSelector((state) => state.auth) + const { service_requests, loading } = useAppSelector((state) => state.service_requests) const { id } = router.query - useEffect(() => { - dispatch(fetch({ id: id })) - }, [id]) + const roleLane = getRoleLaneFromUser(currentUser) + const isCustomer = roleLane === 'customer' + const canManagePlatformFields = roleLane === 'super_admin' + const canManageOperationalFields = roleLane === 'super_admin' || roleLane === 'admin' || roleLane === 'concierge' + const canManageCostFields = roleLane === 'super_admin' || roleLane === 'admin' + const pageTitle = isCustomer ? 'Update Support Request' : 'Update Service Request' + const introCopy = isCustomer + ? 'Update your support need while assignment, workflow status, and internal routing remain system-managed.' + : 'Keep the service request moving, coordinate ownership, and update execution details.' useEffect(() => { - if (typeof service_requests === 'object') { - setInitialValues(service_requests) + if (id) { + dispatch(fetch({ id })) } + }, [dispatch, id]) + + const initialValues = useMemo(() => { + if (service_requests && typeof service_requests === 'object' && !Array.isArray(service_requests)) { + return buildInitialValues(service_requests) + } + + return emptyValues }, [service_requests]) - useEffect(() => { - if (typeof service_requests === 'object') { - const newInitialVal = {...initVals}; - Object.keys(initVals).forEach(el => newInitialVal[el] = (service_requests)[el]) - setInitialValues(newInitialVal); - } - }, [service_requests]) + const handleSubmit = async (values) => { + const payload = { ...values } - const handleSubmit = async (data) => { - await dispatch(update({ id: id, data })) + if (!canManagePlatformFields) { + delete payload.tenant + delete payload.organizations + } + + if (!canManageOperationalFields) { + delete payload.requested_by + delete payload.assigned_to + delete payload.status + delete payload.requested_at + delete payload.completed_at + delete payload.estimated_cost + delete payload.actual_cost + } + + if (!canManageCostFields) { + delete payload.estimated_cost + delete payload.actual_cost + } + + await dispatch(update({ id, data: payload })) await router.push('/service_requests/service_requests-list') } return ( <> - {getPageTitle('Edit service_requests')} + {getPageTitle(pageTitle)} + - - {''} + + {''} - - handleSubmit(values)} - > + + +
+ {introCopy} +
+ +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - setInitialValues({...initialValues, 'requested_at': date})} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - setInitialValues({...initialValues, 'due_at': date})} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - setInitialValues({...initialValues, 'completed_at': date})} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+ {canManagePlatformFields && ( + <> + + + + + + + + + )} + + + + + + {canManageOperationalFields && ( + <> + + + + + + + + + )} + + + + + + + + + + + + + + + {canManageOperationalFields && ( + + + + + + + + + + + + )} + + + + + + + + + + + {canManageOperationalFields && } + + {canManageOperationalFields && } + + {canManageCostFields && ( + <> + + + + + + + + + + + )} + +
+ + + +
+ +
+ + + +
+
+ - - - router.push('/service_requests/service_requests-list')}/> + + + router.push('/service_requests/service_requests-list')} />
@@ -1775,16 +246,8 @@ const EditService_requestsPage = () => { ) } -EditService_requestsPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) +EditServiceRequestsPage.getLayout = function getLayout(page: ReactElement) { + return {page} } -export default EditService_requestsPage +export default EditServiceRequestsPage diff --git a/frontend/src/pages/service_requests/service_requests-list.tsx b/frontend/src/pages/service_requests/service_requests-list.tsx index 12b4d24..27b471c 100644 --- a/frontend/src/pages/service_requests/service_requests-list.tsx +++ b/frontend/src/pages/service_requests/service_requests-list.tsx @@ -1,180 +1,159 @@ -import { mdiChartTimelineVariant } from '@mdi/js' +import { mdiRoomServiceOutline } from '@mdi/js' import Head from 'next/head' -import { uniqueId } from 'lodash'; -import React, { ReactElement, useState } from 'react' +import { uniqueId } from 'lodash' +import React, { ReactElement, useMemo, useState } from 'react' +import axios from 'axios' + +import BaseButton from '../../components/BaseButton' +import BaseButtons from '../../components/BaseButtons' import CardBox from '../../components/CardBox' -import LayoutAuthenticated from '../../layouts/Authenticated' +import CardBoxModal from '../../components/CardBoxModal' +import DragDropFilePicker from '../../components/DragDropFilePicker' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' -import { getPageTitle } from '../../config' import TableService_requests from '../../components/Service_requests/TableService_requests' -import BaseButton from '../../components/BaseButton' -import axios from "axios"; -import Link from "next/link"; -import {useAppDispatch, useAppSelector} from "../../stores/hooks"; -import CardBoxModal from "../../components/CardBoxModal"; -import DragDropFilePicker from "../../components/DragDropFilePicker"; -import {setRefetch, uploadCsv} from '../../stores/service_requests/service_requestsSlice'; +import { getPageTitle } from '../../config' +import { buildNextFilterItem, filterEntityFieldsForRole, getAddFilterState, pruneHiddenFilterItems } from '../../helpers/entityVisibility' +import { humanize } from '../../helpers/humanize' +import { getRoleLaneFromUser } from '../../helpers/roleLanes' +import { hasPermission } from '../../helpers/userPermissions' +import LayoutAuthenticated from '../../layouts/Authenticated' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { setRefetch, uploadCsv } from '../../stores/service_requests/service_requestsSlice' +const ServiceRequestsTablesPage = () => { + const [filterItems, setFilterItems] = useState([]) + const [csvFile, setCsvFile] = useState(null) + const [isModalActive, setIsModalActive] = useState(false) -import {hasPermission} from "../../helpers/userPermissions"; + const { currentUser } = useAppSelector((state) => state.auth) + const dispatch = useAppDispatch() + const roleLane = getRoleLaneFromUser(currentUser) + const canManagePlatformFields = roleLane === 'super_admin' + const canManageOperationalFields = roleLane === 'super_admin' || roleLane === 'admin' || roleLane === 'concierge' + const canManageCostFields = roleLane === 'super_admin' || roleLane === 'admin' + const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SERVICE_REQUESTS') + const title = roleLane === 'customer' ? 'Support Requests' : 'Service Queue' + const subtitle = roleLane === 'customer' + ? 'Track active support requests linked to your stay and any updates from the operations team.' + : 'Triage the live service queue, assign owners, and keep active stays supported.' + const filters = useMemo(() => { + const baseFilters = [ + { label: 'Summary', title: 'summary' }, + { label: 'Details', title: 'details' }, + { label: 'Reservation', title: 'reservation' }, + { label: 'Dueat', title: 'due_at', date: 'true' }, + { label: 'Requesttype', title: 'request_type', type: 'enum', options: ['airport_pickup', 'housekeeping', 'maintenance', 'stocked_fridge', 'chauffeur', 'late_checkout', 'extension', 'custom_service'] }, + { label: 'Status', title: 'status', type: 'enum', options: ['new', 'triaged', 'in_progress', 'scheduled', 'waiting_on_guest', 'completed', 'canceled'] }, + { label: 'Priority', title: 'priority', type: 'enum', options: ['low', 'normal', 'high', 'urgent'] }, + { label: 'Documents', title: 'documents' }, + { label: 'Comments', title: 'comments' }, + ] -const Service_requestsTablesPage = () => { - const [filterItems, setFilterItems] = useState([]); - const [csvFile, setCsvFile] = useState(null); - const [isModalActive, setIsModalActive] = useState(false); - const [showTableView, setShowTableView] = useState(false); + if (canManagePlatformFields) { + baseFilters.unshift({ label: 'Tenant', title: 'tenant' }) + } - - const { currentUser } = useAppSelector((state) => state.auth); - + if (canManageOperationalFields) { + baseFilters.splice(3, 0, { label: 'Requestedby', title: 'requested_by' }, { label: 'Assignedto', title: 'assigned_to' }, { label: 'Requestedat', title: 'requested_at', date: 'true' }, { label: 'Completedat', title: 'completed_at', date: 'true' }) + } - const dispatch = useAppDispatch(); + if (canManageCostFields) { + baseFilters.splice(5, 0, { label: 'Currency', title: 'currency' }, { label: 'Estimatedcost', title: 'estimated_cost', number: 'true' }, { label: 'Actualcost', title: 'actual_cost', number: 'true' }) + } + return filterEntityFieldsForRole('service_requests', roleLane, baseFilters).map((filter) => ({ ...filter, label: humanize(filter.title) })) + }, [canManageCostFields, canManageOperationalFields, canManagePlatformFields, roleLane]) - const [filters] = useState([{label: 'Summary', title: 'summary'},{label: 'Details', title: 'details'},{label: 'Currency', title: 'currency'}, - - {label: 'Estimatedcost', title: 'estimated_cost', number: 'true'},{label: 'Actualcost', title: 'actual_cost', number: 'true'}, - {label: 'Requestedat', title: 'requested_at', date: 'true'},{label: 'Dueat', title: 'due_at', date: 'true'},{label: 'Completedat', title: 'completed_at', date: 'true'}, - - - {label: 'Tenant', title: 'tenant'}, - - - - {label: 'Reservation', title: 'reservation'}, - - - - {label: 'Requestedby', title: 'requested_by'}, - - - - {label: 'Assignedto', title: 'assigned_to'}, - - - {label: 'Documents', title: 'documents'},{label: 'Comments', title: 'comments'}, - {label: 'Requesttype', title: 'request_type', type: 'enum', options: ['airport_pickup','housekeeping','maintenance','stocked_fridge','chauffeur','late_checkout','extension','custom_service']},{label: 'Status', title: 'status', type: 'enum', options: ['new','triaged','in_progress','scheduled','waiting_on_guest','completed','canceled']},{label: 'Priority', title: 'priority', type: 'enum', options: ['low','normal','high','urgent']}, - ]); - - const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SERVICE_REQUESTS'); - + React.useEffect(() => { + setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters)) + }, [filters]) - const addFilter = () => { - const newItem = { - id: uniqueId(), - fields: { - filterValue: '', - filterValueFrom: '', - filterValueTo: '', - selectedField: '', - }, - }; - newItem.fields.selectedField = filters[0].title; - setFilterItems([...filterItems, newItem]); - }; + const addFilterState = useMemo(() => getAddFilterState(filterItems, filters), [filterItems, filters]) - const getService_requestsCSV = async () => { - const response = await axios({url: '/service_requests?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 = 'service_requestsCSV.csv' - link.click() - }; + const addFilter = () => { + setFilterItems((currentItems) => { + const nextItem = buildNextFilterItem(currentItems, filters, () => uniqueId()) - const onModalConfirm = async () => { - if (!csvFile) return; - await dispatch(uploadCsv(csvFile)); - dispatch(setRefetch(true)); - setCsvFile(null); - setIsModalActive(false); - }; + return nextItem ? [...currentItems, nextItem] : currentItems + }) + } - const onModalCancel = () => { - setCsvFile(null); - setIsModalActive(false); - }; + const getServiceRequestsCSV = async () => { + const response = await axios({ url: '/service_requests?filetype=csv', method: 'GET', responseType: 'blob' }) + const type = response.headers['content-type'] + const blob = new Blob([response.data], { type }) + const link = document.createElement('a') + link.href = window.URL.createObjectURL(blob) + link.download = 'service_requestsCSV.csv' + link.click() + } + + const onModalConfirm = async () => { + if (!csvFile) return + await dispatch(uploadCsv(csvFile)) + dispatch(setRefetch(true)) + setCsvFile(null) + setIsModalActive(false) + } return ( <> - {getPageTitle('Service_requests')} + {getPageTitle(title)} - - {''} - - - - {hasCreatePermission && } - - - - - {hasCreatePermission && ( - setIsModalActive(true)} - /> - )} - -
-
-
- -
- Switch to Table + + + +
+
+

Working view

+

Service execution

+

Capture new requests, filter the queue, and keep support delivery moving.

- + +
+ + {hasCreatePermission ? : null} + + + {hasCreatePermission ? setIsModalActive(true)} /> : null} + + + +

{addFilterState.helperText}

+
+
+
+ + + - - - - - + + setIsModalActive(false)}> + ) } -Service_requestsTablesPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) +ServiceRequestsTablesPage.getLayout = function getLayout(page: ReactElement) { + return {page} } -export default Service_requestsTablesPage +export default ServiceRequestsTablesPage diff --git a/frontend/src/pages/service_requests/service_requests-new.tsx b/frontend/src/pages/service_requests/service_requests-new.tsx index f680358..9df6f1c 100644 --- a/frontend/src/pages/service_requests/service_requests-new.tsx +++ b/frontend/src/pages/service_requests/service_requests-new.tsx @@ -1,1004 +1,218 @@ -import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' +import { mdiRoomServiceOutline } from '@mdi/js' import Head from 'next/head' import React, { ReactElement } from 'react' +import { Field, Form, Formik } from 'formik' +import { useRouter } from 'next/router' + +import BaseButton from '../../components/BaseButton' +import BaseButtons from '../../components/BaseButtons' +import BaseDivider from '../../components/BaseDivider' import CardBox from '../../components/CardBox' -import LayoutAuthenticated from '../../layouts/Authenticated' +import FormField from '../../components/FormField' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' -import { getPageTitle } from '../../config' - -import { Field, Form, Formik } from 'formik' -import FormField from '../../components/FormField' -import BaseDivider from '../../components/BaseDivider' -import BaseButtons from '../../components/BaseButtons' -import BaseButton from '../../components/BaseButton' -import FormCheckRadio from '../../components/FormCheckRadio' -import FormCheckRadioGroup from '../../components/FormCheckRadioGroup' -import FormFilePicker from '../../components/FormFilePicker' -import FormImagePicker from '../../components/FormImagePicker' -import { SwitchField } from '../../components/SwitchField' - import { SelectField } from '../../components/SelectField' -import { SelectFieldMany } from "../../components/SelectFieldMany"; -import {RichTextField} from "../../components/RichTextField"; - +import { getPageTitle } from '../../config' +import { getRoleLaneFromUser } from '../../helpers/roleLanes' +import LayoutAuthenticated from '../../layouts/Authenticated' import { create } from '../../stores/service_requests/service_requestsSlice' -import { useAppDispatch } from '../../stores/hooks' -import { useRouter } from 'next/router' -import moment from 'moment'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks' const initialValues = { - - - - - - - - - - - - - - tenant: '', - - - - - - - - - - - - - - - - reservation: '', - - - - - - - - - - - - - - - - requested_by: '', - - - - - - - - - - - - - - - - assigned_to: '', - - - - - - - - - - - - - - request_type: 'airport_pickup', - - - - - - - - - - - - - - - - - status: 'new', - - - - - - - - - - - - - - - - - priority: 'low', - - - - - - - - - - - - - requested_at: '', - - - - - - - - - - - - - - - - due_at: '', - - - - - - - - - - - - - - - - completed_at: '', - - - - - - - - - - - summary: '', - - - - - - - - - - - - - - - - - - details: '', - - - - - - - - - - - - - - estimated_cost: '', - - - - - - - - - - - - - - - - actual_cost: '', - - - - - - - - - - - - - - - - currency: '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - documents: [], - - - - - - - - - - - - - - - - comments: [], - - - - - - - - - - - - - - - organizations: '', - - - + tenant: null, + reservation: null, + requested_by: null, + assigned_to: null, + organizations: null, + request_type: 'airport_pickup', + status: 'new', + priority: 'normal', + requested_at: '', + due_at: '', + completed_at: '', + summary: '', + details: '', + estimated_cost: '', + actual_cost: '', + currency: 'USD', } - -const Service_requestsNew = () => { +const ServiceRequestsNew = () => { const router = useRouter() const dispatch = useAppDispatch() + const { currentUser } = useAppSelector((state) => state.auth) - - + const roleLane = getRoleLaneFromUser(currentUser) + const isCustomer = roleLane === 'customer' + const canManagePlatformFields = roleLane === 'super_admin' + const canManageOperationalFields = roleLane === 'super_admin' || roleLane === 'admin' || roleLane === 'concierge' + const canManageCostFields = roleLane === 'super_admin' || roleLane === 'admin' + const pageTitle = isCustomer ? 'Request Support' : 'Create Service Request' + const introCopy = isCustomer + ? 'Tell the operations team what you need during the stay. Assignment, workflow status, and internal routing will be handled for you.' + : 'Capture the service need, route it to the right operator, and keep execution moving without exposing platform-only fields.' - const handleSubmit = async (data) => { - await dispatch(create(data)) + const handleSubmit = async (values) => { + const payload = { ...values } + + if (!canManagePlatformFields) { + delete payload.tenant + delete payload.organizations + } + + if (!canManageOperationalFields) { + delete payload.requested_by + delete payload.assigned_to + delete payload.status + delete payload.requested_at + delete payload.completed_at + delete payload.estimated_cost + delete payload.actual_cost + } + + if (!canManageCostFields) { + delete payload.estimated_cost + delete payload.actual_cost + } + + await dispatch(create(payload)) await router.push('/service_requests/service_requests-list') } + return ( <> - {getPageTitle('New Item')} + {getPageTitle(pageTitle)} + - - {''} + + {''} - - handleSubmit(values)} - > + + +
+ {introCopy} +
+ +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+ {canManagePlatformFields && ( + <> + + + + + + + + + )} + + + + + + {canManageOperationalFields && ( + <> + + + + + + + + + )} + + + + + + + + + + + + + + + {canManageOperationalFields && ( + + + + + + + + + + + + )} + + + + + + + + + + + {canManageOperationalFields && ( + + + + )} + + + + + + {canManageOperationalFields && ( + + + + )} + + {canManageCostFields && ( + <> + + + + + + + + + + + + + )} + +
+ + + +
+ +
+ + + +
+
+ - - - router.push('/service_requests/service_requests-list')}/> + + + router.push('/service_requests/service_requests-list')} />
@@ -1008,16 +222,8 @@ const Service_requestsNew = () => { ) } -Service_requestsNew.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) +ServiceRequestsNew.getLayout = function getLayout(page: ReactElement) { + return {page} } -export default Service_requestsNew +export default ServiceRequestsNew diff --git a/frontend/src/pages/service_requests/service_requests-table.tsx b/frontend/src/pages/service_requests/service_requests-table.tsx index e88a945..200e587 100644 --- a/frontend/src/pages/service_requests/service_requests-table.tsx +++ b/frontend/src/pages/service_requests/service_requests-table.tsx @@ -1,180 +1,120 @@ -import { mdiChartTimelineVariant } from '@mdi/js' +import { mdiRoomServiceOutline } from '@mdi/js' import Head from 'next/head' -import { uniqueId } from 'lodash'; -import React, { ReactElement, useState } from 'react' +import { uniqueId } from 'lodash' +import Link from 'next/link' +import React, { ReactElement, useMemo, useState } from 'react' +import axios from 'axios' + +import BaseButton from '../../components/BaseButton' import CardBox from '../../components/CardBox' -import LayoutAuthenticated from '../../layouts/Authenticated' +import CardBoxModal from '../../components/CardBoxModal' +import DragDropFilePicker from '../../components/DragDropFilePicker' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' -import { getPageTitle } from '../../config' import TableService_requests from '../../components/Service_requests/TableService_requests' -import BaseButton from '../../components/BaseButton' -import axios from "axios"; -import Link from "next/link"; -import {useAppDispatch, useAppSelector} from "../../stores/hooks"; -import CardBoxModal from "../../components/CardBoxModal"; -import DragDropFilePicker from "../../components/DragDropFilePicker"; -import {setRefetch, uploadCsv} from '../../stores/service_requests/service_requestsSlice'; +import { getPageTitle } from '../../config' +import { filterEntityFieldsForRole, pruneHiddenFilterItems } from '../../helpers/entityVisibility' +import { humanize } from '../../helpers/humanize' +import { getRoleLaneFromUser } from '../../helpers/roleLanes' +import { hasPermission } from '../../helpers/userPermissions' +import LayoutAuthenticated from '../../layouts/Authenticated' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { setRefetch, uploadCsv } from '../../stores/service_requests/service_requestsSlice' +const ServiceRequestsTablePage = () => { + const [filterItems, setFilterItems] = useState([]) + const [csvFile, setCsvFile] = useState(null) + const [isModalActive, setIsModalActive] = useState(false) -import {hasPermission} from "../../helpers/userPermissions"; + const { currentUser } = useAppSelector((state) => state.auth) + const dispatch = useAppDispatch() + const roleLane = getRoleLaneFromUser(currentUser) + const canManagePlatformFields = roleLane === 'super_admin' + const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SERVICE_REQUESTS') + const filters = useMemo(() => { + const baseFilters = [ + { label: 'Summary', title: 'summary' }, + { label: 'Details', title: 'details' }, + { label: 'Reservation', title: 'reservation' }, + { label: 'Dueat', title: 'due_at', date: 'true' }, + { label: 'Requesttype', title: 'request_type', type: 'enum', options: ['airport_pickup', 'housekeeping', 'maintenance', 'stocked_fridge', 'chauffeur', 'late_checkout', 'extension', 'custom_service'] }, + { label: 'Status', title: 'status', type: 'enum', options: ['new', 'triaged', 'in_progress', 'scheduled', 'waiting_on_guest', 'completed', 'canceled'] }, + { label: 'Priority', title: 'priority', type: 'enum', options: ['low', 'normal', 'high', 'urgent'] }, + { label: 'Documents', title: 'documents' }, + { label: 'Comments', title: 'comments' }, + ] -const Service_requestsTablesPage = () => { - const [filterItems, setFilterItems] = useState([]); - const [csvFile, setCsvFile] = useState(null); - const [isModalActive, setIsModalActive] = useState(false); - const [showTableView, setShowTableView] = useState(false); + if (canManagePlatformFields) { + baseFilters.unshift({ label: 'Tenant', title: 'tenant' }) + } - - const { currentUser } = useAppSelector((state) => state.auth); - + return filterEntityFieldsForRole('service_requests', roleLane, baseFilters).map((filter) => ({ ...filter, label: humanize(filter.title) })) + }, [canManagePlatformFields, roleLane]) - const dispatch = useAppDispatch(); + React.useEffect(() => { + setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters)) + }, [filters]) + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { filterValue: '', filterValueFrom: '', filterValueTo: '', selectedField: filters[0].title }, + } + setFilterItems([...filterItems, newItem]) + } - const [filters] = useState([{label: 'Summary', title: 'summary'},{label: 'Details', title: 'details'},{label: 'Currency', title: 'currency'}, - - {label: 'Estimatedcost', title: 'estimated_cost', number: 'true'},{label: 'Actualcost', title: 'actual_cost', number: 'true'}, - {label: 'Requestedat', title: 'requested_at', date: 'true'},{label: 'Dueat', title: 'due_at', date: 'true'},{label: 'Completedat', title: 'completed_at', date: 'true'}, - - - {label: 'Tenant', title: 'tenant'}, - - - - {label: 'Reservation', title: 'reservation'}, - - - - {label: 'Requestedby', title: 'requested_by'}, - - - - {label: 'Assignedto', title: 'assigned_to'}, - - - {label: 'Documents', title: 'documents'},{label: 'Comments', title: 'comments'}, - {label: 'Requesttype', title: 'request_type', type: 'enum', options: ['airport_pickup','housekeeping','maintenance','stocked_fridge','chauffeur','late_checkout','extension','custom_service']},{label: 'Status', title: 'status', type: 'enum', options: ['new','triaged','in_progress','scheduled','waiting_on_guest','completed','canceled']},{label: 'Priority', title: 'priority', type: 'enum', options: ['low','normal','high','urgent']}, - ]); - - const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SERVICE_REQUESTS'); - + const getServiceRequestsCSV = async () => { + const response = await axios({ url: '/service_requests?filetype=csv', method: 'GET', responseType: 'blob' }) + const type = response.headers['content-type'] + const blob = new Blob([response.data], { type }) + const link = document.createElement('a') + link.href = window.URL.createObjectURL(blob) + link.download = 'service_requestsCSV.csv' + link.click() + } - const addFilter = () => { - const newItem = { - id: uniqueId(), - fields: { - filterValue: '', - filterValueFrom: '', - filterValueTo: '', - selectedField: '', - }, - }; - newItem.fields.selectedField = filters[0].title; - setFilterItems([...filterItems, newItem]); - }; - - const getService_requestsCSV = async () => { - const response = await axios({url: '/service_requests?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 = 'service_requestsCSV.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); - }; + const onModalConfirm = async () => { + if (!csvFile) return + await dispatch(uploadCsv(csvFile)) + dispatch(setRefetch(true)) + setCsvFile(null) + setIsModalActive(false) + } return ( <> - {getPageTitle('Service_requests')} + {getPageTitle('Service Queue Table')} - - {''} + + {''} - - - {hasCreatePermission && } - - - - - {hasCreatePermission && ( - setIsModalActive(true)} - /> - )} - -
+ + {hasCreatePermission && } + + + {hasCreatePermission && setIsModalActive(true)} />} +
- - - Back to kanban - - + Back to board
- - + + - - + setIsModalActive(false)}> + ) } -Service_requestsTablesPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) +ServiceRequestsTablePage.getLayout = function getLayout(page: ReactElement) { + return {page} } -export default Service_requestsTablesPage +export default ServiceRequestsTablePage diff --git a/frontend/src/pages/service_requests/service_requests-view.tsx b/frontend/src/pages/service_requests/service_requests-view.tsx index b544790..ac761a8 100644 --- a/frontend/src/pages/service_requests/service_requests-view.tsx +++ b/frontend/src/pages/service_requests/service_requests-view.tsx @@ -1,1573 +1,161 @@ -import React, { ReactElement, useEffect } from 'react'; +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 dayjs from 'dayjs' +import { mdiRoomServiceOutline } from '@mdi/js' +import { useRouter } from 'next/router' + +import BaseButton from '../../components/BaseButton' +import BaseButtons from '../../components/BaseButtons' +import CardBox from '../../components/CardBox' +import SectionMain from '../../components/SectionMain' +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' +import { getPageTitle } from '../../config' +import { getRoleLaneFromUser } from '../../helpers/roleLanes' +import { hasPermission } from '../../helpers/userPermissions' +import LayoutAuthenticated from '../../layouts/Authenticated' import { fetch } from '../../stores/service_requests/service_requestsSlice' -import {saveFile} from "../../helpers/fileSaver"; -import dataFormatter from '../../helpers/dataFormatter'; -import ImageField from "../../components/ImageField"; -import LayoutAuthenticated from "../../layouts/Authenticated"; -import {getPageTitle} from "../../config"; -import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton"; -import SectionMain from "../../components/SectionMain"; -import CardBox from "../../components/CardBox"; -import BaseButton from "../../components/BaseButton"; -import BaseDivider from "../../components/BaseDivider"; -import {mdiChartTimelineVariant} from "@mdi/js"; -import {SwitchField} from "../../components/SwitchField"; -import FormField from "../../components/FormField"; - -import {hasPermission} from "../../helpers/userPermissions"; - - -const Service_requestsView = () => { - const router = useRouter() - const dispatch = useAppDispatch() - const { service_requests } = useAppSelector((state) => state.service_requests) - - const { currentUser } = useAppSelector((state) => state.auth); - - - const { id } = router.query; - - function removeLastCharacter(str) { - console.log(str,`str`) - return str.slice(0, -1); - } - - useEffect(() => { - dispatch(fetch({ id })); - }, [dispatch, id]); - - - return ( - <> - - {getPageTitle('View service_requests')} - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Tenant

- - - - - - - - - - -

{service_requests?.tenant?.name ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Reservation

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

{service_requests?.reservation?.reservation_code ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Requestedby

- - -

{service_requests?.requested_by?.firstName ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Assignedto

- - -

{service_requests?.assigned_to?.firstName ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - -
-

Requesttype

-

{service_requests?.request_type ?? 'No data'}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Status

-

{service_requests?.status ?? 'No data'}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Priority

-

{service_requests?.priority ?? 'No data'}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - {service_requests.requested_at ? :

No Requestedat

} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {service_requests.due_at ? :

No Dueat

} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {service_requests.completed_at ? :

No Completedat

} -
- - - - - - - - - - - - - - - - - - -
-

Summary

-

{service_requests?.summary}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Details

- {service_requests.details - ?

- :

No data

- } -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Estimatedcost

-

{service_requests?.estimated_cost || 'No data'}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Actualcost

-

{service_requests?.actual_cost || 'No data'}

-
- - - - - - - - - - - - - - - - - - - - - - -
-

Currency

-

{service_requests?.currency}

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - <> -

Documents

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {service_requests.documents && Array.isArray(service_requests.documents) && - service_requests.documents.map((item: any) => ( - router.push(`/documents/documents-view/?id=${item.id}`)}> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ))} - -
DocumenttypeFilenameMIMEtypeFilesize(bytes)StoragekeyPublicURLNotesIsprivate
- { item.document_type } - - { item.file_name } - - { item.mime_type } - - { item.file_size_bytes } - - { item.storage_key } - - { item.public_url } - - { item.notes } - - { dataFormatter.booleanFormatter(item.is_private) } -
-
- {!service_requests?.documents?.length &&
No data
} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - <> -

Comments

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {service_requests.comments && Array.isArray(service_requests.comments) && - service_requests.comments.map((item: any) => ( - router.push(`/activity_comments/activity_comments-view/?id=${item.id}`)}> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ))} - -
VisibilityPostedat
- { item.visibility } - - { dataFormatter.dateTimeFormatter(item.posted_at) } -
-
- {!service_requests?.comments?.length &&
No data
} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

organizations

- - - - - - - - -

{service_requests?.organizations?.name ?? 'No data'}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - <> -

Documents Servicerequest

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {service_requests.documents_service_request && Array.isArray(service_requests.documents_service_request) && - service_requests.documents_service_request.map((item: any) => ( - router.push(`/documents/documents-view/?id=${item.id}`)}> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ))} - -
DocumenttypeFilenameMIMEtypeFilesize(bytes)StoragekeyPublicURLNotesIsprivate
- { item.document_type } - - { item.file_name } - - { item.mime_type } - - { item.file_size_bytes } - - { item.storage_key } - - { item.public_url } - - { item.notes } - - { dataFormatter.booleanFormatter(item.is_private) } -
-
- {!service_requests?.documents_service_request?.length &&
No data
} -
- - - - - - <> -

Activity_comments Servicerequest

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {service_requests.activity_comments_service_request && Array.isArray(service_requests.activity_comments_service_request) && - service_requests.activity_comments_service_request.map((item: any) => ( - router.push(`/activity_comments/activity_comments-view/?id=${item.id}`)}> - - - - - - - - - - - - - - - - - - - - - - - - - - - ))} - -
VisibilityPostedat
- { item.visibility } - - { dataFormatter.dateTimeFormatter(item.posted_at) } -
-
- {!service_requests?.activity_comments_service_request?.length &&
No data
} -
- - - - - - - - - - router.push('/service_requests/service_requests-list')} - /> -
-
- - ); -}; - -Service_requestsView.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) +import { useAppDispatch, useAppSelector } from '../../stores/hooks' + +const DetailStat = ({ label, value, hint }: any) => ( + +
+

{label}

+

{value}

+ {hint &&

{hint}

} +
+
+) + +const DetailRow = ({ label, value }: any) => ( +
+

{label}

+
{value || 'Not set'}
+
+) + +const RelatedList = ({ title, items, getLabel, emptyLabel = 'No items yet' }: any) => ( +
+
+

{title}

+ {items.length} +
+ {items.length ? items.slice(0, 4).map((item) =>
{getLabel(item)}
) :

{emptyLabel}

} +
+) + +const formatDateTime = (value?: string) => (value ? dayjs(value).format('MMM D, YYYY h:mm A') : 'Not set') +const formatCurrency = (amount?: string | number, currency?: string) => { + if (amount === undefined || amount === null || amount === '') return 'Not set' + try { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: currency || 'USD', maximumFractionDigits: 0 }).format(Number(amount)) + } catch (error) { + console.error('Failed to format service request currency', error) + return `${amount} ${currency || ''}`.trim() + } } -export default Service_requestsView; \ No newline at end of file +const ServiceRequestsView = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const { service_requests } = useAppSelector((state) => state.service_requests) + const { currentUser } = useAppSelector((state) => state.auth) + const { id } = router.query + + useEffect(() => { + dispatch(fetch({ id })) + }, [dispatch, id]) + + const roleLane = getRoleLaneFromUser(currentUser) + const isCustomer = roleLane === 'customer' + const canManagePlatformFields = roleLane === 'super_admin' + const canManageOperationalFields = roleLane === 'super_admin' || roleLane === 'admin' || roleLane === 'concierge' + const canManageCostFields = roleLane === 'super_admin' || roleLane === 'admin' + const canEdit = currentUser && hasPermission(currentUser, 'UPDATE_SERVICE_REQUESTS') + const pageTitle = isCustomer ? 'Support Request' : 'Service Request' + + return ( + <> + + {getPageTitle(pageTitle)} + + + + + + + {canEdit && } + + + +
+ + + + +
+ +
+
+ +
+
+

Overview

+

Service snapshot

+

What was requested, who owns it, and when it should be completed.

+
+ +
+ {canManagePlatformFields && } + + {canManageOperationalFields && } + {canManageOperationalFields && } + + +
+
+
+ + +
+
+

Details

+

{service_requests?.summary || 'Support request details'}

+
+ +
+

Request context

+

{service_requests?.details || 'No additional details shared yet.'}

+
+
+
+
+ + +
+
+

Linked records

+

Execution context

+

Files and related discussion attached to this request.

+
+ + + + + +
+ item?.file_name || item?.document_type || item?.id} /> + item?.comment_text || item?.body || item?.id} emptyLabel='No comments attached yet.' /> +
+
+
+
+
+ + ) +} + +ServiceRequestsView.getLayout = function getLayout(page: ReactElement) { + return {page} +} + +export default ServiceRequestsView diff --git a/frontend/src/pages/users/users-edit.tsx b/frontend/src/pages/users/users-edit.tsx index 6453c0e..320b2af 100644 --- a/frontend/src/pages/users/users-edit.tsx +++ b/frontend/src/pages/users/users-edit.tsx @@ -1,898 +1,189 @@ -import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js' +import { mdiAccountEditOutline } 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 React, { ReactElement, useEffect, useMemo } from 'react' +import { Field, Form, Formik } from 'formik' +import { useRouter } from 'next/router' +import BaseButton from '../../components/BaseButton' +import BaseButtons from '../../components/BaseButtons' +import BaseDivider from '../../components/BaseDivider' import CardBox from '../../components/CardBox' -import LayoutAuthenticated from '../../layouts/Authenticated' +import FormField from '../../components/FormField' +import FormImagePicker from '../../components/FormImagePicker' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' -import { getPageTitle } from '../../config' - -import { Field, Form, Formik } from 'formik' -import FormField from '../../components/FormField' -import BaseDivider from '../../components/BaseDivider' -import BaseButtons from '../../components/BaseButtons' -import BaseButton from '../../components/BaseButton' -import FormCheckRadio from '../../components/FormCheckRadio' -import FormCheckRadioGroup from '../../components/FormCheckRadioGroup' -import FormFilePicker from '../../components/FormFilePicker' -import FormImagePicker from '../../components/FormImagePicker' -import { SelectField } from "../../components/SelectField"; -import { SelectFieldMany } from "../../components/SelectFieldMany"; +import { SelectField } from '../../components/SelectField' +import { SelectFieldMany } from '../../components/SelectFieldMany' import { SwitchField } from '../../components/SwitchField' -import {RichTextField} from "../../components/RichTextField"; - -import { update, fetch } from '../../stores/users/usersSlice' +import { getPageTitle } from '../../config' +import { getRoleLaneFromUser } from '../../helpers/roleLanes' +import LayoutAuthenticated from '../../layouts/Authenticated' import { useAppDispatch, useAppSelector } from '../../stores/hooks' -import { useRouter } from 'next/router' -import {saveFile} from "../../helpers/fileSaver"; -import dataFormatter from '../../helpers/dataFormatter'; -import ImageField from "../../components/ImageField"; - -import {hasPermission} from "../../helpers/userPermissions"; +import { fetch, update } from '../../stores/users/usersSlice' +const emptyValues = { + firstName: '', + lastName: '', + phoneNumber: '', + email: '', + disabled: false, + avatar: [], + app_role: null, + custom_permissions: [], + organizations: null, +} +const buildInitialValues = (record) => ({ + firstName: record?.firstName || '', + lastName: record?.lastName || '', + phoneNumber: record?.phoneNumber || '', + email: record?.email || '', + disabled: Boolean(record?.disabled), + avatar: record?.avatar || [], + app_role: record?.app_role || null, + custom_permissions: Array.isArray(record?.custom_permissions) ? record.custom_permissions : [], + organizations: record?.organizations || null, +}) const EditUsersPage = () => { const router = useRouter() const dispatch = useAppDispatch() - const initVals = { - - - 'firstName': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'lastName': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'phoneNumber': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - 'email': '', - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - disabled: false, - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - avatar: [], - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - app_role: null, - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - custom_permissions: [], - - - - - - - - - - - - - - - - - - - - - - - - - - organizations: null, - - - - - - password: '' - - } - const [initialValues, setInitialValues] = useState(initVals) - - const { users } = useAppSelector((state) => state.users) - - const { currentUser } = useAppSelector((state) => state.auth); - - + const { currentUser } = useAppSelector((state) => state.auth) + const { users, loading } = useAppSelector((state) => state.users) const { id } = router.query - useEffect(() => { - dispatch(fetch({ id: id })) - }, [id]) + const roleLane = getRoleLaneFromUser(currentUser) + const canManagePlatformAccess = roleLane === 'super_admin' + const pageTitle = canManagePlatformAccess ? 'Edit User' : 'Edit Team Member' + const introCopy = canManagePlatformAccess + ? 'Update the user profile, business role, and platform-level overrides.' + : 'Update the team member profile. Role assignment remains limited to customer and concierge lanes inside your organization.' useEffect(() => { - if (typeof users === 'object') { - setInitialValues(users) + if (id) { + dispatch(fetch({ id })) } + }, [dispatch, id]) + + const initialValues = useMemo(() => { + if (users && typeof users === 'object' && !Array.isArray(users)) { + return buildInitialValues(users) + } + + return emptyValues }, [users]) - useEffect(() => { - if (typeof users === 'object') { - const newInitialVal = {...initVals}; - Object.keys(initVals).forEach(el => newInitialVal[el] = (users)[el]) - setInitialValues(newInitialVal); - } - }, [users]) + const roleQueryParams = { + assignableOnly: true, + includeHighTrust: canManagePlatformAccess, + } - const handleSubmit = async (data) => { - await dispatch(update({ id: id, data })) + const handleSubmit = async (values) => { + const payload = { ...values } + + if (!canManagePlatformAccess) { + delete payload.custom_permissions + delete payload.organizations + } + + await dispatch(update({ id, data: payload })) await router.push('/users/users-list') } return ( <> - {getPageTitle('Edit users')} + {getPageTitle(pageTitle)} + - - {''} + + {''} - - handleSubmit(values)} - > + + +
+ {introCopy} +
+ +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+ + + + + + + + + + + + + + + + + + + + + {canManagePlatformAccess ? ( + + + + ) : ( +
+ Organization access remains pinned to your admin scope. +
+ )} + + + + + + + + + + {canManagePlatformAccess && ( +
+ + + +
+ )} +
+ - - - router.push('/users/users-list')}/> + + + router.push('/users/users-list')} />
@@ -903,15 +194,7 @@ const EditUsersPage = () => { } EditUsersPage.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) + return {page} } export default EditUsersPage diff --git a/frontend/src/pages/users/users-new.tsx b/frontend/src/pages/users/users-new.tsx index e23b7d4..788969b 100644 --- a/frontend/src/pages/users/users-new.tsx +++ b/frontend/src/pages/users/users-new.tsx @@ -1,529 +1,152 @@ -import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' +import { mdiAccountPlusOutline } from '@mdi/js' import Head from 'next/head' import React, { ReactElement } from 'react' +import { Field, Form, Formik } from 'formik' +import { useRouter } from 'next/router' + +import BaseButton from '../../components/BaseButton' +import BaseButtons from '../../components/BaseButtons' +import BaseDivider from '../../components/BaseDivider' import CardBox from '../../components/CardBox' -import LayoutAuthenticated from '../../layouts/Authenticated' +import FormField from '../../components/FormField' +import FormImagePicker from '../../components/FormImagePicker' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' -import { getPageTitle } from '../../config' - -import { Field, Form, Formik } from 'formik' -import FormField from '../../components/FormField' -import BaseDivider from '../../components/BaseDivider' -import BaseButtons from '../../components/BaseButtons' -import BaseButton from '../../components/BaseButton' -import FormCheckRadio from '../../components/FormCheckRadio' -import FormCheckRadioGroup from '../../components/FormCheckRadioGroup' -import FormFilePicker from '../../components/FormFilePicker' -import FormImagePicker from '../../components/FormImagePicker' -import { SwitchField } from '../../components/SwitchField' - import { SelectField } from '../../components/SelectField' -import { SelectFieldMany } from "../../components/SelectFieldMany"; -import {RichTextField} from "../../components/RichTextField"; - +import { SelectFieldMany } from '../../components/SelectFieldMany' +import { SwitchField } from '../../components/SwitchField' +import { getPageTitle } from '../../config' +import { getRoleLaneFromUser } from '../../helpers/roleLanes' +import LayoutAuthenticated from '../../layouts/Authenticated' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' import { create } from '../../stores/users/usersSlice' -import { useAppDispatch } from '../../stores/hooks' -import { useRouter } from 'next/router' -import moment from 'moment'; const initialValues = { - - - firstName: '', - - - - - - - - - - - - - - - - lastName: '', - - - - - - - - - - - - - - - - phoneNumber: '', - - - - - - - - - - - - - - - - email: '', - - - - - - - - - - - - - - - - - - - - - - disabled: false, - - - - - - - - - - - - - - - - - - - - avatar: [], - - - - - - - - - - - - - - - - - app_role: '', - - - - - - - - - - - - - - - - - custom_permissions: [], - - - - - - - - - - - - - - - organizations: '', - - - + firstName: '', + lastName: '', + phoneNumber: '', + email: '', + disabled: false, + avatar: [], + app_role: null, + custom_permissions: [], + organizations: null, } - const UsersNew = () => { const router = useRouter() const dispatch = useAppDispatch() + const { currentUser } = useAppSelector((state) => state.auth) - - + const roleLane = getRoleLaneFromUser(currentUser) + const canManagePlatformAccess = roleLane === 'super_admin' + const pageTitle = canManagePlatformAccess ? 'Invite User' : 'Invite Team Member' + const introCopy = canManagePlatformAccess + ? 'Invite a platform user, choose one of the four business roles, and optionally attach organization-specific access.' + : 'Invite a team member into your organization. Role assignment is limited to customer and concierge lanes, and organization access is pinned automatically.' - const handleSubmit = async (data) => { - await dispatch(create(data)) + const roleQueryParams = { + assignableOnly: true, + includeHighTrust: canManagePlatformAccess, + } + + const handleSubmit = async (values) => { + const payload = { ...values } + + if (!canManagePlatformAccess) { + delete payload.custom_permissions + delete payload.organizations + } + + await dispatch(create(payload)) await router.push('/users/users-list') } + return ( <> - {getPageTitle('New Item')} + {getPageTitle(pageTitle)} + - - {''} + + {''} - - handleSubmit(values)} - > + + +
+ {introCopy} +
+ +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+ + + + + + + + + + + + + + + + + + + + + {canManagePlatformAccess ? ( + + + + ) : ( +
+ Organization access will be assigned automatically from your current admin context. +
+ )} + + + + + + + + + + {canManagePlatformAccess && ( +
+ + + +
+ )} +
+ - - - router.push('/users/users-list')}/> + + + router.push('/users/users-list')} />
@@ -534,15 +157,7 @@ const UsersNew = () => { } UsersNew.getLayout = function getLayout(page: ReactElement) { - return ( - - {page} - - ) + return {page} } export default UsersNew