Autosave: 20260403-073442

This commit is contained in:
Flatlogic Bot 2026-04-03 07:34:43 +00:00
parent d6ec6bb83a
commit 77754d1430
31 changed files with 1318 additions and 3525 deletions

View File

@ -51,15 +51,11 @@ const config = {
} }
}, },
roles: { roles: {
super_admin: 'Super Administrator', super_admin: 'Super Administrator',
admin: 'Administrator', admin: 'Administrator',
concierge: 'Concierge Coordinator',
customer: 'Customer',
user: 'Customer',
user: 'Concierge Coordinator',
}, },
project_uuid: '946cafba-a21f-40cd-bb6a-bc40952c93f4', project_uuid: '946cafba-a21f-40cd-bb6a-bc40952c93f4',

View File

@ -1,14 +1,70 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
const config = require('../../config');
const crypto = require('crypto');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
function isCustomerUser(currentUser) {
return currentUser?.app_role?.name === config.roles.customer;
}
function canManageInternalBookingRequestFields(currentUser) {
return Boolean(currentUser?.app_role?.globalAccess);
}
function generateRequestCode() {
const dateSegment = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const randomSegment = crypto.randomBytes(2).toString('hex').toUpperCase();
return `BR-${dateSegment}-${randomSegment}`;
}
function resolveRequestCode(data, currentUser) {
if (canManageInternalBookingRequestFields(currentUser) && data.request_code) {
return data.request_code;
}
return generateRequestCode();
}
function resolveStatusForCreate(data, currentUser) {
if (canManageInternalBookingRequestFields(currentUser)) {
return data.status || 'draft';
}
return 'submitted';
}
function resolveOrganizationId(data, currentUser) {
if (canManageInternalBookingRequestFields(currentUser)) {
return data.organization || currentUser.organization?.id || null;
}
return currentUser.organization?.id || null;
}
function resolveRequestedById(data, currentUser) {
if (canManageInternalBookingRequestFields(currentUser)) {
return data.requested_by || currentUser.id || null;
}
return currentUser.id || null;
}
function mergeWhereWithScope(where, scope) {
if (!scope) {
return where;
}
return {
[Op.and]: [where, scope],
};
}
module.exports = class Booking_requestsDBApi { module.exports = class Booking_requestsDBApi {
@ -21,15 +77,9 @@ module.exports = class Booking_requestsDBApi {
{ {
id: data.id || undefined, id: data.id || undefined,
request_code: data.request_code request_code: resolveRequestCode(data, currentUser),
||
null
,
status: data.status status: resolveStatusForCreate(data, currentUser),
||
null
,
check_in_at: data.check_in_at check_in_at: data.check_in_at
|| ||
@ -84,15 +134,15 @@ module.exports = class Booking_requestsDBApi {
); );
await booking_requests.setTenant( data.tenant || null, { await booking_requests.setTenant( canManageInternalBookingRequestFields(currentUser) ? data.tenant || null : null, {
transaction, transaction,
}); });
await booking_requests.setOrganization(currentUser.organization.id || null, { await booking_requests.setOrganization(resolveOrganizationId(data, currentUser), {
transaction, transaction,
}); });
await booking_requests.setRequested_by( data.requested_by || null, { await booking_requests.setRequested_by(resolveRequestedById(data, currentUser), {
transaction, transaction,
}); });
@ -211,18 +261,22 @@ module.exports = class Booking_requestsDBApi {
const currentUser = (options && options.currentUser) || {id: null}; const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const globalAccess = currentUser.app_role?.globalAccess; const globalAccess = currentUser.app_role?.globalAccess;
const canManageInternalFields = canManageInternalBookingRequestFields(currentUser);
const booking_requests = await db.booking_requests.findByPk(id, {}, {transaction}); const booking_requests = await db.booking_requests.findOne({
where: mergeWhereWithScope({ id }, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
transaction,
});
const updatePayload = {}; const updatePayload = {};
if (data.request_code !== undefined) updatePayload.request_code = data.request_code; if (data.request_code !== undefined && canManageInternalFields) updatePayload.request_code = data.request_code;
if (data.status !== undefined) updatePayload.status = data.status; if (data.status !== undefined && canManageInternalFields) updatePayload.status = data.status;
if (data.check_in_at !== undefined) updatePayload.check_in_at = data.check_in_at; if (data.check_in_at !== undefined) updatePayload.check_in_at = data.check_in_at;
@ -258,7 +312,7 @@ module.exports = class Booking_requestsDBApi {
if (data.tenant !== undefined) { if (data.tenant !== undefined && canManageInternalFields) {
await booking_requests.setTenant( await booking_requests.setTenant(
data.tenant, data.tenant,
@ -276,7 +330,7 @@ module.exports = class Booking_requestsDBApi {
); );
} }
if (data.requested_by !== undefined) { if (data.requested_by !== undefined && canManageInternalFields) {
await booking_requests.setRequested_by( await booking_requests.setRequested_by(
data.requested_by, data.requested_by,
@ -333,11 +387,11 @@ module.exports = class Booking_requestsDBApi {
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const booking_requests = await db.booking_requests.findAll({ const booking_requests = await db.booking_requests.findAll({
where: { where: mergeWhereWithScope({
id: { id: {
[Op.in]: ids, [Op.in]: ids,
}, },
}, }, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
transaction, transaction,
}); });
@ -361,7 +415,14 @@ module.exports = class Booking_requestsDBApi {
const currentUser = (options && options.currentUser) || {id: null}; const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const booking_requests = await db.booking_requests.findByPk(id, options); const booking_requests = await db.booking_requests.findOne({
where: mergeWhereWithScope({ id }, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
transaction,
});
if (!booking_requests) {
return null;
}
await booking_requests.update({ await booking_requests.update({
deletedBy: currentUser.id deletedBy: currentUser.id
@ -378,11 +439,12 @@ module.exports = class Booking_requestsDBApi {
static async findBy(where, options) { static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const currentUser = (options && options.currentUser) || null;
const booking_requests = await db.booking_requests.findOne( const booking_requests = await db.booking_requests.findOne({
{ where }, where: mergeWhereWithScope(where, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
{ transaction }, transaction,
); });
if (!booking_requests) { if (!booking_requests) {
return booking_requests; return booking_requests;
@ -502,6 +564,7 @@ module.exports = class Booking_requestsDBApi {
const user = (options && options.currentUser) || null; const user = (options && options.currentUser) || null;
const userOrganizations = (user && user.organizations?.id) || null; const userOrganizations = (user && user.organizations?.id) || null;
const customerScope = isCustomerUser(user) ? { requested_byId: user.id } : null;
@ -512,11 +575,12 @@ module.exports = class Booking_requestsDBApi {
} }
if (customerScope) {
where.requested_byId = user.id;
}
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
@ -1005,9 +1069,13 @@ module.exports = class Booking_requestsDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, currentUser) {
let where = {}; let where = {};
if (isCustomerUser(currentUser)) {
where.requested_byId = currentUser.id;
}
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
where.organizationId = organizationId; where.organizationId = organizationId;
@ -1016,6 +1084,9 @@ module.exports = class Booking_requestsDBApi {
if (query) { if (query) {
where = { where = {
[Op.and]: [
where,
{
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike( Utils.ilike(
@ -1024,6 +1095,8 @@ module.exports = class Booking_requestsDBApi {
query, query,
), ),
], ],
},
],
}; };
} }

View File

@ -1,14 +1,36 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
const config = require('../../config');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
function isCustomerUser(currentUser) {
return currentUser?.app_role?.name === config.roles.customer;
}
async function customerCanAccessReservation(reservation, currentUser, transaction) {
if (!reservation || !isCustomerUser(currentUser)) {
return true;
}
if (!reservation.booking_requestId) {
return false;
}
const bookingRequest = await db.booking_requests.findOne({
where: {
id: reservation.booking_requestId,
requested_byId: currentUser.id,
},
transaction,
});
return Boolean(bookingRequest);
}
module.exports = class ReservationsDBApi { module.exports = class ReservationsDBApi {
@ -442,16 +464,18 @@ module.exports = class ReservationsDBApi {
static async findBy(where, options) { static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const currentUser = (options && options.currentUser) || null;
const reservations = await db.reservations.findOne( const reservations = await db.reservations.findOne({ where, transaction });
{ where },
{ transaction },
);
if (!reservations) { if (!reservations) {
return reservations; return reservations;
} }
if (!(await customerCanAccessReservation(reservations, currentUser, transaction))) {
return null;
}
const output = reservations.get({plain: true}); const output = reservations.get({plain: true});
@ -584,6 +608,7 @@ module.exports = class ReservationsDBApi {
const user = (options && options.currentUser) || null; const user = (options && options.currentUser) || null;
const userOrganizations = (user && user.organizations?.id) || null; const userOrganizations = (user && user.organizations?.id) || null;
const isCustomer = isCustomerUser(user);
@ -596,9 +621,6 @@ module.exports = class ReservationsDBApi {
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
@ -628,8 +650,10 @@ module.exports = class ReservationsDBApi {
{ {
model: db.booking_requests, model: db.booking_requests,
as: 'booking_request', as: 'booking_request',
required: isCustomer || Boolean(filter.booking_request),
where: filter.booking_request ? { where: {
...(isCustomer ? { requested_byId: user.id } : {}),
...(filter.booking_request ? {
[Op.or]: [ [Op.or]: [
{ id: { [Op.in]: filter.booking_request.split('|').map(term => Utils.uuid(term)) } }, { id: { [Op.in]: filter.booking_request.split('|').map(term => Utils.uuid(term)) } },
{ {
@ -638,7 +662,8 @@ module.exports = class ReservationsDBApi {
} }
}, },
] ]
} : {}, } : {}),
},
}, },
@ -1204,8 +1229,9 @@ module.exports = class ReservationsDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, currentUser) {
let where = {}; let where = {};
const include = [];
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
@ -1215,6 +1241,9 @@ module.exports = class ReservationsDBApi {
if (query) { if (query) {
where = { where = {
[Op.and]: [
where,
{
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike( Utils.ilike(
@ -1223,12 +1252,24 @@ module.exports = class ReservationsDBApi {
query, query,
), ),
], ],
},
],
}; };
} }
if (isCustomerUser(currentUser)) {
include.push({
model: db.booking_requests,
as: 'booking_request',
required: true,
where: { requested_byId: currentUser.id },
});
}
const records = await db.reservations.findAll({ const records = await db.reservations.findAll({
attributes: [ 'id', 'reservation_code' ], attributes: [ 'id', 'reservation_code' ],
where, where,
include,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
orderBy: [['reservation_code', 'ASC']], orderBy: [['reservation_code', 'ASC']],

View File

@ -1,7 +1,5 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
@ -11,6 +9,34 @@ const config = require('../../config');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
const BUSINESS_ROLE_NAMES = [
config.roles.super_admin,
config.roles.admin,
config.roles.concierge,
config.roles.customer,
];
function appendRoleVisibilityScope(where, globalAccess, businessOnly = false) {
const scopes = [];
if (!globalAccess) {
scopes.push({ name: { [Op.ne]: config.roles.super_admin } });
}
if (businessOnly) {
scopes.push({ name: { [Op.in]: BUSINESS_ROLE_NAMES } });
}
if (!scopes.length) {
return where;
}
return {
[Op.and]: [where, ...scopes],
};
}
module.exports = class RolesDBApi { module.exports = class RolesDBApi {
@ -102,8 +128,6 @@ module.exports = class RolesDBApi {
static async update(id, data, options) { static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null}; const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const globalAccess = currentUser.app_role?.globalAccess;
const roles = await db.roles.findByPk(id, {}, {transaction}); const roles = await db.roles.findByPk(id, {}, {transaction});
@ -259,17 +283,8 @@ module.exports = class RolesDBApi {
const currentPage = +filter.page; const currentPage = +filter.page;
const user = (options && options.currentUser) || null;
const userOrganizations = (user && user.organizations?.id) || null;
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
@ -387,9 +402,9 @@ module.exports = class RolesDBApi {
} }
} }
if (!globalAccess) { const businessOnly = filter.businessOnly === true || filter.businessOnly === 'true';
where = { name: { [Op.ne]: config.roles.super_admin } };
} where = appendRoleVisibilityScope(where, globalAccess, businessOnly);
@ -423,15 +438,9 @@ module.exports = class RolesDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess,) { static async findAllAutocomplete(query, limit, offset, globalAccess, businessOnly = false) {
let where = {}; let where = {};
if (!globalAccess) {
where = { name: { [Op.ne]: config.roles.super_admin } };
}
if (query) { if (query) {
where = { where = {
[Op.or]: [ [Op.or]: [
@ -445,6 +454,8 @@ module.exports = class RolesDBApi {
}; };
} }
where = appendRoleVisibilityScope(where, globalAccess, businessOnly === true || businessOnly === 'true');
const records = await db.roles.findAll({ const records = await db.roles.findAll({
attributes: [ 'id', 'name' ], attributes: [ 'id', 'name' ],
where, where,

View File

@ -1,14 +1,26 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
const config = require('../../config');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
function isCustomerUser(currentUser) {
return currentUser?.app_role?.name === config.roles.customer;
}
function mergeWhereWithScope(where, scope) {
if (!scope) {
return where;
}
return {
[Op.and]: [where, scope],
};
}
module.exports = class Service_requestsDBApi { module.exports = class Service_requestsDBApi {
@ -92,7 +104,7 @@ module.exports = class Service_requestsDBApi {
transaction, transaction,
}); });
await service_requests.setRequested_by( data.requested_by || null, { await service_requests.setRequested_by( isCustomerUser(currentUser) ? currentUser.id : data.requested_by || null, {
transaction, transaction,
}); });
@ -202,9 +214,10 @@ module.exports = class Service_requestsDBApi {
static async update(id, data, options) { static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null}; const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const globalAccess = currentUser.app_role?.globalAccess; const service_requests = await db.service_requests.findOne({
where: mergeWhereWithScope({ id }, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
const service_requests = await db.service_requests.findByPk(id, {}, {transaction}); transaction,
});
@ -271,7 +284,7 @@ module.exports = class Service_requestsDBApi {
if (data.requested_by !== undefined) { if (data.requested_by !== undefined) {
await service_requests.setRequested_by( await service_requests.setRequested_by(
data.requested_by, isCustomerUser(currentUser) ? currentUser.id : data.requested_by,
{ transaction } { transaction }
); );
@ -317,11 +330,11 @@ module.exports = class Service_requestsDBApi {
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const service_requests = await db.service_requests.findAll({ const service_requests = await db.service_requests.findAll({
where: { where: mergeWhereWithScope({
id: { id: {
[Op.in]: ids, [Op.in]: ids,
}, },
}, }, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
transaction, transaction,
}); });
@ -345,7 +358,14 @@ module.exports = class Service_requestsDBApi {
const currentUser = (options && options.currentUser) || {id: null}; const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const service_requests = await db.service_requests.findByPk(id, options); const service_requests = await db.service_requests.findOne({
where: mergeWhereWithScope({ id }, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
transaction,
});
if (!service_requests) {
return null;
}
await service_requests.update({ await service_requests.update({
deletedBy: currentUser.id deletedBy: currentUser.id
@ -362,11 +382,12 @@ module.exports = class Service_requestsDBApi {
static async findBy(where, options) { static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const currentUser = (options && options.currentUser) || null;
const service_requests = await db.service_requests.findOne( const service_requests = await db.service_requests.findOne({
{ where }, where: mergeWhereWithScope(where, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
{ transaction }, transaction,
); });
if (!service_requests) { if (!service_requests) {
return service_requests; return service_requests;
@ -464,6 +485,7 @@ module.exports = class Service_requestsDBApi {
const user = (options && options.currentUser) || null; const user = (options && options.currentUser) || null;
const userOrganizations = (user && user.organizations?.id) || null; const userOrganizations = (user && user.organizations?.id) || null;
const customerScope = isCustomerUser(user) ? { requested_byId: user.id } : null;
@ -474,11 +496,12 @@ module.exports = class Service_requestsDBApi {
} }
if (customerScope) {
where.requested_byId = user.id;
}
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
@ -901,9 +924,13 @@ module.exports = class Service_requestsDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, currentUser) {
let where = {}; let where = {};
if (isCustomerUser(currentUser)) {
where.requested_byId = currentUser.id;
}
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
where.organizationId = organizationId; where.organizationId = organizationId;
@ -912,6 +939,9 @@ module.exports = class Service_requestsDBApi {
if (query) { if (query) {
where = { where = {
[Op.and]: [
where,
{
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike( Utils.ilike(
@ -920,6 +950,8 @@ module.exports = class Service_requestsDBApi {
query, query,
), ),
], ],
},
],
}; };
} }

View File

@ -134,58 +134,52 @@ notes: {
invoices.associate = (db) => { invoices.associate = (db) => {
db.invoices.belongsToMany(db.invoice_line_items, { db.invoices.hasMany(db.invoice_line_items, {
as: 'line_items', as: 'line_items',
foreignKey: { foreignKey: {
name: 'invoices_line_itemsId', name: 'invoiceId',
}, },
constraints: false, constraints: false,
through: 'invoicesLine_itemsInvoice_line_items',
}); });
db.invoices.belongsToMany(db.invoice_line_items, { db.invoices.hasMany(db.invoice_line_items, {
as: 'line_items_filter', as: 'line_items_filter',
foreignKey: { foreignKey: {
name: 'invoices_line_itemsId', name: 'invoiceId',
}, },
constraints: false, constraints: false,
through: 'invoicesLine_itemsInvoice_line_items',
}); });
db.invoices.belongsToMany(db.payments, { db.invoices.hasMany(db.payments, {
as: 'payments', as: 'payments',
foreignKey: { foreignKey: {
name: 'invoices_paymentsId', name: 'invoiceId',
}, },
constraints: false, constraints: false,
through: 'invoicesPaymentsPayments',
}); });
db.invoices.belongsToMany(db.payments, { db.invoices.hasMany(db.payments, {
as: 'payments_filter', as: 'payments_filter',
foreignKey: { foreignKey: {
name: 'invoices_paymentsId', name: 'invoiceId',
}, },
constraints: false, constraints: false,
through: 'invoicesPaymentsPayments',
}); });
db.invoices.belongsToMany(db.documents, { db.invoices.hasMany(db.documents, {
as: 'documents', as: 'documents',
foreignKey: { foreignKey: {
name: 'invoices_documentsId', name: 'invoiceId',
}, },
constraints: false, constraints: false,
through: 'invoicesDocumentsDocuments',
}); });
db.invoices.belongsToMany(db.documents, { db.invoices.hasMany(db.documents, {
as: 'documents_filter', as: 'documents_filter',
foreignKey: { foreignKey: {
name: 'invoices_documentsId', name: 'invoiceId',
}, },
constraints: false, constraints: false,
through: 'invoicesDocumentsDocuments',
}); });

View File

@ -1,9 +1,3 @@
const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) { module.exports = function(sequelize, DataTypes) {
const reservations = sequelize.define( const reservations = sequelize.define(
'reservations', 'reservations',
@ -154,94 +148,84 @@ external_notes: {
reservations.associate = (db) => { reservations.associate = (db) => {
db.reservations.belongsToMany(db.reservation_guests, { db.reservations.hasMany(db.reservation_guests, {
as: 'guests', as: 'guests',
foreignKey: { foreignKey: {
name: 'reservations_guestsId', name: 'reservationId',
}, },
constraints: false, constraints: false,
through: 'reservationsGuestsReservation_guests',
}); });
db.reservations.belongsToMany(db.reservation_guests, { db.reservations.hasMany(db.reservation_guests, {
as: 'guests_filter', as: 'guests_filter',
foreignKey: { foreignKey: {
name: 'reservations_guestsId', name: 'reservationId',
}, },
constraints: false, constraints: false,
through: 'reservationsGuestsReservation_guests',
}); });
db.reservations.belongsToMany(db.service_requests, { db.reservations.hasMany(db.service_requests, {
as: 'service_requests', as: 'service_requests',
foreignKey: { foreignKey: {
name: 'reservations_service_requestsId', name: 'reservationId',
}, },
constraints: false, constraints: false,
through: 'reservationsService_requestsService_requests',
}); });
db.reservations.belongsToMany(db.service_requests, { db.reservations.hasMany(db.service_requests, {
as: 'service_requests_filter', as: 'service_requests_filter',
foreignKey: { foreignKey: {
name: 'reservations_service_requestsId', name: 'reservationId',
}, },
constraints: false, constraints: false,
through: 'reservationsService_requestsService_requests',
}); });
db.reservations.belongsToMany(db.invoices, { db.reservations.hasMany(db.invoices, {
as: 'invoices', as: 'invoices',
foreignKey: { foreignKey: {
name: 'reservations_invoicesId', name: 'reservationId',
}, },
constraints: false, constraints: false,
through: 'reservationsInvoicesInvoices',
}); });
db.reservations.belongsToMany(db.invoices, { db.reservations.hasMany(db.invoices, {
as: 'invoices_filter', as: 'invoices_filter',
foreignKey: { foreignKey: {
name: 'reservations_invoicesId', name: 'reservationId',
}, },
constraints: false, constraints: false,
through: 'reservationsInvoicesInvoices',
}); });
db.reservations.belongsToMany(db.documents, { db.reservations.hasMany(db.documents, {
as: 'documents', as: 'documents',
foreignKey: { foreignKey: {
name: 'reservations_documentsId', name: 'reservationId',
}, },
constraints: false, constraints: false,
through: 'reservationsDocumentsDocuments',
}); });
db.reservations.belongsToMany(db.documents, { db.reservations.hasMany(db.documents, {
as: 'documents_filter', as: 'documents_filter',
foreignKey: { foreignKey: {
name: 'reservations_documentsId', name: 'reservationId',
}, },
constraints: false, constraints: false,
through: 'reservationsDocumentsDocuments',
}); });
db.reservations.belongsToMany(db.activity_comments, { db.reservations.hasMany(db.activity_comments, {
as: 'comments', as: 'comments',
foreignKey: { foreignKey: {
name: 'reservations_commentsId', name: 'reservationId',
}, },
constraints: false, constraints: false,
through: 'reservationsCommentsActivity_comments',
}); });
db.reservations.belongsToMany(db.activity_comments, { db.reservations.hasMany(db.activity_comments, {
as: 'comments_filter', as: 'comments_filter',
foreignKey: { foreignKey: {
name: 'reservations_commentsId', name: 'reservationId',
}, },
constraints: false, constraints: false,
through: 'reservationsCommentsActivity_comments',
}); });

View File

@ -1,9 +1,3 @@
const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) { module.exports = function(sequelize, DataTypes) {
const service_requests = sequelize.define( const service_requests = sequelize.define(
'service_requests', 'service_requests',
@ -172,40 +166,36 @@ currency: {
service_requests.associate = (db) => { service_requests.associate = (db) => {
db.service_requests.belongsToMany(db.documents, { db.service_requests.hasMany(db.documents, {
as: 'documents', as: 'documents',
foreignKey: { foreignKey: {
name: 'service_requests_documentsId', name: 'service_requestId',
}, },
constraints: false, constraints: false,
through: 'service_requestsDocumentsDocuments',
}); });
db.service_requests.belongsToMany(db.documents, { db.service_requests.hasMany(db.documents, {
as: 'documents_filter', as: 'documents_filter',
foreignKey: { foreignKey: {
name: 'service_requests_documentsId', name: 'service_requestId',
}, },
constraints: false, constraints: false,
through: 'service_requestsDocumentsDocuments',
}); });
db.service_requests.belongsToMany(db.activity_comments, { db.service_requests.hasMany(db.activity_comments, {
as: 'comments', as: 'comments',
foreignKey: { foreignKey: {
name: 'service_requests_commentsId', name: 'service_requestId',
}, },
constraints: false, constraints: false,
through: 'service_requestsCommentsActivity_comments',
}); });
db.service_requests.belongsToMany(db.activity_comments, { db.service_requests.hasMany(db.activity_comments, {
as: 'comments_filter', as: 'comments_filter',
foreignKey: { foreignKey: {
name: 'service_requests_commentsId', name: 'service_requestId',
}, },
constraints: false, constraints: false,
through: 'service_requestsCommentsActivity_comments',
}); });

View File

@ -1,9 +1,3 @@
const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const moment = require('moment');
module.exports = function(sequelize, DataTypes) { module.exports = function(sequelize, DataTypes) {
const units = sequelize.define( const units = sequelize.define(
'units', 'units',
@ -85,22 +79,20 @@ notes: {
units.associate = (db) => { units.associate = (db) => {
db.units.belongsToMany(db.unit_availability_blocks, { db.units.hasMany(db.unit_availability_blocks, {
as: 'availability_blocks', as: 'availability_blocks',
foreignKey: { foreignKey: {
name: 'units_availability_blocksId', name: 'unitId',
}, },
constraints: false, constraints: false,
through: 'unitsAvailability_blocksUnit_availability_blocks',
}); });
db.units.belongsToMany(db.unit_availability_blocks, { db.units.hasMany(db.unit_availability_blocks, {
as: 'availability_blocks_filter', as: 'availability_blocks_filter',
foreignKey: { foreignKey: {
name: 'units_availability_blocksId', name: 'unitId',
}, },
constraints: false, constraints: false,
through: 'unitsAvailability_blocksUnit_availability_blocks',
}); });

View File

@ -0,0 +1,244 @@
'use strict';
const { v4: uuid } = require('uuid');
const { QueryTypes } = require('sequelize');
const BUSINESS_ROLES = {
SUPER_ADMIN: 'Super Administrator',
ADMIN: 'Administrator',
CONCIERGE: 'Concierge Coordinator',
CUSTOMER: 'Customer',
PLATFORM_OWNER: 'Platform Owner',
OPERATIONS_DIRECTOR: 'Operations Director',
RESERVATIONS_LEAD: 'Reservations Lead',
FINANCE_CONTROLLER: 'Finance Controller',
};
const rolePermissionMatrix = {
[BUSINESS_ROLES.ADMIN]: [
'CREATE_BOOKING_REQUESTS',
'READ_BOOKING_REQUESTS',
'UPDATE_BOOKING_REQUESTS',
'READ_APPROVAL_STEPS',
'UPDATE_APPROVAL_STEPS',
'CREATE_RESERVATIONS',
'READ_RESERVATIONS',
'UPDATE_RESERVATIONS',
'CREATE_SERVICE_REQUESTS',
'READ_SERVICE_REQUESTS',
'UPDATE_SERVICE_REQUESTS',
'READ_INVOICES',
'CREATE_DOCUMENTS',
'READ_DOCUMENTS',
'UPDATE_DOCUMENTS',
'READ_PROPERTIES',
'READ_UNITS',
'READ_NEGOTIATED_RATES',
],
[BUSINESS_ROLES.CONCIERGE]: [
'CREATE_BOOKING_REQUESTS',
'READ_BOOKING_REQUESTS',
'UPDATE_BOOKING_REQUESTS',
'READ_RESERVATIONS',
'CREATE_SERVICE_REQUESTS',
'READ_SERVICE_REQUESTS',
'UPDATE_SERVICE_REQUESTS',
'CREATE_DOCUMENTS',
'READ_DOCUMENTS',
'UPDATE_DOCUMENTS',
'READ_PROPERTIES',
'READ_UNITS',
],
[BUSINESS_ROLES.CUSTOMER]: [
'CREATE_BOOKING_REQUESTS',
'READ_BOOKING_REQUESTS',
'UPDATE_BOOKING_REQUESTS',
'READ_RESERVATIONS',
'CREATE_SERVICE_REQUESTS',
'READ_SERVICE_REQUESTS',
'UPDATE_SERVICE_REQUESTS',
'READ_DOCUMENTS',
],
[BUSINESS_ROLES.PLATFORM_OWNER]: [
'CREATE_BOOKING_REQUESTS',
'READ_BOOKING_REQUESTS',
'UPDATE_BOOKING_REQUESTS',
'READ_APPROVAL_STEPS',
'UPDATE_APPROVAL_STEPS',
'CREATE_RESERVATIONS',
'READ_RESERVATIONS',
'UPDATE_RESERVATIONS',
'CREATE_SERVICE_REQUESTS',
'READ_SERVICE_REQUESTS',
'UPDATE_SERVICE_REQUESTS',
'READ_INVOICES',
'CREATE_DOCUMENTS',
'READ_DOCUMENTS',
'UPDATE_DOCUMENTS',
'READ_ORGANIZATIONS',
'READ_PROPERTIES',
'READ_UNITS',
'READ_NEGOTIATED_RATES',
],
[BUSINESS_ROLES.OPERATIONS_DIRECTOR]: [
'CREATE_BOOKING_REQUESTS',
'READ_BOOKING_REQUESTS',
'UPDATE_BOOKING_REQUESTS',
'READ_APPROVAL_STEPS',
'UPDATE_APPROVAL_STEPS',
'CREATE_RESERVATIONS',
'READ_RESERVATIONS',
'UPDATE_RESERVATIONS',
'CREATE_SERVICE_REQUESTS',
'READ_SERVICE_REQUESTS',
'UPDATE_SERVICE_REQUESTS',
'CREATE_DOCUMENTS',
'READ_DOCUMENTS',
'UPDATE_DOCUMENTS',
'READ_PROPERTIES',
'READ_UNITS',
'READ_NEGOTIATED_RATES',
],
[BUSINESS_ROLES.RESERVATIONS_LEAD]: [
'CREATE_BOOKING_REQUESTS',
'READ_BOOKING_REQUESTS',
'UPDATE_BOOKING_REQUESTS',
'READ_APPROVAL_STEPS',
'UPDATE_APPROVAL_STEPS',
'CREATE_RESERVATIONS',
'READ_RESERVATIONS',
'UPDATE_RESERVATIONS',
'CREATE_SERVICE_REQUESTS',
'READ_SERVICE_REQUESTS',
'UPDATE_SERVICE_REQUESTS',
'READ_DOCUMENTS',
'READ_PROPERTIES',
'READ_UNITS',
'READ_NEGOTIATED_RATES',
],
[BUSINESS_ROLES.FINANCE_CONTROLLER]: [
'READ_BOOKING_REQUESTS',
'READ_RESERVATIONS',
'READ_INVOICES',
'UPDATE_INVOICES',
'READ_DOCUMENTS',
],
};
async function findRoleByName(queryInterface, name) {
const rows = await queryInterface.sequelize.query(
'SELECT "id", "name" FROM "roles" WHERE "name" = :name LIMIT 1',
{
replacements: { name },
type: QueryTypes.SELECT,
},
);
return rows[0] || null;
}
async function ensureRole(queryInterface, name, now) {
const existingRole = await findRoleByName(queryInterface, name);
if (existingRole) {
return existingRole.id;
}
const id = uuid();
await queryInterface.bulkInsert('roles', [
{
id,
name,
globalAccess: false,
createdAt: now,
updatedAt: now,
},
]);
return id;
}
async function getPermissionIdMap(queryInterface, permissionNames) {
const permissions = await queryInterface.sequelize.query(
'SELECT "id", "name" FROM "permissions" WHERE "name" IN (:permissionNames)',
{
replacements: { permissionNames },
type: QueryTypes.SELECT,
},
);
return new Map(permissions.map((permission) => [permission.name, permission.id]));
}
module.exports = {
async up(queryInterface) {
const now = new Date();
const customerRoleId = await ensureRole(queryInterface, BUSINESS_ROLES.CUSTOMER, now);
await queryInterface.bulkUpdate('roles', { globalAccess: true, updatedAt: now }, { name: BUSINESS_ROLES.SUPER_ADMIN });
await queryInterface.sequelize.query(
'UPDATE "roles" SET "globalAccess" = false, "updatedAt" = :updatedAt WHERE "name" IN (:roleNames)',
{
replacements: {
updatedAt: now,
roleNames: [
BUSINESS_ROLES.ADMIN,
BUSINESS_ROLES.CONCIERGE,
BUSINESS_ROLES.CUSTOMER,
BUSINESS_ROLES.PLATFORM_OWNER,
BUSINESS_ROLES.OPERATIONS_DIRECTOR,
BUSINESS_ROLES.RESERVATIONS_LEAD,
BUSINESS_ROLES.FINANCE_CONTROLLER,
],
},
},
);
const roleIds = {};
for (const roleName of Object.keys(rolePermissionMatrix)) {
const role = await findRoleByName(queryInterface, roleName);
if (!role) {
throw new Error(`Role '${roleName}' was not found while aligning the business role matrix.`);
}
roleIds[roleName] = role.id;
}
roleIds[BUSINESS_ROLES.CUSTOMER] = customerRoleId;
const permissionNames = [...new Set(Object.values(rolePermissionMatrix).flat())];
const permissionIdMap = await getPermissionIdMap(queryInterface, permissionNames);
const missingPermissions = permissionNames.filter((permissionName) => !permissionIdMap.get(permissionName));
if (missingPermissions.length > 0) {
throw new Error(`Missing permissions for role matrix alignment: ${missingPermissions.join(', ')}`);
}
const impactedRoleIds = Object.values(roleIds);
await queryInterface.sequelize.query(
'DELETE FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" IN (:roleIds)',
{
replacements: { roleIds: impactedRoleIds },
},
);
const rows = Object.entries(rolePermissionMatrix).flatMap(([roleName, permissions]) =>
permissions.map((permissionName) => ({
createdAt: now,
updatedAt: now,
roles_permissionsId: roleIds[roleName],
permissionId: permissionIdMap.get(permissionName),
})),
);
if (rows.length > 0) {
await queryInterface.bulkInsert('rolesPermissionsPermissions', rows);
}
await queryInterface.bulkUpdate('users', { app_roleId: customerRoleId, updatedAt: now }, { email: 'client@hello.com' });
await queryInterface.bulkUpdate('users', { app_roleId: roleIds[BUSINESS_ROLES.CONCIERGE], updatedAt: now }, { email: 'john@doe.com' });
},
async down() {
// Intentionally left blank. This seeder aligns live business roles and should not blindly revert production data.
},
};

View File

@ -5,7 +5,6 @@ const Booking_requestsService = require('../services/booking_requests');
const Booking_requestsDBApi = require('../db/api/booking_requests'); const Booking_requestsDBApi = require('../db/api/booking_requests');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router(); const router = express.Router();
@ -409,7 +408,7 @@ router.get('/autocomplete', async (req, res) => {
req.query.query, req.query.query,
req.query.limit, req.query.limit,
req.query.offset, req.query.offset,
globalAccess, organizationId, globalAccess, organizationId, req.currentUser,
); );
res.status(200).send(payload); res.status(200).send(payload);
@ -450,9 +449,12 @@ router.get('/autocomplete', async (req, res) => {
router.get('/:id', wrapAsync(async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => {
const payload = await Booking_requestsDBApi.findBy( const payload = await Booking_requestsDBApi.findBy(
{ id: req.params.id }, { id: req.params.id },
{ currentUser: req.currentUser },
); );
if (!payload) {
return res.status(404).send('Not found');
}
res.status(200).send(payload); res.status(200).send(payload);
})); }));

View File

@ -5,7 +5,6 @@ const ReservationsService = require('../services/reservations');
const ReservationsDBApi = require('../db/api/reservations'); const ReservationsDBApi = require('../db/api/reservations');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router(); const router = express.Router();
@ -406,7 +405,7 @@ router.get('/autocomplete', async (req, res) => {
req.query.query, req.query.query,
req.query.limit, req.query.limit,
req.query.offset, req.query.offset,
globalAccess, organizationId, globalAccess, organizationId, req.currentUser,
); );
res.status(200).send(payload); res.status(200).send(payload);
@ -447,9 +446,12 @@ router.get('/autocomplete', async (req, res) => {
router.get('/:id', wrapAsync(async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => {
const payload = await ReservationsDBApi.findBy( const payload = await ReservationsDBApi.findBy(
{ id: req.params.id }, { id: req.params.id },
{ currentUser: req.currentUser },
); );
if (!payload) {
return res.status(404).send('Not found');
}
res.status(200).send(payload); res.status(200).send(payload);
})); }));

View File

@ -5,7 +5,6 @@ const RolesService = require('../services/roles');
const RolesDBApi = require('../db/api/roles'); const RolesDBApi = require('../db/api/roles');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router(); const router = express.Router();
@ -386,6 +385,7 @@ router.get('/autocomplete', async (req, res) => {
req.query.limit, req.query.limit,
req.query.offset, req.query.offset,
globalAccess, globalAccess,
req.query.businessOnly,
); );
res.status(200).send(payload); res.status(200).send(payload);

View File

@ -5,7 +5,6 @@ const Service_requestsService = require('../services/service_requests');
const Service_requestsDBApi = require('../db/api/service_requests'); const Service_requestsDBApi = require('../db/api/service_requests');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router(); const router = express.Router();
@ -402,7 +401,7 @@ router.get('/autocomplete', async (req, res) => {
req.query.query, req.query.query,
req.query.limit, req.query.limit,
req.query.offset, req.query.offset,
globalAccess, organizationId, globalAccess, organizationId, req.currentUser,
); );
res.status(200).send(payload); res.status(200).send(payload);
@ -443,9 +442,12 @@ router.get('/autocomplete', async (req, res) => {
router.get('/:id', wrapAsync(async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => {
const payload = await Service_requestsDBApi.findBy( const payload = await Service_requestsDBApi.findBy(
{ id: req.params.id }, { id: req.params.id },
{ currentUser: req.currentUser },
); );
if (!payload) {
return res.status(404).send('Not found');
}
res.status(200).send(payload); res.status(200).send(payload);
})); }));

View File

@ -3,8 +3,6 @@ const Booking_requestsDBApi = require('../db/api/booking_requests');
const processFile = require("../middlewares/upload"); const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream'); const stream = require('stream');
@ -28,9 +26,9 @@ module.exports = class Booking_requestsService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async bulkImport(req, res, sendInvitationEmails = true, host) { static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
@ -70,7 +68,7 @@ module.exports = class Booking_requestsService {
try { try {
let booking_requests = await Booking_requestsDBApi.findBy( let booking_requests = await Booking_requestsDBApi.findBy(
{id}, {id},
{transaction}, {transaction, currentUser},
); );
if (!booking_requests) { if (!booking_requests) {
@ -95,7 +93,7 @@ module.exports = class Booking_requestsService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async deleteByIds(ids, currentUser) { static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
@ -117,7 +115,7 @@ module.exports = class Booking_requestsService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await Booking_requestsDBApi.remove( const removedBooking_requests = await Booking_requestsDBApi.remove(
id, id,
{ {
currentUser, currentUser,
@ -125,6 +123,12 @@ module.exports = class Booking_requestsService {
}, },
); );
if (!removedBooking_requests) {
throw new ValidationError(
'booking_requestsNotFound',
);
}
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();

View File

@ -3,8 +3,6 @@ const Service_requestsDBApi = require('../db/api/service_requests');
const processFile = require("../middlewares/upload"); const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser'); const csv = require('csv-parser');
const axios = require('axios');
const config = require('../config');
const stream = require('stream'); const stream = require('stream');
@ -28,9 +26,9 @@ module.exports = class Service_requestsService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async bulkImport(req, res, sendInvitationEmails = true, host) { static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
@ -70,7 +68,7 @@ module.exports = class Service_requestsService {
try { try {
let service_requests = await Service_requestsDBApi.findBy( let service_requests = await Service_requestsDBApi.findBy(
{id}, {id},
{transaction}, {transaction, currentUser},
); );
if (!service_requests) { if (!service_requests) {
@ -95,7 +93,7 @@ module.exports = class Service_requestsService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
}; }
static async deleteByIds(ids, currentUser) { static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
@ -117,7 +115,7 @@ module.exports = class Service_requestsService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
await Service_requestsDBApi.remove( const removedService_requests = await Service_requestsDBApi.remove(
id, id,
{ {
currentUser, currentUser,
@ -125,6 +123,12 @@ module.exports = class Service_requestsService {
}, },
); );
if (!removedService_requests) {
throw new ValidationError(
'service_requestsNotFound',
);
}
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();

View File

@ -55,7 +55,7 @@ const TableSampleRoles = ({ filterItems, setFilterItems, filters, showGrid }) =>
if (request !== filterRequest) setFilterRequest(request); if (request !== filterRequest) setFilterRequest(request);
const { sort, field } = sortModel[0]; const { sort, field } = sortModel[0];
const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; const query = `?page=${page}&limit=${perPage}&businessOnly=true${request}&sort=${sort}&field=${field}`;
dispatch(fetch({ limit: perPage, page, query })); dispatch(fetch({ limit: perPage, page, query }));
}; };

View File

@ -26,7 +26,8 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled
} }
async function callApi(inputValue: string, loadedOptions: any[]) { async function callApi(inputValue: string, loadedOptions: any[]) {
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`; const businessOnly = itemRef === 'roles' ? '&businessOnly=true' : '';
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}${businessOnly}`;
const { data } = await axios(path); const { data } = await axios(path);
return { return {
options: data.map(mapResponseToValuesAndLabels), options: data.map(mapResponseToValuesAndLabels),

View File

@ -28,7 +28,8 @@ export const RoleSelect = ({ options, field, form, itemRef, disabled, currentUse
}; };
async function callApi(inputValue: string, loadedOptions: any[]) { async function callApi(inputValue: string, loadedOptions: any[]) {
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`; const businessOnly = itemRef === 'roles' ? '&businessOnly=true' : '';
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}${businessOnly}`;
const { data } = await axios(path); const { data } = await axios(path);
return { return {
options: data.map(mapResponseToValuesAndLabels), options: data.map(mapResponseToValuesAndLabels),

View File

@ -9,11 +9,6 @@ const menuAside: MenuAsideItem[] = [
icon: 'mdiShieldHomeOutline' in icon ? icon['mdiShieldHomeOutline' as keyof typeof icon] : icon.mdiViewDashboardOutline, icon: 'mdiShieldHomeOutline' in icon ? icon['mdiShieldHomeOutline' as keyof typeof icon] : icon.mdiViewDashboardOutline,
label: 'Command Center', label: 'Command Center',
}, },
{
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{ {
label: 'Operations', label: 'Operations',
icon: icon.mdiClipboardTextOutline, icon: icon.mdiClipboardTextOutline,

File diff suppressed because it is too large Load Diff

View File

@ -30,17 +30,40 @@ const Booking_requestsTablesPage = () => {
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [filters] = useState(([{label: 'Requestcode', title: 'request_code'},{label: 'Purposeofstay', title: 'purpose_of_stay'},{label: 'Specialrequirements', title: 'special_requirements'},{label: 'Budgetcode', title: 'budget_code'},{label: 'Currency', title: 'currency'}, const canManageInternalFields = Boolean(currentUser?.app_role?.globalAccess);
{label: 'Preferredbedrooms', title: 'preferred_bedrooms', number: 'true'},{label: 'Guestcount', title: 'guest_count', number: 'true'}, const createButtonLabel = canManageInternalFields ? 'New request' : 'Request a stay';
{label: 'Maxbudgetamount', title: 'max_budget_amount', number: 'true'}, const filters = React.useMemo(() => {
{label: 'Check-inat', title: 'check_in_at', date: 'true'},{label: 'Check-outat', title: 'check_out_at', date: 'true'}, const baseFilters = [
{label: 'Tenant', title: 'tenant'}, { label: 'Requestcode', title: 'request_code' },
{label: 'Requestedby', title: 'requested_by'}, { label: 'Purposeofstay', title: 'purpose_of_stay' },
{label: 'Preferredproperty', title: 'preferred_property'}, { label: 'Specialrequirements', title: 'special_requirements' },
{label: 'Preferredunittype', title: 'preferred_unit_type'}, { label: 'Budgetcode', title: 'budget_code' },
{label: 'Travelers', title: 'travelers'},{label: 'Approvalsteps', title: 'approval_steps'},{label: 'Documents', title: 'documents'},{label: 'Comments', title: 'comments'}, { label: 'Currency', title: 'currency' },
{label: 'Status', title: 'status', type: 'enum', options: ['draft','submitted','in_review','changes_requested','approved','rejected','expired','converted_to_reservation','canceled']}, { label: 'Preferredbedrooms', title: 'preferred_bedrooms', number: 'true' },
]).map((filter) => ({ ...filter, label: humanize(filter.title) }))); { label: 'Guestcount', title: 'guest_count', number: 'true' },
{ label: 'Maxbudgetamount', title: 'max_budget_amount', number: 'true' },
{ label: 'Check-inat', title: 'check_in_at', date: 'true' },
{ label: 'Check-outat', title: 'check_out_at', date: 'true' },
{ label: 'Preferredproperty', title: 'preferred_property' },
{ label: 'Preferredunittype', title: 'preferred_unit_type' },
{ label: 'Travelers', title: 'travelers' },
{ label: 'Approvalsteps', title: 'approval_steps' },
{ label: 'Documents', title: 'documents' },
{ label: 'Comments', title: 'comments' },
{
label: 'Status',
title: 'status',
type: 'enum',
options: ['draft','submitted','in_review','changes_requested','approved','rejected','expired','converted_to_reservation','canceled'],
},
];
if (canManageInternalFields) {
baseFilters.splice(10, 0, { label: 'Tenant', title: 'tenant' }, { label: 'Requestedby', title: 'requested_by' });
}
return baseFilters.map((filter) => ({ ...filter, label: humanize(filter.title) }));
}, [canManageInternalFields]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BOOKING_REQUESTS'); const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BOOKING_REQUESTS');
@ -109,7 +132,7 @@ const Booking_requestsTablesPage = () => {
classAddon='mr-2 mb-2 last:mr-0' classAddon='mr-2 mb-2 last:mr-0'
> >
{hasCreatePermission ? ( {hasCreatePermission ? (
<BaseButton href={'/booking_requests/booking_requests-new'} color='info' label='New request' /> <BaseButton href={'/booking_requests/booking_requests-new'} color='info' label={createButtonLabel} />
) : null} ) : null}
<BaseButton color='whiteDark' outline label='Add filter' onClick={addFilter} /> <BaseButton color='whiteDark' outline label='Add filter' onClick={addFilter} />
<BaseButton color='whiteDark' outline label='Export CSV' onClick={getBooking_requestsCSV} /> <BaseButton color='whiteDark' outline label='Export CSV' onClick={getBooking_requestsCSV} />

File diff suppressed because it is too large Load Diff

View File

@ -1,68 +1,67 @@
import { mdiChartTimelineVariant } from '@mdi/js' import { mdiChartTimelineVariant } from '@mdi/js'
import Head from 'next/head' import Head from 'next/head'
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash'
import React, { ReactElement, useState } from 'react' 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 CardBox from '../../components/CardBox'
import CardBoxModal from '../../components/CardBoxModal'
import DragDropFilePicker from '../../components/DragDropFilePicker'
import TableBooking_requests from '../../components/Booking_requests/TableBooking_requests'
import { getPageTitle } from '../../config'
import { hasPermission } from '../../helpers/userPermissions'
import LayoutAuthenticated from '../../layouts/Authenticated' import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain' import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import { getPageTitle } from '../../config' import { setRefetch, uploadCsv } from '../../stores/booking_requests/booking_requestsSlice'
import TableBooking_requests from '../../components/Booking_requests/TableBooking_requests' import { useAppDispatch, useAppSelector } from '../../stores/hooks'
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/booking_requests/booking_requestsSlice';
import {hasPermission} from "../../helpers/userPermissions";
const Booking_requestsTablesPage = () => { const Booking_requestsTablesPage = () => {
const [filterItems, setFilterItems] = useState([]); const [filterItems, setFilterItems] = useState([])
const [csvFile, setCsvFile] = useState<File | null>(null); const [csvFile, setCsvFile] = useState<File | null>(null)
const [isModalActive, setIsModalActive] = useState(false); const [isModalActive, setIsModalActive] = useState(false)
const [showTableView, setShowTableView] = useState(false);
const { currentUser } = useAppSelector((state) => state.auth)
const dispatch = useAppDispatch()
const { currentUser } = useAppSelector((state) => state.auth); const canManageInternalFields = Boolean(currentUser?.app_role?.globalAccess)
const createButtonLabel = canManageInternalFields ? 'New item' : 'Request a stay'
const filters = useMemo(() => {
const baseFilters = [
{ label: 'Requestcode', title: 'request_code' },
{ label: 'Purposeofstay', title: 'purpose_of_stay' },
{ label: 'Specialrequirements', title: 'special_requirements' },
{ label: 'Budgetcode', title: 'budget_code' },
{ label: 'Currency', title: 'currency' },
{ label: 'Preferredbedrooms', title: 'preferred_bedrooms', number: 'true' },
{ label: 'Guestcount', title: 'guest_count', number: 'true' },
{ label: 'Maxbudgetamount', title: 'max_budget_amount', number: 'true' },
{ label: 'Check-inat', title: 'check_in_at', date: 'true' },
{ label: 'Check-outat', title: 'check_out_at', date: 'true' },
{ label: 'Preferredproperty', title: 'preferred_property' },
{ label: 'Preferredunittype', title: 'preferred_unit_type' },
{ label: 'Travelers', title: 'travelers' },
{ label: 'Approvalsteps', title: 'approval_steps' },
{ label: 'Documents', title: 'documents' },
{ label: 'Comments', title: 'comments' },
{
label: 'Status',
title: 'status',
type: 'enum',
options: ['draft', 'submitted', 'in_review', 'changes_requested', 'approved', 'rejected', 'expired', 'converted_to_reservation', 'canceled'],
},
]
if (canManageInternalFields) {
baseFilters.splice(10, 0, { label: 'Tenant', title: 'tenant' }, { label: 'Requestedby', title: 'requested_by' })
}
const dispatch = useAppDispatch(); return baseFilters
}, [canManageInternalFields])
const [filters] = useState([{label: 'Requestcode', title: 'request_code'},{label: 'Purposeofstay', title: 'purpose_of_stay'},{label: 'Specialrequirements', title: 'special_requirements'},{label: 'Budgetcode', title: 'budget_code'},{label: 'Currency', title: 'currency'},
{label: 'Preferredbedrooms', title: 'preferred_bedrooms', number: 'true'},{label: 'Guestcount', title: 'guest_count', number: 'true'},
{label: 'Maxbudgetamount', title: 'max_budget_amount', number: 'true'},
{label: 'Check-inat', title: 'check_in_at', date: 'true'},{label: 'Check-outat', title: 'check_out_at', date: 'true'},
{label: 'Tenant', title: 'tenant'},
{label: 'Requestedby', title: 'requested_by'},
{label: 'Preferredproperty', title: 'preferred_property'},
{label: 'Preferredunittype', title: 'preferred_unit_type'},
{label: 'Travelers', title: 'travelers'},{label: 'Approvalsteps', title: 'approval_steps'},{label: 'Documents', title: 'documents'},{label: 'Comments', title: 'comments'},
{label: 'Status', title: 'status', type: 'enum', options: ['draft','submitted','in_review','changes_requested','approved','rejected','expired','converted_to_reservation','canceled']},
]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BOOKING_REQUESTS');
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BOOKING_REQUESTS')
const addFilter = () => { const addFilter = () => {
const newItem = { const newItem = {
@ -71,75 +70,62 @@ const Booking_requestsTablesPage = () => {
filterValue: '', filterValue: '',
filterValueFrom: '', filterValueFrom: '',
filterValueTo: '', filterValueTo: '',
selectedField: '', selectedField: filters[0].title,
}, },
}; }
newItem.fields.selectedField = filters[0].title;
setFilterItems([...filterItems, newItem]); setFilterItems([...filterItems, newItem])
}; }
const getBooking_requestsCSV = async () => { const getBooking_requestsCSV = async () => {
const response = await axios({url: '/booking_requests?filetype=csv', method: 'GET',responseType: 'blob'}); const response = await axios({ url: '/booking_requests?filetype=csv', method: 'GET', responseType: 'blob' })
const type = response.headers['content-type'] 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') const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob) link.href = window.URL.createObjectURL(blob)
link.download = 'booking_requestsCSV.csv' link.download = 'booking_requestsCSV.csv'
link.click() link.click()
}; }
const onModalConfirm = async () => { const onModalConfirm = async () => {
if (!csvFile) return; if (!csvFile) return
await dispatch(uploadCsv(csvFile)); await dispatch(uploadCsv(csvFile))
dispatch(setRefetch(true)); dispatch(setRefetch(true))
setCsvFile(null); setCsvFile(null)
setIsModalActive(false); setIsModalActive(false)
}; }
const onModalCancel = () => { const onModalCancel = () => {
setCsvFile(null); setCsvFile(null)
setIsModalActive(false); setIsModalActive(false)
}; }
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Booking_requests')}</title> <title>{getPageTitle('Booking Requests')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Booking_requests" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='Booking Requests' main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> <CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/booking_requests/booking_requests-new'} color='info' label='New Item'/>}
<BaseButton
className={'mr-3'}
color='info'
label='Filter'
onClick={addFilter}
/>
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getBooking_requestsCSV} />
{hasCreatePermission && ( {hasCreatePermission && (
<BaseButton <BaseButton className='mr-3' href={'/booking_requests/booking_requests-new'} color='info' label={createButtonLabel} />
color='info' )}
label='Upload CSV' <BaseButton className='mr-3' color='info' label='Filter' onClick={addFilter} />
onClick={() => setIsModalActive(true)} <BaseButton className='mr-3' color='info' label='Download CSV' onClick={getBooking_requestsCSV} />
/> {hasCreatePermission && (
<BaseButton color='info' label='Upload CSV' onClick={() => setIsModalActive(true)} />
)} )}
<div className='md:inline-flex items-center ms-auto'> <div className='md:inline-flex items-center ms-auto'>
<div id='delete-rows-button'></div> <div id='delete-rows-button'></div>
<Link href={'/booking_requests/booking_requests-list'}> <Link href={'/booking_requests/booking_requests-list'}>
Back to <span className='capitalize'>kanban</span> Back to <span className='capitalize'>kanban</span>
</Link> </Link>
</div> </div>
</CardBox> </CardBox>
<CardBox className="mb-6" hasTable> <CardBox className='mb-6' hasTable>
<TableBooking_requests <TableBooking_requests
filterItems={filterItems} filterItems={filterItems}
setFilterItems={setFilterItems} setFilterItems={setFilterItems}
@ -152,16 +138,11 @@ const Booking_requestsTablesPage = () => {
title='Upload CSV' title='Upload CSV'
buttonColor='info' buttonColor='info'
buttonLabel={'Confirm'} buttonLabel={'Confirm'}
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
isActive={isModalActive} isActive={isModalActive}
onConfirm={onModalConfirm} onConfirm={onModalConfirm}
onCancel={onModalCancel} onCancel={onModalCancel}
> >
<DragDropFilePicker <DragDropFilePicker file={csvFile} setFile={setCsvFile} formats={'.csv'} />
file={csvFile}
setFile={setCsvFile}
formats={'.csv'}
/>
</CardBoxModal> </CardBoxModal>
</> </>
) )
@ -169,11 +150,7 @@ const Booking_requestsTablesPage = () => {
Booking_requestsTablesPage.getLayout = function getLayout(page: ReactElement) { Booking_requestsTablesPage.getLayout = function getLayout(page: ReactElement) {
return ( return (
<LayoutAuthenticated <LayoutAuthenticated permission={'READ_BOOKING_REQUESTS'}>
permission={'READ_BOOKING_REQUESTS'}
>
{page} {page}
</LayoutAuthenticated> </LayoutAuthenticated>
) )

View File

@ -99,6 +99,12 @@ const BookingRequestsView = () => {
}, [dispatch, id]); }, [dispatch, id]);
const canEdit = currentUser && hasPermission(currentUser, 'UPDATE_BOOKING_REQUESTS'); const canEdit = currentUser && hasPermission(currentUser, 'UPDATE_BOOKING_REQUESTS');
const canManageInternalFields = Boolean(currentUser?.app_role?.globalAccess);
const pageTitle = canManageInternalFields ? 'Booking Request' : 'Stay Request';
const overviewHeading = canManageInternalFields ? 'Guest request snapshot' : 'Stay request snapshot';
const overviewCopy = canManageInternalFields
? 'A light summary of who requested the stay, where they prefer to stay, and what needs follow-up.'
: 'A light summary of the requested stay, preferred accommodation, and what still needs follow-up.';
const stayWindow = const stayWindow =
booking_requests?.check_in_at || booking_requests?.check_out_at booking_requests?.check_in_at || booking_requests?.check_out_at
? `${formatDate(booking_requests?.check_in_at)}${formatDate(booking_requests?.check_out_at)}` ? `${formatDate(booking_requests?.check_in_at)}${formatDate(booking_requests?.check_out_at)}`
@ -107,11 +113,11 @@ const BookingRequestsView = () => {
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Booking Request')}</title> <title>{getPageTitle(pageTitle)}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiClipboardTextOutline} title="Booking Request" main> <SectionTitleLineWithButton icon={mdiClipboardTextOutline} title={pageTitle} main>
<BaseButtons noWrap> <BaseButtons noWrap>
<BaseButton href="/booking_requests/booking_requests-list" color="whiteDark" outline label="Back" /> <BaseButton href="/booking_requests/booking_requests-list" color="whiteDark" outline label="Back" />
{canEdit && ( {canEdit && (
@ -141,13 +147,13 @@ const BookingRequestsView = () => {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Overview</p> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Overview</p>
<h2 className="mt-2 text-xl font-semibold text-white">Guest request snapshot</h2> <h2 className="mt-2 text-xl font-semibold text-white">{overviewHeading}</h2>
<p className="mt-1 text-sm text-slate-400"> <p className="mt-1 text-sm text-slate-400">{overviewCopy}</p>
A light summary of who requested the stay, where they prefer to stay, and what needs follow-up.
</p>
</div> </div>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
{canManageInternalFields && (
<>
<DetailRow <DetailRow
label="Requested By" label="Requested By"
value={booking_requests?.requested_by?.firstName || booking_requests?.requested_by?.email} value={booking_requests?.requested_by?.firstName || booking_requests?.requested_by?.email}
@ -156,6 +162,8 @@ const BookingRequestsView = () => {
{hasPermission(currentUser, 'READ_ORGANIZATIONS') && ( {hasPermission(currentUser, 'READ_ORGANIZATIONS') && (
<DetailRow label="Organization" value={booking_requests?.organization?.name} /> <DetailRow label="Organization" value={booking_requests?.organization?.name} />
)} )}
</>
)}
<DetailRow label="Preferred Property" value={booking_requests?.preferred_property?.name} /> <DetailRow label="Preferred Property" value={booking_requests?.preferred_property?.name} />
<DetailRow label="Preferred Unit Type" value={booking_requests?.preferred_unit_type?.name} /> <DetailRow label="Preferred Unit Type" value={booking_requests?.preferred_unit_type?.name} />
<DetailRow label="Preferred Bedrooms" value={booking_requests?.preferred_bedrooms} /> <DetailRow label="Preferred Bedrooms" value={booking_requests?.preferred_bedrooms} />

View File

@ -134,6 +134,14 @@ type MetricCard = {
value: string; value: string;
detail: string; detail: string;
icon: string; icon: string;
visible?: boolean;
};
type HeroStat = {
title: string;
value: string;
detail: string;
visible?: boolean;
}; };
const emptyOverview: OverviewResponse = { const emptyOverview: OverviewResponse = {
@ -286,6 +294,7 @@ type FocusItem = {
label: string; label: string;
value: string; value: string;
detail: string; detail: string;
visible?: boolean;
}; };
type ActivitySection = { type ActivitySection = {
@ -327,6 +336,29 @@ const FocusListItem = ({ label, value, detail }: FocusItem) => (
</div> </div>
); );
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']),
};
const getRoleLane = (roleName: string, hasGlobalAccess: boolean) => {
if (hasGlobalAccess || ROLE_LANES.superAdmin.has(roleName)) {
return 'super_admin';
}
if (ROLE_LANES.admin.has(roleName)) {
return 'admin';
}
if (ROLE_LANES.customer.has(roleName)) {
return 'customer';
}
return 'concierge';
};
const ActivityCard = ({ title, href, emptyState, items }: ActivitySection) => ( const ActivityCard = ({ title, href, emptyState, items }: ActivitySection) => (
<CardBox className="h-full"> <CardBox className="h-full">
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4 flex items-center justify-between gap-3">
@ -377,23 +409,31 @@ const CommandCenterPage = () => {
currentUser?.organizations?.name || currentUser?.organization?.name || 'your corporate workspace'; currentUser?.organizations?.name || currentUser?.organization?.name || 'your corporate workspace';
const firstName = currentUser?.firstName || currentUser?.email || 'team'; const firstName = currentUser?.firstName || currentUser?.email || 'team';
const roleName = currentUser?.app_role?.name || 'Team member'; const roleName = currentUser?.app_role?.name || 'Team member';
const isSuperAdmin = Boolean(currentUser?.app_role?.globalAccess || roleName === 'Super Administrator'); const roleLane = getRoleLane(roleName, Boolean(currentUser?.app_role?.globalAccess));
const isAdmin = !isSuperAdmin && roleName === 'Administrator'; const isSuperAdmin = roleLane === 'super_admin';
const isAdmin = roleLane === 'admin';
const isCustomer = roleLane === 'customer';
const dashboardLens = isSuperAdmin const dashboardLens = isSuperAdmin
? 'Network oversight' ? 'Network oversight'
: isAdmin : isAdmin
? 'Approval and portfolio operations' ? 'Approval and portfolio operations'
: isCustomer
? 'Customer stay visibility'
: 'Concierge delivery'; : 'Concierge delivery';
const dashboardHeadline = isSuperAdmin const dashboardHeadline = isSuperAdmin
? 'See every handoff across accounts, inventory, service, and revenue.' ? 'See every handoff across accounts, inventory, service, and revenue.'
: isAdmin : isAdmin
? 'Approve faster, place stays cleanly, and keep billing exposure visible.' ? 'Approve faster, place stays cleanly, and keep billing exposure visible.'
: isCustomer
? 'Request stays, follow reservation status, and stay connected during the trip.'
: 'Move travelers from request to arrival without losing operational detail.'; : 'Move travelers from request to arrival without losing operational detail.';
const dashboardDescription = isSuperAdmin const dashboardDescription = isSuperAdmin
? `You share the same command center as every operating role, but your lens spans organizations, portfolio health, and revenue risk across ${organizationName}.` ? `You share the same command center as every operating role, but your lens spans organizations, portfolio health, and revenue risk across ${organizationName}.`
: isAdmin : isAdmin
? `You work from the same live records as coordinators and leadership, with a sharper focus on approvals, reservations, and financial follow-through for ${organizationName}.` ? `You work from the same live records as customers, coordinators, and leadership, with a sharper focus on approvals, reservations, and financial follow-through for ${organizationName}.`
: `You operate from the same live workspace as administrators and leadership, focused on intake, guest movement, and service execution for ${organizationName}.`; : isCustomer
? `You work from the same live records as concierge and administrators, focused on request status, confirmed stays, documents, and active support for ${organizationName}.`
: `You operate from the same live workspace as customers, administrators, and leadership, focused on intake, guest movement, and service execution for ${organizationName}.`;
const loadOverview = useCallback(async () => { const loadOverview = useCallback(async () => {
try { try {
@ -426,26 +466,51 @@ const CommandCenterPage = () => {
value: `${overview.bookingRequests.pendingReview}`, value: `${overview.bookingRequests.pendingReview}`,
detail: `${overview.approvals.pending} approvals are waiting on sign-off.`, detail: `${overview.approvals.pending} approvals are waiting on sign-off.`,
icon: mdiClipboardTextOutline, icon: mdiClipboardTextOutline,
visible: overview.access.bookings || overview.access.approvals,
}, },
{ {
title: 'Upcoming arrivals', title: 'Upcoming arrivals',
value: `${overview.reservations.upcomingArrivals}`, value: `${overview.reservations.upcomingArrivals}`,
detail: `${overview.reservations.inHouse} travelers are currently in house.`, detail: `${overview.reservations.inHouse} travelers are currently in house.`,
icon: mdiCalendarCheck, icon: mdiCalendarCheck,
visible: overview.access.reservations,
}, },
{ {
title: 'Open service queue', title: 'Open service queue',
value: `${overview.serviceRequests.open}`, value: `${overview.serviceRequests.open}`,
detail: `${overview.serviceRequests.urgent} urgent requests need attention.`, detail: `${overview.serviceRequests.urgent} urgent requests need attention.`,
icon: mdiRoomService, icon: mdiRoomService,
visible: overview.access.serviceRequests,
}, },
{ {
title: 'Open balance', title: 'Open balance',
value: formatMoney(overview.invoices.openBalance, overview.invoices.recent[0]?.currency || 'USD'), value: formatMoney(overview.invoices.openBalance, overview.invoices.recent[0]?.currency || 'USD'),
detail: `${overview.invoices.statusCounts.overdue || 0} invoices are overdue.`, detail: `${overview.invoices.statusCounts.overdue || 0} invoices are overdue.`,
icon: mdiFileDocument, icon: mdiFileDocument,
visible: overview.access.invoices,
}, },
]; ].filter((card) => card.visible);
const heroStats: HeroStat[] = [
{
title: 'Corporate accounts',
value: `${overview.organizations.total}`,
detail: 'Organizations in the current operating scope.',
visible: overview.access.accounts,
},
{
title: 'Live properties',
value: `${overview.inventory.activeProperties}`,
detail: 'Properties open for negotiated corporate stays.',
visible: overview.access.inventory,
},
{
title: 'Last refresh',
value: formatDateTime(overview.generatedAt),
detail: 'Live snapshot across operations and billing.',
visible: true,
},
].filter((stat) => stat.visible);
const quickActions: ActionItem[] = [ const quickActions: ActionItem[] = [
{ {
@ -454,7 +519,7 @@ const CommandCenterPage = () => {
href: '/booking_requests/booking_requests-new', href: '/booking_requests/booking_requests-new',
icon: mdiArrowTopRight, icon: mdiArrowTopRight,
visible: canCreateBookingRequest, visible: canCreateBookingRequest,
priority: isSuperAdmin ? 5 : isAdmin ? 4 : 1, priority: isSuperAdmin ? 5 : isAdmin ? 4 : isCustomer ? 1 : 2,
}, },
{ {
title: 'Review approvals', title: 'Review approvals',
@ -462,7 +527,7 @@ const CommandCenterPage = () => {
href: '/approval_steps/approval_steps-list', href: '/approval_steps/approval_steps-list',
icon: mdiCheckDecagram, icon: mdiCheckDecagram,
visible: canReadApprovals, visible: canReadApprovals,
priority: isSuperAdmin ? 3 : isAdmin ? 1 : 4, priority: isSuperAdmin ? 3 : isAdmin ? 1 : isCustomer ? 6 : 4,
}, },
{ {
title: 'Open reservations', title: 'Open reservations',
@ -470,7 +535,7 @@ const CommandCenterPage = () => {
href: '/reservations/reservations-list', href: '/reservations/reservations-list',
icon: mdiCalendarCheck, icon: mdiCalendarCheck,
visible: canReadReservations, visible: canReadReservations,
priority: isSuperAdmin ? 4 : isAdmin ? 2 : 2, priority: isSuperAdmin ? 4 : isAdmin ? 2 : isCustomer ? 2 : 1,
}, },
{ {
title: 'Service queue', title: 'Service queue',
@ -478,7 +543,7 @@ const CommandCenterPage = () => {
href: '/service_requests/service_requests-list', href: '/service_requests/service_requests-list',
icon: mdiRoomService, icon: mdiRoomService,
visible: canReadServiceRequests, visible: canReadServiceRequests,
priority: isSuperAdmin ? 6 : isAdmin ? 5 : 3, priority: isSuperAdmin ? 6 : isAdmin ? 5 : isCustomer ? 3 : 3,
}, },
{ {
title: 'Finance view', title: 'Finance view',
@ -486,7 +551,7 @@ const CommandCenterPage = () => {
href: '/invoices/invoices-list', href: '/invoices/invoices-list',
icon: mdiFileDocument, icon: mdiFileDocument,
visible: canReadInvoices, visible: canReadInvoices,
priority: isSuperAdmin ? 1 : isAdmin ? 3 : 6, priority: isSuperAdmin ? 1 : isAdmin ? 3 : isCustomer ? 6 : 6,
}, },
{ {
title: 'Portfolio view', title: 'Portfolio view',
@ -494,7 +559,7 @@ const CommandCenterPage = () => {
href: '/properties/properties-list', href: '/properties/properties-list',
icon: mdiHomeCity, icon: mdiHomeCity,
visible: overview.access.inventory, visible: overview.access.inventory,
priority: isSuperAdmin ? 2 : isAdmin ? 6 : 5, priority: isSuperAdmin ? 2 : isAdmin ? 6 : isCustomer ? 6 : 5,
}, },
] ]
.filter((item) => item.visible) .filter((item) => item.visible)
@ -504,35 +569,43 @@ const CommandCenterPage = () => {
? 'Leadership lane' ? 'Leadership lane'
: isAdmin : isAdmin
? 'Approval and portfolio lane' ? 'Approval and portfolio lane'
: isCustomer
? 'Customer request lane'
: 'Concierge execution lane'; : 'Concierge execution lane';
const laneDescription = isSuperAdmin const laneDescription = isSuperAdmin
? 'The same shared records, viewed through account health, supply, and revenue exposure.' ? 'The same shared records, viewed through account health, supply, and revenue exposure.'
: isAdmin : isAdmin
? 'The same shared records, centered on approvals, placement quality, and billing follow-through.' ? 'The same shared records, centered on approvals, placement quality, and billing follow-through.'
: isCustomer
? 'The same shared records, centered on stay requests, reservation status, and live service support.'
: 'The same shared records, centered on intake speed, arrivals, and service coordination.'; : 'The same shared records, centered on intake speed, arrivals, and service coordination.';
const focusItems: FocusItem[] = isSuperAdmin const focusItems: FocusItem[] = (isSuperAdmin
? [ ? [
{ {
label: 'Organizations in scope', label: 'Organizations in scope',
value: `${overview.organizations.total}`, value: `${overview.organizations.total}`,
detail: 'Corporate accounts currently visible in your network view.', detail: 'Corporate accounts currently visible in your network view.',
visible: overview.access.accounts,
}, },
{ {
label: 'Active properties', label: 'Active properties',
value: `${overview.inventory.activeProperties}`, value: `${overview.inventory.activeProperties}`,
detail: 'Supply currently available for corporate allocation.', detail: 'Supply currently available for corporate allocation.',
visible: overview.access.inventory,
}, },
{ {
label: 'Open balance', label: 'Open balance',
value: formatMoney(overview.invoices.openBalance, overview.invoices.recent[0]?.currency || 'USD'), value: formatMoney(overview.invoices.openBalance, overview.invoices.recent[0]?.currency || 'USD'),
detail: 'Outstanding invoice exposure across the current operating scope.', detail: 'Outstanding invoice exposure across the current operating scope.',
visible: overview.access.invoices,
}, },
{ {
label: 'Urgent service items', label: 'Urgent service items',
value: `${overview.serviceRequests.urgent}`, value: `${overview.serviceRequests.urgent}`,
detail: 'High-risk stays or property issues that may affect service quality.', detail: 'High-risk stays or property issues that may affect service quality.',
visible: overview.access.serviceRequests,
}, },
] ]
: isAdmin : isAdmin
@ -541,21 +614,52 @@ const CommandCenterPage = () => {
label: 'Approvals waiting', label: 'Approvals waiting',
value: `${overview.approvals.pending}`, value: `${overview.approvals.pending}`,
detail: 'Requests that still need client or internal sign-off.', detail: 'Requests that still need client or internal sign-off.',
visible: overview.access.approvals,
}, },
{ {
label: 'Approved and ready', label: 'Approved and ready',
value: `${overview.bookingRequests.approvedReady}`, value: `${overview.bookingRequests.approvedReady}`,
detail: 'Demand ready to quote or convert into reservations.', detail: 'Demand ready to quote or convert into reservations.',
visible: overview.access.bookings,
}, },
{ {
label: 'Arrivals ahead', label: 'Arrivals ahead',
value: `${overview.reservations.upcomingArrivals}`, value: `${overview.reservations.upcomingArrivals}`,
detail: 'Upcoming arrivals that may need final placement review.', detail: 'Upcoming arrivals that may need final placement review.',
visible: overview.access.reservations,
}, },
{ {
label: 'Open balance', label: 'Open balance',
value: formatMoney(overview.invoices.openBalance, overview.invoices.recent[0]?.currency || 'USD'), value: formatMoney(overview.invoices.openBalance, overview.invoices.recent[0]?.currency || 'USD'),
detail: 'Outstanding billing that may need follow-up before closeout.', detail: 'Outstanding billing that may need follow-up before closeout.',
visible: overview.access.invoices,
},
]
: isCustomer
? [
{
label: 'Requests in motion',
value: `${overview.bookingRequests.pendingReview}`,
detail: 'Stay requests that are still moving through review or confirmation.',
visible: overview.access.bookings,
},
{
label: 'Upcoming stays',
value: `${overview.reservations.upcomingArrivals}`,
detail: 'Confirmed arrivals coming up soon in your current scope.',
visible: overview.access.reservations,
},
{
label: 'Guests in house',
value: `${overview.reservations.inHouse}`,
detail: 'Active stays that may still need service coordination.',
visible: overview.access.reservations,
},
{
label: 'Support queue',
value: `${overview.serviceRequests.open}`,
detail: 'Open service requests tied to live reservations or traveler needs.',
visible: overview.access.serviceRequests,
}, },
] ]
: [ : [
@ -563,30 +667,35 @@ const CommandCenterPage = () => {
label: 'New demand', label: 'New demand',
value: `${overview.bookingRequests.pendingReview}`, value: `${overview.bookingRequests.pendingReview}`,
detail: 'Requests currently entering review or awaiting next action.', detail: 'Requests currently entering review or awaiting next action.',
visible: overview.access.bookings,
}, },
{ {
label: 'Guests in house', label: 'Guests in house',
value: `${overview.reservations.inHouse}`, value: `${overview.reservations.inHouse}`,
detail: 'Travelers currently active across your operating scope.', detail: 'Travelers currently active across your operating scope.',
visible: overview.access.reservations,
}, },
{ {
label: 'Service queue', label: 'Service queue',
value: `${overview.serviceRequests.open}`, value: `${overview.serviceRequests.open}`,
detail: 'Open guest or property requests needing execution.', detail: 'Open guest or property requests needing execution.',
visible: overview.access.serviceRequests,
}, },
{ {
label: 'Departures ahead', label: 'Departures ahead',
value: `${overview.reservations.upcomingDepartures}`, value: `${overview.reservations.upcomingDepartures}`,
detail: 'Upcoming departures that may require extensions or billing checks.', detail: 'Upcoming departures that may require extensions or billing checks.',
visible: overview.access.reservations,
}, },
]; ])
.filter((item) => item.visible);
const connectedWorkflow = [ const connectedWorkflow = [
{ {
title: 'Demand intake', title: 'Request submission',
owner: 'Concierge Coordinator', owner: 'Customer',
value: `${overview.bookingRequests.pendingReview}`, value: `${overview.bookingRequests.pendingReview}`,
detail: 'Booking requests enter here, with traveler needs, dates, and preferred placement captured once for everyone.', detail: 'Customers initiate stay demand once, keeping dates, traveler context, and special requirements attached to the same shared record.',
href: '/booking_requests/booking_requests-list', href: '/booking_requests/booking_requests-list',
visible: canReadBookings || canCreateBookingRequest, visible: canReadBookings || canCreateBookingRequest,
}, },
@ -691,7 +800,7 @@ const CommandCenterPage = () => {
<SectionTitleLineWithButton <SectionTitleLineWithButton
icon={mdiViewDashboardOutline} icon={mdiViewDashboardOutline}
title="Command Center" title="Command Center"
subtitle="One shared operating dashboard with a different lens for each role." subtitle="One shared operating dashboard with role-specific modules and visibility."
main main
> >
<BaseButton color="whiteDark" icon={mdiRefresh} label="Refresh" onClick={loadOverview} /> <BaseButton color="whiteDark" icon={mdiRefresh} label="Refresh" onClick={loadOverview} />
@ -768,30 +877,24 @@ const CommandCenterPage = () => {
</div> </div>
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1"> <div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
<div className="rounded-2xl border border-white/10 bg-white/5 p-4"> {heroStats.map((stat) => (
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Corporate accounts</div> <div key={stat.title} className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="mt-2 text-3xl font-semibold text-white">{overview.organizations.total}</div> <div className="text-xs uppercase tracking-[0.2em] text-slate-400">{stat.title}</div>
<div className="mt-2 text-sm text-slate-400">Organizations in the current operating scope.</div> <div className="mt-2 text-3xl font-semibold text-white">{stat.value}</div>
</div> <div className="mt-2 text-sm text-slate-400">{stat.detail}</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Live properties</div>
<div className="mt-2 text-3xl font-semibold text-white">{overview.inventory.activeProperties}</div>
<div className="mt-2 text-sm text-slate-400">Properties open for negotiated corporate stays.</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Last refresh</div>
<div className="mt-2 text-xl font-semibold text-white">{formatDateTime(overview.generatedAt)}</div>
<div className="mt-2 text-sm text-slate-400">Live snapshot across operations and billing.</div>
</div> </div>
))}
</div> </div>
</div> </div>
</section> </section>
{metricCards.length ? (
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{metricCards.map((card) => ( {metricCards.map((card) => (
<MetricSummaryCard key={card.title} {...card} /> <MetricSummaryCard key={card.title} {...card} />
))} ))}
</div> </div>
) : null}
<div className="mb-8 grid gap-6 xl:grid-cols-[1.1fr_0.9fr]"> <div className="mb-8 grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<CardBox> <CardBox>
@ -838,19 +941,24 @@ const CommandCenterPage = () => {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{focusItems.map((item) => ( {focusItems.length ? (
<FocusListItem key={item.label} {...item} /> focusItems.map((item) => <FocusListItem key={item.label} {...item} />)
))} ) : (
<div className="rounded-xl border border-dashed border-white/10 px-4 py-5 text-sm text-slate-400">
This lane will populate as more role permissions are enabled.
</div>
)}
</div> </div>
</CardBox> </CardBox>
</div> </div>
{connectedWorkflow.length ? (
<CardBox className="mb-8"> <CardBox className="mb-8">
<div className="mb-5 flex items-start justify-between gap-3"> <div className="mb-5 flex items-start justify-between gap-3">
<div> <div>
<h3 className="text-lg font-semibold text-white">Interconnected operating model</h3> <h3 className="text-lg font-semibold text-white">Interconnected operating model</h3>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
All three account types land in the same command center, but each role acts on a different part of the same live workflow. Customers, concierge, administrators, and super admins share the same command center, but each role acts on a different part of the same live workflow.
</p> </p>
</div> </div>
</div> </div>
@ -870,6 +978,7 @@ const CommandCenterPage = () => {
))} ))}
</div> </div>
</CardBox> </CardBox>
) : null}
<div className="grid gap-6 xl:grid-cols-2 2xl:grid-cols-4"> <div className="grid gap-6 xl:grid-cols-2 2xl:grid-cols-4">
{activitySections.map((section) => ( {activitySections.map((section) => (

View File

@ -64,7 +64,7 @@ export default function HomePage() {
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<BaseButton href="/login" color="whiteDark" label="Login" /> <BaseButton href="/login" color="whiteDark" label="Login" />
<BaseButton href="/dashboard" color="info" label="Admin interface" /> <BaseButton href="/command-center" color="info" label="Open workspace" />
</div> </div>
</div> </div>
@ -157,10 +157,10 @@ export default function HomePage() {
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5"> <div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Administration</div> <div className="text-xs uppercase tracking-[0.2em] text-slate-400">Administration</div>
<div className="mt-2 text-2xl font-semibold text-white">Admin dashboard</div> <div className="mt-2 text-2xl font-semibold text-white">Role-based workspace</div>
<div className="mt-2 text-sm leading-6 text-slate-300">Access entity management, permissions, and full system configuration.</div> <div className="mt-2 text-sm leading-6 text-slate-300">Land in one shared command center, then branch into the modules your role actually owns.</div>
<div className="mt-5"> <div className="mt-5">
<BaseButton href="/dashboard" color="whiteDark" label="Open admin" /> <BaseButton href="/command-center" color="whiteDark" label="Open workspace" />
</div> </div>
</div> </div>
</div> </div>

View File

@ -65,7 +65,7 @@ export default function Login() {
// Redirect to dashboard if user is logged in // Redirect to dashboard if user is logged in
useEffect(() => { useEffect(() => {
if (currentUser?.id) { if (currentUser?.id) {
router.push('/dashboard'); router.push('/command-center');
} }
}, [currentUser?.id, router]); }, [currentUser?.id, router]);
// Show error message if there is one // Show error message if there is one
@ -183,12 +183,18 @@ export default function Login() {
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '} onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
<code className={`${textColor}`}>946cafba</code>{' / '} <code className={`${textColor}`}>946cafba</code>{' / '}
to login as Admin</p> to login as Admin</p>
<p className='mb-2'>Use <code
className={`cursor-pointer ${textColor} `}
data-password="bc40952c93f4"
onClick={(e) => setLogin(e.target)}>john@doe.com</code>{' / '}
<code className={`${textColor}`}>bc40952c93f4</code>{' / '}
to login as Concierge</p>
<p>Use <code <p>Use <code
className={`cursor-pointer ${textColor} `} className={`cursor-pointer ${textColor} `}
data-password="bc40952c93f4" data-password="bc40952c93f4"
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '} onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
<code className={`${textColor}`}>bc40952c93f4</code>{' / '} <code className={`${textColor}`}>bc40952c93f4</code>{' / '}
to login as User</p> to login as Customer</p>
</div> </div>
<div> <div>
<BaseIcon <BaseIcon

View File

@ -61,7 +61,7 @@ const RolesTablesPage = () => {
}; };
const getRolesCSV = async () => { const getRolesCSV = async () => {
const response = await axios({url: '/roles?filetype=csv', method: 'GET',responseType: 'blob'}); const response = await axios({url: '/roles?filetype=csv&businessOnly=true', method: 'GET',responseType: 'blob'});
const type = response.headers['content-type'] const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type }) const blob = new Blob([response.data], { type: type })
const link = document.createElement('a') const link = document.createElement('a')

View File

@ -61,7 +61,7 @@ const RolesTablesPage = () => {
}; };
const getRolesCSV = async () => { const getRolesCSV = async () => {
const response = await axios({url: '/roles?filetype=csv', method: 'GET',responseType: 'blob'}); const response = await axios({url: '/roles?filetype=csv&businessOnly=true', method: 'GET',responseType: 'blob'});
const type = response.headers['content-type'] const type = response.headers['content-type']
const blob = new Blob([response.data], { type: type }) const blob = new Blob([response.data], { type: type })
const link = document.createElement('a') const link = document.createElement('a')

View File

@ -74,7 +74,7 @@ const SearchView = () => {
<BaseButton <BaseButton
color='info' color='info'
label='Back' label='Back'
onClick={() => router.push('/dashboard')} onClick={() => router.push('/command-center')}
/> />
</CardBox> </CardBox>
</SectionMain> </SectionMain>