Compare commits

..

No commits in common. "ai-dev" and "master" have entirely different histories.

170 changed files with 31677 additions and 18164 deletions

View File

View File

View File

@ -38,8 +38,7 @@
"sqlite": "4.0.15", "sqlite": "4.0.15",
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"tedious": "^18.2.4", "tedious": "^18.2.4"
"xlsx": "^0.18.5"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"

View File

@ -51,11 +51,15 @@ 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,70 +1,14 @@
const db = require('../models'); const db = require('../models');
const Utils = require('../utils'); const FileDBApi = require('./file');
const config = require('../../config');
const crypto = require('crypto'); const crypto = require('crypto');
const Utils = require('../utils');
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 {
@ -77,9 +21,15 @@ module.exports = class Booking_requestsDBApi {
{ {
id: data.id || undefined, id: data.id || undefined,
request_code: resolveRequestCode(data, currentUser), request_code: data.request_code
||
null
,
status: resolveStatusForCreate(data, currentUser), status: data.status
||
null
,
check_in_at: data.check_in_at check_in_at: data.check_in_at
|| ||
@ -134,15 +84,15 @@ module.exports = class Booking_requestsDBApi {
); );
await booking_requests.setTenant( canManageInternalBookingRequestFields(currentUser) ? data.tenant || null : null, { await booking_requests.setTenant( data.tenant || null, {
transaction, transaction,
}); });
await booking_requests.setOrganization(resolveOrganizationId(data, currentUser), { await booking_requests.setOrganization(currentUser.organization.id || null, {
transaction, transaction,
}); });
await booking_requests.setRequested_by(resolveRequestedById(data, currentUser), { await booking_requests.setRequested_by( data.requested_by || null, {
transaction, transaction,
}); });
@ -261,22 +211,18 @@ 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.findOne({ const booking_requests = await db.booking_requests.findByPk(id, {}, {transaction});
where: mergeWhereWithScope({ id }, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
transaction,
});
const updatePayload = {}; const updatePayload = {};
if (data.request_code !== undefined && canManageInternalFields) updatePayload.request_code = data.request_code; if (data.request_code !== undefined) updatePayload.request_code = data.request_code;
if (data.status !== undefined && canManageInternalFields) updatePayload.status = data.status; if (data.status !== undefined) 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;
@ -312,7 +258,7 @@ module.exports = class Booking_requestsDBApi {
if (data.tenant !== undefined && canManageInternalFields) { if (data.tenant !== undefined) {
await booking_requests.setTenant( await booking_requests.setTenant(
data.tenant, data.tenant,
@ -330,7 +276,7 @@ module.exports = class Booking_requestsDBApi {
); );
} }
if (data.requested_by !== undefined && canManageInternalFields) { if (data.requested_by !== undefined) {
await booking_requests.setRequested_by( await booking_requests.setRequested_by(
data.requested_by, data.requested_by,
@ -387,11 +333,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: mergeWhereWithScope({ where: {
id: { id: {
[Op.in]: ids, [Op.in]: ids,
}, },
}, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null), },
transaction, transaction,
}); });
@ -415,14 +361,7 @@ 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.findOne({ const booking_requests = await db.booking_requests.findByPk(id, options);
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
@ -439,12 +378,11 @@ 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: mergeWhereWithScope(where, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null), { where },
transaction, { transaction },
}); );
if (!booking_requests) { if (!booking_requests) {
return booking_requests; return booking_requests;
@ -564,23 +502,21 @@ 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;
if (userOrganizations) { if (userOrganizations) {
if (options?.currentUser?.organizationsId) { if (options?.currentUser?.organizationsId) {
where.organizationId = options.currentUser.organizationsId; where.organizationsId = options.currentUser.organizationsId;
} }
} }
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 = [
@ -1036,7 +972,7 @@ module.exports = class Booking_requestsDBApi {
if (globalAccess) { if (globalAccess) {
delete where.organizationId; delete where.organizationsId;
} }
@ -1069,12 +1005,8 @@ module.exports = class Booking_requestsDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, currentUser) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
let where = {}; let where = {};
if (isCustomerUser(currentUser)) {
where.requested_byId = currentUser.id;
}
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
@ -1084,18 +1016,13 @@ module.exports = class Booking_requestsDBApi {
if (query) { if (query) {
where = { where = {
[Op.and]: [ [Op.or]: [
where, { ['id']: Utils.uuid(query) },
{ Utils.ilike(
[Op.or]: [ 'booking_requests',
{ ['id']: Utils.uuid(query) }, 'request_code',
Utils.ilike( query,
'booking_requests', ),
'request_code',
query,
),
],
},
], ],
}; };
} }

View File

@ -1,47 +1,14 @@
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 quoteSqlString(value) {
return `'${String(value).replace(/'/g, "''")}'`;
}
function buildCustomerDocumentScope(currentUser) {
if (!isCustomerUser(currentUser)) {
return null;
}
const userId = quoteSqlString(currentUser.id);
return {
[Op.or]: [
{ uploaded_byId: currentUser.id },
Sequelize.literal(`EXISTS (SELECT 1 FROM "booking_requests" br WHERE br."id" = "documents"."booking_requestId" AND br."requested_byId" = ${userId})`),
Sequelize.literal(`EXISTS (SELECT 1 FROM "service_requests" sr WHERE sr."id" = "documents"."service_requestId" AND sr."requested_byId" = ${userId})`),
Sequelize.literal(`EXISTS (SELECT 1 FROM "reservations" r JOIN "booking_requests" br ON br."id" = r."booking_requestId" WHERE r."id" = "documents"."reservationId" AND br."requested_byId" = ${userId})`),
],
};
}
function mergeWhereWithScope(where, scope) {
if (!scope) {
return where;
}
return {
[Op.and]: [where, scope],
};
}
module.exports = class DocumentsDBApi { module.exports = class DocumentsDBApi {
@ -209,10 +176,7 @@ module.exports = class DocumentsDBApi {
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 documents = await db.documents.findOne({ const documents = await db.documents.findByPk(id, {}, {transaction});
where: mergeWhereWithScope({ id }, buildCustomerDocumentScope(currentUser)),
transaction,
});
@ -326,11 +290,11 @@ module.exports = class DocumentsDBApi {
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const documents = await db.documents.findAll({ const documents = await db.documents.findAll({
where: mergeWhereWithScope({ where: {
id: { id: {
[Op.in]: ids, [Op.in]: ids,
}, },
}, buildCustomerDocumentScope(currentUser)), },
transaction, transaction,
}); });
@ -354,10 +318,7 @@ module.exports = class DocumentsDBApi {
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 documents = await db.documents.findOne({ const documents = await db.documents.findByPk(id, options);
where: mergeWhereWithScope({ id }, buildCustomerDocumentScope(currentUser)),
transaction,
});
await documents.update({ await documents.update({
deletedBy: currentUser.id deletedBy: currentUser.id
@ -374,10 +335,9 @@ module.exports = class DocumentsDBApi {
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 documents = await db.documents.findOne( const documents = await db.documents.findOne(
{ where: mergeWhereWithScope(where, buildCustomerDocumentScope(currentUser)) }, { where },
{ transaction }, { transaction },
); );
@ -481,7 +441,11 @@ module.exports = class DocumentsDBApi {
offset = currentPage * limit; offset = currentPage * limit;
let include = [ const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [
{ {
model: db.tenants, model: db.tenants,
@ -770,8 +734,7 @@ module.exports = class DocumentsDBApi {
if (globalAccess) { if (globalAccess) {
delete where.organizationsId; delete where.organizationsId;
} }
where = mergeWhereWithScope(where, buildCustomerDocumentScope(user));
const queryOptions = { const queryOptions = {
where, where,
@ -802,7 +765,7 @@ module.exports = class DocumentsDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, currentUser = null) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
let where = {}; let where = {};
@ -824,8 +787,6 @@ module.exports = class DocumentsDBApi {
}; };
} }
where = mergeWhereWithScope(where, buildCustomerDocumentScope(currentUser));
const records = await db.documents.findAll({ const records = await db.documents.findAll({
attributes: [ 'id', 'file_name' ], attributes: [ 'id', 'file_name' ],
where, where,

View File

@ -1,398 +0,0 @@
const db = require('../models');
const QueryTypes = db.Sequelize.QueryTypes;
const Op = db.Sequelize.Op;
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const getCurrentOrganizationId = (currentUser) => {
if (!currentUser) {
return null;
}
return (
currentUser.organization?.id ||
currentUser.organizations?.id ||
currentUser.organizationId ||
currentUser.organizationsId ||
null
);
};
const createBadRequestError = (message) => {
const error = new Error(message);
error.code = 400;
return error;
};
const getImportCellValue = (item, keys) => {
for (const key of keys) {
const value = item?.[key];
if (value === 0 || value === false) {
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed) {
return trimmed;
}
continue;
}
if (value !== undefined && value !== null && value !== '') {
return value;
}
}
return null;
};
const normalizeReferenceValue = (value) => {
if (value === undefined || value === null) {
return null;
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || null;
}
return value;
};
const buildReferenceClauses = (reference, fields = []) => {
const normalizedReference = normalizeReferenceValue(reference);
if (!normalizedReference) {
return [];
}
const clauses = [];
if (typeof normalizedReference === 'string' && UUID_PATTERN.test(normalizedReference)) {
clauses.push({ id: normalizedReference });
}
for (const field of fields) {
clauses.push({
[field]: {
[Op.iLike]: String(normalizedReference),
},
});
}
return clauses;
};
const buildLookupWhere = (reference, fields = [], extraWhere = {}) => {
const clauses = buildReferenceClauses(reference, fields);
if (!clauses.length) {
return null;
}
const filters = [{ [Op.or]: clauses }];
if (extraWhere && Object.keys(extraWhere).length) {
filters.unshift(extraWhere);
}
if (filters.length === 1) {
return filters[0];
}
return {
[Op.and]: filters,
};
};
const lookupSingleRecordByReference = async ({
model,
label,
reference,
fields = [],
extraWhere = {},
transaction,
}) => {
const normalizedReference = normalizeReferenceValue(reference);
if (!normalizedReference) {
return null;
}
const where = buildLookupWhere(normalizedReference, fields, extraWhere);
if (!where) {
return null;
}
const records = await model.findAll({
where,
transaction,
limit: 2,
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
});
if (!records.length) {
throw createBadRequestError(`${label} "${normalizedReference}" was not found.`);
}
if (records.length > 1) {
throw createBadRequestError(`${label} "${normalizedReference}" matches multiple records. Use the ID instead.`);
}
return records[0];
};
const normalizeImportedBoolean = (value) => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['true', '1', 'yes', 'y'].includes(normalized)) {
return true;
}
if (['false', '0', 'no', 'n'].includes(normalized)) {
return false;
}
}
return value;
};
const normalizeInventoryImportRow = (item = {}) => ({
...item,
id: getImportCellValue(item, ['id']) || undefined,
importHash: getImportCellValue(item, ['importHash']) || null,
tenant: getImportCellValue(item, ['tenant', 'tenantId']),
organizations: getImportCellValue(item, ['organizations', 'organization', 'organizationsId', 'organizationId']),
property: getImportCellValue(item, ['property', 'propertyId']),
unit_type: getImportCellValue(item, ['unit_type', 'unitType', 'unit_typeId', 'unitTypeId']),
is_active: normalizeImportedBoolean(item?.is_active),
});
const prefixImportError = (error, rowNumber) => {
if (!error) {
return error;
}
const prefixedMessage = `Import row ${rowNumber}: ${error.message}`;
if (typeof error.message === 'string' && !error.message.startsWith(`Import row ${rowNumber}:`)) {
error.message = prefixedMessage;
}
return error;
};
const resolveTenantIdForOrganization = async (organizationId, transaction) => {
if (!organizationId) {
return null;
}
const rows = await db.sequelize.query(
`
SELECT "tenants_organizationsId" AS "tenantId"
FROM "tenantsOrganizationsOrganizations"
WHERE "organizationId" = :organizationId
ORDER BY "updatedAt" DESC, "createdAt" DESC
LIMIT 1
`,
{
replacements: { organizationId },
type: QueryTypes.SELECT,
transaction,
},
);
return rows[0]?.tenantId || null;
};
const resolveOrganizationIdsForTenant = async (tenantId, transaction) => {
if (!tenantId) {
return [];
}
const rows = await db.sequelize.query(
`
SELECT "organizationId"
FROM "tenantsOrganizationsOrganizations"
WHERE "tenants_organizationsId" = :tenantId
ORDER BY "updatedAt" DESC, "createdAt" DESC
`,
{
replacements: { tenantId },
type: QueryTypes.SELECT,
transaction,
},
);
return rows.map((row) => row.organizationId).filter(Boolean);
};
const resolveTenantReference = async (reference, transaction) => {
const tenant = await lookupSingleRecordByReference({
model: db.tenants,
label: 'Tenant',
reference,
fields: ['slug', 'name'],
transaction,
});
return tenant?.id || null;
};
const resolveOrganizationReference = async (reference, { tenantId, transaction } = {}) => {
const normalizedReference = normalizeReferenceValue(reference);
if (!normalizedReference) {
return null;
}
const extraWhere = {};
if (tenantId) {
const organizationIds = await resolveOrganizationIdsForTenant(tenantId, transaction);
if (!organizationIds.length) {
throw createBadRequestError('The selected tenant has no organizations available for import.');
}
extraWhere.id = {
[Op.in]: organizationIds,
};
}
const organization = await lookupSingleRecordByReference({
model: db.organizations,
label: 'Organization',
reference: normalizedReference,
fields: ['name'],
extraWhere,
transaction,
});
return organization?.id || null;
};
const resolvePropertyReference = async (reference, { currentUser, organizationId, tenantId, transaction } = {}) => {
const normalizedReference = normalizeReferenceValue(reference);
if (!normalizedReference) {
return null;
}
const globalAccess = Boolean(currentUser?.app_role?.globalAccess);
const extraWhere = {};
if (organizationId) {
extraWhere.organizationsId = organizationId;
} else if (!globalAccess) {
const currentOrganizationId = getCurrentOrganizationId(currentUser);
if (currentOrganizationId) {
extraWhere.organizationsId = currentOrganizationId;
}
}
if (tenantId) {
extraWhere.tenantId = tenantId;
}
const property = await lookupSingleRecordByReference({
model: db.properties,
label: 'Property',
reference: normalizedReference,
fields: ['code', 'name'],
extraWhere,
transaction,
});
return property?.id || null;
};
const resolveUnitTypeReference = async (reference, { currentUser, organizationId, propertyId, transaction } = {}) => {
const normalizedReference = normalizeReferenceValue(reference);
if (!normalizedReference) {
return null;
}
const globalAccess = Boolean(currentUser?.app_role?.globalAccess);
const extraWhere = {};
if (propertyId) {
extraWhere.propertyId = propertyId;
}
if (organizationId) {
extraWhere.organizationsId = organizationId;
} else if (!globalAccess) {
const currentOrganizationId = getCurrentOrganizationId(currentUser);
if (currentOrganizationId) {
extraWhere.organizationsId = currentOrganizationId;
}
}
const unitType = await lookupSingleRecordByReference({
model: db.unit_types,
label: 'Unit type',
reference: normalizedReference,
fields: ['code', 'name'],
extraWhere,
transaction,
});
return unitType?.id || null;
};
const loadPropertyContext = async (propertyId, transaction) => {
if (!propertyId) {
return null;
}
const property = await db.properties.findByPk(propertyId, { transaction });
if (!property) {
throw createBadRequestError('Selected property was not found.');
}
return property;
};
const loadUnitTypeContext = async (unitTypeId, transaction) => {
if (!unitTypeId) {
return null;
}
const unitType = await db.unit_types.findByPk(unitTypeId, { transaction });
if (!unitType) {
throw createBadRequestError('Selected unit type was not found.');
}
return unitType;
};
module.exports = {
createBadRequestError,
getCurrentOrganizationId,
getImportCellValue,
loadPropertyContext,
loadUnitTypeContext,
normalizeImportedBoolean,
normalizeInventoryImportRow,
normalizeReferenceValue,
prefixImportError,
resolveOrganizationIdsForTenant,
resolveOrganizationReference,
resolvePropertyReference,
resolveTenantIdForOrganization,
resolveTenantReference,
resolveUnitTypeReference,
};

View File

@ -1,7 +1,10 @@
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 { resolveOrganizationIdsForTenant } = require('./inventoryContext');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
@ -71,6 +74,8 @@ module.exports = class OrganizationsDBApi {
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 organizations = await db.organizations.findByPk(id, {}, {transaction}); const organizations = await db.organizations.findByPk(id, {}, {transaction});
@ -317,6 +322,9 @@ module.exports = class OrganizationsDBApi {
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
@ -423,30 +431,17 @@ module.exports = class OrganizationsDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, tenantId, options = {}) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
const filters = []; let where = {};
const organizationIdsForTenant = tenantId
? await resolveOrganizationIdsForTenant(Utils.uuid(tenantId), options.transaction)
: [];
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
filters.push({ id: organizationId }); where.organizationId = organizationId;
}
if (tenantId) {
if (!organizationIdsForTenant.length) {
return [];
}
filters.push({
id: {
[Op.in]: organizationIdsForTenant,
},
});
} }
if (query) { if (query) {
filters.push({ where = {
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike( Utils.ilike(
@ -455,19 +450,15 @@ module.exports = class OrganizationsDBApi {
query, query,
), ),
], ],
}); };
} }
const where = filters.length > 1
? { [Op.and]: filters }
: (filters[0] || {});
const records = await db.organizations.findAll({ const records = await db.organizations.findAll({
attributes: [ 'id', 'name' ], attributes: [ 'id', 'name' ],
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
order: [['name', 'ASC']], orderBy: [['name', 'ASC']],
}); });
return records.map((record) => ({ return records.map((record) => ({

View File

@ -1,8 +1,10 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file'); const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
const { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, prefixImportError, resolveOrganizationReference, resolveTenantIdForOrganization, resolveTenantReference } = require('./inventoryContext');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
@ -14,17 +16,6 @@ module.exports = class PropertiesDBApi {
static async create(data, options) { static async create(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;
let organizationId = data.organizations || null;
if (!organizationId && !globalAccess) {
organizationId = getCurrentOrganizationId(currentUser);
}
let tenantId = data.tenant || null;
if (!tenantId && organizationId) {
tenantId = await resolveTenantIdForOrganization(organizationId, transaction);
}
const properties = await db.properties.create( const properties = await db.properties.create(
{ {
@ -79,11 +70,11 @@ module.exports = class PropertiesDBApi {
); );
await properties.setTenant( tenantId || null, { await properties.setTenant( data.tenant || null, {
transaction, transaction,
}); });
await properties.setOrganizations( organizationId || null, { await properties.setOrganizations( data.organizations || null, {
transaction, transaction,
}); });
@ -121,95 +112,83 @@ module.exports = class PropertiesDBApi {
static async bulkImport(data, options) { static async bulkImport(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 seenIds = new Set();
const seenImportHashes = new Set();
const createdRecords = [];
for (let index = 0; index < data.length; index += 1) { // Prepare data - wrapping individual data transformations in a map() method
const item = normalizeInventoryImportRow(data[index]); const propertiesData = data.map((item, index) => ({
const rowNumber = index + 2; id: item.id || undefined,
name: item.name
||
null
,
code: item.code
||
null
,
address: item.address
||
null
,
city: item.city
||
null
,
country: item.country
||
null
,
timezone: item.timezone
||
null
,
description: item.description
||
null
,
is_active: item.is_active
||
false
,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000),
}));
try { // Bulk create items
const globalAccess = Boolean(currentUser.app_role?.globalAccess); const properties = await db.properties.bulkCreate(propertiesData, { transaction });
let tenantId = null;
let organizationId = null;
if (globalAccess) { // For each item created, replace relation files
tenantId = await resolveTenantReference(item.tenant, transaction);
organizationId = await resolveOrganizationReference(item.organizations, { for (let i = 0; i < properties.length; i++) {
tenantId, await FileDBApi.replaceRelationFiles(
transaction, {
}); belongsTo: db.properties.getTableName(),
belongsToColumn: 'images',
if (!organizationId) { belongsToId: properties[i].id,
throw createBadRequestError('Organization is required for property import.'); },
} data[i].images,
} else { options,
organizationId = getCurrentOrganizationId(currentUser); );
}
if (!tenantId && organizationId) {
tenantId = await resolveTenantIdForOrganization(organizationId, transaction);
}
item.tenant = tenantId || null;
item.organizations = organizationId || null;
const itemId = item.id || null;
const importHash = item.importHash || null;
if (options?.ignoreDuplicates) {
if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) {
continue;
}
const duplicateFilters = [];
if (itemId) {
duplicateFilters.push({ id: itemId });
}
if (importHash) {
duplicateFilters.push({ importHash });
}
if (duplicateFilters.length) {
const existingRecord = await db.properties.findOne({
where: {
[Op.or]: duplicateFilters,
},
transaction,
paranoid: false,
});
if (existingRecord) {
continue;
}
}
}
if (itemId) {
seenIds.add(itemId);
}
if (importHash) {
seenImportHashes.add(importHash);
}
const createdRecord = await this.create(item, {
...options,
currentUser,
transaction,
});
createdRecords.push(createdRecord);
} catch (error) {
throw prefixImportError(error, rowNumber);
}
} }
return createdRecords; return properties;
} }
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 properties = await db.properties.findByPk(id, {}, {transaction}); const properties = await db.properties.findByPk(id, {}, {transaction});
@ -472,6 +451,10 @@ module.exports = class PropertiesDBApi {
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
{ {
@ -780,23 +763,17 @@ module.exports = class PropertiesDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, tenantId, selectedOrganizationId) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
const filters = []; let where = {};
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
filters.push({ organizationsId: organizationId }); where.organizationId = organizationId;
}
if (tenantId) {
filters.push({ tenantId: Utils.uuid(tenantId) });
}
if (selectedOrganizationId) {
filters.push({ organizationsId: Utils.uuid(selectedOrganizationId) });
} }
if (query) { if (query) {
filters.push({ where = {
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike( Utils.ilike(
@ -805,19 +782,15 @@ module.exports = class PropertiesDBApi {
query, query,
), ),
], ],
}); };
} }
const where = filters.length > 1
? { [Op.and]: filters }
: (filters[0] || {});
const records = await db.properties.findAll({ const records = await db.properties.findAll({
attributes: [ 'id', 'name' ], attributes: [ 'id', 'name' ],
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
order: [['name', 'ASC']], orderBy: [['name', 'ASC']],
}); });
return records.map((record) => ({ return records.map((record) => ({

View File

@ -1,36 +1,14 @@
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 {
@ -464,18 +442,16 @@ 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({ where, transaction }); const reservations = await db.reservations.findOne(
{ 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});
@ -607,23 +583,22 @@ module.exports = class ReservationsDBApi {
const user = (options && options.currentUser) || null; const user = (options && options.currentUser) || null;
const userOrganizationId = const userOrganizations = (user && user.organizations?.id) || null;
user?.organization?.id ||
user?.organizations?.id ||
user?.organizationId ||
user?.organizationsId ||
null;
const isCustomer = isCustomerUser(user);
if (!globalAccess && userOrganizationId) { if (userOrganizations) {
where.organizationId = userOrganizationId; if (options?.currentUser?.organizationsId) {
where.organizationsId = options.currentUser.organizationsId;
}
} }
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
@ -653,20 +628,17 @@ 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: { where: filter.booking_request ? {
...(isCustomer ? { requested_byId: user.id } : {}), [Op.or]: [
...(filter.booking_request ? { { id: { [Op.in]: filter.booking_request.split('|').map(term => Utils.uuid(term)) } },
[Op.or]: [ {
{ id: { [Op.in]: filter.booking_request.split('|').map(term => Utils.uuid(term)) } }, request_code: {
{ [Op.or]: filter.booking_request.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
request_code: { }
[Op.or]: filter.booking_request.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) },
} ]
}, } : {},
]
} : {}),
},
}, },
@ -1199,7 +1171,7 @@ module.exports = class ReservationsDBApi {
if (globalAccess) { if (globalAccess) {
delete where.organizationId; delete where.organizationsId;
} }
@ -1232,9 +1204,8 @@ module.exports = class ReservationsDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, currentUser) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
let where = {}; let where = {};
const include = [];
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
@ -1244,35 +1215,20 @@ module.exports = class ReservationsDBApi {
if (query) { if (query) {
where = { where = {
[Op.and]: [ [Op.or]: [
where, { ['id']: Utils.uuid(query) },
{ Utils.ilike(
[Op.or]: [ 'reservations',
{ ['id']: Utils.uuid(query) }, 'reservation_code',
Utils.ilike( query,
'reservations', ),
'reservation_code',
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,5 +1,7 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
@ -9,43 +11,6 @@ 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,
];
const HIGH_TRUST_ROLE_NAMES = [
config.roles.super_admin,
config.roles.admin,
];
function appendRoleVisibilityScope(where, globalAccess, businessOnly = false, assignableOnly = false, includeHighTrust = true) {
const scopes = [];
if (!globalAccess) {
scopes.push({ name: { [Op.ne]: config.roles.super_admin } });
}
if (businessOnly || assignableOnly) {
scopes.push({ name: { [Op.in]: BUSINESS_ROLE_NAMES } });
}
if (!includeHighTrust) {
scopes.push({ name: { [Op.notIn]: HIGH_TRUST_ROLE_NAMES } });
}
if (!scopes.length) {
return where;
}
return {
[Op.and]: [where, ...scopes],
};
}
module.exports = class RolesDBApi { module.exports = class RolesDBApi {
@ -137,6 +102,8 @@ 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});
@ -292,8 +259,17 @@ 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 = [
@ -411,11 +387,9 @@ module.exports = class RolesDBApi {
} }
} }
const businessOnly = filter.businessOnly === true || filter.businessOnly === 'true'; if (!globalAccess) {
const assignableOnly = filter.assignableOnly === true || filter.assignableOnly === 'true'; where = { name: { [Op.ne]: config.roles.super_admin } };
const includeHighTrust = !(filter.includeHighTrust === false || filter.includeHighTrust === 'false'); }
where = appendRoleVisibilityScope(where, globalAccess, businessOnly, assignableOnly, includeHighTrust);
@ -449,8 +423,14 @@ module.exports = class RolesDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, businessOnly = false, assignableOnly = false, includeHighTrust = true) { static async findAllAutocomplete(query, limit, offset, globalAccess,) {
let where = {}; let where = {};
if (!globalAccess) {
where = { name: { [Op.ne]: config.roles.super_admin } };
}
if (query) { if (query) {
where = { where = {
@ -465,14 +445,6 @@ module.exports = class RolesDBApi {
}; };
} }
where = appendRoleVisibilityScope(
where,
globalAccess,
businessOnly === true || businessOnly === 'true',
assignableOnly === true || assignableOnly === 'true',
!(includeHighTrust === false || includeHighTrust === 'false'),
);
const records = await db.roles.findAll({ const records = await db.roles.findAll({
attributes: [ 'id', 'name' ], attributes: [ 'id', 'name' ],
where, where,

View File

@ -1,42 +1,14 @@
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],
};
}
function resolveServiceStatusForCreate(data, currentUser) {
if (isCustomerUser(currentUser)) {
return 'new';
}
return data.status || 'new';
}
function resolveRequestedAt(data, currentUser) {
if (isCustomerUser(currentUser)) {
return data.requested_at || new Date();
}
return data.requested_at || null;
}
module.exports = class Service_requestsDBApi { module.exports = class Service_requestsDBApi {
@ -54,7 +26,9 @@ module.exports = class Service_requestsDBApi {
null null
, ,
status: resolveServiceStatusForCreate(data, currentUser) status: data.status
||
null
, ,
priority: data.priority priority: data.priority
@ -62,7 +36,9 @@ module.exports = class Service_requestsDBApi {
null null
, ,
requested_at: resolveRequestedAt(data, currentUser) requested_at: data.requested_at
||
null
, ,
due_at: data.due_at due_at: data.due_at
@ -70,7 +46,7 @@ module.exports = class Service_requestsDBApi {
null null
, ,
completed_at: isCustomerUser(currentUser) ? null : data.completed_at completed_at: data.completed_at
|| ||
null null
, ,
@ -90,7 +66,7 @@ module.exports = class Service_requestsDBApi {
null null
, ,
actual_cost: isCustomerUser(currentUser) ? null : data.actual_cost actual_cost: data.actual_cost
|| ||
null null
, ,
@ -108,7 +84,7 @@ module.exports = class Service_requestsDBApi {
); );
await service_requests.setTenant( isCustomerUser(currentUser) ? null : data.tenant || null, { await service_requests.setTenant( data.tenant || null, {
transaction, transaction,
}); });
@ -116,15 +92,15 @@ module.exports = class Service_requestsDBApi {
transaction, transaction,
}); });
await service_requests.setRequested_by( isCustomerUser(currentUser) ? currentUser.id : data.requested_by || null, { await service_requests.setRequested_by( data.requested_by || null, {
transaction, transaction,
}); });
await service_requests.setAssigned_to( isCustomerUser(currentUser) ? null : data.assigned_to || null, { await service_requests.setAssigned_to( data.assigned_to || null, {
transaction, transaction,
}); });
await service_requests.setOrganizations( isCustomerUser(currentUser) ? currentUser.organization?.id || null : data.organizations || null, { await service_requests.setOrganizations( data.organizations || null, {
transaction, transaction,
}); });
@ -226,10 +202,9 @@ 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 service_requests = await db.service_requests.findOne({ const globalAccess = currentUser.app_role?.globalAccess;
where: mergeWhereWithScope({ id }, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
transaction, const service_requests = await db.service_requests.findByPk(id, {}, {transaction});
});
@ -239,7 +214,7 @@ module.exports = class Service_requestsDBApi {
if (data.request_type !== undefined) updatePayload.request_type = data.request_type; if (data.request_type !== undefined) updatePayload.request_type = data.request_type;
if (data.status !== undefined && !isCustomerUser(currentUser)) updatePayload.status = data.status; if (data.status !== undefined) updatePayload.status = data.status;
if (data.priority !== undefined) updatePayload.priority = data.priority; if (data.priority !== undefined) updatePayload.priority = data.priority;
@ -251,7 +226,7 @@ module.exports = class Service_requestsDBApi {
if (data.due_at !== undefined) updatePayload.due_at = data.due_at; if (data.due_at !== undefined) updatePayload.due_at = data.due_at;
if (data.completed_at !== undefined && !isCustomerUser(currentUser)) updatePayload.completed_at = data.completed_at; if (data.completed_at !== undefined) updatePayload.completed_at = data.completed_at;
if (data.summary !== undefined) updatePayload.summary = data.summary; if (data.summary !== undefined) updatePayload.summary = data.summary;
@ -263,7 +238,7 @@ module.exports = class Service_requestsDBApi {
if (data.estimated_cost !== undefined) updatePayload.estimated_cost = data.estimated_cost; if (data.estimated_cost !== undefined) updatePayload.estimated_cost = data.estimated_cost;
if (data.actual_cost !== undefined && !isCustomerUser(currentUser)) updatePayload.actual_cost = data.actual_cost; if (data.actual_cost !== undefined) updatePayload.actual_cost = data.actual_cost;
if (data.currency !== undefined) updatePayload.currency = data.currency; if (data.currency !== undefined) updatePayload.currency = data.currency;
@ -275,9 +250,11 @@ module.exports = class Service_requestsDBApi {
if (data.tenant !== undefined && !isCustomerUser(currentUser)) { if (data.tenant !== undefined) {
await service_requests.setTenant( await service_requests.setTenant(
data.tenant, data.tenant,
{ transaction } { transaction }
); );
} }
@ -294,22 +271,26 @@ 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(
isCustomerUser(currentUser) ? currentUser.id : data.requested_by, data.requested_by,
{ transaction } { transaction }
); );
} }
if (data.assigned_to !== undefined && !isCustomerUser(currentUser)) { if (data.assigned_to !== undefined) {
await service_requests.setAssigned_to( await service_requests.setAssigned_to(
data.assigned_to, data.assigned_to,
{ transaction } { transaction }
); );
} }
if (data.organizations !== undefined && !isCustomerUser(currentUser)) { if (data.organizations !== undefined) {
await service_requests.setOrganizations( await service_requests.setOrganizations(
data.organizations, data.organizations,
{ transaction } { transaction }
); );
} }
@ -336,11 +317,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: mergeWhereWithScope({ where: {
id: { id: {
[Op.in]: ids, [Op.in]: ids,
}, },
}, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null), },
transaction, transaction,
}); });
@ -364,14 +345,7 @@ 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.findOne({ const service_requests = await db.service_requests.findByPk(id, options);
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
@ -388,12 +362,11 @@ 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: mergeWhereWithScope(where, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null), { where },
transaction, { transaction },
}); );
if (!service_requests) { if (!service_requests) {
return service_requests; return service_requests;
@ -491,7 +464,6 @@ 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;
@ -502,12 +474,11 @@ 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 = [
@ -930,12 +901,8 @@ module.exports = class Service_requestsDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, currentUser) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
let where = {}; let where = {};
if (isCustomerUser(currentUser)) {
where.requested_byId = currentUser.id;
}
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
@ -945,18 +912,13 @@ module.exports = class Service_requestsDBApi {
if (query) { if (query) {
where = { where = {
[Op.and]: [ [Op.or]: [
where, { ['id']: Utils.uuid(query) },
{ Utils.ilike(
[Op.or]: [ 'service_requests',
{ ['id']: Utils.uuid(query) }, 'summary',
Utils.ilike( query,
'service_requests', ),
'summary',
query,
),
],
},
], ],
}; };
} }

View File

@ -1,5 +1,7 @@
const db = require('../models'); const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils'); const Utils = require('../utils');
@ -7,149 +9,6 @@ const Utils = require('../utils');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
const normalizeRelationIds = (items) => {
if (!Array.isArray(items)) {
return [];
}
return [...new Set(items.map((item) => {
if (!item) {
return null;
}
if (typeof item === 'string') {
return item;
}
if (typeof item === 'object' && item.id) {
return item.id;
}
if (typeof item === 'object' && item.value) {
return item.value;
}
return null;
}).filter(Boolean))];
};
const syncTenantOwnedChildren = async ({
tenantId,
model,
selectedIds,
organizationIds,
transaction,
currentUserId,
organizationField = 'organizationsId',
}) => {
const normalizedSelectedIds = normalizeRelationIds(selectedIds);
const detachWhere = {
tenantId,
};
if (normalizedSelectedIds.length) {
detachWhere.id = {
[Op.notIn]: normalizedSelectedIds,
};
}
await model.update(
{
tenantId: null,
updatedById: currentUserId,
},
{
where: detachWhere,
transaction,
},
);
if (!normalizedSelectedIds.length) {
return normalizedSelectedIds;
}
await model.update(
{
tenantId,
updatedById: currentUserId,
},
{
where: {
id: {
[Op.in]: normalizedSelectedIds,
},
},
transaction,
},
);
if (!organizationField) {
return normalizedSelectedIds;
}
if (!organizationIds.length) {
await model.update(
{
[organizationField]: null,
updatedById: currentUserId,
},
{
where: {
id: {
[Op.in]: normalizedSelectedIds,
},
tenantId,
},
transaction,
},
);
return normalizedSelectedIds;
}
if (organizationIds.length === 1) {
await model.update(
{
[organizationField]: organizationIds[0],
updatedById: currentUserId,
},
{
where: {
id: {
[Op.in]: normalizedSelectedIds,
},
tenantId,
},
transaction,
},
);
return normalizedSelectedIds;
}
await model.update(
{
[organizationField]: null,
updatedById: currentUserId,
},
{
where: {
id: {
[Op.in]: normalizedSelectedIds,
},
tenantId,
[organizationField]: {
[Op.notIn]: organizationIds,
},
},
transaction,
},
);
return normalizedSelectedIds;
};
module.exports = class TenantsDBApi { module.exports = class TenantsDBApi {
@ -207,29 +66,19 @@ module.exports = class TenantsDBApi {
const organizationIds = normalizeRelationIds(data.organizations);
await tenants.setOrganizations(data.organizations || [], {
await tenants.setOrganizations(organizationIds, {
transaction, transaction,
}); });
await syncTenantOwnedChildren({ await tenants.setProperties(data.properties || [], {
tenantId: tenants.id,
model: db.properties,
selectedIds: data.properties || [],
organizationIds,
transaction, transaction,
currentUserId: currentUser.id,
}); });
await syncTenantOwnedChildren({ await tenants.setAudit_logs(data.audit_logs || [], {
tenantId: tenants.id,
model: db.audit_logs,
selectedIds: data.audit_logs || [],
organizationIds,
transaction, transaction,
currentUserId: currentUser.id,
}); });
@ -299,6 +148,8 @@ module.exports = class TenantsDBApi {
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 tenants = await db.tenants.findByPk(id, {}, {transaction}); const tenants = await db.tenants.findByPk(id, {}, {transaction});
@ -334,37 +185,18 @@ module.exports = class TenantsDBApi {
let organizationIds = null;
if (data.organizations !== undefined) { if (data.organizations !== undefined) {
organizationIds = normalizeRelationIds(data.organizations); await tenants.setOrganizations(data.organizations, { transaction });
await tenants.setOrganizations(organizationIds, { transaction });
} else {
organizationIds = normalizeRelationIds(
(await tenants.getOrganizations({ transaction })) || [],
);
} }
if (data.properties !== undefined) { if (data.properties !== undefined) {
await syncTenantOwnedChildren({ await tenants.setProperties(data.properties, { transaction });
tenantId: tenants.id,
model: db.properties,
selectedIds: data.properties,
organizationIds,
transaction,
currentUserId: currentUser.id,
});
} }
if (data.audit_logs !== undefined) { if (data.audit_logs !== undefined) {
await syncTenantOwnedChildren({ await tenants.setAudit_logs(data.audit_logs, { transaction });
tenantId: tenants.id,
model: db.audit_logs,
selectedIds: data.audit_logs,
organizationIds,
transaction,
currentUserId: currentUser.id,
});
} }
@ -517,10 +349,16 @@ module.exports = class TenantsDBApi {
output.organizations = await tenants.getOrganizations({ output.organizations = await tenants.getOrganizations({
transaction transaction
}); });
output.properties = output.properties_tenant;
output.properties = await tenants.getProperties({
output.audit_logs = output.audit_logs_tenant; transaction
});
output.audit_logs = await tenants.getAudit_logs({
transaction
});
@ -542,19 +380,41 @@ module.exports = class TenantsDBApi {
if (userOrganizations) {
if (options?.currentUser?.organizationsId) {
where.organizationsId = options.currentUser.organizationsId;
}
}
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
{ {
model: db.organizations, model: db.organizations,
as: 'organizations', as: 'organizations',
required: !globalAccess && Boolean(userOrganizations), required: false,
where: !globalAccess && userOrganizations
? {
id: Utils.uuid(userOrganizations),
}
: undefined,
}, },
{
model: db.properties,
as: 'properties',
required: false,
},
{
model: db.audit_logs,
as: 'audit_logs',
required: false,
},
]; ];
if (filter) { if (filter) {
@ -685,7 +545,7 @@ module.exports = class TenantsDBApi {
include = [ include = [
{ {
model: db.properties, model: db.properties,
as: 'properties_tenant', as: 'properties_filter',
required: searchTerms.length > 0, required: searchTerms.length > 0,
where: searchTerms.length > 0 ? { where: searchTerms.length > 0 ? {
[Op.or]: [ [Op.or]: [
@ -708,7 +568,7 @@ module.exports = class TenantsDBApi {
include = [ include = [
{ {
model: db.audit_logs, model: db.audit_logs,
as: 'audit_logs_tenant', as: 'audit_logs_filter',
required: searchTerms.length > 0, required: searchTerms.length > 0,
where: searchTerms.length > 0 ? { where: searchTerms.length > 0 ? {
[Op.or]: [ [Op.or]: [
@ -752,6 +612,11 @@ module.exports = class TenantsDBApi {
} }
if (globalAccess) {
delete where.organizationsId;
}
const queryOptions = { const queryOptions = {
where, where,
@ -784,20 +649,12 @@ module.exports = class TenantsDBApi {
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
let where = {}; let where = {};
let include = [];
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
include = [ where.organizationId = organizationId;
{
model: db.organizations,
as: 'organizations_filter',
required: true,
where: {
id: Utils.uuid(organizationId),
},
},
];
} }
if (query) { if (query) {
where = { where = {
@ -815,7 +672,6 @@ module.exports = class TenantsDBApi {
const records = await db.tenants.findAll({ const records = await db.tenants.findAll({
attributes: [ 'id', 'name' ], attributes: [ 'id', 'name' ],
where, where,
include,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
orderBy: [['name', 'ASC']], orderBy: [['name', 'ASC']],

View File

@ -1,7 +1,10 @@
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 { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, loadPropertyContext, prefixImportError, resolvePropertyReference } = require('./inventoryContext');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
@ -13,17 +16,6 @@ module.exports = class Unit_typesDBApi {
static async create(data, options) { static async create(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;
if (!data.property) {
throw createBadRequestError('Select a property before creating a unit type.');
}
const property = await loadPropertyContext(data.property || null, transaction);
let organizationId = data.organizations || property?.organizationsId || null;
if (!organizationId && !globalAccess) {
organizationId = getCurrentOrganizationId(currentUser);
}
const unit_types = await db.unit_types.create( const unit_types = await db.unit_types.create(
{ {
@ -91,7 +83,7 @@ module.exports = class Unit_typesDBApi {
transaction, transaction,
}); });
await unit_types.setOrganizations( organizationId || null, { await unit_types.setOrganizations( data.organizations || null, {
transaction, transaction,
}); });
@ -111,76 +103,74 @@ module.exports = class Unit_typesDBApi {
static async bulkImport(data, options) { static async bulkImport(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 seenIds = new Set();
const seenImportHashes = new Set();
const createdRecords = [];
for (let index = 0; index < data.length; index += 1) { // Prepare data - wrapping individual data transformations in a map() method
const item = normalizeInventoryImportRow(data[index]); const unit_typesData = data.map((item, index) => ({
const rowNumber = index + 2; id: item.id || undefined,
name: item.name
||
null
,
code: item.code
||
null
,
max_occupancy: item.max_occupancy
||
null
,
bedrooms: item.bedrooms
||
null
,
bathrooms: item.bathrooms
||
null
,
size_sqm: item.size_sqm
||
null
,
description: item.description
||
null
,
base_nightly_rate: item.base_nightly_rate
||
null
,
base_monthly_rate: item.base_monthly_rate
||
null
,
minimum_stay_nights: item.minimum_stay_nights
||
null
,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000),
}));
try { // Bulk create items
const propertyId = await resolvePropertyReference(item.property, { const unit_types = await db.unit_types.bulkCreate(unit_typesData, { transaction });
currentUser,
transaction,
});
if (!propertyId) { // For each item created, replace relation files
throw createBadRequestError('Property is required for unit type import.');
}
item.property = propertyId; return unit_types;
const itemId = item.id || null;
const importHash = item.importHash || null;
if (options?.ignoreDuplicates) {
if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) {
continue;
}
const duplicateFilters = [];
if (itemId) {
duplicateFilters.push({ id: itemId });
}
if (importHash) {
duplicateFilters.push({ importHash });
}
if (duplicateFilters.length) {
const existingRecord = await db.unit_types.findOne({
where: {
[Op.or]: duplicateFilters,
},
transaction,
paranoid: false,
});
if (existingRecord) {
continue;
}
}
}
if (itemId) {
seenIds.add(itemId);
}
if (importHash) {
seenImportHashes.add(importHash);
}
const createdRecord = await this.create(item, {
...options,
currentUser,
transaction,
});
createdRecords.push(createdRecord);
} catch (error) {
throw prefixImportError(error, rowNumber);
}
}
return createdRecords;
} }
static async update(id, data, options) { static async update(id, data, options) {
@ -189,20 +179,9 @@ module.exports = class Unit_typesDBApi {
const globalAccess = currentUser.app_role?.globalAccess; const globalAccess = currentUser.app_role?.globalAccess;
const unit_types = await db.unit_types.findByPk(id, {}, {transaction}); const unit_types = await db.unit_types.findByPk(id, {}, {transaction});
const nextPropertyId = data.property !== undefined ? data.property : unit_types?.propertyId;
if (!nextPropertyId) {
throw createBadRequestError('Select a property before saving this unit type.');
}
const property = await loadPropertyContext(nextPropertyId || null, transaction);
let organizationId = data.organizations;
if (organizationId === undefined && data.property !== undefined) {
organizationId = property?.organizationsId || null;
}
if ((organizationId === undefined || organizationId === null) && !globalAccess && !unit_types?.organizationsId) {
organizationId = getCurrentOrganizationId(currentUser);
}
const updatePayload = {}; const updatePayload = {};
@ -251,10 +230,10 @@ module.exports = class Unit_typesDBApi {
); );
} }
if (organizationId !== undefined) { if (data.organizations !== undefined) {
await unit_types.setOrganizations( await unit_types.setOrganizations(
organizationId, data.organizations,
{ transaction } { transaction }
); );
@ -425,6 +404,10 @@ module.exports = class Unit_typesDBApi {
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
{ {
@ -787,19 +770,17 @@ module.exports = class Unit_typesDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, propertyId) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
const filters = []; let where = {};
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
filters.push({ organizationsId: organizationId }); where.organizationId = organizationId;
}
if (propertyId) {
filters.push({ propertyId: Utils.uuid(propertyId) });
} }
if (query) { if (query) {
filters.push({ where = {
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike( Utils.ilike(
@ -808,19 +789,15 @@ module.exports = class Unit_typesDBApi {
query, query,
), ),
], ],
}); };
} }
const where = filters.length > 1
? { [Op.and]: filters }
: (filters[0] || {});
const records = await db.unit_types.findAll({ const records = await db.unit_types.findAll({
attributes: [ 'id', 'name' ], attributes: [ 'id', 'name' ],
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
order: [['name', 'ASC']], orderBy: [['name', 'ASC']],
}); });
return records.map((record) => ({ return records.map((record) => ({

View File

@ -1,7 +1,10 @@
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 { createBadRequestError, getCurrentOrganizationId, normalizeInventoryImportRow, loadPropertyContext, loadUnitTypeContext, prefixImportError, resolvePropertyReference, resolveUnitTypeReference } = require('./inventoryContext');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
@ -13,31 +16,6 @@ module.exports = class UnitsDBApi {
static async create(data, options) { static async create(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;
let propertyId = data.property || null;
const unitType = await loadUnitTypeContext(data.unit_type || null, transaction);
if (!propertyId && unitType?.propertyId) {
propertyId = unitType.propertyId;
}
if (!propertyId) {
throw createBadRequestError('Select a property before creating a unit.');
}
if (!data.unit_type) {
throw createBadRequestError('Select a unit type before creating a unit.');
}
const property = await loadPropertyContext(propertyId || null, transaction);
if (property && unitType?.propertyId && unitType.propertyId !== property.id) {
throw createBadRequestError('Selected unit type does not belong to the selected property.');
}
let organizationId = data.organizations || property?.organizationsId || unitType?.organizationsId || null;
if (!organizationId && !globalAccess) {
organizationId = getCurrentOrganizationId(currentUser);
}
const units = await db.units.create( const units = await db.units.create(
{ {
@ -76,7 +54,7 @@ module.exports = class UnitsDBApi {
); );
await units.setProperty( propertyId || null, { await units.setProperty( data.property || null, {
transaction, transaction,
}); });
@ -84,7 +62,7 @@ module.exports = class UnitsDBApi {
transaction, transaction,
}); });
await units.setOrganizations( organizationId || null, { await units.setOrganizations( data.organizations || null, {
transaction, transaction,
}); });
@ -104,89 +82,49 @@ module.exports = class UnitsDBApi {
static async bulkImport(data, options) { static async bulkImport(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 seenIds = new Set();
const seenImportHashes = new Set();
const createdRecords = [];
for (let index = 0; index < data.length; index += 1) { // Prepare data - wrapping individual data transformations in a map() method
const item = normalizeInventoryImportRow(data[index]); const unitsData = data.map((item, index) => ({
const rowNumber = index + 2; id: item.id || undefined,
unit_number: item.unit_number
||
null
,
floor: item.floor
||
null
,
status: item.status
||
null
,
max_occupancy_override: item.max_occupancy_override
||
null
,
notes: item.notes
||
null
,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000),
}));
try { // Bulk create items
const propertyId = await resolvePropertyReference(item.property, { const units = await db.units.bulkCreate(unitsData, { transaction });
currentUser,
transaction,
});
if (!propertyId) { // For each item created, replace relation files
throw createBadRequestError('Property is required for unit import.');
}
const property = await loadPropertyContext(propertyId, transaction); return units;
const unitTypeId = await resolveUnitTypeReference(item.unit_type, {
currentUser,
organizationId: property?.organizationsId || null,
propertyId,
transaction,
});
if (!unitTypeId) {
throw createBadRequestError('Unit type is required for unit import.');
}
item.property = propertyId;
item.unit_type = unitTypeId;
const itemId = item.id || null;
const importHash = item.importHash || null;
if (options?.ignoreDuplicates) {
if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) {
continue;
}
const duplicateFilters = [];
if (itemId) {
duplicateFilters.push({ id: itemId });
}
if (importHash) {
duplicateFilters.push({ importHash });
}
if (duplicateFilters.length) {
const existingRecord = await db.units.findOne({
where: {
[Op.or]: duplicateFilters,
},
transaction,
paranoid: false,
});
if (existingRecord) {
continue;
}
}
}
if (itemId) {
seenIds.add(itemId);
}
if (importHash) {
seenImportHashes.add(importHash);
}
const createdRecord = await this.create(item, {
...options,
currentUser,
transaction,
});
createdRecords.push(createdRecord);
} catch (error) {
throw prefixImportError(error, rowNumber);
}
}
return createdRecords;
} }
static async update(id, data, options) { static async update(id, data, options) {
@ -195,30 +133,9 @@ module.exports = class UnitsDBApi {
const globalAccess = currentUser.app_role?.globalAccess; const globalAccess = currentUser.app_role?.globalAccess;
const units = await db.units.findByPk(id, {}, {transaction}); const units = await db.units.findByPk(id, {}, {transaction});
const nextPropertyId = data.property !== undefined ? data.property : units?.propertyId;
const nextUnitTypeId = data.unit_type !== undefined ? data.unit_type : units?.unit_typeId;
const unitType = await loadUnitTypeContext(nextUnitTypeId || null, transaction);
const propertyId = nextPropertyId || unitType?.propertyId || null;
if (!propertyId) {
throw createBadRequestError('Select a property before saving this unit.');
}
if (!nextUnitTypeId) {
throw createBadRequestError('Select a unit type before saving this unit.');
}
const property = await loadPropertyContext(propertyId || null, transaction);
if (property && unitType?.propertyId && unitType.propertyId !== property.id) {
throw createBadRequestError('Selected unit type does not belong to the selected property.');
}
let organizationId = data.organizations;
if (organizationId === undefined && (data.property !== undefined || data.unit_type !== undefined)) {
organizationId = property?.organizationsId || unitType?.organizationsId || null;
}
if ((organizationId === undefined || organizationId === null) && !globalAccess && !units?.organizationsId) {
organizationId = getCurrentOrganizationId(currentUser);
}
const updatePayload = {}; const updatePayload = {};
@ -243,10 +160,10 @@ module.exports = class UnitsDBApi {
if (data.property !== undefined || (data.unit_type !== undefined && propertyId !== units?.propertyId)) { if (data.property !== undefined) {
await units.setProperty( await units.setProperty(
propertyId, data.property,
{ transaction } { transaction }
); );
@ -261,10 +178,10 @@ module.exports = class UnitsDBApi {
); );
} }
if (organizationId !== undefined) { if (data.organizations !== undefined) {
await units.setOrganizations( await units.setOrganizations(
organizationId, data.organizations,
{ transaction } { transaction }
); );
@ -432,6 +349,9 @@ module.exports = class UnitsDBApi {
offset = currentPage * limit; offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [ let include = [
@ -677,23 +597,17 @@ module.exports = class UnitsDBApi {
} }
} }
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, propertyId, unitTypeId) { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
const filters = []; let where = {};
if (!globalAccess && organizationId) { if (!globalAccess && organizationId) {
filters.push({ organizationsId: organizationId }); where.organizationId = organizationId;
} }
if (propertyId) {
filters.push({ propertyId: Utils.uuid(propertyId) });
}
if (unitTypeId) {
filters.push({ unit_typeId: Utils.uuid(unitTypeId) });
}
if (query) { if (query) {
filters.push({ where = {
[Op.or]: [ [Op.or]: [
{ ['id']: Utils.uuid(query) }, { ['id']: Utils.uuid(query) },
Utils.ilike( Utils.ilike(
@ -702,19 +616,15 @@ module.exports = class UnitsDBApi {
query, query,
), ),
], ],
}); };
} }
const where = filters.length > 1
? { [Op.and]: filters }
: (filters[0] || {});
const records = await db.units.findAll({ const records = await db.units.findAll({
attributes: [ 'id', 'unit_number' ], attributes: [ 'id', 'unit_number' ],
where, where,
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined, offset: offset ? Number(offset) : undefined,
order: [['unit_number', 'ASC']], orderBy: [['unit_number', 'ASC']],
}); });
return records.map((record) => ({ return records.map((record) => ({

View File

@ -12,57 +12,6 @@ const config = require('../../config');
const Sequelize = db.Sequelize; const Sequelize = db.Sequelize;
const Op = Sequelize.Op; const Op = Sequelize.Op;
const resolveCurrentOrganizationContext = async (output, transaction) => {
const directOrganization = output.organizations;
if (directOrganization && directOrganization.id) {
output.organization = output.organization || directOrganization;
output.organizationId = output.organizationId || directOrganization.id;
output.organizationsId = output.organizationsId || directOrganization.id;
output.organizationName = output.organizationName || directOrganization.name || null;
return output;
}
const membership = (Array.isArray(output.organization_memberships_user)
? [...output.organization_memberships_user]
: [])
.sort((left, right) => {
if (Boolean(left?.is_primary) !== Boolean(right?.is_primary)) {
return left?.is_primary ? -1 : 1;
}
if (Boolean(left?.is_active) !== Boolean(right?.is_active)) {
return left?.is_active ? -1 : 1;
}
return 0;
})
.find((item) => item?.organizationId);
if (!membership?.organizationId) {
return output;
}
const organization = await db.organizations.findByPk(membership.organizationId, {
transaction,
});
if (!organization) {
return output;
}
const organizationData = organization.get({ plain: true });
output.organizations = organizationData;
output.organization = organizationData;
output.organizationId = organizationData.id;
output.organizationsId = organizationData.id;
output.organizationName = output.organizationName || organizationData.name || null;
return output;
};
module.exports = class UsersDBApi { module.exports = class UsersDBApi {
static async create(data,globalAccess, options) { static async create(data,globalAccess, options) {
@ -147,7 +96,7 @@ module.exports = class UsersDBApi {
if (!data.data.app_role) { if (!data.data.app_role) {
const role = await db.roles.findOne({ const role = await db.roles.findOne({
where: { name: config.roles?.user || 'User' }, where: { name: 'User' },
}); });
if (role) { if (role) {
await users.setApp_role(role, { await users.setApp_role(role, {
@ -570,7 +519,7 @@ module.exports = class UsersDBApi {
transaction transaction
}); });
await resolveCurrentOrganizationContext(output, transaction);
return output; return output;
} }
@ -599,7 +548,11 @@ module.exports = class UsersDBApi {
offset = currentPage * limit; offset = currentPage * limit;
let include = [ const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [
{ {
model: db.roles, model: db.roles,
@ -954,23 +907,19 @@ module.exports = class UsersDBApi {
static async createFromAuth(data, options) { static async createFromAuth(data, options) {
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const organizationId = data.organizationId || data.organizationsId || null;
const users = await db.users.create( const users = await db.users.create(
{ {
email: data.email, email: data.email,
firstName: data.firstName, firstName: data.firstName,
authenticationUid: data.authenticationUid, authenticationUid: data.authenticationUid,
password: data.password, password: data.password,
organizationId: data.organizationId,
}, },
{ transaction }, { transaction },
); );
if (organizationId) {
await users.setOrganizations(organizationId, {
transaction,
});
}
const app_role = await db.roles.findOne({ const app_role = await db.roles.findOne({
where: { name: config.roles?.user || "User" }, where: { name: config.roles?.user || "User" },
}); });

View File

@ -1,89 +0,0 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.\"tenantsOrganizationsOrganizations\"') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
const tableName = rows[0].regclass_name;
if (tableName) {
await transaction.commit();
return;
}
await queryInterface.createTable(
'tenantsOrganizationsOrganizations',
{
createdAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
tenants_organizationsId: {
type: Sequelize.DataTypes.UUID,
allowNull: false,
primaryKey: true,
references: {
model: 'tenants',
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
organizationId: {
type: Sequelize.DataTypes.UUID,
allowNull: false,
primaryKey: true,
references: {
model: 'organizations',
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
},
{ transaction },
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.\"tenantsOrganizationsOrganizations\"') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
const tableName = rows[0].regclass_name;
if (!tableName) {
await transaction.commit();
return;
}
await queryInterface.dropTable('tenantsOrganizationsOrganizations', { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
};

View File

@ -1,45 +0,0 @@
'use strict';
module.exports = {
async up(queryInterface) {
const sequelize = queryInterface.sequelize;
const [roles] = await sequelize.query(
`SELECT "id" FROM "roles" WHERE "name" = 'Administrator' LIMIT 1;`,
);
if (!roles.length) {
return;
}
const [permissions] = await sequelize.query(
`SELECT "id", "name" FROM "permissions" WHERE "name" IN ('READ_TENANTS', 'READ_ORGANIZATIONS');`,
);
const now = new Date();
for (const permission of permissions) {
await sequelize.query(
`INSERT INTO "rolesPermissionsPermissions" ("createdAt", "updatedAt", "roles_permissionsId", "permissionId")
SELECT :createdAt, :updatedAt, :roleId, :permissionId
WHERE NOT EXISTS (
SELECT 1
FROM "rolesPermissionsPermissions"
WHERE "roles_permissionsId" = :roleId
AND "permissionId" = :permissionId
);`,
{
replacements: {
createdAt: now,
updatedAt: now,
roleId: roles[0].id,
permissionId: permission.id,
},
},
);
}
},
async down() {
// Intentionally left blank. This protects live permission assignments.
},
};

View File

@ -1,114 +0,0 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tableDefinitions = [
{
tableName: 'tenantsPropertiesProperties',
sourceKey: 'tenants_propertiesId',
sourceTable: 'tenants',
targetKey: 'propertyId',
targetTable: 'properties',
},
{
tableName: 'tenantsAudit_logsAudit_logs',
sourceKey: 'tenants_audit_logsId',
sourceTable: 'tenants',
targetKey: 'auditLogId',
targetTable: 'audit_logs',
},
];
for (const definition of tableDefinitions) {
const rows = await queryInterface.sequelize.query(
`SELECT to_regclass('public."${definition.tableName}"') AS regclass_name;`,
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
const tableName = rows[0].regclass_name;
if (tableName) {
continue;
}
await queryInterface.createTable(
definition.tableName,
{
createdAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
[definition.sourceKey]: {
type: Sequelize.DataTypes.UUID,
allowNull: false,
primaryKey: true,
references: {
model: definition.sourceTable,
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
[definition.targetKey]: {
type: Sequelize.DataTypes.UUID,
allowNull: false,
primaryKey: true,
references: {
model: definition.targetTable,
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
},
{ transaction },
);
}
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tableNames = [
'tenantsPropertiesProperties',
'tenantsAudit_logsAudit_logs',
];
for (const tableName of tableNames) {
const rows = await queryInterface.sequelize.query(
`SELECT to_regclass('public."${tableName}"') AS regclass_name;`,
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
const existingTableName = rows[0].regclass_name;
if (!existingTableName) {
continue;
}
await queryInterface.dropTable(tableName, { transaction });
}
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
};

View File

@ -1,119 +0,0 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.\"tenantsOrganizationsOrganizations\"') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
const tableName = rows[0].regclass_name;
if (!tableName) {
await transaction.commit();
return;
}
await queryInterface.sequelize.query(
`
INSERT INTO "tenantsOrganizationsOrganizations" (
"tenants_organizationsId",
"organizationId",
"createdAt",
"updatedAt"
)
SELECT DISTINCT
relation_pairs.tenant_id,
relation_pairs.organization_id,
NOW(),
NOW()
FROM (
SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id
FROM booking_requests
WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id
FROM reservations
WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id
FROM documents
WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id
FROM invoices
WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id
FROM role_assignments
WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id
FROM activity_comments
WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id
FROM service_requests
WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id
FROM properties
WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id
FROM audit_logs
WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id
FROM notifications
WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id
FROM checklists
WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id
FROM job_runs
WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL
) AS relation_pairs
ON CONFLICT ("tenants_organizationsId", "organizationId") DO NOTHING;
`,
{ transaction },
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
async down() {
return Promise.resolve();
},
};

View File

@ -1,56 +0,0 @@
'use strict';
const USER_MANAGEMENT_PERMISSIONS = [
'CREATE_USERS',
'READ_USERS',
'UPDATE_USERS',
'DELETE_USERS',
'READ_ROLES',
];
module.exports = {
async up(queryInterface) {
const sequelize = queryInterface.sequelize;
const [roles] = await sequelize.query(
`SELECT "id" FROM "roles" WHERE "name" = 'Administrator' LIMIT 1;`,
);
if (!roles.length) {
return;
}
const [permissions] = await sequelize.query(
`SELECT "id", "name" FROM "permissions" WHERE "name" IN (:permissionNames);`,
{
replacements: { permissionNames: USER_MANAGEMENT_PERMISSIONS },
},
);
const now = new Date();
for (const permission of permissions) {
await sequelize.query(
`INSERT INTO "rolesPermissionsPermissions" ("createdAt", "updatedAt", "roles_permissionsId", "permissionId")
SELECT :createdAt, :updatedAt, :roleId, :permissionId
WHERE NOT EXISTS (
SELECT 1
FROM "rolesPermissionsPermissions"
WHERE "roles_permissionsId" = :roleId
AND "permissionId" = :permissionId
);`,
{
replacements: {
createdAt: now,
updatedAt: now,
roleId: roles[0].id,
permissionId: permission.id,
},
},
);
}
},
async down() {
// Intentionally left blank. This protects live permission assignments.
},
};

View File

@ -1,3 +1,9 @@
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 booking_requests = sequelize.define( const booking_requests = sequelize.define(
'booking_requests', 'booking_requests',
@ -8,59 +14,111 @@ module.exports = function(sequelize, DataTypes) {
primaryKey: true, primaryKey: true,
}, },
request_code: { request_code: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
}, },
status: { status: {
type: DataTypes.ENUM, type: DataTypes.ENUM,
values: [ values: [
'draft',
'submitted', "draft",
'in_review',
'changes_requested',
'approved', "submitted",
'rejected',
'expired',
'converted_to_reservation', "in_review",
'canceled',
"changes_requested",
"approved",
"rejected",
"expired",
"converted_to_reservation",
"canceled"
], ],
}, },
check_in_at: { check_in_at: {
type: DataTypes.DATE, type: DataTypes.DATE,
}, },
check_out_at: { check_out_at: {
type: DataTypes.DATE, type: DataTypes.DATE,
}, },
preferred_bedrooms: { preferred_bedrooms: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
}, },
guest_count: { guest_count: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
}, },
purpose_of_stay: { purpose_of_stay: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
}, },
special_requirements: { special_requirements: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
}, },
budget_code: { budget_code: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
}, },
max_budget_amount: { max_budget_amount: {
type: DataTypes.DECIMAL, type: DataTypes.DECIMAL,
}, },
currency: { currency: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
}, },
importHash: { importHash: {
@ -77,110 +135,157 @@ module.exports = function(sequelize, DataTypes) {
); );
booking_requests.associate = (db) => { booking_requests.associate = (db) => {
db.booking_requests.hasMany(db.booking_request_travelers, {
db.booking_requests.belongsToMany(db.booking_request_travelers, {
as: 'travelers', as: 'travelers',
foreignKey: { foreignKey: {
name: 'booking_requestId', name: 'booking_requests_travelersId',
}, },
constraints: false, constraints: false,
through: 'booking_requestsTravelersBooking_request_travelers',
}); });
db.booking_requests.hasMany(db.booking_request_travelers, { db.booking_requests.belongsToMany(db.booking_request_travelers, {
as: 'travelers_filter', as: 'travelers_filter',
foreignKey: { foreignKey: {
name: 'booking_requestId', name: 'booking_requests_travelersId',
}, },
constraints: false, constraints: false,
through: 'booking_requestsTravelersBooking_request_travelers',
}); });
db.booking_requests.hasMany(db.approval_steps, { db.booking_requests.belongsToMany(db.approval_steps, {
as: 'approval_steps', as: 'approval_steps',
foreignKey: { foreignKey: {
name: 'booking_requestId', name: 'booking_requests_approval_stepsId',
}, },
constraints: false, constraints: false,
through: 'booking_requestsApproval_stepsApproval_steps',
}); });
db.booking_requests.hasMany(db.approval_steps, { db.booking_requests.belongsToMany(db.approval_steps, {
as: 'approval_steps_filter', as: 'approval_steps_filter',
foreignKey: { foreignKey: {
name: 'booking_requestId', name: 'booking_requests_approval_stepsId',
}, },
constraints: false, constraints: false,
through: 'booking_requestsApproval_stepsApproval_steps',
}); });
db.booking_requests.hasMany(db.documents, { db.booking_requests.belongsToMany(db.documents, {
as: 'documents', as: 'documents',
foreignKey: { foreignKey: {
name: 'booking_requestId', name: 'booking_requests_documentsId',
}, },
constraints: false, constraints: false,
through: 'booking_requestsDocumentsDocuments',
}); });
db.booking_requests.hasMany(db.documents, { db.booking_requests.belongsToMany(db.documents, {
as: 'documents_filter', as: 'documents_filter',
foreignKey: { foreignKey: {
name: 'booking_requestId', name: 'booking_requests_documentsId',
}, },
constraints: false, constraints: false,
through: 'booking_requestsDocumentsDocuments',
}); });
db.booking_requests.hasMany(db.activity_comments, { db.booking_requests.belongsToMany(db.activity_comments, {
as: 'comments', as: 'comments',
foreignKey: { foreignKey: {
name: 'booking_requestId', name: 'booking_requests_commentsId',
}, },
constraints: false, constraints: false,
through: 'booking_requestsCommentsActivity_comments',
}); });
db.booking_requests.hasMany(db.activity_comments, { db.booking_requests.belongsToMany(db.activity_comments, {
as: 'comments_filter', as: 'comments_filter',
foreignKey: { foreignKey: {
name: 'booking_requestId', name: 'booking_requests_commentsId',
}, },
constraints: false, constraints: false,
through: 'booking_requestsCommentsActivity_comments',
}); });
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
db.booking_requests.hasMany(db.booking_request_travelers, { db.booking_requests.hasMany(db.booking_request_travelers, {
as: 'booking_request_travelers_booking_request', as: 'booking_request_travelers_booking_request',
foreignKey: { foreignKey: {
name: 'booking_requestId', name: 'booking_requestId',
}, },
constraints: false, constraints: false,
}); });
db.booking_requests.hasMany(db.approval_steps, { db.booking_requests.hasMany(db.approval_steps, {
as: 'approval_steps_booking_request', as: 'approval_steps_booking_request',
foreignKey: { foreignKey: {
name: 'booking_requestId', name: 'booking_requestId',
}, },
constraints: false, constraints: false,
}); });
db.booking_requests.hasMany(db.reservations, { db.booking_requests.hasMany(db.reservations, {
as: 'reservations_booking_request', as: 'reservations_booking_request',
foreignKey: { foreignKey: {
name: 'booking_requestId', name: 'booking_requestId',
}, },
constraints: false, constraints: false,
}); });
db.booking_requests.hasMany(db.documents, { db.booking_requests.hasMany(db.documents, {
as: 'documents_booking_request', as: 'documents_booking_request',
foreignKey: { foreignKey: {
name: 'booking_requestId', name: 'booking_requestId',
}, },
constraints: false, constraints: false,
}); });
db.booking_requests.hasMany(db.activity_comments, { db.booking_requests.hasMany(db.activity_comments, {
as: 'activity_comments_booking_request', as: 'activity_comments_booking_request',
foreignKey: { foreignKey: {
name: 'booking_requestId', name: 'booking_requestId',
}, },
constraints: false, constraints: false,
}); });
//end loop
db.booking_requests.belongsTo(db.tenants, { db.booking_requests.belongsTo(db.tenants, {
as: 'tenant', as: 'tenant',
foreignKey: { foreignKey: {
@ -221,6 +326,9 @@ module.exports = function(sequelize, DataTypes) {
constraints: false, constraints: false,
}); });
db.booking_requests.belongsTo(db.users, { db.booking_requests.belongsTo(db.users, {
as: 'createdBy', as: 'createdBy',
}); });
@ -230,5 +338,9 @@ module.exports = function(sequelize, DataTypes) {
}); });
}; };
return booking_requests; return booking_requests;
}; };

View File

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

View File

@ -1,3 +1,8 @@
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 properties = sequelize.define( const properties = sequelize.define(
@ -83,52 +88,58 @@ is_active: {
properties.associate = (db) => { properties.associate = (db) => {
db.properties.hasMany(db.unit_types, { db.properties.belongsToMany(db.unit_types, {
as: 'unit_types', as: 'unit_types',
foreignKey: { foreignKey: {
name: 'propertyId', name: 'properties_unit_typesId',
}, },
constraints: false, constraints: false,
through: 'propertiesUnit_typesUnit_types',
}); });
db.properties.hasMany(db.unit_types, { db.properties.belongsToMany(db.unit_types, {
as: 'unit_types_filter', as: 'unit_types_filter',
foreignKey: { foreignKey: {
name: 'propertyId', name: 'properties_unit_typesId',
}, },
constraints: false, constraints: false,
through: 'propertiesUnit_typesUnit_types',
}); });
db.properties.hasMany(db.units, { db.properties.belongsToMany(db.units, {
as: 'units', as: 'units',
foreignKey: { foreignKey: {
name: 'propertyId', name: 'properties_unitsId',
}, },
constraints: false, constraints: false,
through: 'propertiesUnitsUnits',
}); });
db.properties.hasMany(db.units, { db.properties.belongsToMany(db.units, {
as: 'units_filter', as: 'units_filter',
foreignKey: { foreignKey: {
name: 'propertyId', name: 'properties_unitsId',
}, },
constraints: false, constraints: false,
through: 'propertiesUnitsUnits',
}); });
db.properties.hasMany(db.amenities, { db.properties.belongsToMany(db.amenities, {
as: 'amenities', as: 'amenities',
foreignKey: { foreignKey: {
name: 'propertyId', name: 'properties_amenitiesId',
}, },
constraints: false, constraints: false,
through: 'propertiesAmenitiesAmenities',
}); });
db.properties.hasMany(db.amenities, { db.properties.belongsToMany(db.amenities, {
as: 'amenities_filter', as: 'amenities_filter',
foreignKey: { foreignKey: {
name: 'propertyId', name: 'properties_amenitiesId',
}, },
constraints: false, constraints: false,
through: 'propertiesAmenitiesAmenities',
}); });

View File

@ -1,3 +1,9 @@
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',
@ -148,84 +154,94 @@ external_notes: {
reservations.associate = (db) => { reservations.associate = (db) => {
db.reservations.hasMany(db.reservation_guests, { db.reservations.belongsToMany(db.reservation_guests, {
as: 'guests', as: 'guests',
foreignKey: { foreignKey: {
name: 'reservationId', name: 'reservations_guestsId',
}, },
constraints: false, constraints: false,
through: 'reservationsGuestsReservation_guests',
}); });
db.reservations.hasMany(db.reservation_guests, { db.reservations.belongsToMany(db.reservation_guests, {
as: 'guests_filter', as: 'guests_filter',
foreignKey: { foreignKey: {
name: 'reservationId', name: 'reservations_guestsId',
}, },
constraints: false, constraints: false,
through: 'reservationsGuestsReservation_guests',
}); });
db.reservations.hasMany(db.service_requests, { db.reservations.belongsToMany(db.service_requests, {
as: 'service_requests', as: 'service_requests',
foreignKey: { foreignKey: {
name: 'reservationId', name: 'reservations_service_requestsId',
}, },
constraints: false, constraints: false,
through: 'reservationsService_requestsService_requests',
}); });
db.reservations.hasMany(db.service_requests, { db.reservations.belongsToMany(db.service_requests, {
as: 'service_requests_filter', as: 'service_requests_filter',
foreignKey: { foreignKey: {
name: 'reservationId', name: 'reservations_service_requestsId',
}, },
constraints: false, constraints: false,
through: 'reservationsService_requestsService_requests',
}); });
db.reservations.hasMany(db.invoices, { db.reservations.belongsToMany(db.invoices, {
as: 'invoices', as: 'invoices',
foreignKey: { foreignKey: {
name: 'reservationId', name: 'reservations_invoicesId',
}, },
constraints: false, constraints: false,
through: 'reservationsInvoicesInvoices',
}); });
db.reservations.hasMany(db.invoices, { db.reservations.belongsToMany(db.invoices, {
as: 'invoices_filter', as: 'invoices_filter',
foreignKey: { foreignKey: {
name: 'reservationId', name: 'reservations_invoicesId',
}, },
constraints: false, constraints: false,
through: 'reservationsInvoicesInvoices',
}); });
db.reservations.hasMany(db.documents, { db.reservations.belongsToMany(db.documents, {
as: 'documents', as: 'documents',
foreignKey: { foreignKey: {
name: 'reservationId', name: 'reservations_documentsId',
}, },
constraints: false, constraints: false,
through: 'reservationsDocumentsDocuments',
}); });
db.reservations.hasMany(db.documents, { db.reservations.belongsToMany(db.documents, {
as: 'documents_filter', as: 'documents_filter',
foreignKey: { foreignKey: {
name: 'reservationId', name: 'reservations_documentsId',
}, },
constraints: false, constraints: false,
through: 'reservationsDocumentsDocuments',
}); });
db.reservations.hasMany(db.activity_comments, { db.reservations.belongsToMany(db.activity_comments, {
as: 'comments', as: 'comments',
foreignKey: { foreignKey: {
name: 'reservationId', name: 'reservations_commentsId',
}, },
constraints: false, constraints: false,
through: 'reservationsCommentsActivity_comments',
}); });
db.reservations.hasMany(db.activity_comments, { db.reservations.belongsToMany(db.activity_comments, {
as: 'comments_filter', as: 'comments_filter',
foreignKey: { foreignKey: {
name: 'reservationId', name: 'reservations_commentsId',
}, },
constraints: false, constraints: false,
through: 'reservationsCommentsActivity_comments',
}); });

View File

@ -1,3 +1,9 @@
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',
@ -166,36 +172,40 @@ currency: {
service_requests.associate = (db) => { service_requests.associate = (db) => {
db.service_requests.hasMany(db.documents, { db.service_requests.belongsToMany(db.documents, {
as: 'documents', as: 'documents',
foreignKey: { foreignKey: {
name: 'service_requestId', name: 'service_requests_documentsId',
}, },
constraints: false, constraints: false,
through: 'service_requestsDocumentsDocuments',
}); });
db.service_requests.hasMany(db.documents, { db.service_requests.belongsToMany(db.documents, {
as: 'documents_filter', as: 'documents_filter',
foreignKey: { foreignKey: {
name: 'service_requestId', name: 'service_requests_documentsId',
}, },
constraints: false, constraints: false,
through: 'service_requestsDocumentsDocuments',
}); });
db.service_requests.hasMany(db.activity_comments, { db.service_requests.belongsToMany(db.activity_comments, {
as: 'comments', as: 'comments',
foreignKey: { foreignKey: {
name: 'service_requestId', name: 'service_requests_commentsId',
}, },
constraints: false, constraints: false,
through: 'service_requestsCommentsActivity_comments',
}); });
db.service_requests.hasMany(db.activity_comments, { db.service_requests.belongsToMany(db.activity_comments, {
as: 'comments_filter', as: 'comments_filter',
foreignKey: { foreignKey: {
name: 'service_requestId', name: 'service_requests_commentsId',
}, },
constraints: false, constraints: false,
through: 'service_requestsCommentsActivity_comments',
}); });

View File

@ -1,3 +1,8 @@
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 unit_types = sequelize.define( const unit_types = sequelize.define(
@ -94,20 +99,22 @@ minimum_stay_nights: {
unit_types.associate = (db) => { unit_types.associate = (db) => {
db.unit_types.hasMany(db.units, { db.unit_types.belongsToMany(db.units, {
as: 'units', as: 'units',
foreignKey: { foreignKey: {
name: 'unit_typeId', name: 'unit_types_unitsId',
}, },
constraints: false, constraints: false,
through: 'unit_typesUnitsUnits',
}); });
db.unit_types.hasMany(db.units, { db.unit_types.belongsToMany(db.units, {
as: 'units_filter', as: 'units_filter',
foreignKey: { foreignKey: {
name: 'unit_typeId', name: 'unit_types_unitsId',
}, },
constraints: false, constraints: false,
through: 'unit_typesUnitsUnits',
}); });

View File

@ -1,3 +1,9 @@
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',
@ -79,20 +85,22 @@ notes: {
units.associate = (db) => { units.associate = (db) => {
db.units.hasMany(db.unit_availability_blocks, { db.units.belongsToMany(db.unit_availability_blocks, {
as: 'availability_blocks', as: 'availability_blocks',
foreignKey: { foreignKey: {
name: 'unitId', name: 'units_availability_blocksId',
}, },
constraints: false, constraints: false,
through: 'unitsAvailability_blocksUnit_availability_blocks',
}); });
db.units.hasMany(db.unit_availability_blocks, { db.units.belongsToMany(db.unit_availability_blocks, {
as: 'availability_blocks_filter', as: 'availability_blocks_filter',
foreignKey: { foreignKey: {
name: 'unitId', name: 'units_availability_blocksId',
}, },
constraints: false, constraints: false,
through: 'unitsAvailability_blocksUnit_availability_blocks',
}); });

View File

@ -1,246 +0,0 @@
'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_ORGANIZATIONS',
'READ_TENANTS',
'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

@ -81,8 +81,6 @@ const checklist_itemsRoutes = require('./routes/checklist_items');
const job_runsRoutes = require('./routes/job_runs'); const job_runsRoutes = require('./routes/job_runs');
const corporateStayPortalRoutes = require('./routes/corporate_stay_portal');
const getBaseUrl = (url) => { const getBaseUrl = (url) => {
if (!url) return ''; if (!url) return '';
@ -199,8 +197,6 @@ app.use('/api/checklist_items', passport.authenticate('jwt', {session: false}),
app.use('/api/job_runs', passport.authenticate('jwt', {session: false}), job_runsRoutes); app.use('/api/job_runs', passport.authenticate('jwt', {session: false}), job_runsRoutes);
app.use('/api/corporate-stay-portal', passport.authenticate('jwt', {session: false}), corporateStayPortalRoutes);
app.use( app.use(
'/api/openai', '/api/openai',
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),

View File

@ -5,6 +5,7 @@ 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();
@ -408,7 +409,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, req.currentUser, globalAccess, organizationId,
); );
res.status(200).send(payload); res.status(200).send(payload);
@ -449,12 +450,9 @@ 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

@ -1,472 +0,0 @@
const express = require('express');
const { Op } = require('sequelize');
const db = require('../db/models');
const wrapAsync = require('../helpers').wrapAsync;
const PortfolioWorkbookImportService = require('../services/portfolioWorkbookImport');
const router = express.Router();
const bookingPermissions = ['READ_BOOKING_REQUESTS'];
const approvalPermissions = ['READ_APPROVAL_STEPS'];
const reservationPermissions = ['READ_RESERVATIONS'];
const servicePermissions = ['READ_SERVICE_REQUESTS'];
const financePermissions = ['READ_INVOICES'];
const inventoryPermissions = ['READ_PROPERTIES', 'READ_UNITS'];
const accountPermissions = ['READ_ORGANIZATIONS'];
const portfolioImportReadPermissions = ['READ_TENANTS', 'READ_ORGANIZATIONS', 'READ_PROPERTIES', 'READ_UNIT_TYPES', 'READ_UNITS', 'CREATE_TENANTS', 'CREATE_ORGANIZATIONS', 'CREATE_PROPERTIES', 'CREATE_UNIT_TYPES', 'CREATE_UNITS'];
const portfolioImportCreatePermissions = ['CREATE_TENANTS', 'CREATE_ORGANIZATIONS', 'CREATE_PROPERTIES', 'CREATE_UNIT_TYPES', 'CREATE_UNITS'];
function getPermissionSet(currentUser) {
return new Set([
...((currentUser?.custom_permissions || []).map((permission) => permission.name)),
...((currentUser?.app_role_permissions || []).map((permission) => permission.name)),
]);
}
function hasAnyPermission(currentUser, permissions) {
if (currentUser?.app_role?.globalAccess) {
return true;
}
const permissionSet = getPermissionSet(currentUser);
return permissions.some((permission) => permissionSet.has(permission));
}
function scopedWhere(currentUser, key, extraWhere = {}) {
if (currentUser?.app_role?.globalAccess || !currentUser?.organizationId) {
return extraWhere;
}
return {
...extraWhere,
[key]: currentUser.organizationId,
};
}
function groupRowsToCounts(rows, field) {
return rows.reduce((accumulator, row) => {
accumulator[row[field]] = Number(row.count || 0);
return accumulator;
}, {});
}
function formatUserDisplay(user) {
if (!user) {
return null;
}
const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
return fullName || user.email || 'Assigned staff';
}
router.get(
'/portfolio-import-template',
wrapAsync(async (req, res) => {
if (!hasAnyPermission(req.currentUser, portfolioImportReadPermissions)) {
const error = new Error('You do not have permission to access the portfolio import template.');
error.code = 403;
throw error;
}
const workbookBuffer = PortfolioWorkbookImportService.getTemplateBuffer();
res.status(200);
res.attachment('portfolio-import-template.xlsx');
res.type('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.send(workbookBuffer);
}),
);
router.post(
'/portfolio-import',
wrapAsync(async (req, res) => {
if (!hasAnyPermission(req.currentUser, portfolioImportCreatePermissions)) {
const error = new Error('You do not have permission to run the portfolio workbook import.');
error.code = 403;
throw error;
}
const payload = await PortfolioWorkbookImportService.importWorkbook(req, res);
res.status(200).send(payload);
}),
);
async function getGroupedCounts(model, statusField, where) {
const rows = await model.findAll({
attributes: [
statusField,
[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count'],
],
where,
group: [statusField],
raw: true,
});
return groupRowsToCounts(rows, statusField);
}
router.get(
'/overview',
wrapAsync(async (req, res) => {
const { currentUser } = req;
const now = new Date();
const nextSevenDays = new Date(now);
nextSevenDays.setDate(nextSevenDays.getDate() + 7);
const access = {
accounts: hasAnyPermission(currentUser, accountPermissions),
bookings: hasAnyPermission(currentUser, bookingPermissions),
approvals: hasAnyPermission(currentUser, approvalPermissions),
reservations: hasAnyPermission(currentUser, reservationPermissions),
serviceRequests: hasAnyPermission(currentUser, servicePermissions),
invoices: hasAnyPermission(currentUser, financePermissions),
inventory: hasAnyPermission(currentUser, inventoryPermissions),
};
const response = {
generatedAt: now.toISOString(),
access,
organizations: {
total: 0,
},
bookingRequests: {
statusCounts: {},
pendingReview: 0,
approvedReady: 0,
recent: [],
},
approvals: {
pending: 0,
},
reservations: {
statusCounts: {},
upcomingArrivals: 0,
upcomingDepartures: 0,
inHouse: 0,
recent: [],
},
serviceRequests: {
statusCounts: {},
open: 0,
urgent: 0,
recent: [],
},
invoices: {
statusCounts: {},
openBalance: 0,
recent: [],
},
inventory: {
activeProperties: 0,
unitStatusCounts: {},
},
};
if (access.accounts) {
response.organizations.total = await db.organizations.count({
where: currentUser?.app_role?.globalAccess
? {}
: { id: currentUser.organizationId },
});
}
if (access.bookings) {
const bookingWhere = scopedWhere(currentUser, 'organizationId');
response.bookingRequests.statusCounts = await getGroupedCounts(
db.booking_requests,
'status',
bookingWhere,
);
response.bookingRequests.pendingReview = [
'submitted',
'in_review',
'changes_requested',
].reduce(
(sum, status) => sum + (response.bookingRequests.statusCounts[status] || 0),
0,
);
response.bookingRequests.approvedReady = (
response.bookingRequests.statusCounts.approved || 0
);
const recentBookingRequests = await db.booking_requests.findAll({
where: bookingWhere,
attributes: [
'id',
'request_code',
'status',
'check_in_at',
'check_out_at',
'guest_count',
'preferred_bedrooms',
'updatedAt',
],
include: [
{
model: db.organizations,
as: 'organization',
attributes: ['id', 'name'],
required: false,
},
{
model: db.properties,
as: 'preferred_property',
attributes: ['id', 'name', 'city'],
required: false,
},
{
model: db.unit_types,
as: 'preferred_unit_type',
attributes: ['id', 'name'],
required: false,
},
{
model: db.users,
as: 'requested_by',
attributes: ['id', 'firstName', 'lastName', 'email'],
required: false,
},
],
order: [['updatedAt', 'DESC']],
limit: 5,
});
response.bookingRequests.recent = recentBookingRequests.map((item) => ({
id: item.id,
request_code: item.request_code,
status: item.status,
check_in_at: item.check_in_at,
check_out_at: item.check_out_at,
guest_count: item.guest_count,
preferred_bedrooms: item.preferred_bedrooms,
updatedAt: item.updatedAt,
organizationName: item.organization?.name || 'Unassigned account',
requestedBy: formatUserDisplay(item.requested_by),
propertyName: item.preferred_property?.name || 'Open inventory',
unitTypeName: item.preferred_unit_type?.name || 'Any unit type',
}));
}
if (access.approvals) {
response.approvals.pending = await db.approval_steps.count({
where: scopedWhere(currentUser, 'organizationsId', { decision: 'pending' }),
});
}
if (access.reservations) {
const reservationWhere = scopedWhere(currentUser, 'organizationId');
response.reservations.statusCounts = await getGroupedCounts(
db.reservations,
'status',
reservationWhere,
);
response.reservations.upcomingArrivals = await db.reservations.count({
where: scopedWhere(currentUser, 'organizationId', {
status: { [Op.in]: ['confirmed', 'checked_in'] },
check_in_at: { [Op.between]: [now, nextSevenDays] },
}),
});
response.reservations.upcomingDepartures = await db.reservations.count({
where: scopedWhere(currentUser, 'organizationId', {
status: { [Op.in]: ['confirmed', 'checked_in'] },
check_out_at: { [Op.between]: [now, nextSevenDays] },
}),
});
response.reservations.inHouse = await db.reservations.count({
where: scopedWhere(currentUser, 'organizationId', {
status: 'checked_in',
}),
});
const recentReservations = await db.reservations.findAll({
where: reservationWhere,
attributes: [
'id',
'reservation_code',
'status',
'check_in_at',
'check_out_at',
'guest_count',
'currency',
'nightly_rate',
'updatedAt',
],
include: [
{
model: db.organizations,
as: 'organization',
attributes: ['id', 'name'],
required: false,
},
{
model: db.properties,
as: 'property',
attributes: ['id', 'name', 'city'],
required: false,
},
{
model: db.units,
as: 'unit',
attributes: ['id', 'unit_number', 'status'],
required: false,
},
{
model: db.booking_requests,
as: 'booking_request',
attributes: ['id', 'request_code'],
required: false,
},
],
order: [['check_in_at', 'ASC']],
limit: 5,
});
response.reservations.recent = recentReservations.map((item) => ({
id: item.id,
reservation_code: item.reservation_code,
status: item.status,
check_in_at: item.check_in_at,
check_out_at: item.check_out_at,
guest_count: item.guest_count,
updatedAt: item.updatedAt,
organizationName: item.organization?.name || 'Unassigned account',
propertyName: item.property?.name || 'Property pending',
unitNumber: item.unit?.unit_number || 'TBD',
sourceRequestCode: item.booking_request?.request_code || null,
}));
}
if (access.serviceRequests) {
const serviceWhere = scopedWhere(currentUser, 'organizationsId');
response.serviceRequests.statusCounts = await getGroupedCounts(
db.service_requests,
'status',
serviceWhere,
);
response.serviceRequests.open = Object.entries(response.serviceRequests.statusCounts)
.filter(([status]) => !['completed', 'canceled'].includes(status))
.reduce((sum, [, count]) => sum + Number(count || 0), 0);
response.serviceRequests.urgent = await db.service_requests.count({
where: scopedWhere(currentUser, 'organizationsId', {
priority: 'urgent',
status: { [Op.notIn]: ['completed', 'canceled'] },
}),
});
const recentServiceRequests = await db.service_requests.findAll({
where: serviceWhere,
attributes: [
'id',
'request_type',
'status',
'priority',
'summary',
'due_at',
'updatedAt',
],
include: [
{
model: db.reservations,
as: 'reservation',
attributes: ['id', 'reservation_code'],
required: false,
},
{
model: db.users,
as: 'assigned_to',
attributes: ['id', 'firstName', 'lastName', 'email'],
required: false,
},
],
order: [['updatedAt', 'DESC']],
limit: 5,
});
response.serviceRequests.recent = recentServiceRequests.map((item) => ({
id: item.id,
request_type: item.request_type,
status: item.status,
priority: item.priority,
summary: item.summary,
due_at: item.due_at,
updatedAt: item.updatedAt,
reservationCode: item.reservation?.reservation_code || null,
assignedTo: formatUserDisplay(item.assigned_to),
}));
}
if (access.invoices) {
const invoiceWhere = scopedWhere(currentUser, 'organizationId');
response.invoices.statusCounts = await getGroupedCounts(
db.invoices,
'status',
invoiceWhere,
);
const openBalance = await db.invoices.sum('balance_due', {
where: scopedWhere(currentUser, 'organizationId', {
status: { [Op.in]: ['issued', 'overdue', 'partially_paid'] },
}),
});
response.invoices.openBalance = Number(openBalance || 0);
const recentInvoices = await db.invoices.findAll({
where: invoiceWhere,
attributes: [
'id',
'invoice_number',
'status',
'total_amount',
'balance_due',
'currency',
'due_at',
'updatedAt',
],
include: [
{
model: db.organizations,
as: 'organization',
attributes: ['id', 'name'],
required: false,
},
{
model: db.reservations,
as: 'reservation',
attributes: ['id', 'reservation_code'],
required: false,
},
],
order: [['updatedAt', 'DESC']],
limit: 5,
});
response.invoices.recent = recentInvoices.map((item) => ({
id: item.id,
invoice_number: item.invoice_number,
status: item.status,
total_amount: Number(item.total_amount || 0),
balance_due: Number(item.balance_due || 0),
currency: item.currency || 'USD',
due_at: item.due_at,
updatedAt: item.updatedAt,
organizationName: item.organization?.name || 'Unassigned account',
reservationCode: item.reservation?.reservation_code || null,
}));
}
if (access.inventory) {
response.inventory.activeProperties = await db.properties.count({
where: scopedWhere(currentUser, 'organizationsId', { is_active: true }),
});
response.inventory.unitStatusCounts = await getGroupedCounts(
db.units,
'status',
scopedWhere(currentUser, 'organizationsId'),
);
}
res.status(200).send(response);
}),
);
module.exports = router;

View File

@ -5,6 +5,9 @@ const DocumentsService = require('../services/documents');
const DocumentsDBApi = require('../db/api/documents'); const DocumentsDBApi = require('../db/api/documents');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router(); const router = express.Router();
const { parse } = require('json2csv'); const { parse } = require('json2csv');
@ -401,7 +404,6 @@ router.get('/autocomplete', async (req, res) => {
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);
@ -442,12 +444,9 @@ router.get('/autocomplete', async (req, res) => {
router.get('/:id', wrapAsync(async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => {
const payload = await DocumentsDBApi.findBy( const payload = await DocumentsDBApi.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

@ -1,31 +1,13 @@
const express = require('express'); const express = require('express');
const db = require('../db/models');
const OrganizationsDBApi = require('../db/api/organizations'); const OrganizationsDBApi = require('../db/api/organizations');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router(); const router = express.Router();
async function loadLinkedTenantsForOrganization(organizationId) {
const linkedTenants = await db.tenants.findAll({
attributes: ['id', 'name', 'slug', 'primary_domain', 'timezone', 'default_currency', 'is_active'],
include: [
{
model: db.organizations,
as: 'organizations_filter',
required: true,
attributes: [],
through: { attributes: [] },
where: { id: organizationId },
},
],
limit: 3,
order: [['name', 'asc']],
});
return linkedTenants.map((tenant) => tenant.get({ plain: true }));
}
/** /**
* @swagger * @swagger
* /api/organizations: * /api/organizations:
@ -51,29 +33,23 @@ async function loadLinkedTenantsForOrganization(organizationId) {
* 500: * 500:
* description: Some server error * description: Some server error
*/ */
router.get( router.get(
'/', '/',
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
const payload = await OrganizationsDBApi.findAll(req.query || {}, true, {}); const payload = await OrganizationsDBApi.findAll(req.query);
const simplifiedPayload = payload.rows.map(org => ({
const simplifiedPayload = await Promise.all( id: org.id,
(payload.rows || []).map(async (org) => { name: org.name
const linkedTenants = await loadLinkedTenantsForOrganization(org.id); }));
res.status(200).send(simplifiedPayload);
return {
id: org.id,
name: org.name,
linkedTenants,
linkedTenantNames: linkedTenants
.map((tenant) => tenant.name)
.filter(Boolean),
primaryTenant: linkedTenants[0] || null,
};
}),
);
res.status(200).send(simplifiedPayload);
}), }),
); );
module.exports = router; module.exports = router;

View File

@ -5,6 +5,8 @@ const OrganizationsService = require('../services/organizations');
const OrganizationsDBApi = require('../db/api/organizations'); const OrganizationsDBApi = require('../db/api/organizations');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router(); const router = express.Router();
@ -375,16 +377,17 @@ router.get('/count', wrapAsync(async (req, res) => {
* description: Some server error * description: Some server error
*/ */
router.get('/autocomplete', async (req, res) => { router.get('/autocomplete', async (req, res) => {
const globalAccess = req.currentUser.app_role.globalAccess; const globalAccess = req.currentUser.app_role.globalAccess;
const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId;
const organizationId = req.currentUser.organization?.id
const payload = await OrganizationsDBApi.findAllAutocomplete( const payload = await OrganizationsDBApi.findAllAutocomplete(
req.query.query, req.query.query,
req.query.limit, req.query.limit,
req.query.offset, req.query.offset,
globalAccess, globalAccess, organizationId,
organizationId,
req.query.tenantId || req.query.tenant,
); );
res.status(200).send(payload); res.status(200).send(payload);

View File

@ -5,34 +5,18 @@ const PropertiesService = require('../services/properties');
const PropertiesDBApi = require('../db/api/properties'); const PropertiesDBApi = require('../db/api/properties');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router(); const router = express.Router();
const { parse } = require('json2csv'); const { parse } = require('json2csv');
const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates');
const { const {
checkCrudPermissions, checkCrudPermissions,
checkPermissions,
} = require('../middlewares/check-permissions'); } = require('../middlewares/check-permissions');
router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => {
const globalAccess = req.currentUser.app_role.globalAccess;
const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId;
const payload = await PropertiesDBApi.findAllAutocomplete(
req.query.query,
req.query.limit,
req.query.offset,
globalAccess,
organizationId,
req.query.tenantId || req.query.tenant,
req.query.organizationsId || req.query.organizationId || req.query.organizations,
);
res.status(200).send(payload);
});
router.use(checkCrudPermissions('properties')); router.use(checkCrudPermissions('properties'));
@ -153,15 +137,6 @@ router.post('/', wrapAsync(async (req, res) => {
* description: Some server error * description: Some server error
* *
*/ */
router.get('/import-template', wrapAsync(async (req, res) => {
const template = getInventoryImportTemplate('properties', req.currentUser);
const csv = buildInventoryImportTemplateCsv('properties', req.currentUser);
res.status(200);
res.attachment(template.fileName);
res.send(csv);
}));
router.post('/bulk-import', wrapAsync(async (req, res) => { router.post('/bulk-import', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer); const link = new URL(referer);
@ -419,6 +394,22 @@ router.get('/count', wrapAsync(async (req, res) => {
* 500: * 500:
* description: Some server error * description: Some server error
*/ */
router.get('/autocomplete', async (req, res) => {
const globalAccess = req.currentUser.app_role.globalAccess;
const organizationId = req.currentUser.organization?.id
const payload = await PropertiesDBApi.findAllAutocomplete(
req.query.query,
req.query.limit,
req.query.offset,
globalAccess, organizationId,
);
res.status(200).send(payload);
});
/** /**
* @swagger * @swagger

View File

@ -5,6 +5,7 @@ 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();
@ -405,7 +406,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, req.currentUser, globalAccess, organizationId,
); );
res.status(200).send(payload); res.status(200).send(payload);
@ -446,12 +447,9 @@ 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,6 +5,7 @@ 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();
@ -385,9 +386,6 @@ router.get('/autocomplete', async (req, res) => {
req.query.limit, req.query.limit,
req.query.offset, req.query.offset,
globalAccess, globalAccess,
req.query.businessOnly,
req.query.assignableOnly,
req.query.includeHighTrust,
); );
res.status(200).send(payload); res.status(200).send(payload);

View File

@ -5,6 +5,7 @@ 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();
@ -401,7 +402,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, req.currentUser, globalAccess, organizationId,
); );
res.status(200).send(payload); res.status(200).send(payload);
@ -442,12 +443,9 @@ 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

@ -5,33 +5,18 @@ const Unit_typesService = require('../services/unit_types');
const Unit_typesDBApi = require('../db/api/unit_types'); const Unit_typesDBApi = require('../db/api/unit_types');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router(); const router = express.Router();
const { parse } = require('json2csv'); const { parse } = require('json2csv');
const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates');
const { const {
checkCrudPermissions, checkCrudPermissions,
checkPermissions,
} = require('../middlewares/check-permissions'); } = require('../middlewares/check-permissions');
router.get('/autocomplete', checkPermissions('READ_BOOKING_REQUESTS'), async (req, res) => {
const globalAccess = req.currentUser.app_role.globalAccess;
const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId;
const payload = await Unit_typesDBApi.findAllAutocomplete(
req.query.query,
req.query.limit,
req.query.offset,
globalAccess,
organizationId,
req.query.propertyId || req.query.property,
);
res.status(200).send(payload);
});
router.use(checkCrudPermissions('unit_types')); router.use(checkCrudPermissions('unit_types'));
@ -161,15 +146,6 @@ router.post('/', wrapAsync(async (req, res) => {
* description: Some server error * description: Some server error
* *
*/ */
router.get('/import-template', wrapAsync(async (req, res) => {
const template = getInventoryImportTemplate('unit_types', req.currentUser);
const csv = buildInventoryImportTemplateCsv('unit_types', req.currentUser);
res.status(200);
res.attachment(template.fileName);
res.send(csv);
}));
router.post('/bulk-import', wrapAsync(async (req, res) => { router.post('/bulk-import', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer); const link = new URL(referer);
@ -427,6 +403,22 @@ router.get('/count', wrapAsync(async (req, res) => {
* 500: * 500:
* description: Some server error * description: Some server error
*/ */
router.get('/autocomplete', async (req, res) => {
const globalAccess = req.currentUser.app_role.globalAccess;
const organizationId = req.currentUser.organization?.id
const payload = await Unit_typesDBApi.findAllAutocomplete(
req.query.query,
req.query.limit,
req.query.offset,
globalAccess, organizationId,
);
res.status(200).send(payload);
});
/** /**
* @swagger * @swagger

View File

@ -5,11 +5,12 @@ const UnitsService = require('../services/units');
const UnitsDBApi = require('../db/api/units'); const UnitsDBApi = require('../db/api/units');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const config = require('../config');
const router = express.Router(); const router = express.Router();
const { parse } = require('json2csv'); const { parse } = require('json2csv');
const { buildInventoryImportTemplateCsv, getInventoryImportTemplate } = require('../services/inventoryImportTemplates');
const { const {
@ -128,15 +129,6 @@ router.post('/', wrapAsync(async (req, res) => {
* description: Some server error * description: Some server error
* *
*/ */
router.get('/import-template', wrapAsync(async (req, res) => {
const template = getInventoryImportTemplate('units', req.currentUser);
const csv = buildInventoryImportTemplateCsv('units', req.currentUser);
res.status(200);
res.attachment(template.fileName);
res.send(csv);
}));
router.post('/bulk-import', wrapAsync(async (req, res) => { router.post('/bulk-import', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer); const link = new URL(referer);
@ -395,17 +387,17 @@ router.get('/count', wrapAsync(async (req, res) => {
* description: Some server error * description: Some server error
*/ */
router.get('/autocomplete', async (req, res) => { router.get('/autocomplete', async (req, res) => {
const globalAccess = req.currentUser.app_role.globalAccess; const globalAccess = req.currentUser.app_role.globalAccess;
const organizationId = req.currentUser.organization?.id || req.currentUser.organizations?.id || req.currentUser.organizationId || req.currentUser.organizationsId;
const organizationId = req.currentUser.organization?.id
const payload = await UnitsDBApi.findAllAutocomplete( const payload = await UnitsDBApi.findAllAutocomplete(
req.query.query, req.query.query,
req.query.limit, req.query.limit,
req.query.offset, req.query.offset,
globalAccess, globalAccess, organizationId,
organizationId,
req.query.propertyId || req.query.property,
req.query.unitTypeId || req.query.unit_type || req.query.unitType,
); );
res.status(200).send(payload); res.status(200).send(payload);

View File

@ -3,6 +3,8 @@ 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');
@ -26,9 +28,9 @@ module.exports = class Booking_requestsService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
} };
static async bulkImport(req, res) { static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
@ -68,7 +70,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, currentUser}, {transaction},
); );
if (!booking_requests) { if (!booking_requests) {
@ -93,7 +95,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();
@ -115,7 +117,7 @@ module.exports = class Booking_requestsService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
const removedBooking_requests = await Booking_requestsDBApi.remove( await Booking_requestsDBApi.remove(
id, id,
{ {
currentUser, currentUser,
@ -123,12 +125,6 @@ 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

@ -1,152 +0,0 @@
const csv = require('csv-parser');
const stream = require('stream');
const processFile = require('../middlewares/upload');
const ValidationError = require('./notifications/errors/validation');
const ALLOWED_CSV_MIME_TYPES = new Set([
'text/csv',
'application/csv',
'application/vnd.ms-excel',
'text/plain',
'application/octet-stream',
]);
const isCsvFilename = (filename = '') => filename.toLowerCase().endsWith('.csv');
const isBlankRow = (row = {}) =>
Object.values(row).every((value) => value === undefined || value === null || String(value).trim() === '');
const createCsvValidationError = (message) => {
const error = new Error(message);
error.code = 400;
return error;
};
const splitCsvHeaderLine = (line = '') => {
const headers = [];
let current = '';
let insideQuotes = false;
for (let index = 0; index < line.length; index += 1) {
const character = line[index];
const nextCharacter = line[index + 1];
if (character === '"') {
if (insideQuotes && nextCharacter === '"') {
current += '"';
index += 1;
} else {
insideQuotes = !insideQuotes;
}
continue;
}
if (character === ',' && !insideQuotes) {
headers.push(current.trim());
current = '';
continue;
}
current += character;
}
headers.push(current.trim());
return headers
.map((header) => header.replace(/^\uFEFF/, '').trim())
.filter((header) => header.length);
};
const getCsvHeadersFromBuffer = (buffer) => {
const fileContents = Buffer.from(buffer).toString('utf8').replace(/^\uFEFF/, '');
const firstNonEmptyLine = fileContents
.split(/\r?\n/)
.find((line) => line.trim().length > 0);
if (!firstNonEmptyLine) {
return [];
}
return splitCsvHeaderLine(firstNonEmptyLine);
};
const validateCsvHeaders = (headers = [], options = {}) => {
const allowedHeaders = (options.allowedHeaders || options.columns || []).map((header) => header.trim());
const requiredHeaders = (options.requiredHeaders || []).map((header) => header.trim());
if (!allowedHeaders.length) {
return;
}
const unknownHeaders = headers.filter((header) => !allowedHeaders.includes(header));
const missingRequiredHeaders = requiredHeaders.filter((header) => !headers.includes(header));
if (!unknownHeaders.length && !missingRequiredHeaders.length) {
return;
}
const details = [];
if (missingRequiredHeaders.length) {
details.push(`Missing required columns: ${missingRequiredHeaders.join(', ')}`);
}
if (unknownHeaders.length) {
details.push(`Unexpected columns: ${unknownHeaders.join(', ')}`);
}
details.push(`Allowed columns: ${allowedHeaders.join(', ')}`);
throw createCsvValidationError(details.join('. '));
};
const parseCsvImportFile = async (req, res, options = {}) => {
await processFile(req, res);
if (!req.file || !req.file.buffer || !req.file.buffer.length) {
throw new ValidationError('importer.errors.invalidFileEmpty');
}
const filename = req.file.originalname || req.body?.filename || '';
const mimetype = req.file.mimetype || '';
if (!isCsvFilename(filename) && !ALLOWED_CSV_MIME_TYPES.has(mimetype)) {
throw new ValidationError('importer.errors.invalidFileUpload');
}
const csvHeaders = getCsvHeadersFromBuffer(req.file.buffer);
validateCsvHeaders(csvHeaders, options);
const bufferStream = new stream.PassThrough();
const results = [];
bufferStream.end(Buffer.from(req.file.buffer));
await new Promise((resolve, reject) => {
bufferStream
.pipe(
csv({
mapHeaders: ({ header }) => (header ? header.trim() : header),
mapValues: ({ value }) => (typeof value === 'string' ? value.trim() : value),
}),
)
.on('data', (data) => {
if (!isBlankRow(data)) {
results.push(data);
}
})
.on('end', resolve)
.on('error', reject);
});
if (!results.length) {
throw new ValidationError('importer.errors.invalidFileEmpty');
}
return results;
};
module.exports = {
parseCsvImportFile,
};

View File

@ -1,72 +0,0 @@
const { parse } = require('json2csv');
const getTemplateForProperties = (currentUser) => {
const globalAccess = Boolean(currentUser?.app_role?.globalAccess);
const columns = globalAccess
? ['tenant', 'organization', 'name', 'code', 'address', 'city', 'country', 'timezone', 'description', 'is_active']
: ['name', 'code', 'address', 'city', 'country', 'timezone', 'description', 'is_active'];
return {
columns,
requiredColumns: globalAccess ? ['organization'] : [],
fileName: 'properties-import-template.csv',
};
};
const getTemplateForUnitTypes = () => ({
columns: [
'property',
'name',
'code',
'description',
'max_occupancy',
'bedrooms',
'bathrooms',
'minimum_stay_nights',
'size_sqm',
'base_nightly_rate',
'base_monthly_rate',
],
requiredColumns: ['property'],
fileName: 'unit-types-import-template.csv',
});
const getTemplateForUnits = () => ({
columns: [
'property',
'unit_type',
'unit_number',
'floor',
'status',
'max_occupancy_override',
'notes',
],
requiredColumns: ['property', 'unit_type'],
fileName: 'units-import-template.csv',
});
const TEMPLATE_FACTORIES = {
properties: getTemplateForProperties,
unit_types: getTemplateForUnitTypes,
units: getTemplateForUnits,
};
const getInventoryImportTemplate = (entity, currentUser) => {
const factory = TEMPLATE_FACTORIES[entity];
if (!factory) {
throw new Error(`Unknown inventory import template entity: ${entity}`);
}
return factory(currentUser);
};
const buildInventoryImportTemplateCsv = (entity, currentUser) => {
const template = getInventoryImportTemplate(entity, currentUser);
return parse([], { fields: template.columns });
};
module.exports = {
buildInventoryImportTemplateCsv,
getInventoryImportTemplate,
};

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,11 @@
const db = require('../db/models'); const db = require('../db/models');
const PropertiesDBApi = require('../db/api/properties'); const PropertiesDBApi = require('../db/api/properties');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const { parseCsvImportFile } = require('./importer'); const csv = require('csv-parser');
const { getInventoryImportTemplate } = require('./inventoryImportTemplates'); const axios = require('axios');
const config = require('../config');
const stream = require('stream');
@ -25,17 +28,28 @@ module.exports = class PropertiesService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
} };
static async bulkImport(req, res) { static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
const results = await parseCsvImportFile( await processFile(req, res);
req, const bufferStream = new stream.PassThrough();
res, const results = [];
getInventoryImportTemplate('properties', req.currentUser),
); await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await PropertiesDBApi.bulkImport(results, { await PropertiesDBApi.bulkImport(results, {
transaction, transaction,
@ -81,7 +95,7 @@ module.exports = class PropertiesService {
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();
@ -118,6 +132,7 @@ module.exports = class PropertiesService {
} }
} }
}
};

View File

@ -3,6 +3,8 @@ 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');
@ -26,9 +28,9 @@ module.exports = class Service_requestsService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
} };
static async bulkImport(req, res) { static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
@ -68,7 +70,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, currentUser}, {transaction},
); );
if (!service_requests) { if (!service_requests) {
@ -93,7 +95,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();
@ -115,7 +117,7 @@ module.exports = class Service_requestsService {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
const removedService_requests = await Service_requestsDBApi.remove( await Service_requestsDBApi.remove(
id, id,
{ {
currentUser, currentUser,
@ -123,12 +125,6 @@ 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

@ -1,8 +1,11 @@
const db = require('../db/models'); const db = require('../db/models');
const Unit_typesDBApi = require('../db/api/unit_types'); const Unit_typesDBApi = require('../db/api/unit_types');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const { parseCsvImportFile } = require('./importer'); const csv = require('csv-parser');
const { getInventoryImportTemplate } = require('./inventoryImportTemplates'); const axios = require('axios');
const config = require('../config');
const stream = require('stream');
@ -25,17 +28,28 @@ module.exports = class Unit_typesService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
} };
static async bulkImport(req, res) { static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
const results = await parseCsvImportFile( await processFile(req, res);
req, const bufferStream = new stream.PassThrough();
res, const results = [];
getInventoryImportTemplate('unit_types', req.currentUser),
); await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await Unit_typesDBApi.bulkImport(results, { await Unit_typesDBApi.bulkImport(results, {
transaction, transaction,
@ -81,7 +95,7 @@ module.exports = class Unit_typesService {
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();
@ -118,6 +132,7 @@ module.exports = class Unit_typesService {
} }
} }
}
};

View File

@ -1,8 +1,11 @@
const db = require('../db/models'); const db = require('../db/models');
const UnitsDBApi = require('../db/api/units'); const UnitsDBApi = require('../db/api/units');
const processFile = require("../middlewares/upload");
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
const { parseCsvImportFile } = require('./importer'); const csv = require('csv-parser');
const { getInventoryImportTemplate } = require('./inventoryImportTemplates'); const axios = require('axios');
const config = require('../config');
const stream = require('stream');
@ -25,17 +28,28 @@ module.exports = class UnitsService {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
} };
static async bulkImport(req, res) { static async bulkImport(req, res, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
const results = await parseCsvImportFile( await processFile(req, res);
req, const bufferStream = new stream.PassThrough();
res, const results = [];
getInventoryImportTemplate('units', req.currentUser),
); await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
})
await UnitsDBApi.bulkImport(results, { await UnitsDBApi.bulkImport(results, {
transaction, transaction,
@ -81,7 +95,7 @@ module.exports = class UnitsService {
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();
@ -118,6 +132,7 @@ module.exports = class UnitsService {
} }
} }
}
};

View File

@ -1,143 +1,56 @@
const db = require('../db/models'); const db = require('../db/models');
const UsersDBApi = require('../db/api/users'); const UsersDBApi = require('../db/api/users');
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 config = require('../config');
const stream = require('stream'); const stream = require('stream');
const InvitationEmail = require('./email/list/invitation');
const EmailSender = require('./email');
const AuthService = require('./auth'); const AuthService = require('./auth');
const BUSINESS_ASSIGNABLE_ROLE_NAMES = [
config.roles.super_admin,
config.roles.admin,
config.roles.concierge,
config.roles.customer,
];
const HIGH_TRUST_ROLE_NAMES = [
config.roles.super_admin,
config.roles.admin,
];
async function findRoleById(roleId, transaction) {
if (!roleId) {
return null;
}
return db.roles.findByPk(roleId, { transaction });
}
async function findDefaultRole(transaction) {
return db.roles.findOne({
where: { name: config.roles.user },
transaction,
});
}
function isAdminOrSuperAdmin(currentUser) {
return [config.roles.admin, config.roles.super_admin].includes(currentUser?.app_role?.name);
}
function isHighTrustRole(roleName) {
return Boolean(roleName) && HIGH_TRUST_ROLE_NAMES.includes(roleName);
}
async function assertManageableExistingUser(user, currentUser) {
if (!user) {
throw new ValidationError('iam.errors.userNotFound');
}
if (!isAdminOrSuperAdmin(currentUser)) {
throw new ValidationError('errors.forbidden.message');
}
if (currentUser?.id === user.id) {
throw new ValidationError('iam.errors.deletingHimself');
}
if (!currentUser?.app_role?.globalAccess && isHighTrustRole(user?.app_role?.name)) {
throw new ValidationError('errors.forbidden.message');
}
}
async function normalizeManagedUserPayload(data, currentUser, transaction, existingUser = null) {
const payload = { ...data };
const isSuperAdmin = Boolean(currentUser?.app_role?.globalAccess);
const currentOrganizationId = currentUser?.organization?.id || currentUser?.organizationId || null;
if (!payload.app_role) {
payload.app_role = existingUser?.app_role?.id || null;
}
let selectedRole = await findRoleById(payload.app_role, transaction);
if (!selectedRole && !existingUser) {
selectedRole = await findDefaultRole(transaction);
if (selectedRole) {
payload.app_role = selectedRole.id;
}
}
if (selectedRole && !BUSINESS_ASSIGNABLE_ROLE_NAMES.includes(selectedRole.name)) {
throw new ValidationError('errors.forbidden.message');
}
if (existingUser?.app_role?.name && !isSuperAdmin && isHighTrustRole(existingUser.app_role.name)) {
throw new ValidationError('errors.forbidden.message');
}
if (selectedRole?.name && !isSuperAdmin && isHighTrustRole(selectedRole.name)) {
throw new ValidationError('errors.forbidden.message');
}
if (!isSuperAdmin) {
payload.custom_permissions = existingUser?.custom_permissions?.map((permission) => permission.id) || [];
payload.organizations = currentOrganizationId || existingUser?.organizations?.id || null;
}
if (!payload.organizations && existingUser?.organizations?.id) {
payload.organizations = existingUser.organizations.id;
}
return payload;
}
module.exports = class UsersService { module.exports = class UsersService {
static async create(data, currentUser, sendInvitationEmails = true, host) { static async create(data, currentUser, sendInvitationEmails = true, host) {
const transaction = await db.sequelize.transaction(); let transaction = await db.sequelize.transaction();
const email = data.email;
const emailsToInvite = []; const globalAccess = currentUser.app_role.globalAccess;
let email = data.email;
let emailsToInvite = [];
try { try {
if (!email) { if (email) {
throw new ValidationError('iam.errors.emailRequired'); let user = await UsersDBApi.findBy({email}, {transaction});
if (user) {
throw new ValidationError(
'iam.errors.userAlreadyExists',
);
} else {
await UsersDBApi.create(
{data},
globalAccess,
{
currentUser,
transaction,
},
);
emailsToInvite.push(email);
}
} else {
throw new ValidationError('iam.errors.emailRequired')
} }
const user = await UsersDBApi.findBy({ email }, { transaction });
if (user) {
throw new ValidationError('iam.errors.userAlreadyExists');
}
const normalizedPayload = await normalizeManagedUserPayload(data, currentUser, transaction);
await UsersDBApi.create(
{ data: normalizedPayload },
currentUser.app_role.globalAccess,
{
currentUser,
transaction,
},
);
emailsToInvite.push(email);
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
if (emailsToInvite && emailsToInvite.length) {
if (!sendInvitationEmails) return;
if (emailsToInvite.length && sendInvitationEmails) {
AuthService.sendPasswordResetEmail(email, 'invitation', host); AuthService.sendPasswordResetEmail(email, 'invitation', host);
} }
} }
@ -151,36 +64,42 @@ module.exports = class UsersService {
const bufferStream = new stream.PassThrough(); const bufferStream = new stream.PassThrough();
const results = []; const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
bufferStream bufferStream
.pipe(csv()) .pipe(csv())
.on('data', (data) => results.push(data)) .on('data', (data) => results.push(data))
.on('end', resolve) .on('end', () => {
.on('error', reject); console.log('results csv', results);
resolve();
})
.on('error', (error) => reject(error));
}); });
const hasAllEmails = results.every((result) => result.email); const hasAllEmails = results.every((result) => result.email);
if (!hasAllEmails) { if (!hasAllEmails) {
throw new ValidationError('importer.errors.userEmailMissing'); throw new ValidationError('importer.errors.userEmailMissing');
} }
await UsersDBApi.bulkImport(results, { await UsersDBApi.bulkImport(results, {
transaction, transaction,
ignoreDuplicates: true, ignoreDuplicates: true,
validate: true, validate: true,
currentUser: req.currentUser, currentUser: req.currentUser
}); });
emailsToInvite = results.map((result) => result.email); emailsToInvite = results.map((result) => result.email);
await transaction.commit(); await transaction.commit();
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
if (emailsToInvite.length && !sendInvitationEmails) { if (emailsToInvite && emailsToInvite.length && !sendInvitationEmails) {
emailsToInvite.forEach((email) => { emailsToInvite.forEach((email) => {
AuthService.sendPasswordResetEmail(email, 'invitation', host); AuthService.sendPasswordResetEmail(email, 'invitation', host);
}); });
@ -189,30 +108,27 @@ module.exports = class UsersService {
static async update(data, id, currentUser) { static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
const globalAccess = currentUser.app_role.globalAccess;
try { try {
const user = await UsersDBApi.findBy({ id }, { transaction }); let users = await UsersDBApi.findBy(
{id},
{transaction},
);
if (!user) { if (!users) {
throw new ValidationError('iam.errors.userNotFound'); throw new ValidationError(
'iam.errors.userNotFound',
);
} }
if (!currentUser?.app_role?.globalAccess) {
if (currentUser?.id === id) {
throw new ValidationError('errors.forbidden.message');
}
if (isHighTrustRole(user?.app_role?.name)) {
throw new ValidationError('errors.forbidden.message');
}
}
const normalizedPayload = await normalizeManagedUserPayload(data, currentUser, transaction, user);
const updatedUser = await UsersDBApi.update( const updatedUser = await UsersDBApi.update(
id, id,
normalizedPayload, data,
currentUser.app_role.globalAccess,
globalAccess,
{ {
currentUser, currentUser,
transaction, transaction,
@ -221,60 +137,43 @@ module.exports = class UsersService {
await transaction.commit(); await transaction.commit();
return updatedUser; return updatedUser;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
} };
static async remove(id, currentUser) { static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction(); const transaction = await db.sequelize.transaction();
try { try {
const user = await UsersDBApi.findBy({ id }, { transaction }); if (currentUser.id === id) {
throw new ValidationError(
await assertManageableExistingUser(user, currentUser); 'iam.errors.deletingHimself',
);
await UsersDBApi.remove(id, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const normalizedIds = Array.from(new Set((Array.isArray(ids) ? ids : []).filter(Boolean)));
if (!normalizedIds.length) {
return [];
} }
const users = await Promise.all( if (currentUser.app_role?.name !== config.roles.admin && currentUser.app_role?.name !== config.roles.super_admin ) {
normalizedIds.map((id) => UsersDBApi.findBy({ id }, { transaction })), throw new ValidationError(
'errors.forbidden.message',
);
}
await UsersDBApi.remove(
id,
{
currentUser,
transaction,
},
); );
for (const user of users) {
await assertManageableExistingUser(user, currentUser);
}
const deletedUsers = await UsersDBApi.deleteByIds(normalizedIds, {
currentUser,
transaction,
});
await transaction.commit(); await transaction.commit();
return deletedUsers;
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
throw error; throw error;
} }
} }
}; };

File diff suppressed because it is too large Load Diff

View File

@ -3,22 +3,8 @@
*/ */
const output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone'; const output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone';
const backendProxyTarget = process.env.BACKEND_INTERNAL_URL || 'http://127.0.0.1:3000';
const nextConfig = { const nextConfig = {
trailingSlash: true, trailingSlash: true,
async rewrites() {
if (process.env.NODE_ENV === 'production') {
return [];
}
return [
{
source: '/api/:path*',
destination: `${backendProxyTarget}/api/:path*`,
},
];
},
distDir: 'build', distDir: 'build',
output, output,
basePath: "", basePath: "",

View File

@ -15,58 +15,66 @@ type Props = {
const AsideMenuItem = ({ item, isDropdownList = false }: Props) => { const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
const [isLinkActive, setIsLinkActive] = useState(false) const [isLinkActive, setIsLinkActive] = useState(false)
const [isDropdownActive, setIsDropdownActive] = useState(!isDropdownList && !!item.menu) const [isDropdownActive, setIsDropdownActive] = useState(false)
const asideMenuItemStyle = useAppSelector((state) => state.style.asideMenuItemStyle) const asideMenuItemStyle = useAppSelector((state) => state.style.asideMenuItemStyle)
const asideMenuDropdownStyle = useAppSelector((state) => state.style.asideMenuDropdownStyle) const asideMenuDropdownStyle = useAppSelector((state) => state.style.asideMenuDropdownStyle)
const asideMenuItemActiveStyle = useAppSelector((state) => state.style.asideMenuItemActiveStyle) const asideMenuItemActiveStyle = useAppSelector((state) => state.style.asideMenuItemActiveStyle)
const borders = useAppSelector((state) => state.style.borders) const borders = useAppSelector((state) => state.style.borders);
const activeLinkColor = useAppSelector((state) => state.style.activeLinkColor) const activeLinkColor = useAppSelector(
(state) => state.style.activeLinkColor,
);
const activeClassAddon = !item.color && isLinkActive ? asideMenuItemActiveStyle : '' const activeClassAddon = !item.color && isLinkActive ? asideMenuItemActiveStyle : ''
const { asPath, isReady } = useRouter() const { asPath, isReady } = useRouter()
useEffect(() => { useEffect(() => {
if (item.href && isReady) { if (item.href && isReady) {
const linkPathName = new URL(item.href, location.href).pathname + '/' const linkPathName = new URL(item.href, location.href).pathname + '/';
const activePathname = new URL(asPath, location.href).pathname const activePathname = new URL(asPath, location.href).pathname
const activeView = activePathname.split('/')[1] const activeView = activePathname.split('/')[1];
const linkPathNameView = linkPathName.split('/')[1] const linkPathNameView = linkPathName.split('/')[1];
setIsLinkActive(linkPathNameView === activeView) setIsLinkActive(linkPathNameView === activeView);
} }
}, [item.href, isReady, asPath]) }, [item.href, isReady, asPath])
const asideMenuItemInnerContents = ( const asideMenuItemInnerContents = (
<> <>
{item.icon && ( {item.icon && (
<BaseIcon path={item.icon} className={`mt-0.5 flex-none ${activeClassAddon}`} size="18" /> <BaseIcon path={item.icon} className={`flex-none mx-3 ${activeClassAddon}`} size="18" />
)} )}
<span <span
className={`grow break-words text-sm leading-5 ${item.menu ? '' : 'pr-3'} ${activeClassAddon}`} className={`grow text-ellipsis line-clamp-1 ${
item.menu ? '' : 'pr-12'
} ${activeClassAddon}`}
> >
{item.label} {item.label}
</span> </span>
{item.menu && ( {item.menu && (
<BaseIcon <BaseIcon
path={isDropdownActive ? mdiMinus : mdiPlus} path={isDropdownActive ? mdiMinus : mdiPlus}
className={`mt-0.5 flex-none ${activeClassAddon}`} className={`flex-none ${activeClassAddon}`}
size="18" w="w-12"
/> />
)} )}
</> </>
) )
const componentClass = [ const componentClass = [
'flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3 transition-colors', 'flex cursor-pointer py-1.5 ',
isDropdownList ? 'ml-2 text-sm' : '', isDropdownList ? 'px-6 text-sm' : '',
item.color ? getButtonColor(item.color, false, true) : `${asideMenuItemStyle}`, item.color
isLinkActive ? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800` : '', ? getButtonColor(item.color, false, true)
].join(' ') : `${asideMenuItemStyle}`,
isLinkActive
? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800`
: '',
].join(' ');
return ( return (
<li className={'px-2 py-1'}> <li className={'px-3 py-1.5'}>
{item.withDevider && <hr className={`${borders} mb-3`} />} {item.withDevider && <hr className={`${borders} mb-3`} />}
{item.href && ( {item.href && (
<Link href={item.href} target={item.target} className={componentClass}> <Link href={item.href} target={item.target} className={componentClass}>
@ -81,8 +89,8 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
{item.menu && ( {item.menu && (
<AsideMenuList <AsideMenuList
menu={item.menu} menu={item.menu}
className={`${asideMenuDropdownStyle} mt-2 rounded-xl border border-white/5 p-1 ${ className={`${asideMenuDropdownStyle} ${
isDropdownActive ? 'block dark:bg-slate-800/40' : 'hidden' isDropdownActive ? 'block dark:bg-slate-800/50' : 'hidden'
}`} }`}
isDropdownList isDropdownList
/> />

View File

@ -1,165 +1,88 @@
import React, { useEffect, useState } from 'react'; import React from 'react'
import { mdiClose, mdiMinus, mdiPlus } from '@mdi/js'; import { mdiLogout, mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon'; import BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList'; import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces'; import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks'; import { useAppSelector } from '../stores/hooks'
import { loadLinkedTenantSummary } from '../helpers/organizationTenants'; import Link from 'next/link';
import type { LinkedTenantRecord } from '../helpers/organizationTenants';
import { useAppDispatch } from '../stores/hooks';
import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';
const ASIDE_WIDTH_STORAGE_KEY = 'aside-width';
const DEFAULT_ASIDE_WIDTH = 320;
const MIN_ASIDE_WIDTH = 288;
const MAX_ASIDE_WIDTH = 400;
const ASIDE_WIDTH_STEP = 24;
type Props = { type Props = {
menu: MenuAsideItem[]; menu: MenuAsideItem[]
className?: string; className?: string
onAsideLgCloseClick: () => void; onAsideLgCloseClick: () => void
}; }
export default function AsideMenuLayer({ menu, className = '', ...props }: Props) { export default function AsideMenuLayer({ menu, className = '', ...props }: Props) {
const corners = useAppSelector((state) => state.style.corners); const corners = useAppSelector((state) => state.style.corners);
const asideStyle = useAppSelector((state) => state.style.asideStyle); const asideStyle = useAppSelector((state) => state.style.asideStyle)
const asideBrandStyle = useAppSelector((state) => state.style.asideBrandStyle); const asideBrandStyle = useAppSelector((state) => state.style.asideBrandStyle)
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle); const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
const darkMode = useAppSelector((state) => state.style.darkMode); const darkMode = useAppSelector((state) => state.style.darkMode)
const { currentUser } = useAppSelector((state) => state.auth);
const [asideWidth, setAsideWidth] = useState(DEFAULT_ASIDE_WIDTH);
const [linkedTenants, setLinkedTenants] = useState<LinkedTenantRecord[]>([]);
useEffect(() => {
if (typeof window === 'undefined') return;
const storedValue = Number(window.localStorage.getItem(ASIDE_WIDTH_STORAGE_KEY));
if (!Number.isNaN(storedValue) && storedValue >= MIN_ASIDE_WIDTH && storedValue <= MAX_ASIDE_WIDTH) {
setAsideWidth(storedValue);
}
}, []);
useEffect(() => {
if (typeof document === 'undefined') return;
document.documentElement.style.setProperty('--aside-width', `${asideWidth}px`);
if (typeof window !== 'undefined') {
window.localStorage.setItem(ASIDE_WIDTH_STORAGE_KEY, String(asideWidth));
}
}, [asideWidth]);
useEffect(() => {
const organizationId =
currentUser?.organizations?.id || currentUser?.organization?.id || currentUser?.organizationsId || currentUser?.organizationId;
if (!organizationId) {
setLinkedTenants([]);
return;
}
let isMounted = true;
loadLinkedTenantSummary(organizationId)
.then((summary) => {
if (isMounted) {
setLinkedTenants(summary.rows.slice(0, 2));
}
})
.catch((error) => {
console.error('Failed to load sidebar tenant context:', error);
if (isMounted) {
setLinkedTenants([]);
}
});
return () => {
isMounted = false;
};
}, [
currentUser?.organizations?.id,
currentUser?.organization?.id,
currentUser?.organizationsId,
currentUser?.organizationId,
]);
const handleAsideLgCloseClick = (e: React.MouseEvent) => { const handleAsideLgCloseClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault()
props.onAsideLgCloseClick(); props.onAsideLgCloseClick()
}; }
const resizeAside = (direction: 'narrower' | 'wider') => { const dispatch = useAppDispatch();
setAsideWidth((currentWidth) => { const { currentUser } = useAppSelector((state) => state.auth);
const nextWidth = const organizationsId = currentUser?.organizations?.id;
direction === 'wider' ? currentWidth + ASIDE_WIDTH_STEP : currentWidth - ASIDE_WIDTH_STEP; const [organizations, setOrganizations] = React.useState(null);
return Math.min(MAX_ASIDE_WIDTH, Math.max(MIN_ASIDE_WIDTH, nextWidth)); const fetchOrganizations = createAsyncThunk('/org-for-auth', async () => {
}); try {
}; const response = await axios.get('/org-for-auth');
setOrganizations(response.data);
return response.data;
} catch (error) {
console.error(error.response);
throw error;
}
});
React.useEffect(() => {
dispatch(fetchOrganizations());
}, [dispatch]);
let organizationName = organizations?.find(item => item.id === organizationsId)?.name;
if(organizationName?.length > 25){
organizationName = organizationName?.substring(0, 25) + '...';
}
const organizationName =
currentUser?.organizations?.name || currentUser?.organization?.name || 'Corporate workspace';
const tenantContextLabel = linkedTenants.length
? `Tenant${linkedTenants.length > 1 ? 's' : ''}: ${linkedTenants
.map((tenant) => tenant.name || 'Unnamed tenant')
.join(', ')}`
: 'No tenant link surfaced yet';
return ( return (
<aside <aside
id="asideMenu" id='asideMenu'
className={`${className} fixed top-0 z-40 flex h-screen w-56 overflow-hidden transition-position lg:py-3 lg:pl-3 xl:w-[var(--aside-width)]`} className={`${className} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
> >
<div <div
className={`flex flex-1 flex-col overflow-hidden border border-white/10 shadow-2xl dark:bg-dark-900 ${asideStyle} ${corners}`} className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`}
> >
<div className={`flex min-h-20 items-start justify-between gap-3 px-4 py-4 ${asideBrandStyle}`}>
<div className="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-cyan-200/80">
Gracey
</div>
<div className="mt-1 break-words text-base font-semibold leading-6 text-white">
Corporate Stay Portal
</div>
<p className="mt-1 break-words text-sm leading-5 text-slate-300">{organizationName}</p>
<p className="mt-1 break-words text-xs leading-5 text-cyan-100/80">{tenantContextLabel}</p>
</div>
<div className="flex items-center gap-1">
<div className="hidden items-center gap-1 xl:flex">
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-slate-200 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => resizeAside('narrower')}
disabled={asideWidth <= MIN_ASIDE_WIDTH}
aria-label="Make sidebar narrower"
title="Make sidebar narrower"
>
<BaseIcon path={mdiMinus} size="16" />
</button>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-slate-200 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => resizeAside('wider')}
disabled={asideWidth >= MAX_ASIDE_WIDTH}
aria-label="Make sidebar wider"
title="Make sidebar wider"
>
<BaseIcon path={mdiPlus} size="16" />
</button>
</div>
<button
className="hidden p-2 text-slate-300 transition hover:text-white lg:inline-block xl:hidden"
onClick={handleAsideLgCloseClick}
type="button"
aria-label="Close sidebar"
>
<BaseIcon path={mdiClose} />
</button>
</div>
</div>
<div <div
className={`flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 ${ className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
>
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
<b className="font-black">Gracey Corporate Stay Portal</b>
{organizationName && <p>{organizationName}</p>}
</div>
<button
className="hidden lg:inline-block xl:hidden p-3"
onClick={handleAsideLgCloseClick}
>
<BaseIcon path={mdiClose} />
</button>
</div>
<div
className={`flex-1 overflow-y-auto overflow-x-hidden ${
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`} }`}
> >
@ -167,5 +90,5 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
</div> </div>
</div> </div>
</aside> </aside>
); )
} }

View File

@ -1,244 +1,175 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useState, useRef } from 'react';
import { import {
Calendar, Calendar,
EventProps, Views,
SlotInfo, momentLocalizer,
Views, SlotInfo,
momentLocalizer, EventProps,
} from 'react-big-calendar'; } from 'react-big-calendar';
import moment from 'moment'; import moment from 'moment';
import Link from 'next/link';
import 'react-big-calendar/lib/css/react-big-calendar.css'; import 'react-big-calendar/lib/css/react-big-calendar.css';
import BaseButton from './BaseButton';
import ListActionsPopover from './ListActionsPopover'; import ListActionsPopover from './ListActionsPopover';
import LoadingSpinner from './LoadingSpinner'; import Link from 'next/link';
import CardBoxComponentEmpty from './CardBoxComponentEmpty';
import { useAppSelector } from '../stores/hooks'; import { useAppSelector } from '../stores/hooks';
import { hasPermission } from '../helpers/userPermissions'; import { hasPermission } from '../helpers/userPermissions';
const localizer = momentLocalizer(moment); const localizer = momentLocalizer(moment);
type TEvent = { type TEvent = {
id: string; id: string;
title: string; title: string;
start: Date; start: Date;
end: Date; end: Date;
[key: string]: any;
}; };
type Props = { type Props = {
events: any[]; events: any[];
handleDeleteAction: (id: string) => void; handleDeleteAction: (id: string) => void;
handleCreateEventAction: (slotInfo: SlotInfo) => void; handleCreateEventAction: (slotInfo: SlotInfo) => void;
onDateRangeChange: (range: { start: string; end: string }) => void; onDateRangeChange: (range: { start: string; end: string }) => void;
entityName: string; entityName: string;
showField: string; showField: string;
pathEdit?: string; pathEdit?: string;
pathView?: string; pathView?: string;
isLoading?: boolean; 'start-data-key': string;
emptyTitle?: string; 'end-data-key': string;
emptyDescription?: string;
'start-data-key': string;
'end-data-key': string;
};
const formatEventWindow = (event: TEvent) => {
const sameDay = moment(event.start).isSame(event.end, 'day');
if (sameDay) {
return `${moment(event.start).format('MMM D')} · ${moment(event.start).format('h:mm A')} - ${moment(event.end).format('h:mm A')}`;
}
return `${moment(event.start).format('MMM D')} - ${moment(event.end).format('MMM D')}`;
};
const CalendarToolbar = ({ label, onNavigate }: any) => {
return (
<div className='mb-4 flex flex-col gap-3 border-b border-gray-200 pb-4 md:flex-row md:items-center md:justify-between dark:border-dark-700'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500'>
Schedule
</p>
<p className='mt-1 text-lg font-semibold text-gray-900 dark:text-white'>{label}</p>
</div>
<div className='flex items-center gap-2'>
<BaseButton small outline color='info' label='Prev' onClick={() => onNavigate('PREV')} />
<BaseButton small outline color='info' label='Today' onClick={() => onNavigate('TODAY')} />
<BaseButton small outline color='info' label='Next' onClick={() => onNavigate('NEXT')} />
</div>
</div>
);
}; };
const BigCalendar = ({ const BigCalendar = ({
events, events,
handleDeleteAction, handleDeleteAction,
handleCreateEventAction, handleCreateEventAction,
onDateRangeChange, onDateRangeChange,
entityName, entityName,
showField, showField,
pathEdit, pathEdit,
pathView, pathView,
isLoading = false, 'start-data-key': startDataKey,
emptyTitle = 'Nothing is scheduled in this window', 'end-data-key': endDataKey,
emptyDescription = 'Adjust the date range or create a new item to start filling this calendar.', }: Props) => {
'start-data-key': startDataKey, const [myEvents, setMyEvents] = useState<TEvent[]>([]);
'end-data-key': endDataKey, const prevRange = useRef<{ start: string; end: string } | null>(null);
}: Props) => {
const [myEvents, setMyEvents] = useState<TEvent[]>([]);
const prevRange = useRef<{ start: string; end: string } | null>(null);
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = const currentUser = useAppSelector((state) => state.auth.currentUser);
currentUser && hasPermission(currentUser, `UPDATE_${entityName.toUpperCase()}`); const hasUpdatePermission =
const hasCreatePermission = currentUser &&
currentUser && hasPermission(currentUser, `CREATE_${entityName.toUpperCase()}`); hasPermission(currentUser, `UPDATE_${entityName.toUpperCase()}`);
const hasCreatePermission =
currentUser &&
hasPermission(currentUser, `CREATE_${entityName.toUpperCase()}`);
const { defaultDate, scrollToTime } = useMemo( const { defaultDate, scrollToTime } = useMemo(
() => ({ () => ({
defaultDate: new Date(), defaultDate: new Date(),
scrollToTime: new Date(1970, 1, 1, 8), scrollToTime: new Date(1970, 1, 1, 6),
}), }),
[], [],
); );
useEffect(() => { useEffect(() => {
if (!Array.isArray(events) || !events.length) { if (!events || !Array.isArray(events) || !events?.length) return;
setMyEvents([]);
return;
}
const formattedEvents = events.map((event) => ({ const formattedEvents = events.map((event) => ({
...event, ...event,
start: new Date(event[startDataKey]), start: new Date(event[startDataKey]),
end: new Date(event[endDataKey]), end: new Date(event[endDataKey]),
title: event[showField], title: event[showField],
})); }));
setMyEvents(formattedEvents); setMyEvents(formattedEvents);
}, [endDataKey, events, showField, startDataKey]); }, [endDataKey, events, startDataKey, showField]);
const onRangeChange = (range: Date[] | { start: Date; end: Date }) => { const onRangeChange = (
const newRange = { start: '', end: '' }; range: Date[] | { start: Date; end: Date },
const format = 'YYYY-MM-DDTHH:mm'; ) => {
const newRange = { start: '', end: '' };
const format = 'YYYY-MM-DDTHH:mm';
if (Array.isArray(range)) { if (Array.isArray(range)) {
newRange.start = moment(range[0]).format(format); newRange.start = moment(range[0]).format(format);
newRange.end = moment(range[range.length - 1]).format(format); newRange.end = moment(range[range.length - 1]).format(format);
} else { } else {
newRange.start = moment(range.start).format(format); newRange.start = moment(range.start).format(format);
newRange.end = moment(range.end).format(format); newRange.end = moment(range.end).format(format);
} }
if (newRange.start === newRange.end) { if (newRange.start === newRange.end) {
newRange.end = moment(newRange.end).add(1, 'days').format(format); newRange.end = moment(newRange.end).add(1, 'days').format(format);
} }
if ( // check if the range fits in the previous range
prevRange.current && if (
prevRange.current.start <= newRange.start && prevRange.current &&
prevRange.current.end >= newRange.end prevRange.current.start <= newRange.start &&
) { prevRange.current.end >= newRange.end
return; ) {
} return;
}
prevRange.current = { start: newRange.start, end: newRange.end }; prevRange.current = { start: newRange.start, end: newRange.end };
onDateRangeChange(newRange); onDateRangeChange(newRange);
}; };
return ( return (
<div className='rounded-2xl border border-gray-200/80 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800/80'> <div className='h-[600px] p-4'>
{isLoading ? ( <Calendar
<LoadingSpinner defaultDate={defaultDate}
compact defaultView={Views.MONTH}
label='Loading calendar view' events={myEvents}
detail='Syncing the current date range and live reservations.' localizer={localizer}
/> selectable={hasCreatePermission}
) : null} onSelectSlot={handleCreateEventAction}
onRangeChange={onRangeChange}
{!isLoading && myEvents.length === 0 ? ( scrollToTime={scrollToTime}
<CardBoxComponentEmpty components={{
compact event: (props) => (
title={emptyTitle} <MyCustomEvent
description={emptyDescription} {...props}
/> onDelete={handleDeleteAction}
) : null} hasUpdatePermission={hasUpdatePermission}
pathEdit ={pathEdit}
<div className={isLoading ? 'pointer-events-none opacity-40' : ''}> pathView={pathView}
<Calendar />
className='min-h-[680px]' ),
defaultDate={defaultDate} }}
defaultView={Views.MONTH} />
events={myEvents} </div>
localizer={localizer} );
selectable={hasCreatePermission}
onSelectSlot={handleCreateEventAction}
onRangeChange={onRangeChange}
scrollToTime={scrollToTime}
dayPropGetter={(date) => ({
style: {
backgroundColor: [0, 6].includes(date.getDay()) ? 'rgba(148, 163, 184, 0.06)' : 'transparent',
},
})}
eventPropGetter={() => ({
style: {
backgroundColor: '#eff6ff',
border: '1px solid #bfdbfe',
borderRadius: '14px',
color: '#1e3a8a',
padding: '2px 4px',
boxShadow: 'none',
},
})}
components={{
toolbar: (toolbarProps: any) => <CalendarToolbar {...toolbarProps} />,
event: (props) => (
<MyCustomEvent
{...props}
onDelete={handleDeleteAction}
hasUpdatePermission={!!hasUpdatePermission}
pathEdit={pathEdit}
pathView={pathView}
/>
),
}}
/>
</div>
</div>
);
}; };
const MyCustomEvent = ( const MyCustomEvent = (
props: { props: {
onDelete: (id: string) => void; onDelete: (id: string) => void;
hasUpdatePermission: boolean; hasUpdatePermission: boolean;
pathEdit?: string; pathEdit?: string;
pathView?: string; pathView?: string;
} & EventProps<TEvent>, } & EventProps<TEvent>,
) => { ) => {
const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } = props; const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } = props;
return ( return (
<div className='flex items-start justify-between gap-2 overflow-hidden rounded-xl'> <div className={'flex items-center justify-between relative'}>
<div className='min-w-0'> <Link
<Link href={`${pathView}${event.id}`}
href={`${pathView}${event.id}`} className={'text-ellipsis overflow-hidden grow'}
className='block truncate text-xs font-semibold text-blue-900' >
> {title}
{title} </Link>
</Link> <ListActionsPopover
<p className='truncate text-[11px] text-blue-700/80'>{formatEventWindow(event)}</p> className={'w-2 h-2 text-white'}
</div> iconClassName={'text-white w-5'}
<ListActionsPopover itemId={event.id}
className='h-5 w-5 text-blue-700' onDelete={onDelete}
iconClassName='w-4 text-blue-700' pathEdit={`${pathEdit}${event.id}`}
itemId={event.id} pathView={`${pathView}${event.id}`}
onDelete={onDelete} hasUpdatePermission={hasUpdatePermission}
pathEdit={`${pathEdit}${event.id}`} />
pathView={`${pathView}${event.id}`} </div>
hasUpdatePermission={hasUpdatePermission} );
/>
</div>
);
}; };
export default BigCalendar; export default BigCalendar;

View File

@ -15,7 +15,6 @@ import {
import {loadColumns} from "./configureBooking_requestsCols"; import {loadColumns} from "./configureBooking_requestsCols";
import _ from 'lodash'; import _ from 'lodash';
import dataFormatter from '../../helpers/dataFormatter' import dataFormatter from '../../helpers/dataFormatter'
import { getIncompleteFilterHint, isFilterItemIncomplete, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
import {dataGridStyles} from "../../styles"; import {dataGridStyles} from "../../styles";
@ -23,23 +22,7 @@ import KanbanBoard from '../KanbanBoard/KanbanBoard';
import axios from 'axios'; import axios from 'axios';
const perPage = 10 const perPage = 10
const compactColumnVisibilityModel = {
tenant: false,
organization: false,
preferred_unit_type: false,
preferred_bedrooms: false,
purpose_of_stay: false,
special_requirements: false,
budget_code: false,
currency: false,
travelers: false,
approval_steps: false,
documents: false,
comments: false,
}
const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, showGrid }) => { const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, showGrid }) => {
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"}); const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
@ -100,14 +83,6 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
} }
}, [refetch, dispatch]); }, [refetch, dispatch]);
const validFilterItems = useMemo(() => pruneHiddenFilterItems(filterItems, filters), [filterItems, filters]);
useEffect(() => {
if (validFilterItems !== filterItems) {
setFilterItems(validFilterItems);
}
}, [filterItems, setFilterItems, validFilterItems]);
const [isModalInfoActive, setIsModalInfoActive] = useState(false) const [isModalInfoActive, setIsModalInfoActive] = useState(false)
const [isModalTrashActive, setIsModalTrashActive] = useState(false) const [isModalTrashActive, setIsModalTrashActive] = useState(false)
@ -122,15 +97,25 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
setKanbanColumns([ setKanbanColumns([
{ id: "draft", label: _.startCase("draft") },
{ id: "submitted", label: _.startCase("submitted") }, { id: "draft", label: "draft" },
{ id: "in_review", label: _.startCase("in_review") },
{ id: "changes_requested", label: _.startCase("changes_requested") }, { id: "submitted", label: "submitted" },
{ id: "approved", label: _.startCase("approved") },
{ id: "rejected", label: _.startCase("rejected") }, { id: "in_review", label: "in_review" },
{ id: "expired", label: _.startCase("expired") },
{ id: "converted_to_reservation", label: _.startCase("converted_to_reservation") }, { id: "changes_requested", label: "changes_requested" },
{ id: "canceled", label: _.startCase("canceled") },
{ id: "approved", label: "approved" },
{ id: "rejected", label: "rejected" },
{ id: "expired", label: "expired" },
{ id: "converted_to_reservation", label: "converted_to_reservation" },
{ id: "canceled", label: "canceled" },
]); ]);
@ -151,56 +136,9 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
} }
}; };
const bookingKanbanOverview = useMemo(() => {
const totals = booking_requests.reduce(
(accumulator, item) => {
const guestCount = Number(item?.guest_count || 0);
const status = item?.status || 'draft';
accumulator.total += 1;
accumulator.guests += Number.isNaN(guestCount) ? 0 : guestCount;
accumulator[status] = (accumulator[status] || 0) + 1;
return accumulator;
},
{
total: 0,
guests: 0,
submitted: 0,
in_review: 0,
changes_requested: 0,
approved: 0,
converted_to_reservation: 0,
},
);
return [
{
label: 'Visible requests',
value: totals.total,
hint: 'Currently in view',
},
{
label: 'Needs review',
value: totals.submitted + totals.in_review + totals.changes_requested,
hint: 'Submitted or in progress',
},
{
label: 'Approved',
value: totals.approved,
hint: 'Ready to place',
},
{
label: 'Guests',
value: totals.guests,
hint: 'Across visible requests',
},
];
}, [booking_requests]);
const generateFilterRequests = useMemo(() => { const generateFilterRequests = useMemo(() => {
let request = '&'; let request = '&';
validFilterItems.forEach((item) => { filterItems.forEach((item) => {
const isRangeFilter = filters.find( const isRangeFilter = filters.find(
(filter) => (filter) =>
filter.title === item.fields.selectedField && filter.title === item.fields.selectedField &&
@ -224,10 +162,10 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
} }
}); });
return request; return request;
}, [filters, validFilterItems]); }, [filterItems, filters]);
const deleteFilter = (value) => { const deleteFilter = (value) => {
const newItems = validFilterItems.filter((item) => item.id !== value); const newItems = filterItems.filter((item) => item.id !== value);
if (newItems.length) { if (newItems.length) {
setFilterItems(newItems); setFilterItems(newItems);
@ -252,7 +190,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
const name = e.target.name; const name = e.target.name;
setFilterItems( setFilterItems(
validFilterItems.map((item) => { filterItems.map((item) => {
if (item.id !== id) return item; if (item.id !== id) return item;
if (name === 'selectedField') return { id, fields: { [name]: value } }; if (name === 'selectedField') return { id, fields: { [name]: value } };
@ -305,16 +243,16 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
}; };
const controlClasses = const controlClasses =
'w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-400 ' + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' +
' ' + bgColor + ' ' + focusRing + ' ' + corners + ' ' + ` ${bgColor} ${focusRing} ${corners} ` +
'dark:bg-slate-800/80 my-1'; 'dark:bg-slate-800 border';
const dataGrid = ( const dataGrid = (
<div className='relative overflow-x-auto'> <div className='relative overflow-x-auto'>
<DataGrid <DataGrid
autoHeight autoHeight
rowHeight={56} rowHeight={64}
sx={dataGridStyles} sx={dataGridStyles}
className={'datagrid--table'} className={'datagrid--table'}
getRowClassName={() => `datagrid--row`} getRowClassName={() => `datagrid--row`}
@ -326,9 +264,6 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
pageSize: 10, pageSize: 10,
}, },
}, },
columns: {
columnVisibilityModel: compactColumnVisibilityModel,
},
}} }}
disableRowSelectionOnClick disableRowSelectionOnClick
onProcessRowUpdateError={(params) => { onProcessRowUpdateError={(params) => {
@ -367,8 +302,8 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
return ( return (
<> <>
{validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ? {filterItems && Array.isArray( filterItems ) && filterItems.length ?
<CardBox className='mb-6 border border-white/10 shadow-none'> <CardBox>
<Formik <Formik
initialValues={{ initialValues={{
checkboxes: ['lorem'], checkboxes: ['lorem'],
@ -379,14 +314,11 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
> >
<Form> <Form>
<> <>
{validFilterItems && validFilterItems.map((filterItem) => { {filterItems && filterItems.map((filterItem) => {
const showIncompleteHint = isFilterItemIncomplete(filterItem, filters)
const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null
return ( return (
<div key={filterItem.id} className="mb-3 grid gap-3 rounded-2xl border border-white/10 bg-white/5 p-4 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_minmax(0,14rem)]"> <div key={filterItem.id} className="flex mb-4">
<div className="flex min-w-0 flex-col"> <div className="flex flex-col w-full mr-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Filter</div> <div className=" text-gray-500 font-bold">Filter</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='selectedField' name='selectedField'
@ -408,8 +340,8 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
{filters.find((filter) => {filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField filter.title === filterItem?.fields?.selectedField
)?.type === 'enum' ? ( )?.type === 'enum' ? (
<div className="flex min-w-0 flex-col"> <div className="flex flex-col w-full mr-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400"> <div className="text-gray-500 font-bold">
Value Value
</div> </div>
<Field <Field
@ -433,9 +365,9 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
) : filters.find((filter) => ) : filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField filter.title === filterItem?.fields?.selectedField
)?.number ? ( )?.number ? (
<div className="grid min-w-0 gap-3 md:grid-cols-2"> <div className="flex flex-row w-full mr-3">
<div className="flex min-w-0 flex-col"> <div className="flex flex-col w-full mr-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">From</div> <div className=" text-gray-500 font-bold">From</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='filterValueFrom' name='filterValueFrom'
@ -446,7 +378,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
/> />
</div> </div>
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">To</div> <div className=" text-gray-500 font-bold">To</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='filterValueTo' name='filterValueTo'
@ -462,9 +394,9 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
filter.title === filter.title ===
filterItem?.fields?.selectedField filterItem?.fields?.selectedField
)?.date ? ( )?.date ? (
<div className='grid min-w-0 gap-3 md:grid-cols-2'> <div className='flex flex-row w-full mr-3'>
<div className='flex flex-col w-full mr-3'> <div className='flex flex-col w-full mr-3'>
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'> <div className=' text-gray-500 font-bold'>
From From
</div> </div>
<Field <Field
@ -478,7 +410,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
/> />
</div> </div>
<div className='flex flex-col w-full'> <div className='flex flex-col w-full'>
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>To</div> <div className=' text-gray-500 font-bold'>To</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='filterValueTo' name='filterValueTo'
@ -491,8 +423,8 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex min-w-0 flex-col"> <div className="flex flex-col w-full mr-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Contains</div> <div className=" text-gray-500 font-bold">Contains</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='filterValue' name='filterValue'
@ -503,37 +435,31 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
/> />
</div> </div>
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Action</div> <div className=" text-gray-500 font-bold">Action</div>
<BaseButton <BaseButton
className="my-1 w-full md:w-auto" className="my-2"
type='reset' type='reset'
color='whiteDark' color='danger'
outline
label='Delete' label='Delete'
onClick={() => { onClick={() => {
deleteFilter(filterItem.id) deleteFilter(filterItem.id)
}} }}
/> />
{incompleteHint ? (
<p className='rounded-xl border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-100'>
{incompleteHint}
</p>
) : null}
</div> </div>
</div> </div>
) )
})} })}
<div className="flex flex-wrap items-center gap-2"> <div className="flex">
<BaseButton <BaseButton
className="my-1 mr-0" className="my-2 mr-3"
type='submit' color='info' type='submit' color='info'
label='Apply' label='Apply'
onClick={handleSubmit} onClick={handleSubmit}
/> />
<BaseButton <BaseButton
className="my-1" className="my-2"
type='reset' color='whiteDark' outline type='reset' color='info' outline
label='Cancel' label='Cancel'
onClick={handleReset} onClick={handleReset}
/> />
@ -557,32 +483,15 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
{!showGrid && kanbanColumns && ( {!showGrid && kanbanColumns && (
<> <KanbanBoard
<div className='mb-6 grid gap-3 md:grid-cols-4'> columnFieldName={'status'}
{bookingKanbanOverview.map((item) => ( showFieldName={'request_code'}
<CardBox key={item.label} className='rounded-2xl border border-gray-200/80 shadow-none dark:border-dark-700'> entityName={'booking_requests'}
<div className='p-4'> filtersQuery={kanbanFilters}
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500'> deleteThunk={deleteItem}
{item.label} updateThunk={update}
</p> columns={kanbanColumns}
<p className='mt-2 text-2xl font-semibold text-gray-900 dark:text-white'> />
{item.value}
</p>
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>{item.hint}</p>
</div>
</CardBox>
))}
</div>
<KanbanBoard
columnFieldName={'status'}
showFieldName={'request_code'}
entityName={'booking_requests'}
filtersQuery={kanbanFilters}
deleteThunk={deleteItem}
updateThunk={update}
columns={kanbanColumns}
/>
</>
)} )}

View File

@ -14,7 +14,6 @@ import DataGridMultiSelect from "../DataGridMultiSelect";
import ListActionsPopover from '../ListActionsPopover'; import ListActionsPopover from '../ListActionsPopover';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
import { getRoleLaneFromUser } from "../../helpers/roleLanes";
type Params = (id: string) => void; type Params = (id: string) => void;
@ -39,15 +38,8 @@ export const loadColumns = async (
} }
const hasUpdatePermission = hasPermission(user, 'UPDATE_BOOKING_REQUESTS') const hasUpdatePermission = hasPermission(user, 'UPDATE_BOOKING_REQUESTS')
const roleLane = getRoleLaneFromUser(user)
const hiddenFieldsByLane = {
super_admin: new Set([]),
admin: new Set(['tenant', 'organization', 'requested_by']),
concierge: new Set(['tenant', 'organization', 'requested_by', 'approval_steps', 'comments']),
customer: new Set(['tenant', 'organization', 'requested_by', 'approval_steps', 'comments', 'budget_code', 'max_budget_amount', 'currency']),
}
const columns = [ return [
{ {
field: 'tenant', field: 'tenant',
@ -437,6 +429,4 @@ export const loadColumns = async (
}, },
}, },
]; ];
return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field));
}; };

View File

@ -1,27 +1,9 @@
import React from 'react' import React from 'react'
type Props = { const CardBoxComponentEmpty = () => {
title?: string
description?: string
compact?: boolean
}
const CardBoxComponentEmpty = ({
title = 'Nothing to review yet',
description = 'When records appear here, this view will turn into a live operating workspace.',
compact = false,
}: Props) => {
return ( return (
<div <div className="text-center py-24 text-gray-500 dark:text-slate-400">
className={`flex items-center justify-center px-4 text-center ${compact ? 'py-10' : 'py-24'}`} <p>Nothing&apos;s here</p>
>
<div className="max-w-md">
<div className="mx-auto inline-flex rounded-full border border-dashed border-gray-300 bg-gray-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-gray-500 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-400">
Empty state
</div>
<p className="mt-4 text-base font-semibold text-gray-900 dark:text-white">{title}</p>
<p className="mt-2 text-sm leading-6 text-gray-500 dark:text-slate-400">{description}</p>
</div>
</div> </div>
) )
} }

View File

@ -1,110 +0,0 @@
import React, { ReactNode } from 'react'
import BaseButton from './BaseButton'
type ConnectedEntityCardDetail = {
label: string
value?: ReactNode
}
type ConnectedEntityCardAction = {
href: string
label: string
color?: string
outline?: boolean
small?: boolean
className?: string
}
type ConnectedEntityCardProps = {
entityLabel: string
title?: ReactNode
titleFallback?: string
badges?: ReactNode[]
details?: ConnectedEntityCardDetail[]
actions?: ConnectedEntityCardAction[]
helperText?: ReactNode
className?: string
}
const ConnectedEntityCard = ({
entityLabel,
title,
titleFallback = 'Unnamed record',
badges = [],
details = [],
actions = [],
helperText,
className = '',
}: ConnectedEntityCardProps) => {
const visibleDetails = details.filter((detail) => {
const value = detail?.value
return value !== undefined && value !== null && value !== ''
})
const visibleBadges = badges.filter(Boolean)
return (
<div
className={`rounded-xl border border-blue-100 bg-white p-4 shadow-sm dark:border-blue-950/60 dark:bg-dark-900 ${className}`.trim()}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100">
{entityLabel}
</span>
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100">
Linked record
</span>
</div>
<p className="mt-3 break-words text-base font-semibold text-gray-800 dark:text-gray-100">
{title || titleFallback}
</p>
{visibleBadges.length ? <div className="mt-3 flex flex-wrap gap-2">{visibleBadges}</div> : null}
{visibleDetails.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{visibleDetails.map((detail) => (
<span
key={`${detail.label}-${String(detail.value)}`}
className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100"
>
<span className="font-semibold text-gray-900 dark:text-gray-100">{detail.label}:</span>{' '}
{detail.value}
</span>
))}
</div>
) : null}
</div>
{actions.length ? (
<div className="flex flex-col gap-2 sm:items-end">
{actions.map((action) => (
<BaseButton
key={`${action.href}-${action.label}`}
href={action.href}
color={action.color || 'info'}
outline={action.outline}
small={action.small ?? true}
label={action.label}
className={action.className || 'w-full sm:w-auto'}
/>
))}
{helperText ? (
<p className="text-xs font-medium text-blue-700 dark:text-blue-200 sm:max-w-[16rem] sm:text-right">
{helperText}
</p>
) : null}
</div>
) : null}
</div>
</div>
)
}
export type { ConnectedEntityCardAction, ConnectedEntityCardDetail }
export default ConnectedEntityCard

View File

@ -1,106 +0,0 @@
import React, { ReactNode } from 'react'
import BaseButton from './BaseButton'
type ConnectedEntityAction = {
href: string
label: string
color?: string
outline?: boolean
small?: boolean
className?: string
}
type ConnectedEntityNoticeProps = {
title?: string
description?: ReactNode
actions?: ConnectedEntityAction[]
contextLabel?: string
contextHref?: string
contextActionLabel?: string
className?: string
compact?: boolean
}
const ConnectedEntityNotice = ({
title = 'Connected entities',
description,
actions = [],
contextLabel,
contextHref,
contextActionLabel = 'Open',
className = '',
compact = false,
}: ConnectedEntityNoticeProps) => {
if (compact) {
if (!contextLabel) {
return null
}
return (
<div
className={`mb-4 inline-flex max-w-full flex-wrap items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100 ${className}`.trim()}
>
<span className="font-semibold uppercase tracking-[0.18em]">Opened from</span>
<span className="max-w-full break-words font-medium">{contextLabel}</span>
{contextHref ? (
<BaseButton
href={contextHref}
color="white"
outline
small
label={contextActionLabel}
className="!px-2.5 !py-0.5"
/>
) : null}
</div>
)
}
return (
<div
className={`mb-6 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100 ${className}`.trim()}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="font-semibold">{title}</p>
{contextLabel ? (
<div className="mt-2 inline-flex max-w-full flex-wrap items-center gap-2 rounded-full border border-blue-200/80 bg-white/70 px-3 py-1 text-xs font-medium text-blue-900 dark:border-blue-800 dark:bg-blue-900/40 dark:text-blue-100">
<span className="font-semibold uppercase tracking-[0.18em]">Opened from</span>
<span className="break-words">{contextLabel}</span>
{contextHref ? (
<BaseButton
href={contextHref}
color="white"
outline
small
label={contextActionLabel}
className="!px-2.5 !py-0.5"
/>
) : null}
</div>
) : null}
{description ? <div className="mt-2 leading-6">{description}</div> : null}
</div>
{actions.length ? (
<div className="flex flex-col gap-2 sm:items-end">
{actions.map((action) => (
<BaseButton
key={`${action.href}-${action.label}`}
href={action.href}
color={action.color || 'info'}
outline={action.outline}
small={action.small ?? true}
label={action.label}
className={action.className || 'w-full sm:w-auto'}
/>
))}
</div>
) : null}
</div>
</div>
)
}
export type { ConnectedEntityAction }
export default ConnectedEntityNotice

View File

@ -1,486 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import {
mdiChevronDown,
mdiChevronUp,
mdiDomain,
mdiEmailOutline,
mdiOfficeBuilding,
mdiOpenInNew,
mdiShieldAccount,
} from '@mdi/js'
import BaseButton from './BaseButton'
import BaseIcon from './BaseIcon'
import CardBoxModal from './CardBoxModal'
import ClickOutside from './ClickOutside'
import ConnectedEntityCard from './ConnectedEntityCard'
import TenantStatusChip from './TenantStatusChip'
import { useAppSelector } from '../stores/hooks'
import {
emptyOrganizationTenantSummary,
getOrganizationViewHref,
getTenantViewHref,
loadLinkedTenantSummary,
} from '../helpers/organizationTenants'
import { hasPermission } from '../helpers/userPermissions'
const CurrentWorkspaceChip = () => {
const router = useRouter()
const triggerRef = useRef(null)
const { currentUser } = useAppSelector((state) => state.auth)
const [linkedTenantSummary, setLinkedTenantSummary] = useState(emptyOrganizationTenantSummary)
const [isLoadingTenants, setIsLoadingTenants] = useState(false)
const [isPopoverActive, setIsPopoverActive] = useState(false)
const [isWorkspaceModalActive, setIsWorkspaceModalActive] = useState(false)
const organizationId = useMemo(
() =>
currentUser?.organizations?.id ||
currentUser?.organization?.id ||
currentUser?.organizationsId ||
currentUser?.organizationId ||
'',
[
currentUser?.organization?.id,
currentUser?.organizationId,
currentUser?.organizations?.id,
currentUser?.organizationsId,
],
)
const canViewOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS')
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS')
useEffect(() => {
let isMounted = true
if (!organizationId || !canViewTenants) {
setLinkedTenantSummary(emptyOrganizationTenantSummary)
setIsLoadingTenants(false)
return () => {
isMounted = false
}
}
setIsLoadingTenants(true)
loadLinkedTenantSummary(organizationId)
.then((summary) => {
if (!isMounted) {
return
}
setLinkedTenantSummary(summary)
})
.catch((error) => {
console.error('Failed to load current workspace chip context:', error)
if (!isMounted) {
return
}
setLinkedTenantSummary(emptyOrganizationTenantSummary)
})
.finally(() => {
if (isMounted) {
setIsLoadingTenants(false)
}
})
return () => {
isMounted = false
}
}, [canViewTenants, organizationId])
useEffect(() => {
setIsPopoverActive(false)
setIsWorkspaceModalActive(false)
}, [router.asPath])
useEffect(() => {
if (!isPopoverActive && !isWorkspaceModalActive) {
return () => undefined
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsPopoverActive(false)
setIsWorkspaceModalActive(false)
}
}
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('keydown', handleEscape)
}
}, [isPopoverActive, isWorkspaceModalActive])
if (!currentUser) {
return null
}
const organizationName =
currentUser?.organizations?.name ||
currentUser?.organization?.name ||
currentUser?.organizationName ||
'No organization assigned yet'
const organizationSlug =
currentUser?.organizations?.slug || currentUser?.organization?.slug || currentUser?.organizationSlug || ''
const organizationDomain =
currentUser?.organizations?.domain ||
currentUser?.organization?.domain ||
currentUser?.organizations?.primary_domain ||
currentUser?.organization?.primary_domain ||
currentUser?.organizationDomain ||
''
const appRoleName = currentUser?.app_role?.name || 'User'
const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : []
const linkedTenantCount = linkedTenantSummary.count || 0
const tenantLabel = !organizationId
? 'No workspace linked'
: !canViewTenants
? 'Tenant access restricted'
: isLoadingTenants
? 'Loading tenants…'
: `${linkedTenantCount} tenant${linkedTenantCount === 1 ? '' : 's'}`
const tenantEmptyStateMessage = !organizationId
? 'No organization is attached to this account yet.'
: !canViewTenants
? 'Your current role can open the workspace, but it does not include tenant-read access.'
: 'No tenant link surfaced yet for this workspace.'
const organizationHref =
canViewOrganizations && organizationId ? getOrganizationViewHref(organizationId) : '/profile'
const hasOrganizationMetadata = Boolean(organizationSlug || organizationDomain)
const handleOpenWorkspaceModal = () => {
setIsPopoverActive(false)
setIsWorkspaceModalActive(true)
}
const handleCloseWorkspaceModal = () => {
setIsWorkspaceModalActive(false)
}
return (
<>
<div className="relative hidden xl:block" ref={triggerRef}>
<button
type="button"
className="group flex items-center gap-3 rounded-xl border border-blue-200 bg-blue-50/80 px-3 py-2 text-left transition-colors hover:border-blue-300 hover:bg-blue-100/80 dark:border-blue-900/70 dark:bg-blue-950/30 dark:hover:border-blue-800 dark:hover:bg-blue-950/40"
title={`Current workspace: ${organizationName}`}
aria-haspopup="dialog"
aria-expanded={isPopoverActive}
onClick={() => setIsPopoverActive((currentValue) => !currentValue)}
>
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-blue-100 text-blue-900 dark:bg-blue-900/60 dark:text-blue-100">
<BaseIcon path={mdiOfficeBuilding} size="18" />
</span>
<span className="min-w-0">
<span className="block text-[10px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100">
Current workspace
</span>
<span className="block truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{organizationName}
</span>
<span className="mt-0.5 flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
<BaseIcon path={mdiDomain} size="14" />
<span className="truncate">
{tenantLabel} {appRoleName}
</span>
</span>
</span>
<span className="flex-none text-blue-800 dark:text-blue-200">
<BaseIcon path={isPopoverActive ? mdiChevronUp : mdiChevronDown} size="16" />
</span>
</button>
{isPopoverActive ? (
<div className="absolute right-0 top-full z-40 mt-2 w-[26rem] max-w-[calc(100vw-2rem)]">
<ClickOutside onClickOutside={() => setIsPopoverActive(false)} excludedElements={[triggerRef]}>
<div className="overflow-hidden rounded-2xl border border-blue-100 bg-white shadow-2xl dark:border-blue-950/70 dark:bg-dark-900">
<div className="border-b border-blue-100 bg-blue-50/80 px-4 py-3 dark:border-blue-950/70 dark:bg-blue-950/30">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100">
Current workspace
</p>
<p className="mt-1 truncate text-base font-semibold text-gray-900 dark:text-gray-100">
{organizationName}
</p>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-700 dark:text-gray-200">
<span className="inline-flex items-center gap-1 rounded-full bg-white/80 px-2.5 py-1 dark:bg-dark-800">
<BaseIcon path={mdiShieldAccount} size="14" />
{appRoleName}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-white/80 px-2.5 py-1 dark:bg-dark-800">
<BaseIcon path={mdiDomain} size="14" />
{tenantLabel}
</span>
{organizationSlug ? (
<span className="inline-flex items-center gap-1 rounded-full bg-white/80 px-2.5 py-1 dark:bg-dark-800">
Slug: {organizationSlug}
</span>
) : null}
{organizationDomain ? (
<span className="inline-flex items-center gap-1 rounded-full bg-white/80 px-2.5 py-1 dark:bg-dark-800">
Domain: {organizationDomain}
</span>
) : null}
</div>
</div>
<BaseButton
href={organizationHref}
label={canViewOrganizations && organizationId ? 'Open' : 'Profile'}
color="info"
outline
small
icon={mdiOpenInNew}
/>
</div>
</div>
<div className="space-y-4 px-4 py-4">
<div className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-3 dark:border-dark-700 dark:bg-dark-800">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-blue-100 text-blue-900 dark:bg-blue-900/60 dark:text-blue-100">
<BaseIcon path={mdiOfficeBuilding} size="16" />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-500 dark:text-gray-300">
Organization access
</p>
<p className="mt-1 truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{organizationName}
</p>
<p className="mt-1 flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
<BaseIcon path={mdiEmailOutline} size="14" />
<span className="truncate">{currentUser?.email || 'No email surfaced yet'}</span>
</p>
{hasOrganizationMetadata ? (
<div className="mt-2 flex flex-wrap gap-2">
{organizationSlug ? (
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-900 dark:text-gray-100">
Slug: {organizationSlug}
</span>
) : null}
{organizationDomain ? (
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-900 dark:text-gray-100">
Domain: {organizationDomain}
</span>
) : null}
</div>
) : null}
</div>
</div>
</div>
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-500 dark:text-gray-300">
Linked tenants
</p>
<div className="flex items-center gap-3">
<button
type="button"
className="text-xs font-semibold text-blue-700 hover:text-blue-800 dark:text-blue-200 dark:hover:text-blue-100"
onClick={handleOpenWorkspaceModal}
>
Workspace details
</button>
<Link
href="/profile"
className="text-xs font-semibold text-blue-700 hover:text-blue-800 dark:text-blue-200 dark:hover:text-blue-100"
>
View profile context
</Link>
</div>
</div>
<div className="mt-3 space-y-2">
{isLoadingTenants ? (
<div className="rounded-xl border border-dashed border-blue-200 bg-blue-50/60 px-3 py-3 text-sm text-blue-900 dark:border-blue-900/70 dark:bg-blue-950/20 dark:text-blue-100">
Loading tenant context
</div>
) : linkedTenants.length ? (
linkedTenants.slice(0, 3).map((tenant) => (
<div
key={tenant.id}
className="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-dark-700 dark:bg-dark-800"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{tenant.name || 'Unnamed tenant'}
</p>
<div className="mt-2 flex flex-wrap gap-2">
<TenantStatusChip isActive={tenant.is_active} />
{tenant.slug ? (
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100">
Slug: {tenant.slug}
</span>
) : null}
{tenant.primary_domain ? (
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100">
Domain: {tenant.primary_domain}
</span>
) : null}
</div>
</div>
{canViewTenants && tenant.id ? (
<BaseButton
href={getTenantViewHref(tenant.id, organizationId, organizationName)}
label="Open"
color="info"
outline
small
/>
) : null}
</div>
</div>
))
) : (
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 px-3 py-3 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300">
{tenantEmptyStateMessage}
</div>
)}
</div>
{!isLoadingTenants && linkedTenantCount > 3 ? (
<p className="mt-2 text-xs text-gray-500 dark:text-gray-300">
Showing 3 of {linkedTenantCount} linked tenants in this quick view.
</p>
) : null}
</div>
</div>
</div>
</ClickOutside>
</div>
) : null}
</div>
<CardBoxModal
title="Workspace details"
buttonColor="info"
buttonLabel="Done"
isActive={isWorkspaceModalActive}
onConfirm={handleCloseWorkspaceModal}
onCancel={handleCloseWorkspaceModal}
>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600 dark:text-gray-300">
This view expands the same workspace summary from the navbar so the user can inspect their organization context without leaving the current page.
</p>
</div>
<ConnectedEntityCard
entityLabel="Workspace"
title={organizationName}
titleFallback="No organization assigned yet"
badges={[
<span
key="workspace-role"
className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100"
>
Role: {appRoleName}
</span>,
<span
key="workspace-tenants"
className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100"
>
{tenantLabel}
</span>,
]}
details={[
{ label: 'Email', value: currentUser?.email },
{ label: 'Slug', value: organizationSlug },
{ label: 'Domain', value: organizationDomain },
]}
actions={[
{
href: organizationHref,
label: canViewOrganizations && organizationId ? 'Open workspace' : 'Open profile',
color: 'info',
},
{
href: '/profile',
label: 'View profile',
color: 'info',
outline: true,
},
]}
helperText={
organizationId
? 'This is the organization currently attached to the signed-in account context.'
: 'This account does not have an organization link yet.'
}
/>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-300">
Linked tenants
</p>
{linkedTenantCount > linkedTenants.length && !isLoadingTenants ? (
<p className="text-xs text-gray-500 dark:text-gray-300">
Showing {linkedTenants.length} of {linkedTenantCount} linked tenants.
</p>
) : null}
</div>
{isLoadingTenants ? (
<div className="rounded-xl border border-dashed border-blue-200 bg-blue-50/60 px-4 py-3 text-sm text-blue-900 dark:border-blue-900/70 dark:bg-blue-950/20 dark:text-blue-100">
Loading tenant context for this workspace
</div>
) : linkedTenants.length ? (
<div className="space-y-3">
{linkedTenants.map((tenant) => (
<ConnectedEntityCard
key={tenant.id}
entityLabel="Tenant"
title={tenant.name || 'Unnamed tenant'}
badges={[<TenantStatusChip key={`${tenant.id}-status`} isActive={tenant.is_active} />]}
details={[
{ label: 'Slug', value: tenant.slug },
{ label: 'Domain', value: tenant.primary_domain },
{ label: 'Timezone', value: tenant.timezone },
{ label: 'Currency', value: tenant.default_currency },
]}
actions={
canViewTenants && tenant.id
? [
{
href: getTenantViewHref(tenant.id, organizationId, organizationName),
label: 'Open tenant',
color: 'info',
outline: true,
},
]
: []
}
helperText="This tenant is linked behind the organization context attached to the current account."
/>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300">
{tenantEmptyStateMessage}
</div>
)}
</div>
</div>
</CardBoxModal>
</>
)
}
export default CurrentWorkspaceChip

View File

@ -15,7 +15,6 @@ import {
import {loadColumns} from "./configureDocumentsCols"; import {loadColumns} from "./configureDocumentsCols";
import _ from 'lodash'; import _ from 'lodash';
import dataFormatter from '../../helpers/dataFormatter' import dataFormatter from '../../helpers/dataFormatter'
import { getIncompleteFilterHint, isFilterItemIncomplete, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
import {dataGridStyles} from "../../styles"; import {dataGridStyles} from "../../styles";
@ -78,14 +77,6 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
} }
}, [refetch, dispatch]); }, [refetch, dispatch]);
const validFilterItems = useMemo(() => pruneHiddenFilterItems(filterItems, filters), [filterItems, filters]);
useEffect(() => {
if (validFilterItems !== filterItems) {
setFilterItems(validFilterItems);
}
}, [filterItems, setFilterItems, validFilterItems]);
const [isModalInfoActive, setIsModalInfoActive] = useState(false) const [isModalInfoActive, setIsModalInfoActive] = useState(false)
const [isModalTrashActive, setIsModalTrashActive] = useState(false) const [isModalTrashActive, setIsModalTrashActive] = useState(false)
@ -112,7 +103,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
const generateFilterRequests = useMemo(() => { const generateFilterRequests = useMemo(() => {
let request = '&'; let request = '&';
validFilterItems.forEach((item) => { filterItems.forEach((item) => {
const isRangeFilter = filters.find( const isRangeFilter = filters.find(
(filter) => (filter) =>
filter.title === item.fields.selectedField && filter.title === item.fields.selectedField &&
@ -136,10 +127,10 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
} }
}); });
return request; return request;
}, [filters, validFilterItems]); }, [filterItems, filters]);
const deleteFilter = (value) => { const deleteFilter = (value) => {
const newItems = validFilterItems.filter((item) => item.id !== value); const newItems = filterItems.filter((item) => item.id !== value);
if (newItems.length) { if (newItems.length) {
setFilterItems(newItems); setFilterItems(newItems);
@ -160,7 +151,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
const name = e.target.name; const name = e.target.name;
setFilterItems( setFilterItems(
validFilterItems.map((item) => { filterItems.map((item) => {
if (item.id !== id) return item; if (item.id !== id) return item;
if (name === 'selectedField') return { id, fields: { [name]: value } }; if (name === 'selectedField') return { id, fields: { [name]: value } };
@ -270,7 +261,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
return ( return (
<> <>
{validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ? {filterItems && Array.isArray( filterItems ) && filterItems.length ?
<CardBox> <CardBox>
<Formik <Formik
initialValues={{ initialValues={{
@ -282,13 +273,10 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
> >
<Form> <Form>
<> <>
{validFilterItems && validFilterItems.map((filterItem) => { {filterItems && filterItems.map((filterItem) => {
const showIncompleteHint = isFilterItemIncomplete(filterItem, filters)
const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null
return ( return (
<div key={filterItem.id} className="mb-4 flex flex-col gap-3 rounded-xl border border-white/10 p-3 md:flex-row md:items-end"> <div key={filterItem.id} className="flex mb-4">
<div className="flex w-full flex-col md:mr-3"> <div className="flex flex-col w-full mr-3">
<div className=" text-gray-500 font-bold">Filter</div> <div className=" text-gray-500 font-bold">Filter</div>
<Field <Field
className={controlClasses} className={controlClasses}
@ -311,7 +299,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
{filters.find((filter) => {filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField filter.title === filterItem?.fields?.selectedField
)?.type === 'enum' ? ( )?.type === 'enum' ? (
<div className="flex w-full flex-col md:mr-3"> <div className="flex flex-col w-full mr-3">
<div className="text-gray-500 font-bold"> <div className="text-gray-500 font-bold">
Value Value
</div> </div>
@ -336,8 +324,8 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
) : filters.find((filter) => ) : filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField filter.title === filterItem?.fields?.selectedField
)?.number ? ( )?.number ? (
<div className="flex w-full flex-col gap-3 sm:flex-row md:mr-3"> <div className="flex flex-row w-full mr-3">
<div className="flex w-full flex-col md:mr-3"> <div className="flex flex-col w-full mr-3">
<div className=" text-gray-500 font-bold">From</div> <div className=" text-gray-500 font-bold">From</div>
<Field <Field
className={controlClasses} className={controlClasses}
@ -365,7 +353,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
filter.title === filter.title ===
filterItem?.fields?.selectedField filterItem?.fields?.selectedField
)?.date ? ( )?.date ? (
<div className='flex w-full flex-col gap-3 sm:flex-row md:mr-3'> <div className='flex flex-row w-full mr-3'>
<div className='flex flex-col w-full mr-3'> <div className='flex flex-col w-full mr-3'>
<div className=' text-gray-500 font-bold'> <div className=' text-gray-500 font-bold'>
From From
@ -394,7 +382,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex w-full flex-col md:mr-3"> <div className="flex flex-col w-full mr-3">
<div className=" text-gray-500 font-bold">Contains</div> <div className=" text-gray-500 font-bold">Contains</div>
<Field <Field
className={controlClasses} className={controlClasses}
@ -406,10 +394,10 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
/> />
</div> </div>
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col">
<div className=" text-gray-500 font-bold">Action</div> <div className=" text-gray-500 font-bold">Action</div>
<BaseButton <BaseButton
className="my-1 w-full sm:my-2 sm:w-auto" className="my-2"
type='reset' type='reset'
color='danger' color='danger'
label='Delete' label='Delete'
@ -417,24 +405,19 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
deleteFilter(filterItem.id) deleteFilter(filterItem.id)
}} }}
/> />
{incompleteHint ? (
<p className='rounded-xl border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-100'>
{incompleteHint}
</p>
) : null}
</div> </div>
</div> </div>
) )
})} })}
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex">
<BaseButton <BaseButton
className="my-1 w-full sm:my-2 sm:mr-3 sm:w-auto" className="my-2 mr-3"
type='submit' color='info' type='submit' color='info'
label='Apply' label='Apply'
onClick={handleSubmit} onClick={handleSubmit}
/> />
<BaseButton <BaseButton
className="my-1 w-full sm:my-2 sm:w-auto" className="my-2"
type='reset' color='info' outline type='reset' color='info' outline
label='Cancel' label='Cancel'
onClick={handleReset} onClick={handleReset}

View File

@ -14,7 +14,6 @@ import DataGridMultiSelect from "../DataGridMultiSelect";
import ListActionsPopover from '../ListActionsPopover'; import ListActionsPopover from '../ListActionsPopover';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
import { getRoleLaneFromUser } from "../../helpers/roleLanes";
type Params = (id: string) => void; type Params = (id: string) => void;
@ -39,15 +38,8 @@ export const loadColumns = async (
} }
const hasUpdatePermission = hasPermission(user, 'UPDATE_DOCUMENTS') const hasUpdatePermission = hasPermission(user, 'UPDATE_DOCUMENTS')
const roleLane = getRoleLaneFromUser(user)
const hiddenFieldsByLane = {
super_admin: new Set([]),
admin: new Set(['tenant', 'organization', 'storage_key', 'public_url']),
concierge: new Set(['tenant', 'organization', 'storage_key', 'public_url', 'mime_type', 'file_size_bytes', 'invoice', 'is_private']),
customer: new Set(['tenant', 'organization', 'storage_key', 'public_url', 'mime_type', 'file_size_bytes', 'uploaded_by', 'invoice', 'is_private', 'notes']),
}
const columns = [ return [
{ {
field: 'tenant', field: 'tenant',
@ -349,6 +341,4 @@ export const loadColumns = async (
}, },
}, },
]; ];
return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field));
}; };

View File

@ -34,7 +34,7 @@ const FormField = ({ icons = [], ...props }: Props) => {
} }
const controlClassName = [ const controlClassName = [
`px-3 py-2 max-w-full w-full ${corners} border-gray-300 text-gray-900 placeholder:text-gray-400 dark:border-dark-700 dark:text-white dark:placeholder-gray-400`, `px-3 py-2 max-w-full border-gray-300 dark:border-dark-700 ${corners} w-full dark:placeholder-gray-400`,
`${focusRing}`, `${focusRing}`,
props.hasTextareaHeight ? 'h-24' : 'h-12', props.hasTextareaHeight ? 'h-24' : 'h-12',
props.isBorderless ? 'border-0' : 'border', props.isBorderless ? 'border-0' : 'border',
@ -54,13 +54,10 @@ const FormField = ({ icons = [], ...props }: Props) => {
</label> </label>
)} )}
<div className={`${elementWrapperClass}`}> <div className={`${elementWrapperClass}`}>
{Children.map(props.children, (child: ReactElement, index) => { {Children.map(props.children, (child: ReactElement, index) => (
const childClassName = (child.props as { className?: string })?.className || ''
return (
<div className="relative"> <div className="relative">
{cloneElement(child as ReactElement<{ className?: string }>, { {cloneElement(child as ReactElement<{ className?: string }>, {
className: `${controlClassName} ${childClassName} ${icons[index] ? 'pl-10' : ''}`.trim(), className: `${controlClassName} ${icons[index] ? 'pl-10' : ''}`,
})} })}
{icons[index] && ( {icons[index] && (
<BaseIcon <BaseIcon
@ -71,8 +68,7 @@ const FormField = ({ icons = [], ...props }: Props) => {
/> />
)} )}
</div> </div>
) ))}
})}
</div> </div>
{props.help && ( {props.help && (
<div className='text-xs text-gray-500 dark:text-dark-600 mt-1'>{props.help}</div> <div className='text-xs text-gray-500 dark:text-dark-600 mt-1'>{props.help}</div>

View File

@ -1,57 +0,0 @@
import { InventoryTemplateConfig } from '../helpers/inventoryImportTemplates';
type Props = {
config: InventoryTemplateConfig;
};
const InventoryImportGuidance = ({ config }: Props) => {
const exampleCsvHeader = config.columns.join(',');
const exampleCsvRow = config.columns
.map((column) => config.exampleRow.find((entry) => entry.column === column)?.value || '')
.join(',');
return (
<div className='mb-4 rounded-2xl border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-600 dark:border-white/10 dark:text-slate-300'>
<p className='font-semibold'>Use the import template for this workflow</p>
<p className='mt-1'>{config.workflowHint}</p>
<p className='mt-1'>{config.referenceHint}</p>
<p className='mt-2'>
<span className='font-semibold'>Allowed columns:</span> {config.columns.join(', ')}
</p>
<p className='mt-1'>
<span className='font-semibold'>Required relationship columns:</span>{' '}
{config.requiredColumns.length ? config.requiredColumns.join(', ') : 'None'}
</p>
{config.exampleReferences.length > 0 && (
<div className='mt-3'>
<p className='font-semibold'>Reference examples</p>
<ul className='mt-2 space-y-1'>
{config.exampleReferences.map((example) => (
<li key={example.column} className='leading-5'>
<span className='font-semibold'>{example.column}:</span>{' '}
<code className='rounded bg-slate-100 px-1 py-0.5 text-xs text-slate-800 dark:bg-slate-900 dark:text-slate-100'>
{example.value}
</code>
{example.note ? <span className='ml-1'>{example.note}</span> : null}
</li>
))}
</ul>
</div>
)}
<div className='mt-3'>
<p className='font-semibold'>Example row</p>
<p className='mt-1 text-xs text-slate-500 dark:text-slate-400'>
Replace these example values with real records from your workspace before uploading.
</p>
<pre className='mt-2 overflow-x-auto rounded-xl bg-slate-100 p-3 text-xs leading-5 text-slate-800 dark:bg-slate-900 dark:text-slate-100'>
{`${exampleCsvHeader}
${exampleCsvRow}`}
</pre>
</div>
</div>
);
};
export default InventoryImportGuidance;

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import KanbanColumn from './KanbanColumn';
import { AsyncThunk } from '@reduxjs/toolkit'; import { AsyncThunk } from '@reduxjs/toolkit';
import { DndProvider } from 'react-dnd'; import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import KanbanColumn from './KanbanColumn';
type Props = { type Props = {
columns: Array<{ id: string; label: string }>; columns: Array<{id: string, label: string}>;
filtersQuery: string; filtersQuery: string;
entityName: string; entityName: string;
columnFieldName: string; columnFieldName: string;
@ -24,24 +24,27 @@ const KanbanBoard = ({
updateThunk, updateThunk,
}: Props) => { }: Props) => {
return ( return (
<DndProvider backend={HTML5Backend}> <div
<div className='rounded-2xl border border-gray-200/80 bg-white/70 p-2 shadow-sm dark:border-dark-700 dark:bg-dark-800/70'> className={
<div className='flex min-h-[420px] flex-1 gap-4 overflow-x-auto overflow-y-hidden pb-2'> 'pb-2 flex-grow min-h-[400px] flex-1 grid grid-rows-1 auto-cols-min grid-flow-col gap-x-3 overflow-y-hidden overflow-x-auto'
{columns.map((column) => ( }
>
<DndProvider backend={HTML5Backend}>
{columns.map((column) => (
<div key={column.id}>
<KanbanColumn <KanbanColumn
key={column.id} entityName={entityName}
entityName={entityName} columnFieldName={columnFieldName}
columnFieldName={columnFieldName} showFieldName={showFieldName}
showFieldName={showFieldName} column={column}
column={column} filtersQuery={filtersQuery}
filtersQuery={filtersQuery} deleteThunk={deleteThunk}
deleteThunk={deleteThunk} updateThunk={updateThunk}
updateThunk={updateThunk}
/> />
))} </div>
</div> ))}
</div> </DndProvider>
</DndProvider> </div>
); );
}; };

View File

@ -1,133 +1,64 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import moment from 'moment'; import moment from 'moment';
import { DragSourceMonitor, useDrag } from 'react-dnd';
import ListActionsPopover from '../ListActionsPopover'; import ListActionsPopover from '../ListActionsPopover';
import { DragSourceMonitor, useDrag } from 'react-dnd';
type Props = { type Props = {
item: any; item: any;
column: { id: string; label: string }; column: { id: string; label: string };
entityName: string; entityName: string;
showFieldName: string; showFieldName: string;
setItemIdToDelete: (id: string) => void; setItemIdToDelete: (id: string) => void;
};
const humanize = (value?: string | null) => {
if (!value) return '';
return value
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
};
const formatDateRange = (start?: string, end?: string) => {
if (!start && !end) return '';
if (start && end) {
return `${moment(start).format('MMM D')} ${moment(end).format('MMM D')}`;
}
return moment(start || end).format('MMM D');
}; };
const KanbanCard = ({ const KanbanCard = ({
item, item,
entityName, entityName,
showFieldName, showFieldName,
setItemIdToDelete, setItemIdToDelete,
column, column,
}: Props) => { }: Props) => {
const [{ isDragging }, drag] = useDrag( const [{ isDragging }, drag] = useDrag(
() => ({ () => ({
type: 'box', type: 'box',
item: { item, column }, item: { item, column },
collect: (monitor: DragSourceMonitor) => ({ collect: (monitor: DragSourceMonitor) => ({
isDragging: monitor.isDragging(), isDragging: monitor.isDragging(),
}), }),
}), }),
[item, column], [item],
); );
const title = item?.[showFieldName] ?? 'Untitled'; return (
const dateRange = formatDateRange(item?.check_in_at || item?.start_at, item?.check_out_at || item?.end_at); <div
const locationLabel = ref={drag}
item?.preferred_property?.name || className={
item?.property?.name || `bg-midnightBlueTheme-cardColor dark:bg-dark-800 rounded-md space-y-2 p-4 relative ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`
item?.unit?.unit_number || }
item?.unit_type?.name || >
''; <div className={'flex items-center justify-between'}>
const supportingLabel = <Link
item?.requested_by?.email || href={`/${entityName}/${entityName}-view/?id=${item.id}`}
item?.requested_by?.firstName || className={'text-base font-semibold'}
item?.organization?.name || >
item?.organizations?.name || {item[showFieldName] ?? 'No data'}
''; </Link>
const updatedAt = item?.updatedAt || item?.createdAt; </div>
const stats = [ <div className={'flex items-center justify-between'}>
item?.guest_count <p>{moment(item.createdAt).format('MMM DD hh:mm a')}</p>
? { label: 'Guests', value: String(item.guest_count) } <ListActionsPopover
: null, itemId={item.id}
item?.priority pathEdit={`/${entityName}/${entityName}-edit/?id=${item.id}`}
? { label: 'Priority', value: humanize(item.priority) } pathView={`/${entityName}/${entityName}-view/?id=${item.id}`}
: null, onDelete={(id) => setItemIdToDelete(id)}
dateRange hasUpdatePermission={true}
? { label: entityName === 'booking_requests' ? 'Stay' : 'Dates', value: dateRange } className={'w-2 h-2 text-white'}
: null, iconClassName={'w-5'}
locationLabel ? { label: 'Location', value: locationLabel } : null, />
].filter(Boolean) as Array<{ label: string; value: string }>;
return (
<div
ref={drag}
className={`rounded-2xl border border-gray-200/80 bg-white p-4 shadow-sm transition ${
isDragging ? 'cursor-grabbing opacity-70' : 'cursor-grab hover:-translate-y-0.5'
} dark:border-dark-700 dark:bg-dark-900`}
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<Link
href={`/${entityName}/${entityName}-view/?id=${item.id}`}
className='block truncate text-sm font-semibold text-gray-900 dark:text-white'
>
{title}
</Link>
{supportingLabel && (
<p className='mt-1 truncate text-xs text-gray-500 dark:text-gray-400'>
{supportingLabel}
</p>
)}
</div>
<ListActionsPopover
itemId={item.id}
pathEdit={`/${entityName}/${entityName}-edit/?id=${item.id}`}
pathView={`/${entityName}/${entityName}-view/?id=${item.id}`}
onDelete={(id) => setItemIdToDelete(id)}
hasUpdatePermission={true}
className='h-5 w-5 text-gray-400 dark:text-gray-500'
iconClassName='w-5 text-gray-400 dark:text-gray-500'
/>
</div>
{stats.length > 0 && (
<div className='mt-3 grid gap-2'>
{stats.slice(0, 3).map((stat) => (
<div
key={`${item.id}-${stat.label}`}
className='flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2 text-xs dark:bg-dark-800/80'
>
<span className='text-gray-500 dark:text-gray-400'>{stat.label}</span>
<span className='ml-3 truncate font-medium text-gray-700 dark:text-gray-200'>
{stat.value}
</span>
</div> </div>
))}
</div> </div>
)} );
<div className='mt-4 flex items-center justify-between text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500'>
<span>{humanize(column.label)}</span>
<span>{updatedAt ? moment(updatedAt).format('MMM D') : '—'}</span>
</div>
</div>
);
}; };
export default KanbanCard; export default KanbanCard;

View File

@ -1,204 +1,209 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import Axios from 'axios'; import Axios from 'axios';
import { AsyncThunk } from '@reduxjs/toolkit';
import { useDrop } from 'react-dnd';
import CardBox from '../CardBox'; import CardBox from '../CardBox';
import CardBoxModal from '../CardBoxModal'; import CardBoxModal from '../CardBoxModal';
import LoadingSpinner from '../LoadingSpinner'; import { AsyncThunk } from '@reduxjs/toolkit';
import CardBoxComponentEmpty from '../CardBoxComponentEmpty'; import { useDrop } from 'react-dnd';
import KanbanCard from './KanbanCard'; import KanbanCard from './KanbanCard';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
type Props = { type Props = {
column: { id: string; label: string }; column: { id: string; label: string };
entityName: string; entityName: string;
columnFieldName: string; columnFieldName: string;
showFieldName: string; showFieldName: string;
filtersQuery: string; filtersQuery: any;
deleteThunk: AsyncThunk<any, any, any>; deleteThunk: AsyncThunk<any, any, any>;
updateThunk: AsyncThunk<any, any, any>; updateThunk: AsyncThunk<any, any, any>;
}; };
type DropResult = { type DropResult = {
sourceColumn: { id: string; label: string }; sourceColumn: { id: string; label: string };
item: any; item: any;
}; };
const perPage = 10; const perPage = 10;
const KanbanColumn = ({ const KanbanColumn = ({
column, column,
entityName, entityName,
columnFieldName, columnFieldName,
showFieldName, showFieldName,
filtersQuery, filtersQuery,
deleteThunk, deleteThunk,
updateThunk, updateThunk,
}: Props) => { }: Props) => {
const dispatch = useAppDispatch(); const [currentPage, setCurrentPage] = useState(0);
const currentUser = useAppSelector((state) => state.auth.currentUser); const [count, setCount] = useState(0);
const listInnerRef = useRef<HTMLDivElement | null>(null); const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [itemIdToDelete, setItemIdToDelete] = useState('');
const currentUser = useAppSelector((state) => state.auth.currentUser);
const listInnerRef = useRef<HTMLDivElement | null>(null);
const dispatch = useAppDispatch();
const [currentPage, setCurrentPage] = useState(0); const [{ dropResult }, drop] = useDrop<
const [count, setCount] = useState(0); {
const [data, setData] = useState<any[]>([]); item: any;
const [loading, setLoading] = useState(false); column: {
const [itemIdToDelete, setItemIdToDelete] = useState(''); id: string;
label: string;
const [{ dropResult }, drop] = useDrop( };
() => ({ },
accept: 'box', unknown,
drop: ({ item, column: sourceColumn }: { item: any; column: { id: string; label: string } }) => { {
if (sourceColumn.id === column.id) return; dropResult: DropResult;
dispatch(
updateThunk({
id: item.id,
data: {
[columnFieldName]: column.id,
},
}),
).then(() => {
const movedItem = {
...item,
[columnFieldName]: column.id,
};
setData((prevState) => [movedItem, ...prevState.filter((record) => record.id !== item.id)]);
setCount((prevState) => prevState + 1);
});
return { sourceColumn, item };
},
collect: (monitor) => ({
dropResult: monitor.getDropResult() as DropResult | null,
}),
}),
[column.id, columnFieldName, dispatch, updateThunk],
);
const loadData = useCallback(
async (page: number, filters = '') => {
const query = `?page=${page}&limit=${perPage}&field=createdAt&sort=desc&${columnFieldName}=${column.id}&${filters}`;
setLoading(true);
try {
const res = await Axios.get(`/${entityName}${query}`);
setData((prevState) => (page === 0 ? res.data.rows : [...prevState, ...res.data.rows]));
setCount(res.data.count);
setCurrentPage(page);
} catch (error) {
console.error(`Failed to load ${entityName} kanban column ${column.id}`, error);
} finally {
setLoading(false);
}
},
[column.id, columnFieldName, entityName],
);
useEffect(() => {
if (!currentUser) return;
loadData(0, filtersQuery);
}, [currentUser, loadData, filtersQuery]);
useEffect(() => {
if (!dropResult?.sourceColumn || dropResult.sourceColumn.id !== column.id) {
return;
}
setData((prevState) => prevState.filter((item) => item.id !== dropResult.item.id));
setCount((prevState) => Math.max(prevState - 1, 0));
}, [column.id, dropResult]);
const onScroll = () => {
if (!listInnerRef.current || loading) return;
const { scrollTop, scrollHeight, clientHeight } = listInnerRef.current;
if (scrollTop + clientHeight >= scrollHeight - 12 && data.length < count) {
loadData(currentPage + 1, filtersQuery);
}
};
const onDeleteConfirm = () => {
if (!itemIdToDelete) return;
dispatch(deleteThunk(itemIdToDelete))
.then((res) => {
if (res.meta.requestStatus === 'fulfilled') {
setItemIdToDelete('');
loadData(0, filtersQuery);
} }
}) >(
.catch((error) => { () => ({
console.error(`Failed to delete ${entityName} item ${itemIdToDelete}`, error); accept: 'box',
}) drop: ({
.finally(() => { item,
setItemIdToDelete(''); column: sourceColumn,
}); }: {
}; item: any;
column: { id: string; label: string };
}) => {
if (sourceColumn.id === column.id) return;
return ( dispatch(
<> updateThunk({
<CardBox hasComponentLayout className='w-[280px] overflow-hidden rounded-2xl border border-gray-200/80 bg-gray-50/80 shadow-none dark:border-dark-700 dark:bg-dark-900/70'> id: item.id,
<div className='flex items-center justify-between border-b border-gray-200/80 px-4 py-4 dark:border-dark-700'> data: {
<div> [columnFieldName]: column.id,
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500'> },
Stage }),
</p> ).then((res) => {
<p className='mt-1 text-sm font-semibold text-gray-900 dark:text-white'>{column.label}</p> setData((prevState) => (prevState ? [...prevState, item] : [item]));
</div> setCount((prevState) => prevState + 1);
<div className='rounded-full bg-white px-3 py-1 text-xs font-semibold text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300'> });
{count}
</div>
</div>
<div
ref={(node) => {
drop(node);
listInnerRef.current = node;
}}
className='max-h-[560px] flex-1 space-y-3 overflow-y-auto p-3'
onScroll={onScroll}
>
{data.map((item) => (
<KanbanCard
key={item.id}
item={item}
column={column}
showFieldName={showFieldName}
entityName={entityName}
setItemIdToDelete={setItemIdToDelete}
/>
))}
{!loading && !data.length && ( return { sourceColumn, item };
<CardBoxComponentEmpty },
compact collect: (monitor) => ({
title={`No ${entityName.replace(/_/g, ' ')} in ${column.label}`} dropResult: monitor.getDropResult(),
description='When records move into this stage, they will appear here automatically.' }),
/> }),
)} [],
);
{loading && ( const loadData = useCallback(
<LoadingSpinner (page: number, filters = '') => {
compact const query = `?page=${page}&limit=${perPage}&field=createdAt&sort=desc&${columnFieldName}=${column.id}&${filters}`;
label={`Loading ${column.label.toLowerCase()} stage`} setLoading(true);
detail='Pulling the latest items for this lane.' Axios.get(`${entityName}${query}`)
/> .then((res) => {
)} setData((prevState) =>
</div> page === 0 ? res.data.rows : [...prevState, ...res.data.rows],
</CardBox> );
<CardBoxModal setCount(res.data.count);
title='Please confirm' setCurrentPage(page);
buttonColor='info' })
buttonLabel={loading ? 'Deleting...' : 'Confirm'} .catch((err) => {
isActive={!!itemIdToDelete} console.error(err);
onConfirm={onDeleteConfirm} })
onCancel={() => setItemIdToDelete('')} .finally(() => {
> setLoading(false);
<p>Are you sure you want to delete this item?</p> });
</CardBoxModal> },
</> [currentUser, column],
); );
useEffect(() => {
if (!currentUser) return;
loadData(0, filtersQuery);
}, [currentUser, loadData, filtersQuery]);
useEffect(() => {
loadData(0, filtersQuery);
}, [loadData, filtersQuery]);
useEffect(() => {
if (dropResult?.sourceColumn && dropResult.sourceColumn.id === column.id) {
setData((prevState) =>
prevState.filter((item) => item.id !== dropResult.item.id),
);
setCount((prevState) => prevState - 1);
}
}, [dropResult]);
const onScroll = () => {
if (listInnerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = listInnerRef.current;
if (Math.floor(scrollTop + clientHeight) === scrollHeight) {
if (data.length < count && !loading) {
loadData(currentPage + 1, filtersQuery);
}
}
}
};
const onDeleteConfirm = () => {
if (!itemIdToDelete) return;
dispatch(deleteThunk(itemIdToDelete))
.then((res) => {
if (res.meta.requestStatus === 'fulfilled') {
setItemIdToDelete('');
loadData(0, filtersQuery);
}
})
.catch((err) => {
console.error(err);
})
.finally(() => {
setItemIdToDelete('');
});
};
return (
<>
<CardBox
hasComponentLayout
className={
'w-72 rounded-md h-fit max-h-full overflow-hidden flex flex-col'
}
>
<div className={'flex items-center justify-between p-3'}>
<p className={'uppercase'}>{column.label}</p>
<p>{count}</p>
</div>
<div
ref={(node) => {
drop(node);
listInnerRef.current = node;
}}
className={'p-3 space-y-3 flex-1 overflow-y-auto max-h-[400px]'}
onScroll={onScroll}
>
{data?.map((item) => (
<div key={item.id}>
<KanbanCard
item={item}
column={column}
showFieldName={showFieldName}
entityName={entityName}
setItemIdToDelete={setItemIdToDelete}
/>
</div>
))}
{!data?.length && (
<p className={'text-center py-8 bg-midnightBlueTheme-cardColor dark:bg-dark-800'}>No data</p>
)}
</div>
</CardBox>
<CardBoxModal
title='Please confirm'
buttonColor='info'
buttonLabel={loading ? 'Deleting...' : 'Confirm'}
isActive={!!itemIdToDelete}
onConfirm={onDeleteConfirm}
onCancel={() => setItemIdToDelete('')}
>
<p>Are you sure you want to delete this item?</p>
</CardBoxModal>
</>
);
}; };
export default KanbanColumn; export default KanbanColumn;

View File

@ -1,30 +1,18 @@
import React from 'react'; import React from 'react';
type Props = { const LoadingSpinner = () => {
label?: string;
detail?: string;
compact?: boolean;
};
const LoadingSpinner = ({
label = 'Loading your workspace',
detail = 'Refreshing the latest operational data.',
compact = false,
}: Props) => {
return ( return (
<div <div className='flex items-center justify-center h-40'>
className={`flex items-center justify-center ${compact ? 'min-h-[140px]' : 'min-h-[220px]'} px-4 py-6`} <div className='relative w-12'>
> <div
<div className="flex max-w-md flex-col items-center text-center"> className='w-12 h-12 rounded-full absolute border-4 border-solid border-gray-200 dark:border-slate-800'
<div className="relative h-14 w-14"> ></div>
<div className="absolute inset-0 rounded-full border-4 border-solid border-gray-200 dark:border-slate-800" /> <div
<div className="absolute inset-0 animate-spin rounded-full border-4 border-solid border-midnightBlueTheme-iconsColor border-t-transparent dark:border-blue-500" /> className="w-12 h-12 rounded-full animate-spin absolute border-4 border-solid border-midnightBlueTheme-iconsColor dark:border-blue-500 border-t-transparent"
</div> ></div>
<p className="mt-5 text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
<p className="mt-2 text-sm leading-6 text-gray-500 dark:text-slate-400">{detail}</p>
</div> </div>
</div> </div>
); );
}; };
export default LoadingSpinner; export default LoadingSpinner;

View File

@ -1,203 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react'
import CardBox from './CardBox'
import ConnectedEntityCard from './ConnectedEntityCard'
import TenantStatusChip from './TenantStatusChip'
import { useAppSelector } from '../stores/hooks'
import {
emptyOrganizationTenantSummary,
getOrganizationViewHref,
getTenantViewHref,
loadLinkedTenantSummary,
} from '../helpers/organizationTenants'
import { hasPermission } from '../helpers/userPermissions'
type MyOrgTenantSummaryProps = {
className?: string
}
const MyOrgTenantSummary = ({ className = '' }: MyOrgTenantSummaryProps) => {
const { currentUser } = useAppSelector((state) => state.auth)
const [linkedTenantSummary, setLinkedTenantSummary] = useState(emptyOrganizationTenantSummary)
const [isLoadingTenants, setIsLoadingTenants] = useState(false)
const organizationId = useMemo(
() =>
currentUser?.organizations?.id ||
currentUser?.organization?.id ||
currentUser?.organizationsId ||
currentUser?.organizationId ||
'',
[
currentUser?.organization?.id,
currentUser?.organizationId,
currentUser?.organizations?.id,
currentUser?.organizationsId,
],
)
const organizationName =
currentUser?.organizations?.name ||
currentUser?.organization?.name ||
currentUser?.organizationName ||
'No organization assigned yet'
const canViewOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS')
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS')
useEffect(() => {
let isMounted = true
if (!organizationId || !canViewTenants) {
setLinkedTenantSummary(emptyOrganizationTenantSummary)
setIsLoadingTenants(false)
return () => {
isMounted = false
}
}
setIsLoadingTenants(true)
loadLinkedTenantSummary(organizationId)
.then((summary) => {
if (!isMounted) {
return
}
setLinkedTenantSummary(summary)
})
.catch((error) => {
console.error('Failed to load current user org/tenant context:', error)
if (!isMounted) {
return
}
setLinkedTenantSummary(emptyOrganizationTenantSummary)
})
.finally(() => {
if (isMounted) {
setIsLoadingTenants(false)
}
})
return () => {
isMounted = false
}
}, [canViewTenants, organizationId])
if (!currentUser) {
return null
}
const appRoleName = currentUser?.app_role?.name || 'No role surfaced yet'
const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : []
const tenantSummaryValue = !organizationId
? 'No workspace linked'
: !canViewTenants
? 'Restricted'
: isLoadingTenants
? 'Loading…'
: String(linkedTenantSummary.count || 0)
const tenantEmptyStateMessage = !organizationId
? 'No organization is attached to this account yet.'
: !canViewTenants
? 'Your current role does not include tenant-read access for this organization context.'
: 'No tenant link surfaced yet for your organization.'
return (
<CardBox className={className}>
<div className="space-y-4">
<div>
<div className="inline-flex rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100">
My account context
</div>
<h3 className="mt-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
Your organization and tenant access
</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
This makes it clear which organization your signed-in account belongs to and which tenant links sit behind it.
</p>
</div>
<ConnectedEntityCard
entityLabel="My organization"
title={organizationName}
titleFallback="No organization assigned yet"
details={[
{ label: 'Account role', value: appRoleName },
{ label: 'Email', value: currentUser?.email },
{
label: 'Linked tenants',
value: tenantSummaryValue,
},
]}
actions={
canViewOrganizations && organizationId
? [
{
href: getOrganizationViewHref(organizationId),
label: 'View organization',
color: 'info',
outline: true,
},
]
: []
}
helperText={
organizationId
? 'This is the workspace context attached to your account after signup and login.'
: 'Your account does not have an organization link yet.'
}
/>
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-300">
My tenant links
</p>
{isLoadingTenants ? (
<div className="rounded-xl border border-dashed border-blue-200 bg-blue-50/60 px-4 py-3 text-sm text-blue-900 dark:border-blue-900/70 dark:bg-blue-950/20 dark:text-blue-100">
Loading tenant context for your organization
</div>
) : linkedTenants.length ? (
<div className="space-y-3">
{linkedTenants.map((tenant) => (
<ConnectedEntityCard
key={tenant.id}
entityLabel="My tenant"
title={tenant.name || 'Unnamed tenant'}
badges={[<TenantStatusChip key={`${tenant.id}-status`} isActive={tenant.is_active} />]}
details={[
{ label: 'Slug', value: tenant.slug },
{ label: 'Domain', value: tenant.primary_domain },
{ label: 'Timezone', value: tenant.timezone },
{ label: 'Currency', value: tenant.default_currency },
]}
actions={
canViewTenants && tenant.id
? [
{
href: getTenantViewHref(tenant.id, organizationId, organizationName),
label: 'View tenant',
color: 'info',
outline: true,
},
]
: []
}
helperText="This tenant link is part of the organization context attached to your account."
/>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300">
{tenantEmptyStateMessage}
</div>
)}
</div>
</div>
</CardBox>
)
}
export default MyOrgTenantSummary

View File

@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react' import React, {useEffect, useRef} from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider' import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'

View File

@ -1,22 +1,24 @@
import React, { useEffect, useState } from 'react' import React from 'react';
import Link from 'next/link' import ImageField from '../ImageField';
import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks';
import dataFormatter from '../../helpers/dataFormatter';
import { Pagination } from '../Pagination';
import {saveFile} from "../../helpers/fileSaver";
import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
import LinkedTenantsPreview from './LinkedTenantsPreview'
import ListActionsPopover from '../ListActionsPopover'
import LoadingSpinner from '../LoadingSpinner'
import { Pagination } from '../Pagination'
import { useAppSelector } from '../../stores/hooks'
import { hasPermission } from '../../helpers/userPermissions'
import { loadLinkedTenantSummaries, OrganizationTenantSummaryMap } from '../../helpers/organizationTenants'
type Props = { type Props = {
organizations: any[] organizations: any[];
loading: boolean loading: boolean;
onDelete: (id: string) => void onDelete: (id: string) => void;
currentPage: number currentPage: number;
numPages: number numPages: number;
onPageChange: (page: number) => void onPageChange: (page: number) => void;
} };
const CardOrganizations = ({ const CardOrganizations = ({
organizations, organizations,
@ -26,122 +28,84 @@ const CardOrganizations = ({
numPages, numPages,
onPageChange, onPageChange,
}: Props) => { }: Props) => {
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle) const asideScrollbarsStyle = useAppSelector(
const bgColor = useAppSelector((state) => state.style.cardsColor) (state) => state.style.asideScrollbarsStyle,
const darkMode = useAppSelector((state) => state.style.darkMode) );
const corners = useAppSelector((state) => state.style.corners) const bgColor = useAppSelector((state) => state.style.cardsColor);
const focusRing = useAppSelector((state) => state.style.focusRingColor) const darkMode = useAppSelector((state) => state.style.darkMode);
const corners = useAppSelector((state) => state.style.corners);
const currentUser = useAppSelector((state) => state.auth.currentUser) const focusRing = useAppSelector((state) => state.style.focusRingColor);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS')
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS') const currentUser = useAppSelector((state) => state.auth.currentUser);
const [linkedTenantSummaries, setLinkedTenantSummaries] = useState<OrganizationTenantSummaryMap>({}) const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS')
useEffect(() => {
if (!Array.isArray(organizations) || !organizations.length || !canViewTenants) {
setLinkedTenantSummaries({})
return
}
let isActive = true
loadLinkedTenantSummaries(organizations.map((item: any) => item?.id))
.then((summaries) => {
if (isActive) {
setLinkedTenantSummaries(summaries)
}
})
.catch((error) => {
console.error('Failed to load linked tenants for organization cards:', error)
if (isActive) {
setLinkedTenantSummaries({})
}
})
return () => {
isActive = false
}
}, [canViewTenants, organizations])
return ( return (
<div className='p-4'> <div className={'p-4'}>
{loading && <LoadingSpinner />} {loading && <LoadingSpinner />}
<ul role='list' className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'> <ul
{!loading && role='list'
organizations.map((item) => { className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
const linkedSummary = linkedTenantSummaries[item.id] >
const linkedCount = linkedSummary?.count || 0 {!loading && organizations.map((item, index) => (
const linkedTenantLabel = !canViewTenants <li
? 'Tenant access restricted' key={item.id}
: linkedCount className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
? `${linkedCount} linked tenant${linkedCount === 1 ? '' : 's'}` darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
: 'No tenant link' }`}
>
<div className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
<Link href={`/organizations/organizations-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
{item.name}
</Link>
return ( <div className='ml-auto '>
<li <ListActionsPopover
key={item.id} onDelete={onDelete}
className={`overflow-hidden ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${ itemId={item.id}
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
}`} pathView={`/organizations/organizations-view/?id=${item.id}`}
>
<div hasUpdatePermission={hasUpdatePermission}
className={`relative border-b border-gray-900/5 bg-gray-50 p-6 ${bgColor} dark:bg-dark-800`}
> />
<div className='flex items-start gap-4'> </div>
<div className='min-w-0 flex-1'> </div>
<div className='flex flex-wrap items-center gap-2'> <dl className='divide-y divide-gray-600 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
<span className='rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100'>
Organization
</span> <div className='flex justify-between gap-x-4 py-3'>
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'> <dt className=' text-gray-500 dark:text-dark-600'>Name</dt>
{linkedTenantLabel} <dd className='flex items-start gap-x-2'>
</span> <div className='font-medium line-clamp-4'>
</div> { item.name }
</div>
<Link href={`/organizations/organizations-view/?id=${item.id}`} className='mt-3 block line-clamp-2 text-lg font-bold leading-6'> </dd>
{item.name || 'Unnamed organization'}
</Link>
</div>
<div className='ml-auto'>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
pathView={`/organizations/organizations-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div>
</div> </div>
<div className='space-y-4 px-6 py-4 text-sm leading-6'>
<div className='rounded-xl border border-blue-100 bg-blue-50/60 p-4 dark:border-blue-950/60 dark:bg-blue-950/20'> </dl>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100'> </li>
Connected entities ))}
</p>
<div className='mt-3'>
<LinkedTenantsPreview
summary={linkedSummary}
emptyMessage={canViewTenants ? 'This organization is not linked to a tenant yet.' : 'Tenant access restricted'}
/>
</div>
</div>
</div>
</li>
)
})}
{!loading && organizations.length === 0 && ( {!loading && organizations.length === 0 && (
<div className='col-span-full flex h-40 items-center justify-center'> <div className='col-span-full flex items-center justify-center h-40'>
<p>No data to display</p> <p className=''>No data to display</p>
</div> </div>
)} )}
</ul> </ul>
<div className='my-6 flex items-center justify-center'> <div className={'flex items-center justify-center my-6'}>
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} /> <Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div> </div>
</div> </div>
) );
} };
export default CardOrganizations export default CardOrganizations;

View File

@ -1,51 +0,0 @@
import React from 'react'
import {
emptyOrganizationTenantSummary,
OrganizationTenantSummary,
} from '../../helpers/organizationTenants'
type Props = {
summary?: OrganizationTenantSummary
emptyMessage?: string
compact?: boolean
}
const LinkedTenantsPreview = ({
summary = emptyOrganizationTenantSummary,
emptyMessage = 'No linked tenants',
compact = false,
}: Props) => {
const tenants = Array.isArray(summary?.rows) ? summary.rows : []
if (!summary?.count) {
return <div className='text-sm text-gray-500 dark:text-dark-600'>{emptyMessage}</div>
}
const preview = compact ? tenants.slice(0, 2) : tenants.slice(0, 3)
const remainingCount = Math.max(summary.count - preview.length, 0)
return (
<div className='flex min-w-0 flex-wrap items-center gap-1.5'>
<span className='rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-950/40 dark:text-blue-100'>
{summary.count} linked
</span>
{preview.map((tenant: any) => (
<span
key={tenant.id || tenant.name}
className='max-w-full truncate rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'
title={tenant.name}
>
{tenant.name || 'Unnamed tenant'}
</span>
))}
{remainingCount > 0 ? (
<span className='rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
+{remainingCount} more
</span>
) : null}
</div>
)
}
export default LinkedTenantsPreview

View File

@ -1,133 +1,88 @@
import React, { useEffect, useState } from 'react' import React from 'react';
import Link from 'next/link' import CardBox from '../CardBox';
import ImageField from '../ImageField';
import dataFormatter from '../../helpers/dataFormatter';
import {saveFile} from "../../helpers/fileSaver";
import ListActionsPopover from "../ListActionsPopover";
import {useAppSelector} from "../../stores/hooks";
import {Pagination} from "../Pagination";
import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
import CardBox from '../CardBox'
import ListActionsPopover from '../ListActionsPopover'
import { useAppSelector } from '../../stores/hooks'
import { Pagination } from '../Pagination'
import LoadingSpinner from '../LoadingSpinner'
import LinkedTenantsPreview from './LinkedTenantsPreview'
import { hasPermission } from '../../helpers/userPermissions'
import { loadLinkedTenantSummaries, OrganizationTenantSummaryMap } from '../../helpers/organizationTenants'
type Props = { type Props = {
organizations: any[] organizations: any[];
loading: boolean loading: boolean;
onDelete: (id: string) => void onDelete: (id: string) => void;
currentPage: number currentPage: number;
numPages: number numPages: number;
onPageChange: (page: number) => void onPageChange: (page: number) => void;
} };
const ListOrganizations = ({ organizations, loading, onDelete, currentPage, numPages, onPageChange }: Props) => { const ListOrganizations = ({ organizations, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
const currentUser = useAppSelector((state) => state.auth.currentUser)
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS') const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS')
const corners = useAppSelector((state) => state.style.corners);
const bgColor = useAppSelector((state) => state.style.cardsColor);
const corners = useAppSelector((state) => state.style.corners)
const bgColor = useAppSelector((state) => state.style.cardsColor)
const [linkedTenantSummaries, setLinkedTenantSummaries] = useState<OrganizationTenantSummaryMap>({})
useEffect(() => { return (
if (!Array.isArray(organizations) || !organizations.length || !canViewTenants) { <>
setLinkedTenantSummaries({}) <div className='relative overflow-x-auto p-4 space-y-4'>
return {loading && <LoadingSpinner />}
} {!loading && organizations.map((item) => (
<div key={item.id}>
<CardBox hasTable isList className={'rounded shadow-none'}>
<div className={`flex ${bgColor} ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 border border-gray-600 items-center overflow-hidden`}>
<Link
href={`/organizations/organizations-view/?id=${item.id}`}
className={
'flex-1 px-4 py-6 h-24 flex divide-x-2 divide-gray-600 items-center overflow-hidden`}> dark:divide-dark-700 overflow-x-auto'
}
>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Name</p>
<p className={'line-clamp-2'}>{ item.name }</p>
</div>
let isActive = true
</Link>
loadLinkedTenantSummaries(organizations.map((item: any) => item?.id)) <ListActionsPopover
.then((summaries) => { onDelete={onDelete}
if (isActive) { itemId={item.id}
setLinkedTenantSummaries(summaries) pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
} pathView={`/organizations/organizations-view/?id=${item.id}`}
})
.catch((error) => { hasUpdatePermission={hasUpdatePermission}
console.error('Failed to load linked tenants for organization list rows:', error)
if (isActive) { />
setLinkedTenantSummaries({})
}
})
return () => {
isActive = false
}
}, [canViewTenants, organizations])
return (
<>
<div className='relative space-y-4 overflow-x-auto p-4'>
{loading && <LoadingSpinner />}
{!loading &&
organizations.map((item) => {
const linkedSummary = linkedTenantSummaries[item.id]
const linkedCount = linkedSummary?.count || 0
const linkedTenantLabel = !canViewTenants
? 'Tenant access restricted'
: linkedCount
? `${linkedCount} linked tenant${linkedCount === 1 ? '' : 's'}`
: 'No tenant link'
return (
<div key={item.id}>
<CardBox hasTable isList className='rounded shadow-none'>
<div
className={`flex items-start overflow-hidden border border-gray-600 ${bgColor} ${
corners !== 'rounded-full' ? corners : 'rounded-3xl'
} dark:bg-dark-900`}
>
<Link href={`/organizations/organizations-view/?id=${item.id}`} className='min-w-0 flex-1 p-4'>
<div className='flex flex-wrap items-center gap-2'>
<span className='rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100'>
Organization
</span>
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
{linkedTenantLabel}
</span>
</div>
<p className='mt-3 line-clamp-2 text-base font-semibold text-gray-900 dark:text-gray-100'>
{item.name || 'Unnamed organization'}
</p>
<div className='mt-3 rounded-xl border border-blue-100 bg-blue-50/60 p-3 dark:border-blue-950/60 dark:bg-blue-950/20'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100'>
Connected entities
</p>
<div className='mt-2'>
<LinkedTenantsPreview
summary={linkedSummary}
emptyMessage={canViewTenants ? 'This organization is not linked to a tenant yet.' : 'Tenant access restricted'}
compact
/>
</div> </div>
</div> </CardBox>
</Link>
<div className='flex shrink-0 items-start p-4'>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
pathView={`/organizations/organizations-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div> </div>
</CardBox> ))}
</div> {!loading && organizations.length === 0 && (
) <div className='col-span-full flex items-center justify-center h-40'>
})} <p className=''>No data to display</p>
{!loading && organizations.length === 0 && ( </div>
<div className='col-span-full flex h-40 items-center justify-center'> )}
<p>No data to display</p> </div>
</div> <div className={'flex items-center justify-center my-6'}>
)} <Pagination
</div> currentPage={currentPage}
<div className='my-6 flex items-center justify-center'> numPages={numPages}
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} /> setCurrentPage={onPageChange}
</div> />
</> </div>
) </>
} )
};
export default ListOrganizations export default ListOrganizations

View File

@ -13,8 +13,6 @@ import {
GridColDef, GridColDef,
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import {loadColumns} from "./configureOrganizationsCols"; import {loadColumns} from "./configureOrganizationsCols";
import { loadLinkedTenantSummaries } from '../../helpers/organizationTenants'
import { hasPermission } from '../../helpers/userPermissions'
import _ from 'lodash'; import _ from 'lodash';
import dataFormatter from '../../helpers/dataFormatter' import dataFormatter from '../../helpers/dataFormatter'
import {dataGridStyles} from "../../styles"; import {dataGridStyles} from "../../styles";
@ -35,7 +33,6 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
const [filterRequest, setFilterRequest] = React.useState(''); const [filterRequest, setFilterRequest] = React.useState('');
const [columns, setColumns] = useState<GridColDef[]>([]); const [columns, setColumns] = useState<GridColDef[]>([]);
const [selectedRows, setSelectedRows] = useState([]); const [selectedRows, setSelectedRows] = useState([]);
const [linkedTenantSummaries, setLinkedTenantSummaries] = useState({});
const [sortModel, setSortModel] = useState([ const [sortModel, setSortModel] = useState([
{ {
field: '', field: '',
@ -45,7 +42,6 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
const { organizations, loading, count, notify: organizationsNotify, refetch } = useAppSelector((state) => state.organizations) const { organizations, loading, count, notify: organizationsNotify, refetch } = useAppSelector((state) => state.organizations)
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS');
const focusRing = useAppSelector((state) => state.style.focusRingColor); const focusRing = useAppSelector((state) => state.style.focusRingColor);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor); const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
const corners = useAppSelector((state) => state.style.corners); const corners = useAppSelector((state) => state.style.corners);
@ -174,45 +170,17 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
loadData(page); loadData(page);
setCurrentPage(page); setCurrentPage(page);
}; };
useEffect(() => {
const organizationRows = Array.isArray(organizations) ? organizations : [];
if (!organizationRows.length || !canViewTenants) {
setLinkedTenantSummaries({}); useEffect(() => {
return; if (!currentUser) return;
}
let isActive = true; loadColumns(
handleDeleteModalAction,
loadLinkedTenantSummaries(organizationRows.map((item: any) => item?.id)) `organizations`,
.then((summaries) => { currentUser,
if (isActive) { ).then((newCols) => setColumns(newCols));
setLinkedTenantSummaries(summaries); }, [currentUser]);
}
})
.catch((error) => {
console.error('Failed to load linked tenants for organization table:', error);
if (isActive) {
setLinkedTenantSummaries({});
}
});
return () => {
isActive = false;
};
}, [canViewTenants, organizations]);
useEffect(() => {
if (!currentUser) return;
loadColumns(
handleDeleteModalAction,
`organizations`,
currentUser,
linkedTenantSummaries,
canViewTenants,
).then((newCols) => setColumns(newCols));
}, [canViewTenants, currentUser, linkedTenantSummaries]);
@ -243,7 +211,7 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
<div className='relative overflow-x-auto'> <div className='relative overflow-x-auto'>
<DataGrid <DataGrid
autoHeight autoHeight
rowHeight={78} rowHeight={64}
sx={dataGridStyles} sx={dataGridStyles}
className={'datagrid--table'} className={'datagrid--table'}
getRowClassName={() => `datagrid--row`} getRowClassName={() => `datagrid--row`}

View File

@ -1,70 +1,83 @@
import React from 'react' import React from 'react';
import { GridRowParams } from '@mui/x-data-grid' import BaseIcon from '../BaseIcon';
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
import axios from 'axios';
import {
GridActionsCellItem,
GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid';
import ImageField from '../ImageField';
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter'
import DataGridMultiSelect from "../DataGridMultiSelect";
import ListActionsPopover from '../ListActionsPopover';
import LinkedTenantsPreview from './LinkedTenantsPreview' import {hasPermission} from "../../helpers/userPermissions";
import ListActionsPopover from '../ListActionsPopover'
import { hasPermission } from '../../helpers/userPermissions'
import { OrganizationTenantSummaryMap } from '../../helpers/organizationTenants'
type Params = (id: string) => void type Params = (id: string) => void;
export const loadColumns = async ( export const loadColumns = async (
onDelete: Params, onDelete: Params,
_entityName: string, entityName: string,
user,
linkedTenantSummaries: OrganizationTenantSummaryMap = {}, user
canViewTenants = true,
) => { ) => {
const hasUpdatePermission = hasPermission(user, 'UPDATE_ORGANIZATIONS') async function callOptionsApi(entityName: string) {
return [ if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
{
field: 'name', try {
headerName: 'Name', const data = await axios(`/${entityName}/autocomplete?limit=100`);
flex: 1, return data.data;
minWidth: 180, } catch (error) {
filterable: false, console.log(error);
headerClassName: 'datagrid--header', return [];
cellClassName: 'datagrid--cell', }
editable: hasUpdatePermission, }
},
{ const hasUpdatePermission = hasPermission(user, 'UPDATE_ORGANIZATIONS')
field: 'linkedTenants',
headerName: 'Linked tenants', return [
flex: 1.2,
minWidth: 220, {
filterable: false, field: 'name',
sortable: false, headerName: 'Name',
editable: false, flex: 1,
headerClassName: 'datagrid--header', minWidth: 120,
cellClassName: 'datagrid--cell', filterable: false,
renderCell: (params: any) => ( headerClassName: 'datagrid--header',
<div className='flex min-h-[52px] items-center py-2'> cellClassName: 'datagrid--cell',
<LinkedTenantsPreview
summary={linkedTenantSummaries[params?.row?.id]}
compact editable: hasUpdatePermission,
emptyMessage={canViewTenants ? 'Not linked yet' : 'Tenant access restricted'}
/>
</div> },
),
}, {
{ field: 'actions',
field: 'actions', type: 'actions',
type: 'actions', minWidth: 30,
minWidth: 30, headerClassName: 'datagrid--header',
headerClassName: 'datagrid--header', cellClassName: 'datagrid--cell',
cellClassName: 'datagrid--cell', getActions: (params: GridRowParams) => {
getActions: (params: GridRowParams) => [
<div key={params?.row?.id}> return [
<ListActionsPopover <div key={params?.row?.id}>
onDelete={onDelete} <ListActionsPopover
itemId={params?.row?.id} onDelete={onDelete}
pathEdit={`/organizations/organizations-edit/?id=${params?.row?.id}`} itemId={params?.row?.id}
pathView={`/organizations/organizations-view/?id=${params?.row?.id}`} pathEdit={`/organizations/organizations-edit/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission} pathView={`/organizations/organizations-view/?id=${params?.row?.id}`}
/>
</div>, hasUpdatePermission={hasUpdatePermission}
],
}, />
] </div>,
} ]
},
},
];
};

View File

@ -1,12 +1,15 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import ImageField from '../ImageField';
import CardBox from '../CardBox';
import ListActionsPopover from '../ListActionsPopover'; import ListActionsPopover from '../ListActionsPopover';
import { Pagination } from '../Pagination';
import LoadingSpinner from '../LoadingSpinner';
import CardBoxComponentEmpty from '../CardBoxComponentEmpty';
import { useAppSelector } from '../../stores/hooks'; import { useAppSelector } from '../../stores/hooks';
import { hasPermission } from '../../helpers/userPermissions'; import dataFormatter from '../../helpers/dataFormatter';
import { Pagination } from '../Pagination';
import {saveFile} from "../../helpers/fileSaver";
import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
type Props = { type Props = {
properties: any[]; properties: any[];
@ -25,110 +28,241 @@ const CardProperties = ({
numPages, numPages,
onPageChange, onPageChange,
}: Props) => { }: Props) => {
const currentUser = useAppSelector((state) => state.auth.currentUser); const asideScrollbarsStyle = useAppSelector(
const corners = useAppSelector((state) => state.style.corners); (state) => state.style.asideScrollbarsStyle,
const bgColor = useAppSelector((state) => state.style.cardsColor); );
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PROPERTIES'); const bgColor = useAppSelector((state) => state.style.cardsColor);
const cardRadius = corners !== 'rounded-full' ? corners : 'rounded-3xl'; const darkMode = useAppSelector((state) => state.style.darkMode);
const corners = useAppSelector((state) => state.style.corners);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PROPERTIES')
return ( return (
<> <div className={'p-4'}>
<div className='space-y-4 p-4'> {loading && <LoadingSpinner />}
{loading && ( <ul
<LoadingSpinner role='list'
label='Loading properties' className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
detail='Preparing the current portfolio snapshot and live inventory.' >
/> {!loading && properties.map((item, index) => (
)} <li
key={item.id}
{!loading && className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
properties.map((item) => { darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
const location = [item.city, item.country].filter(Boolean).join(', ') || item.address || 'Location not set'; }`}
const unitsCount = Array.isArray(item.units) ? item.units.length : 0; >
const unitTypesCount = Array.isArray(item.unit_types) ? item.unit_types.length : 0;
const amenitiesCount = Array.isArray(item.amenities) ? item.amenities.length : 0; <div className={`flex items-center ${bgColor} p-6 md:p-0 md:block gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
return ( <Link
<CardBox key={item.id} className='shadow-none'> href={`/properties/properties-view/?id=${item.id}`}
<div className={'cursor-pointer'}
className={`border border-gray-200 dark:border-dark-700 ${cardRadius} ${bgColor} p-5`}
> >
<div className='flex flex-col gap-4 md:flex-row md:items-start'> <ImageField
<div className='min-w-0 flex-1'> name={'Avatar'}
<div className='flex flex-wrap items-center gap-3'> image={item.images}
<Link className='w-12 h-12 md:w-full md:h-44 rounded-lg md:rounded-b-none overflow-hidden ring-1 ring-gray-900/10'
href={`/properties/properties-view/?id=${item.id}`} imageClassName='h-full w-full flex-none rounded-lg md:rounded-b-none bg-white object-cover'
className='text-lg font-semibold leading-6 text-gray-900 dark:text-white' />
> <p className={'px-6 py-2 font-semibold'}>{item.name}</p>
{item.name || item.code || 'Untitled property'} </Link>
</Link>
<span
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${
item.is_active
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300'
: 'bg-gray-100 text-gray-700 dark:bg-slate-700 dark:text-slate-200'
}`}
>
{item.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<p className='mt-1 text-sm text-gray-500 dark:text-dark-600'>{location}</p>
{item.description && (
<p className='mt-3 line-clamp-2 text-sm text-gray-600 dark:text-slate-300'>
{item.description}
</p>
)}
</div>
<div className='self-start'> <div className='ml-auto md:absolute md:top-0 md:right-0 '>
<ListActionsPopover <ListActionsPopover
onDelete={onDelete} onDelete={onDelete}
itemId={item.id} itemId={item.id}
pathEdit={`/properties/properties-edit/?id=${item.id}`} pathEdit={`/properties/properties-edit/?id=${item.id}`}
pathView={`/properties/properties-view/?id=${item.id}`} pathView={`/properties/properties-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/> hasUpdatePermission={hasUpdatePermission}
</div>
</div> />
</div>
<div className='mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4'> </div>
<div> <dl className='divide-y divide-gray-600 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
<p className='text-xs uppercase tracking-wide text-gray-500'>Code</p>
<p className='mt-1 text-sm font-medium'>{item.code || '—'}</p>
</div> <div className='flex justify-between gap-x-4 py-3'>
<div> <dt className=' text-gray-500 dark:text-dark-600'>Tenant</dt>
<p className='text-xs uppercase tracking-wide text-gray-500'>Timezone</p> <dd className='flex items-start gap-x-2'>
<p className='mt-1 text-sm font-medium'>{item.timezone || '—'}</p> <div className='font-medium line-clamp-4'>
</div> { dataFormatter.tenantsOneListFormatter(item.tenant) }
<div> </div>
<p className='text-xs uppercase tracking-wide text-gray-500'>Units</p> </dd>
<p className='mt-1 text-sm font-medium'>{unitsCount}</p>
</div>
<div>
<p className='text-xs uppercase tracking-wide text-gray-500'>Unit types / amenities</p>
<p className='mt-1 text-sm font-medium'>
{unitTypesCount} / {amenitiesCount}
</p>
</div>
</div>
</div> </div>
</CardBox>
);
})}
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Propertyname</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.name }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Propertycode</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.code }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Address</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.address }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>City</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.city }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Country</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.country }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Timezone</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.timezone }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Description</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.description }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Images</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium'>
<ImageField
name={'Avatar'}
image={item.images}
className='mx-auto w-8 h-8'
/>
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Isactive</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.booleanFormatter(item.is_active) }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Unittypes</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.unit_typesManyListFormatter(item.unit_types).join(', ')}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Units</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.unitsManyListFormatter(item.units).join(', ')}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Amenities</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.amenitiesManyListFormatter(item.amenities).join(', ')}
</div>
</dd>
</div>
</dl>
</li>
))}
{!loading && properties.length === 0 && ( {!loading && properties.length === 0 && (
<CardBoxComponentEmpty <div className='col-span-full flex items-center justify-center h-40'>
title='No properties match this view' <p className=''>No data to display</p>
description='Try clearing filters or add a property to bring the portfolio into view.' </div>
/>
)} )}
</ul>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div> </div>
</div>
<div className='my-6 flex items-center justify-center'>
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
</div>
</>
); );
}; };
export default CardProperties export default CardProperties;

View File

@ -21,17 +21,7 @@ import {dataGridStyles} from "../../styles";
import CardProperties from './CardProperties'; import CardProperties from './CardProperties';
const perPage = 10 const perPage = 10
const compactColumnVisibilityModel = {
address: false,
description: false,
images: false,
unit_types: false,
units: false,
amenities: false,
}
const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid }) => { const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid }) => {
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"}); const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
@ -214,16 +204,16 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
}; };
const controlClasses = const controlClasses =
'w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-400 ' + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' +
' ' + bgColor + ' ' + focusRing + ' ' + corners + ' ' + ` ${bgColor} ${focusRing} ${corners} ` +
'dark:bg-slate-800/80 my-1'; 'dark:bg-slate-800 border';
const dataGrid = ( const dataGrid = (
<div className='relative overflow-x-auto'> <div className='relative overflow-x-auto'>
<DataGrid <DataGrid
autoHeight autoHeight
rowHeight={56} rowHeight={64}
sx={dataGridStyles} sx={dataGridStyles}
className={'datagrid--table'} className={'datagrid--table'}
getRowClassName={() => `datagrid--row`} getRowClassName={() => `datagrid--row`}
@ -235,9 +225,6 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
pageSize: 10, pageSize: 10,
}, },
}, },
columns: {
columnVisibilityModel: compactColumnVisibilityModel,
},
}} }}
disableRowSelectionOnClick disableRowSelectionOnClick
onProcessRowUpdateError={(params) => { onProcessRowUpdateError={(params) => {
@ -277,7 +264,7 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
return ( return (
<> <>
{filterItems && Array.isArray( filterItems ) && filterItems.length ? {filterItems && Array.isArray( filterItems ) && filterItems.length ?
<CardBox className='mb-6 border border-white/10 shadow-none'> <CardBox>
<Formik <Formik
initialValues={{ initialValues={{
checkboxes: ['lorem'], checkboxes: ['lorem'],
@ -290,9 +277,9 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
<> <>
{filterItems && filterItems.map((filterItem) => { {filterItems && filterItems.map((filterItem) => {
return ( return (
<div key={filterItem.id} className="mb-3 grid gap-3 rounded-2xl border border-white/10 bg-white/5 p-4 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_auto]"> <div key={filterItem.id} className="flex mb-4">
<div className="flex min-w-0 flex-col"> <div className="flex flex-col w-full mr-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Filter</div> <div className=" text-gray-500 font-bold">Filter</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='selectedField' name='selectedField'
@ -314,8 +301,8 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
{filters.find((filter) => {filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField filter.title === filterItem?.fields?.selectedField
)?.type === 'enum' ? ( )?.type === 'enum' ? (
<div className="flex min-w-0 flex-col"> <div className="flex flex-col w-full mr-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400"> <div className="text-gray-500 font-bold">
Value Value
</div> </div>
<Field <Field
@ -339,9 +326,9 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
) : filters.find((filter) => ) : filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField filter.title === filterItem?.fields?.selectedField
)?.number ? ( )?.number ? (
<div className="grid min-w-0 gap-3 md:grid-cols-2"> <div className="flex flex-row w-full mr-3">
<div className="flex min-w-0 flex-col"> <div className="flex flex-col w-full mr-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">From</div> <div className=" text-gray-500 font-bold">From</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='filterValueFrom' name='filterValueFrom'
@ -352,7 +339,7 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
/> />
</div> </div>
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">To</div> <div className=" text-gray-500 font-bold">To</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='filterValueTo' name='filterValueTo'
@ -368,9 +355,9 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
filter.title === filter.title ===
filterItem?.fields?.selectedField filterItem?.fields?.selectedField
)?.date ? ( )?.date ? (
<div className='grid min-w-0 gap-3 md:grid-cols-2'> <div className='flex flex-row w-full mr-3'>
<div className='flex flex-col w-full mr-3'> <div className='flex flex-col w-full mr-3'>
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'> <div className=' text-gray-500 font-bold'>
From From
</div> </div>
<Field <Field
@ -384,7 +371,7 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
/> />
</div> </div>
<div className='flex flex-col w-full'> <div className='flex flex-col w-full'>
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>To</div> <div className=' text-gray-500 font-bold'>To</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='filterValueTo' name='filterValueTo'
@ -397,8 +384,8 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex min-w-0 flex-col"> <div className="flex flex-col w-full mr-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Contains</div> <div className=" text-gray-500 font-bold">Contains</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='filterValue' name='filterValue'
@ -410,12 +397,11 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
</div> </div>
)} )}
<div className="flex flex-col"> <div className="flex flex-col">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Action</div> <div className=" text-gray-500 font-bold">Action</div>
<BaseButton <BaseButton
className="my-1" className="my-2"
type='reset' type='reset'
color='whiteDark' color='danger'
outline
label='Delete' label='Delete'
onClick={() => { onClick={() => {
deleteFilter(filterItem.id) deleteFilter(filterItem.id)
@ -425,16 +411,16 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
</div> </div>
) )
})} })}
<div className="flex flex-wrap items-center gap-2"> <div className="flex">
<BaseButton <BaseButton
className="my-1 mr-0" className="my-2 mr-3"
type='submit' color='info' type='submit' color='info'
label='Apply' label='Apply'
onClick={handleSubmit} onClick={handleSubmit}
/> />
<BaseButton <BaseButton
className="my-1" className="my-2"
type='reset' color='whiteDark' outline type='reset' color='info' outline
label='Cancel' label='Cancel'
onClick={handleReset} onClick={handleReset}
/> />

View File

@ -1,44 +0,0 @@
import Link from 'next/link';
import React from 'react';
import BaseButton from './BaseButton';
import { webPagesNavBar } from '../menuNavBar';
type Props = {
title?: string;
subtitle?: string;
};
export default function PublicSiteFooter({
title = 'Gracey Corporate Stay Portal',
subtitle = 'A cleaner public front door for bookings, guest operations, and billing.',
}: Props) {
const year = new Date().getFullYear();
return (
<footer className="border-t border-slate-200 bg-white">
<div className="mx-auto grid max-w-6xl gap-8 px-6 py-10 lg:grid-cols-[1.2fr_0.8fr] lg:px-10">
<div>
<div className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-500">{title}</div>
<p className="mt-3 max-w-xl text-sm leading-7 text-slate-600">{subtitle}</p>
<div className="mt-5 text-sm text-slate-500">© {year} {title}. All rights reserved.</div>
</div>
<div className="flex flex-col gap-6 lg:items-end">
<nav className="flex flex-wrap gap-4 text-sm text-slate-600 lg:justify-end">
{webPagesNavBar.map((item) => (
<Link key={item.href || item.label} href={item.href || '/'} className="transition-colors hover:text-slate-950">
{item.label}
</Link>
))}
</nav>
<div className="flex flex-wrap gap-3 lg:justify-end">
<BaseButton href="/login" color="whiteDark" label="Login" small />
<BaseButton href="/command-center" color="info" label="Open workspace" small />
</div>
</div>
</div>
</footer>
);
}

View File

@ -1,41 +0,0 @@
import Link from 'next/link';
import React from 'react';
import BaseButton from './BaseButton';
import { webPagesNavBar } from '../menuNavBar';
type Props = {
title?: string;
subtitle?: string;
};
export default function PublicSiteHeader({
title = 'Gracey Corporate Stay Portal',
subtitle = 'Corporate stay operations',
}: Props) {
return (
<header className="sticky top-0 z-30 border-b border-slate-200/80 bg-white/90 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-6 py-4 lg:px-10">
<Link href="/" className="min-w-0">
<div className="text-[11px] font-medium uppercase tracking-[0.28em] text-slate-500">{subtitle}</div>
<div className="truncate text-base font-semibold text-slate-950 md:text-lg">{title}</div>
</Link>
<div className="hidden items-center gap-6 md:flex">
<nav className="flex items-center gap-5 text-sm text-slate-600">
{webPagesNavBar.map((item) => (
<Link key={item.href || item.label} href={item.href || '/'} className="transition-colors hover:text-slate-950">
{item.label}
</Link>
))}
</nav>
<div className="flex items-center gap-3">
<BaseButton href="/login" color="whiteDark" label="Login" small />
<BaseButton href="/command-center" color="info" label="Open workspace" small />
</div>
</div>
</div>
</header>
);
}

View File

@ -15,7 +15,6 @@ import {
import {loadColumns} from "./configureReservationsCols"; import {loadColumns} from "./configureReservationsCols";
import _ from 'lodash'; import _ from 'lodash';
import dataFormatter from '../../helpers/dataFormatter' import dataFormatter from '../../helpers/dataFormatter'
import { getIncompleteFilterHint, isFilterItemIncomplete, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
import {dataGridStyles} from "../../styles"; import {dataGridStyles} from "../../styles";
@ -23,28 +22,7 @@ import BigCalendar from "../BigCalendar";
import { SlotInfo } from 'react-big-calendar'; import { SlotInfo } from 'react-big-calendar';
const perPage = 25 const perPage = 100
const compactColumnVisibilityModel = {
tenant: false,
organization: false,
booking_request: false,
unit_type: false,
actual_check_in_at: false,
actual_check_out_at: false,
early_check_in: false,
late_check_out: false,
monthly_rate: false,
currency: false,
internal_notes: false,
external_notes: false,
guests: false,
service_requests: false,
invoices: false,
documents: false,
comments: false,
}
const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGrid }) => { const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGrid }) => {
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"}); const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
@ -102,14 +80,6 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
} }
}, [refetch, dispatch]); }, [refetch, dispatch]);
const validFilterItems = useMemo(() => pruneHiddenFilterItems(filterItems, filters), [filterItems, filters]);
useEffect(() => {
if (validFilterItems !== filterItems) {
setFilterItems(validFilterItems);
}
}, [filterItems, setFilterItems, validFilterItems]);
const [isModalInfoActive, setIsModalInfoActive] = useState(false) const [isModalInfoActive, setIsModalInfoActive] = useState(false)
const [isModalTrashActive, setIsModalTrashActive] = useState(false) const [isModalTrashActive, setIsModalTrashActive] = useState(false)
@ -140,66 +110,9 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
} }
}; };
const reservationCalendarOverview = useMemo(() => {
const today = new Date();
const nextSevenDays = new Date(today);
nextSevenDays.setDate(nextSevenDays.getDate() + 7);
const totals = reservations.reduce(
(accumulator, item) => {
const checkIn = item?.check_in_at ? new Date(item.check_in_at) : null;
const checkOut = item?.check_out_at ? new Date(item.check_out_at) : null;
const status = item?.status || 'quoted';
accumulator.total += 1;
accumulator[status] = (accumulator[status] || 0) + 1;
if (checkIn && checkIn >= today && checkIn <= nextSevenDays) {
accumulator.upcomingArrivals += 1;
}
if (checkOut && checkOut >= today && checkOut <= nextSevenDays) {
accumulator.upcomingDepartures += 1;
}
return accumulator;
},
{
total: 0,
confirmed: 0,
checked_in: 0,
upcomingArrivals: 0,
upcomingDepartures: 0,
},
);
return [
{
label: 'Visible stays',
value: totals.total,
hint: 'Loaded in this calendar range',
},
{
label: 'Confirmed',
value: totals.confirmed,
hint: 'Booked and upcoming',
},
{
label: 'In house',
value: totals.checked_in,
hint: 'Currently checked in',
},
{
label: 'Departures soon',
value: totals.upcomingDepartures,
hint: 'Next 7 days',
},
];
}, [reservations]);
const generateFilterRequests = useMemo(() => { const generateFilterRequests = useMemo(() => {
let request = '&'; let request = '&';
validFilterItems.forEach((item) => { filterItems.forEach((item) => {
const isRangeFilter = filters.find( const isRangeFilter = filters.find(
(filter) => (filter) =>
filter.title === item.fields.selectedField && filter.title === item.fields.selectedField &&
@ -223,10 +136,10 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
} }
}); });
return request; return request;
}, [filters, validFilterItems]); }, [filterItems, filters]);
const deleteFilter = (value) => { const deleteFilter = (value) => {
const newItems = validFilterItems.filter((item) => item.id !== value); const newItems = filterItems.filter((item) => item.id !== value);
if (newItems.length) { if (newItems.length) {
setFilterItems(newItems); setFilterItems(newItems);
@ -247,7 +160,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
const name = e.target.name; const name = e.target.name;
setFilterItems( setFilterItems(
validFilterItems.map((item) => { filterItems.map((item) => {
if (item.id !== id) return item; if (item.id !== id) return item;
if (name === 'selectedField') return { id, fields: { [name]: value } }; if (name === 'selectedField') return { id, fields: { [name]: value } };
@ -298,16 +211,16 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
}; };
const controlClasses = const controlClasses =
'w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-400 ' + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' +
' ' + bgColor + ' ' + focusRing + ' ' + corners + ' ' + ` ${bgColor} ${focusRing} ${corners} ` +
'dark:bg-slate-800/80 my-1'; 'dark:bg-slate-800 border';
const dataGrid = ( const dataGrid = (
<div className='relative overflow-x-auto'> <div className='relative overflow-x-auto'>
<DataGrid <DataGrid
autoHeight autoHeight
rowHeight={56} rowHeight={64}
sx={dataGridStyles} sx={dataGridStyles}
className={'datagrid--table'} className={'datagrid--table'}
getRowClassName={() => `datagrid--row`} getRowClassName={() => `datagrid--row`}
@ -316,12 +229,9 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
initialState={{ initialState={{
pagination: { pagination: {
paginationModel: { paginationModel: {
pageSize: 25, pageSize: 10,
}, },
}, },
columns: {
columnVisibilityModel: compactColumnVisibilityModel,
},
}} }}
disableRowSelectionOnClick disableRowSelectionOnClick
onProcessRowUpdateError={(params) => { onProcessRowUpdateError={(params) => {
@ -348,7 +258,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
: setSortModel([{ field: '', sort: 'desc' }]); : setSortModel([{ field: '', sort: 'desc' }]);
}} }}
rowCount={count} rowCount={count}
pageSizeOptions={[25]} pageSizeOptions={[10]}
paginationMode={'server'} paginationMode={'server'}
loading={loading} loading={loading}
onPaginationModelChange={(params) => { onPaginationModelChange={(params) => {
@ -360,8 +270,8 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
return ( return (
<> <>
{validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ? {filterItems && Array.isArray( filterItems ) && filterItems.length ?
<CardBox className='mb-6 border border-white/10 shadow-none'> <CardBox>
<Formik <Formik
initialValues={{ initialValues={{
checkboxes: ['lorem'], checkboxes: ['lorem'],
@ -372,14 +282,11 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
> >
<Form> <Form>
<> <>
{validFilterItems && validFilterItems.map((filterItem) => { {filterItems && filterItems.map((filterItem) => {
const showIncompleteHint = isFilterItemIncomplete(filterItem, filters)
const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null
return ( return (
<div key={filterItem.id} className="mb-3 grid gap-3 rounded-2xl border border-white/10 bg-white/5 p-4 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_minmax(0,14rem)]"> <div key={filterItem.id} className="flex mb-4">
<div className="flex min-w-0 flex-col"> <div className="flex flex-col w-full mr-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Filter</div> <div className=" text-gray-500 font-bold">Filter</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='selectedField' name='selectedField'
@ -401,8 +308,8 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
{filters.find((filter) => {filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField filter.title === filterItem?.fields?.selectedField
)?.type === 'enum' ? ( )?.type === 'enum' ? (
<div className="flex min-w-0 flex-col"> <div className="flex flex-col w-full mr-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400"> <div className="text-gray-500 font-bold">
Value Value
</div> </div>
<Field <Field
@ -426,9 +333,9 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
) : filters.find((filter) => ) : filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField filter.title === filterItem?.fields?.selectedField
)?.number ? ( )?.number ? (
<div className="grid min-w-0 gap-3 md:grid-cols-2"> <div className="flex flex-row w-full mr-3">
<div className="flex min-w-0 flex-col"> <div className="flex flex-col w-full mr-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">From</div> <div className=" text-gray-500 font-bold">From</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='filterValueFrom' name='filterValueFrom'
@ -439,7 +346,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
/> />
</div> </div>
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">To</div> <div className=" text-gray-500 font-bold">To</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='filterValueTo' name='filterValueTo'
@ -455,9 +362,9 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
filter.title === filter.title ===
filterItem?.fields?.selectedField filterItem?.fields?.selectedField
)?.date ? ( )?.date ? (
<div className='grid min-w-0 gap-3 md:grid-cols-2'> <div className='flex flex-row w-full mr-3'>
<div className='flex flex-col w-full mr-3'> <div className='flex flex-col w-full mr-3'>
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'> <div className=' text-gray-500 font-bold'>
From From
</div> </div>
<Field <Field
@ -471,7 +378,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
/> />
</div> </div>
<div className='flex flex-col w-full'> <div className='flex flex-col w-full'>
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>To</div> <div className=' text-gray-500 font-bold'>To</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='filterValueTo' name='filterValueTo'
@ -484,8 +391,8 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex min-w-0 flex-col"> <div className="flex flex-col w-full mr-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Contains</div> <div className=" text-gray-500 font-bold">Contains</div>
<Field <Field
className={controlClasses} className={controlClasses}
name='filterValue' name='filterValue'
@ -496,37 +403,31 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
/> />
</div> </div>
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Action</div> <div className=" text-gray-500 font-bold">Action</div>
<BaseButton <BaseButton
className="my-1 w-full md:w-auto" className="my-2"
type='reset' type='reset'
color='whiteDark' color='danger'
outline
label='Delete' label='Delete'
onClick={() => { onClick={() => {
deleteFilter(filterItem.id) deleteFilter(filterItem.id)
}} }}
/> />
{incompleteHint ? (
<p className='rounded-xl border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-100'>
{incompleteHint}
</p>
) : null}
</div> </div>
</div> </div>
) )
})} })}
<div className="flex flex-wrap items-center gap-2"> <div className="flex">
<BaseButton <BaseButton
className="my-1 mr-0" className="my-2 mr-3"
type='submit' color='info' type='submit' color='info'
label='Apply' label='Apply'
onClick={handleSubmit} onClick={handleSubmit}
/> />
<BaseButton <BaseButton
className="my-1" className="my-2"
type='reset' color='whiteDark' outline type='reset' color='info' outline
label='Cancel' label='Cancel'
onClick={handleReset} onClick={handleReset}
/> />
@ -549,22 +450,6 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
{!showGrid && ( {!showGrid && (
<>
<div className='mb-6 grid gap-3 md:grid-cols-4'>
{reservationCalendarOverview.map((item) => (
<CardBox key={item.label} className='rounded-2xl border border-gray-200/80 shadow-none dark:border-dark-700'>
<div className='p-4'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500'>
{item.label}
</p>
<p className='mt-2 text-2xl font-semibold text-gray-900 dark:text-white'>
{item.value}
</p>
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>{item.hint}</p>
</div>
</CardBox>
))}
</div>
<BigCalendar <BigCalendar
events={reservations} events={reservations}
showField={'reservation_code'} showField={'reservation_code'}
@ -577,12 +462,8 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
onDateRangeChange={(range) => { onDateRangeChange={(range) => {
loadData(0,`&calendarStart=${range.start}&calendarEnd=${range.end}`); loadData(0,`&calendarStart=${range.start}&calendarEnd=${range.end}`);
}} }}
isLoading={loading}
emptyTitle='No reservations in this range'
emptyDescription='Adjust the calendar window or add a reservation to start filling the schedule.'
entityName={'reservations'} entityName={'reservations'}
/> />
</>
)} )}

View File

@ -14,7 +14,6 @@ import DataGridMultiSelect from "../DataGridMultiSelect";
import ListActionsPopover from '../ListActionsPopover'; import ListActionsPopover from '../ListActionsPopover';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
import { getRoleLaneFromUser } from "../../helpers/roleLanes";
type Params = (id: string) => void; type Params = (id: string) => void;
@ -39,15 +38,8 @@ export const loadColumns = async (
} }
const hasUpdatePermission = hasPermission(user, 'UPDATE_RESERVATIONS') const hasUpdatePermission = hasPermission(user, 'UPDATE_RESERVATIONS')
const roleLane = getRoleLaneFromUser(user)
const hiddenFieldsByLane = {
super_admin: new Set([]),
admin: new Set(['tenant', 'organization']),
concierge: new Set(['tenant', 'organization', 'nightly_rate', 'monthly_rate', 'currency', 'internal_notes', 'invoices', 'comments']),
customer: new Set(['tenant', 'organization', 'booking_request', 'nightly_rate', 'monthly_rate', 'currency', 'internal_notes', 'invoices', 'comments', 'service_requests']),
}
const columns = [ return [
{ {
field: 'tenant', field: 'tenant',
@ -532,6 +524,4 @@ export const loadColumns = async (
}, },
}, },
]; ];
return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field));
}; };

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}&businessOnly=true${request}&sort=${sort}&field=${field}`; const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`;
dispatch(fetch({ limit: perPage, page, query })); dispatch(fetch({ limit: perPage, page, query }));
}; };

View File

@ -6,5 +6,5 @@ type Props = {
} }
export default function SectionMain({ children }: Props) { export default function SectionMain({ children }: Props) {
return <section className={`px-5 py-6 md:px-8 md:py-8 ${containerMaxW}`}>{children}</section> return <section className={`p-6 ${containerMaxW}`}>{children}</section>
} }

View File

@ -1,4 +1,6 @@
import { mdiCog } from '@mdi/js'
import React, { Children, ReactNode } from 'react' import React, { Children, ReactNode } from 'react'
import BaseButton from './BaseButton'
import BaseIcon from './BaseIcon' import BaseIcon from './BaseIcon'
import IconRounded from './IconRounded' import IconRounded from './IconRounded'
import { humanize } from '../helpers/humanize'; import { humanize } from '../helpers/humanize';
@ -7,35 +9,21 @@ type Props = {
icon: string icon: string
title: string title: string
main?: boolean main?: boolean
subtitle?: string
children?: ReactNode children?: ReactNode
} }
export default function SectionTitleLineWithButton({ export default function SectionTitleLineWithButton({ icon, title, main = false, children }: Props) {
icon, const hasChildren = !!Children.count(children)
title,
main = false,
subtitle,
children,
}: Props) {
const childArray = Children.toArray(children).filter((child) => !(typeof child === 'string' && child.trim() === ''))
const hasChildren = childArray.length > 0
return ( return (
<section <section className={`${main ? '' : 'pt-6'} mb-6 flex items-center justify-between`}>
className={`${main ? '' : 'pt-6'} mb-6 flex flex-col gap-4 md:flex-row md:items-start md:justify-between`} <div className="flex items-center justify-start">
> {icon && main && <IconRounded icon={icon} color="light" className="mr-3" bg />}
<div className="flex min-w-0 items-start justify-start gap-3"> {icon && !main && <BaseIcon path={icon} className="mr-2" size="20" />}
{icon && main && <IconRounded icon={icon} color="light" className="mt-1" bg />} <h1 className={`leading-tight ${main ? 'text-3xl' : 'text-2xl'}`}>{humanize(title)}</h1>
{icon && !main && <BaseIcon path={icon} className="mt-1" size="20" />}
<div className="min-w-0">
<h1 className={`leading-tight ${main ? 'text-3xl' : 'text-2xl'}`}>{humanize(title)}</h1>
{subtitle ? (
<p className="mt-2 max-w-3xl text-sm leading-6 text-gray-500 dark:text-slate-400">{subtitle}</p>
) : null}
</div>
</div> </div>
{hasChildren ? <div className="flex flex-wrap items-center gap-3">{childArray}</div> : null} {children}
{!hasChildren && <BaseButton icon={mdiCog} color="whiteDark" />}
</section> </section>
) )
} }

View File

@ -1,91 +1,38 @@
import React, { useEffect, useId, useMemo, useState } from 'react'; import React, {useEffect, useId, useState} from 'react';
import { AsyncPaginate } from 'react-select-async-paginate'; import { AsyncPaginate } from 'react-select-async-paginate';
import axios from 'axios' import axios from 'axios'
const buildQueryString = (itemRef, queryParams) => { export const SelectField = ({ options, field, form, itemRef, showField, disabled }) => {
const params = new URLSearchParams();
if (itemRef === 'roles') {
params.set('businessOnly', 'true');
}
if (typeof queryParams === 'string') {
const extraParams = new URLSearchParams(queryParams);
extraParams.forEach((value, key) => params.set(key, value));
return params.toString();
}
if (queryParams && typeof queryParams === 'object') {
Object.entries(queryParams).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
params.set(key, String(value));
});
}
return params.toString();
};
export const SelectField = ({ options, field, form, itemRef, showField, disabled, queryParams, placeholder, noOptionsMessage }) => {
const [value, setValue] = useState(null) const [value, setValue] = useState(null)
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
const extraQueryString = useMemo(() => buildQueryString(itemRef, queryParams), [itemRef, queryParams]);
useEffect(() => { useEffect(() => {
if (field?.value?.id) { if(options?.id && field?.value?.id) {
setValue({ value: field.value.id, label: field.value[showField || 'name'] || field.value.label }); setValue({value: field.value?.id, label: field.value[showField]})
form.setFieldValue(field.name, field.value.id); form.setFieldValue(field.name, field.value?.id);
} else if (options?.id) { } else if (!field.value) {
const label = options[showField || 'name'] || options.label || options.firstName || options.email || options.request_code || options.reservation_code || options.summary;
setValue({ value: options.id, label });
if (field?.value !== options.id) {
form.setFieldValue(field.name, options.id);
}
} else if (!field?.value) {
setValue(null); setValue(null);
} }
}, [options, field?.value, form, field?.name, showField]) }, [options?.id, field?.value?.id, field?.value])
const mapResponseToValuesAndLabels = (data) => ({ const mapResponseToValuesAndLabels = (data) => ({
value: data.id, value: data.id,
label: data.label, label: data.label,
}) })
const handleChange = (option) => { const handleChange = (option) => {
form.setFieldValue(field.name, option?.value || null) form.setFieldValue(field.name, option?.value || null)
setValue(option) setValue(option)
} }
const getNoOptionsMessage = ({ inputValue }: { inputValue: string }) => {
if (typeof noOptionsMessage === 'function') {
return noOptionsMessage({ inputValue, itemRef })
}
if (typeof noOptionsMessage === 'string') {
return inputValue ? `No matching results for "${inputValue}"` : noOptionsMessage
}
return inputValue ? `No matching results for "${inputValue}"` : 'No options available yet'
}
async function callApi(inputValue: string, loadedOptions: any[]) { async function callApi(inputValue: string, loadedOptions: any[]) {
const params = new URLSearchParams(extraQueryString); const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
params.set('limit', String(PAGE_SIZE));
params.set('offset', String(loadedOptions.length));
if (inputValue) {
params.set('query', inputValue);
}
const path = `/${itemRef}/autocomplete?${params.toString()}`;
const { data } = await axios(path); const { data } = await axios(path);
return { return {
options: data.map(mapResponseToValuesAndLabels), options: data.map(mapResponseToValuesAndLabels),
hasMore: data.length === PAGE_SIZE, hasMore: data.length === PAGE_SIZE,
} }
} }
return ( return (
<AsyncPaginate <AsyncPaginate
classNames={{ classNames={{
@ -100,8 +47,7 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled
defaultOptions defaultOptions
isDisabled={disabled} isDisabled={disabled}
isClearable isClearable
placeholder={placeholder || 'Select...'}
noOptionsMessage={getNoOptionsMessage}
/> />
) )
} }

View File

@ -1,95 +1,31 @@
import React, { useEffect, useId, useState } from 'react'; import React, {useEffect, useId, useState} from 'react';
import { AsyncPaginate } from 'react-select-async-paginate'; import { AsyncPaginate } from 'react-select-async-paginate';
import axios from 'axios'; import axios from 'axios';
const getOptionLabel = (item: any, showField?: string) => {
if (!item) {
return '';
}
if (showField && item[showField]) {
return item[showField];
}
return (
item.label ||
item.name ||
item.firstName ||
item.email ||
item.action ||
item.title ||
item.request_code ||
item.reservation_code ||
item.file_name ||
item.summary ||
item.reference ||
item.id ||
''
);
};
const toSelectOption = (item: any, showField?: string) => {
if (!item) {
return null;
}
const value = item.value || item.id;
if (!value) {
return null;
}
return {
value,
label: getOptionLabel(item, showField),
};
};
export const SelectFieldMany = ({ options, field, form, itemRef, showField }) => { export const SelectFieldMany = ({ options, field, form, itemRef, showField }) => {
const [value, setValue] = useState([]); const [value, setValue] = useState([]);
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
useEffect(() => { useEffect(() => {
if (!Array.isArray(field?.value) || field.value.length === 0) { if (field.value?.[0] && typeof field.value[0] !== 'string') {
form.setFieldValue(
field.name,
field.value.map((el) => el.id),
);
} else if (!field.value || field.value.length === 0) {
setValue([]); setValue([]);
return;
} }
}, [field.name, field.value, form]);
const nextValue = field.value useEffect(() => {
.map((item) => { if (options) {
if (typeof item === 'string') { setValue(options.map((el) => ({ value: el.id, label: el[showField] })));
const matchingOption = Array.isArray(options) form.setFieldValue(
? options.find((option) => option?.id === item || option?.value === item) field.name,
: null; options.map((el) => ({ value: el.id, label: el[showField] })),
);
return {
value: item,
label: getOptionLabel(matchingOption, showField) || item,
};
}
return toSelectOption(item, showField);
})
.filter(Boolean);
setValue(nextValue);
const normalizedIds = field.value
.map((item) => {
if (typeof item === 'string') {
return item;
}
return item?.id || item?.value || null;
})
.filter(Boolean);
const shouldNormalizeFieldValue = field.value.some((item) => typeof item !== 'string');
if (shouldNormalizeFieldValue) {
form.setFieldValue(field.name, normalizedIds);
} }
}, [field?.name, field?.value, form, options, showField]); }, [options]);
const mapResponseToValuesAndLabels = (data) => ({ const mapResponseToValuesAndLabels = (data) => ({
value: data.id, value: data.id,
@ -97,12 +33,10 @@ export const SelectFieldMany = ({ options, field, form, itemRef, showField }) =>
}); });
const handleChange = (data: any) => { const handleChange = (data: any) => {
const nextValue = Array.isArray(data) ? data : []; setValue(data)
setValue(nextValue);
form.setFieldValue( form.setFieldValue(
field.name, field.name,
nextValue.map((item) => item?.value).filter(Boolean), data.map(el => (el?.value || null)),
); );
}; };
@ -112,23 +46,22 @@ export const SelectFieldMany = ({ options, field, form, itemRef, showField }) =>
return { return {
options: data.map(mapResponseToValuesAndLabels), options: data.map(mapResponseToValuesAndLabels),
hasMore: data.length === PAGE_SIZE, hasMore: data.length === PAGE_SIZE,
}; }
} }
return ( return (
<AsyncPaginate <AsyncPaginate
classNames={{ classNames={{
control: () => 'px-1 py-2', control: () => 'px-1 py-2',
}} }}
classNamePrefix='react-select' classNamePrefix='react-select'
instanceId={useId()} instanceId={useId()}
value={value} value={value}
isMulti isMulti
debounceTimeout={1000} debounceTimeout={1000}
loadOptions={callApi} loadOptions={callApi}
onChange={handleChange} onChange={handleChange}
defaultOptions defaultOptions
isClearable isClearable
/> />
); );
}; };

View File

@ -15,7 +15,6 @@ import {
import {loadColumns} from "./configureService_requestsCols"; import {loadColumns} from "./configureService_requestsCols";
import _ from 'lodash'; import _ from 'lodash';
import dataFormatter from '../../helpers/dataFormatter' import dataFormatter from '../../helpers/dataFormatter'
import { getIncompleteFilterHint, isFilterItemIncomplete, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
import {dataGridStyles} from "../../styles"; import {dataGridStyles} from "../../styles";
@ -84,14 +83,6 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
} }
}, [refetch, dispatch]); }, [refetch, dispatch]);
const validFilterItems = useMemo(() => pruneHiddenFilterItems(filterItems, filters), [filterItems, filters]);
useEffect(() => {
if (validFilterItems !== filterItems) {
setFilterItems(validFilterItems);
}
}, [filterItems, setFilterItems, validFilterItems]);
const [isModalInfoActive, setIsModalInfoActive] = useState(false) const [isModalInfoActive, setIsModalInfoActive] = useState(false)
const [isModalTrashActive, setIsModalTrashActive] = useState(false) const [isModalTrashActive, setIsModalTrashActive] = useState(false)
@ -143,7 +134,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
const generateFilterRequests = useMemo(() => { const generateFilterRequests = useMemo(() => {
let request = '&'; let request = '&';
validFilterItems.forEach((item) => { filterItems.forEach((item) => {
const isRangeFilter = filters.find( const isRangeFilter = filters.find(
(filter) => (filter) =>
filter.title === item.fields.selectedField && filter.title === item.fields.selectedField &&
@ -167,10 +158,10 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
} }
}); });
return request; return request;
}, [filters, validFilterItems]); }, [filterItems, filters]);
const deleteFilter = (value) => { const deleteFilter = (value) => {
const newItems = validFilterItems.filter((item) => item.id !== value); const newItems = filterItems.filter((item) => item.id !== value);
if (newItems.length) { if (newItems.length) {
setFilterItems(newItems); setFilterItems(newItems);
@ -195,7 +186,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
const name = e.target.name; const name = e.target.name;
setFilterItems( setFilterItems(
validFilterItems.map((item) => { filterItems.map((item) => {
if (item.id !== id) return item; if (item.id !== id) return item;
if (name === 'selectedField') return { id, fields: { [name]: value } }; if (name === 'selectedField') return { id, fields: { [name]: value } };
@ -307,7 +298,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
return ( return (
<> <>
{validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ? {filterItems && Array.isArray( filterItems ) && filterItems.length ?
<CardBox> <CardBox>
<Formik <Formik
initialValues={{ initialValues={{
@ -319,13 +310,10 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
> >
<Form> <Form>
<> <>
{validFilterItems && validFilterItems.map((filterItem) => { {filterItems && filterItems.map((filterItem) => {
const showIncompleteHint = isFilterItemIncomplete(filterItem, filters)
const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null
return ( return (
<div key={filterItem.id} className="mb-4 flex flex-col gap-3 rounded-xl border border-white/10 p-3 md:flex-row md:items-end"> <div key={filterItem.id} className="flex mb-4">
<div className="flex w-full flex-col md:mr-3"> <div className="flex flex-col w-full mr-3">
<div className=" text-gray-500 font-bold">Filter</div> <div className=" text-gray-500 font-bold">Filter</div>
<Field <Field
className={controlClasses} className={controlClasses}
@ -348,7 +336,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
{filters.find((filter) => {filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField filter.title === filterItem?.fields?.selectedField
)?.type === 'enum' ? ( )?.type === 'enum' ? (
<div className="flex w-full flex-col md:mr-3"> <div className="flex flex-col w-full mr-3">
<div className="text-gray-500 font-bold"> <div className="text-gray-500 font-bold">
Value Value
</div> </div>
@ -373,8 +361,8 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
) : filters.find((filter) => ) : filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField filter.title === filterItem?.fields?.selectedField
)?.number ? ( )?.number ? (
<div className="flex w-full flex-col gap-3 sm:flex-row md:mr-3"> <div className="flex flex-row w-full mr-3">
<div className="flex w-full flex-col md:mr-3"> <div className="flex flex-col w-full mr-3">
<div className=" text-gray-500 font-bold">From</div> <div className=" text-gray-500 font-bold">From</div>
<Field <Field
className={controlClasses} className={controlClasses}
@ -402,7 +390,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
filter.title === filter.title ===
filterItem?.fields?.selectedField filterItem?.fields?.selectedField
)?.date ? ( )?.date ? (
<div className='flex w-full flex-col gap-3 sm:flex-row md:mr-3'> <div className='flex flex-row w-full mr-3'>
<div className='flex flex-col w-full mr-3'> <div className='flex flex-col w-full mr-3'>
<div className=' text-gray-500 font-bold'> <div className=' text-gray-500 font-bold'>
From From
@ -431,7 +419,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex w-full flex-col md:mr-3"> <div className="flex flex-col w-full mr-3">
<div className=" text-gray-500 font-bold">Contains</div> <div className=" text-gray-500 font-bold">Contains</div>
<Field <Field
className={controlClasses} className={controlClasses}
@ -443,10 +431,10 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
/> />
</div> </div>
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col">
<div className=" text-gray-500 font-bold">Action</div> <div className=" text-gray-500 font-bold">Action</div>
<BaseButton <BaseButton
className="my-1 w-full sm:my-2 sm:w-auto" className="my-2"
type='reset' type='reset'
color='danger' color='danger'
label='Delete' label='Delete'
@ -454,24 +442,19 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
deleteFilter(filterItem.id) deleteFilter(filterItem.id)
}} }}
/> />
{incompleteHint ? (
<p className='rounded-xl border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-100'>
{incompleteHint}
</p>
) : null}
</div> </div>
</div> </div>
) )
})} })}
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex">
<BaseButton <BaseButton
className="my-1 w-full sm:my-2 sm:mr-3 sm:w-auto" className="my-2 mr-3"
type='submit' color='info' type='submit' color='info'
label='Apply' label='Apply'
onClick={handleSubmit} onClick={handleSubmit}
/> />
<BaseButton <BaseButton
className="my-1 w-full sm:my-2 sm:w-auto" className="my-2"
type='reset' color='info' outline type='reset' color='info' outline
label='Cancel' label='Cancel'
onClick={handleReset} onClick={handleReset}

View File

@ -14,7 +14,6 @@ import DataGridMultiSelect from "../DataGridMultiSelect";
import ListActionsPopover from '../ListActionsPopover'; import ListActionsPopover from '../ListActionsPopover';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
import { getRoleLaneFromUser } from "../../helpers/roleLanes";
type Params = (id: string) => void; type Params = (id: string) => void;
@ -39,15 +38,8 @@ export const loadColumns = async (
} }
const hasUpdatePermission = hasPermission(user, 'UPDATE_SERVICE_REQUESTS') const hasUpdatePermission = hasPermission(user, 'UPDATE_SERVICE_REQUESTS')
const roleLane = getRoleLaneFromUser(user)
const hiddenFieldsByLane = {
super_admin: new Set([]),
admin: new Set(['tenant']),
concierge: new Set(['tenant', 'estimated_cost', 'actual_cost', 'currency', 'comments']),
customer: new Set(['tenant', 'requested_by', 'assigned_to', 'estimated_cost', 'actual_cost', 'currency', 'comments']),
}
const columns = [ return [
{ {
field: 'tenant', field: 'tenant',
@ -377,6 +369,4 @@ export const loadColumns = async (
}, },
}, },
]; ];
return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field));
}; };

View File

@ -1,23 +0,0 @@
import React from 'react'
type TenantStatusChipProps = {
isActive?: boolean | null
className?: string
}
const TenantStatusChip = ({ isActive = false, className = '' }: TenantStatusChipProps) => {
const label = isActive ? 'Active' : 'Inactive'
const toneClasses = isActive
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-200'
: 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/70 dark:bg-rose-950/40 dark:text-rose-200'
return (
<span
className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold ${toneClasses} ${className}`.trim()}
>
{label}
</span>
)
}
export default TenantStatusChip

View File

@ -1,22 +1,24 @@
import React from 'react' import React from 'react';
import Link from 'next/link' import ImageField from '../ImageField';
import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks';
import dataFormatter from '../../helpers/dataFormatter';
import { Pagination } from '../Pagination';
import {saveFile} from "../../helpers/fileSaver";
import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
import ListActionsPopover from '../ListActionsPopover'
import { useAppSelector } from '../../stores/hooks'
import dataFormatter from '../../helpers/dataFormatter'
import { Pagination } from '../Pagination'
import LoadingSpinner from '../LoadingSpinner'
import TenantStatusChip from '../TenantStatusChip'
import { hasPermission } from '../../helpers/userPermissions'
type Props = { type Props = {
tenants: any[] tenants: any[];
loading: boolean loading: boolean;
onDelete: (id: string) => void onDelete: (id: string) => void;
currentPage: number currentPage: number;
numPages: number numPages: number;
onPageChange: (page: number) => void onPageChange: (page: number) => void;
} };
const CardTenants = ({ const CardTenants = ({
tenants, tenants,
@ -26,149 +28,192 @@ const CardTenants = ({
numPages, numPages,
onPageChange, onPageChange,
}: Props) => { }: Props) => {
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle) const asideScrollbarsStyle = useAppSelector(
const bgColor = useAppSelector((state) => state.style.cardsColor) (state) => state.style.asideScrollbarsStyle,
const darkMode = useAppSelector((state) => state.style.darkMode) );
const corners = useAppSelector((state) => state.style.corners) const bgColor = useAppSelector((state) => state.style.cardsColor);
const focusRing = useAppSelector((state) => state.style.focusRingColor) const darkMode = useAppSelector((state) => state.style.darkMode);
const corners = useAppSelector((state) => state.style.corners);
const currentUser = useAppSelector((state) => state.auth.currentUser) const focusRing = useAppSelector((state) => state.style.focusRingColor);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TENANTS')
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TENANTS')
return ( return (
<div className='p-4'> <div className={'p-4'}>
{loading && <LoadingSpinner />} {loading && <LoadingSpinner />}
<ul role='list' className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'> <ul
{!loading && role='list'
tenants.map((item) => { className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
const linkedOrganizations = Array.isArray(item.organizations) ? item.organizations : [] >
{!loading && tenants.map((item, index) => (
<li
key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`}
>
<div className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
<Link href={`/tenants/tenants-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
{item.name}
</Link>
return ( <div className='ml-auto '>
<li <ListActionsPopover
key={item.id} onDelete={onDelete}
className={`overflow-hidden ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${ itemId={item.id}
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle pathEdit={`/tenants/tenants-edit/?id=${item.id}`}
}`} pathView={`/tenants/tenants-view/?id=${item.id}`}
>
<div className={`relative border-b border-gray-900/5 bg-gray-50 p-6 dark:bg-dark-800 ${bgColor}`}> hasUpdatePermission={hasUpdatePermission}
<div className='flex items-start gap-4'>
<div className='min-w-0 flex-1'> />
<div className='flex flex-wrap items-center gap-2'> </div>
<span className='rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100'> </div>
Tenant <dl className='divide-y divide-gray-600 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
</span>
<TenantStatusChip isActive={item.is_active} />
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'> <div className='flex justify-between gap-x-4 py-3'>
{linkedOrganizations.length <dt className=' text-gray-500 dark:text-dark-600'>Tenantname</dt>
? `${linkedOrganizations.length} linked organization${linkedOrganizations.length === 1 ? '' : 's'}` <dd className='flex items-start gap-x-2'>
: 'No linked organizations'} <div className='font-medium line-clamp-4'>
</span> { item.name }
</div> </div>
<Link href={`/tenants/tenants-view/?id=${item.id}`} className='mt-3 block line-clamp-2 text-lg font-bold leading-6'>
{item.name || 'Unnamed tenant'}
</Link>
</div>
<div className='ml-auto'>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/tenants/tenants-edit/?id=${item.id}`}
pathView={`/tenants/tenants-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div>
</div>
<dl className='h-64 overflow-y-auto space-y-4 px-6 py-4 text-sm leading-6'>
<div className='flex flex-wrap gap-2'>
{item.slug ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Slug:</span> {item.slug}
</span>
) : null}
{item.primary_domain ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Domain:</span> {item.primary_domain}
</span>
) : null}
{item.timezone ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Timezone:</span> {item.timezone}
</span>
) : null}
{item.default_currency ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Currency:</span> {item.default_currency}
</span>
) : null}
{item.legal_name ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Legal:</span> {item.legal_name}
</span>
) : null}
</div>
<div className='rounded-xl border border-blue-100 bg-blue-50/60 p-4 dark:border-blue-950/60 dark:bg-blue-950/20'>
<dt className='text-xs font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100'>
Connected entities
</dt>
<dd className='mt-3 flex flex-wrap items-center gap-2'>
{linkedOrganizations.length ? (
<>
<span className='rounded-full bg-blue-100 px-2.5 py-1 text-xs font-semibold text-blue-900 dark:bg-blue-950/50 dark:text-blue-100'>
{linkedOrganizations.length} linked
</span>
{linkedOrganizations.map((organization: any) => (
<span
key={organization.id || organization.name}
className='rounded-full bg-white px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'
title={organization.name}
>
{organization.name || 'Unnamed organization'}
</span>
))}
</>
) : (
<div className='font-medium text-gray-500 dark:text-dark-600'>No linked organizations</div>
)}
</dd> </dd>
</div> </div>
{dataFormatter.propertiesManyListFormatter(item.properties).length ? (
<div>
<dt className='text-gray-500 dark:text-dark-600'>Properties</dt> <div className='flex justify-between gap-x-4 py-3'>
<dd className='mt-1 font-medium line-clamp-3'> <dt className=' text-gray-500 dark:text-dark-600'>Tenantslug</dt>
{dataFormatter.propertiesManyListFormatter(item.properties).join(', ')} <dd className='flex items-start gap-x-2'>
</dd> <div className='font-medium line-clamp-4'>
</div> { item.slug }
) : null} </div>
</dd>
</div>
{dataFormatter.audit_logsManyListFormatter(item.audit_logs).length ? (
<div>
<dt className='text-gray-500 dark:text-dark-600'>Audit logs</dt> <div className='flex justify-between gap-x-4 py-3'>
<dd className='mt-1 font-medium line-clamp-3'> <dt className=' text-gray-500 dark:text-dark-600'>Legalname</dt>
{dataFormatter.audit_logsManyListFormatter(item.audit_logs).join(', ')} <dd className='flex items-start gap-x-2'>
</dd> <div className='font-medium line-clamp-4'>
</div> { item.legal_name }
) : null} </div>
</dl> </dd>
</li> </div>
)
})}
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Primarydomain</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.primary_domain }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Timezone</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.timezone }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Defaultcurrency</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.default_currency }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Isactive</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.booleanFormatter(item.is_active) }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Organizations</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.organizationsManyListFormatter(item.organizations).join(', ')}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Properties</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.propertiesManyListFormatter(item.properties).join(', ')}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Auditlogs</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.audit_logsManyListFormatter(item.audit_logs).join(', ')}
</div>
</dd>
</div>
</dl>
</li>
))}
{!loading && tenants.length === 0 && ( {!loading && tenants.length === 0 && (
<div className='col-span-full flex h-40 items-center justify-center'> <div className='col-span-full flex items-center justify-center h-40'>
<p>No data to display</p> <p className=''>No data to display</p>
</div> </div>
)} )}
</ul> </ul>
<div className='my-6 flex items-center justify-center'> <div className={'flex items-center justify-center my-6'}>
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} /> <Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div> </div>
</div> </div>
) );
} };
export default CardTenants export default CardTenants;

View File

@ -1,138 +1,160 @@
import React from 'react' import React from 'react';
import Link from 'next/link' import CardBox from '../CardBox';
import ImageField from '../ImageField';
import dataFormatter from '../../helpers/dataFormatter';
import {saveFile} from "../../helpers/fileSaver";
import ListActionsPopover from "../ListActionsPopover";
import {useAppSelector} from "../../stores/hooks";
import {Pagination} from "../Pagination";
import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
import CardBox from '../CardBox'
import ListActionsPopover from '../ListActionsPopover'
import { useAppSelector } from '../../stores/hooks'
import { Pagination } from '../Pagination'
import LoadingSpinner from '../LoadingSpinner'
import TenantStatusChip from '../TenantStatusChip'
import { hasPermission } from '../../helpers/userPermissions'
type Props = { type Props = {
tenants: any[] tenants: any[];
loading: boolean loading: boolean;
onDelete: (id: string) => void onDelete: (id: string) => void;
currentPage: number currentPage: number;
numPages: number numPages: number;
onPageChange: (page: number) => void onPageChange: (page: number) => void;
} };
const ListTenants = ({ tenants, loading, onDelete, currentPage, numPages, onPageChange }: Props) => { const ListTenants = ({ tenants, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
const currentUser = useAppSelector((state) => state.auth.currentUser)
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TENANTS') const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TENANTS')
const corners = useAppSelector((state) => state.style.corners);
const bgColor = useAppSelector((state) => state.style.cardsColor);
const corners = useAppSelector((state) => state.style.corners)
const bgColor = useAppSelector((state) => state.style.cardsColor)
return ( return (
<> <>
<div className='relative space-y-4 overflow-x-auto p-4'> <div className='relative overflow-x-auto p-4 space-y-4'>
{loading && <LoadingSpinner />} {loading && <LoadingSpinner />}
{!loading && {!loading && tenants.map((item) => (
tenants.map((item) => { <div key={item.id}>
const linkedOrganizations = Array.isArray(item.organizations) ? item.organizations : [] <CardBox hasTable isList className={'rounded shadow-none'}>
<div className={`flex ${bgColor} ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 border border-gray-600 items-center overflow-hidden`}>
<Link
href={`/tenants/tenants-view/?id=${item.id}`}
className={
'flex-1 px-4 py-6 h-24 flex divide-x-2 divide-gray-600 items-center overflow-hidden`}> dark:divide-dark-700 overflow-x-auto'
}
>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Tenantname</p>
<p className={'line-clamp-2'}>{ item.name }</p>
</div>
return (
<div key={item.id}>
<CardBox hasTable isList className='rounded shadow-none'> <div className={'flex-1 px-3'}>
<div <p className={'text-xs text-gray-500 '}>Tenantslug</p>
className={`flex items-start overflow-hidden border border-gray-600 ${bgColor} ${ <p className={'line-clamp-2'}>{ item.slug }</p>
corners !== 'rounded-full' ? corners : 'rounded-3xl' </div>
} dark:bg-dark-900`}
>
<Link href={`/tenants/tenants-view/?id=${item.id}`} className='min-w-0 flex-1 p-4'>
<div className='flex flex-wrap items-center gap-2'>
<span className='rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100'>
Tenant
</span>
<TenantStatusChip isActive={item.is_active} />
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
{linkedOrganizations.length
? `${linkedOrganizations.length} linked organization${linkedOrganizations.length === 1 ? '' : 's'}`
: 'No linked organizations'}
</span>
</div>
<p className='mt-3 line-clamp-2 text-base font-semibold text-gray-900 dark:text-gray-100'>
{item.name || 'Unnamed tenant'}
</p> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Legalname</p>
<p className={'line-clamp-2'}>{ item.legal_name }</p>
</div>
<div className='mt-3 flex flex-wrap gap-2'>
{item.slug ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'> <div className={'flex-1 px-3'}>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Slug:</span> {item.slug} <p className={'text-xs text-gray-500 '}>Primarydomain</p>
</span> <p className={'line-clamp-2'}>{ item.primary_domain }</p>
) : null} </div>
{item.primary_domain ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Domain:</span> {item.primary_domain}
</span>
) : null}
{item.timezone ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Timezone:</span> {item.timezone}
</span>
) : null}
{item.default_currency ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Currency:</span> {item.default_currency}
</span>
) : null}
</div>
<div className='mt-3 rounded-xl border border-blue-100 bg-blue-50/60 p-3 dark:border-blue-950/60 dark:bg-blue-950/20'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100'>
Connected entities <div className={'flex-1 px-3'}>
</p> <p className={'text-xs text-gray-500 '}>Timezone</p>
<div className='mt-2 flex flex-wrap items-center gap-2'> <p className={'line-clamp-2'}>{ item.timezone }</p>
{linkedOrganizations.length ? ( </div>
<>
<span className='rounded-full bg-blue-100 px-2.5 py-1 text-xs font-semibold text-blue-900 dark:bg-blue-950/50 dark:text-blue-100'>
{linkedOrganizations.length} linked
</span>
{linkedOrganizations.map((organization: any) => ( <div className={'flex-1 px-3'}>
<span <p className={'text-xs text-gray-500 '}>Defaultcurrency</p>
key={organization.id || organization.name} <p className={'line-clamp-2'}>{ item.default_currency }</p>
className='rounded-full bg-white px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100' </div>
title={organization.name}
>
{organization.name || 'Unnamed organization'}
</span>
))} <div className={'flex-1 px-3'}>
</> <p className={'text-xs text-gray-500 '}>Isactive</p>
) : ( <p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.is_active) }</p>
<span className='text-sm text-gray-500 dark:text-dark-600'>No linked organizations</span> </div>
)}
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Organizations</p>
<p className={'line-clamp-2'}>{ dataFormatter.organizationsManyListFormatter(item.organizations).join(', ')}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Properties</p>
<p className={'line-clamp-2'}>{ dataFormatter.propertiesManyListFormatter(item.properties).join(', ')}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Auditlogs</p>
<p className={'line-clamp-2'}>{ dataFormatter.audit_logsManyListFormatter(item.audit_logs).join(', ')}</p>
</div>
</Link>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/tenants/tenants-edit/?id=${item.id}`}
pathView={`/tenants/tenants-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div> </div>
</div> </CardBox>
</Link>
<div className='flex shrink-0 items-start p-4'>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/tenants/tenants-edit/?id=${item.id}`}
pathView={`/tenants/tenants-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div> </div>
</CardBox> ))}
</div> {!loading && tenants.length === 0 && (
) <div className='col-span-full flex items-center justify-center h-40'>
})} <p className=''>No data to display</p>
{!loading && tenants.length === 0 && ( </div>
<div className='col-span-full flex h-40 items-center justify-center'> )}
<p>No data to display</p> </div>
</div> <div className={'flex items-center justify-center my-6'}>
)} <Pagination
</div> currentPage={currentPage}
<div className='my-6 flex items-center justify-center'> numPages={numPages}
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} /> setCurrentPage={onPageChange}
</div> />
</> </div>
) </>
} )
};
export default ListTenants export default ListTenants

View File

@ -211,7 +211,7 @@ const TableSampleTenants = ({ filterItems, setFilterItems, filters, showGrid })
<div className='relative overflow-x-auto'> <div className='relative overflow-x-auto'>
<DataGrid <DataGrid
autoHeight autoHeight
rowHeight={72} rowHeight={64}
sx={dataGridStyles} sx={dataGridStyles}
className={'datagrid--table'} className={'datagrid--table'}
getRowClassName={() => `datagrid--row`} getRowClassName={() => `datagrid--row`}

View File

@ -149,9 +149,9 @@ export const loadColumns = async (
{ {
field: 'organizations', field: 'organizations',
headerName: 'Linked organizations', headerName: 'Organizations',
flex: 1.2, flex: 1,
minWidth: 220, minWidth: 120,
filterable: false, filterable: false,
headerClassName: 'datagrid--header', headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell', cellClassName: 'datagrid--cell',
@ -161,42 +161,6 @@ export const loadColumns = async (
type: 'singleSelect', type: 'singleSelect',
valueFormatter: ({ value }) => valueFormatter: ({ value }) =>
dataFormatter.organizationsManyListFormatter(value).join(', '), dataFormatter.organizationsManyListFormatter(value).join(', '),
renderCell: (params: any) => {
const organizations = Array.isArray(params.value) ? params.value : [];
if (!organizations.length) {
return (
<div className='py-2 text-sm text-gray-500 dark:text-dark-600'>
No linked organizations
</div>
);
}
const preview = organizations.slice(0, 2);
const remainingCount = organizations.length - preview.length;
return (
<div className='flex min-w-0 flex-wrap items-center gap-1 py-2'>
<span className='rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-950/40 dark:text-blue-100'>
{organizations.length} linked
</span>
{preview.map((organization: any) => (
<span
key={organization.id || organization.name}
className='max-w-full truncate rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'
title={organization.name}
>
{organization.name}
</span>
))}
{remainingCount > 0 ? (
<span className='rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
+{remainingCount} more
</span>
) : null}
</div>
);
},
renderEditCell: (params) => ( renderEditCell: (params) => (
<DataGridMultiSelect {...params} entityName={'organizations'}/> <DataGridMultiSelect {...params} entityName={'organizations'}/>
), ),

View File

@ -462,7 +462,6 @@ const TableSampleUnit_availability_blocks = ({ filterItems, setFilterItems, filt
onDateRangeChange={(range) => { onDateRangeChange={(range) => {
loadData(0,`&calendarStart=${range.start}&calendarEnd=${range.end}`); loadData(0,`&calendarStart=${range.start}&calendarEnd=${range.end}`);
}} }}
isLoading={loading}
entityName={'unit_availability_blocks'} entityName={'unit_availability_blocks'}
/> />
)} )}

View File

@ -1,12 +1,15 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import ImageField from '../ImageField';
import CardBox from '../CardBox';
import ListActionsPopover from '../ListActionsPopover'; import ListActionsPopover from '../ListActionsPopover';
import { Pagination } from '../Pagination';
import LoadingSpinner from '../LoadingSpinner';
import { useAppSelector } from '../../stores/hooks'; import { useAppSelector } from '../../stores/hooks';
import dataFormatter from '../../helpers/dataFormatter'; import dataFormatter from '../../helpers/dataFormatter';
import { hasPermission } from '../../helpers/userPermissions'; import { Pagination } from '../Pagination';
import {saveFile} from "../../helpers/fileSaver";
import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
type Props = { type Props = {
units: any[]; units: any[];
@ -17,121 +20,176 @@ type Props = {
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
}; };
const statusTone = (status?: string) => { const CardUnits = ({
switch (status) { units,
case 'available': loading,
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300'; onDelete,
case 'occupied': currentPage,
case 'reserved': numPages,
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-300'; onPageChange,
case 'maintenance': }: Props) => {
case 'out_of_service': const asideScrollbarsStyle = useAppSelector(
return 'bg-rose-100 text-rose-700 dark:bg-rose-500/10 dark:text-rose-300'; (state) => state.style.asideScrollbarsStyle,
default: );
return 'bg-gray-100 text-gray-700 dark:bg-slate-700 dark:text-slate-200'; const bgColor = useAppSelector((state) => state.style.cardsColor);
} const darkMode = useAppSelector((state) => state.style.darkMode);
}; const corners = useAppSelector((state) => state.style.corners);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const CardUnits = ({ units, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
const currentUser = useAppSelector((state) => state.auth.currentUser); const currentUser = useAppSelector((state) => state.auth.currentUser);
const corners = useAppSelector((state) => state.style.corners); const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_UNITS')
const bgColor = useAppSelector((state) => state.style.cardsColor);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_UNITS');
const cardRadius = corners !== 'rounded-full' ? corners : 'rounded-3xl';
return ( return (
<> <div className={'p-4'}>
<div className='p-4 space-y-4'> {loading && <LoadingSpinner />}
{loading && <LoadingSpinner />} <ul
role='list'
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
>
{!loading && units.map((item, index) => (
<li
key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`}
>
<div className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
<Link href={`/units/units-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
{item.unit_number}
</Link>
{!loading && <div className='ml-auto '>
units.map((item) => { <ListActionsPopover
const property = dataFormatter.propertiesOneListFormatter(item.property) || 'Unassigned property'; onDelete={onDelete}
const unitType = dataFormatter.unit_typesOneListFormatter(item.unit_type) || 'No unit type'; itemId={item.id}
const availabilityCount = Array.isArray(item.availability_blocks) pathEdit={`/units/units-edit/?id=${item.id}`}
? item.availability_blocks.length pathView={`/units/units-view/?id=${item.id}`}
: 0;
hasUpdatePermission={hasUpdatePermission}
return (
<CardBox key={item.id} className='shadow-none'> />
<div </div>
className={`border border-gray-200 dark:border-dark-700 ${cardRadius} ${bgColor} p-5`} </div>
> <dl className='divide-y divide-gray-600 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
<div className='flex flex-col gap-4 md:flex-row md:items-start'>
<div className='min-w-0 flex-1'>
<div className='flex flex-wrap items-center gap-3'> <div className='flex justify-between gap-x-4 py-3'>
<Link <dt className=' text-gray-500 dark:text-dark-600'>Property</dt>
href={`/units/units-view/?id=${item.id}`} <dd className='flex items-start gap-x-2'>
className='text-lg font-semibold leading-6 text-gray-900 dark:text-white' <div className='font-medium line-clamp-4'>
> { dataFormatter.propertiesOneListFormatter(item.property) }
{item.unit_number || 'Untitled unit'} </div>
</Link> </dd>
<span
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold capitalize ${statusTone(
item.status,
)}`}
>
{item.status || 'unknown'}
</span>
</div>
<p className='mt-1 text-sm text-gray-500 dark:text-dark-600'>
{property} {unitType}
</p>
{item.notes && (
<p className='mt-3 line-clamp-2 text-sm text-gray-600 dark:text-slate-300'>
{item.notes}
</p>
)}
</div>
<div className='self-start'>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/units/units-edit/?id=${item.id}`}
pathView={`/units/units-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div>
<div className='mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<div>
<p className='text-xs uppercase tracking-wide text-gray-500'>Floor</p>
<p className='mt-1 text-sm font-medium'>{item.floor || '—'}</p>
</div>
<div>
<p className='text-xs uppercase tracking-wide text-gray-500'>Max occupancy</p>
<p className='mt-1 text-sm font-medium'>
{item.max_occupancy_override ?? 'Default'}
</p>
</div>
<div>
<p className='text-xs uppercase tracking-wide text-gray-500'>Availability blocks</p>
<p className='mt-1 text-sm font-medium'>{availabilityCount}</p>
</div>
<div>
<p className='text-xs uppercase tracking-wide text-gray-500'>Type</p>
<p className='mt-1 text-sm font-medium'>{unitType}</p>
</div>
</div>
</div> </div>
</CardBox>
);
})}
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Unittype</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.unit_typesOneListFormatter(item.unit_type) }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Unitnumber</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.unit_number }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Floor</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.floor }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Status</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.status }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Maxoccupancyoverride</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.max_occupancy_override }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Notes</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.notes }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Availabilityblocks</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.unit_availability_blocksManyListFormatter(item.availability_blocks).join(', ')}
</div>
</dd>
</div>
</dl>
</li>
))}
{!loading && units.length === 0 && ( {!loading && units.length === 0 && (
<div className='flex h-40 items-center justify-center'> <div className='col-span-full flex items-center justify-center h-40'>
<p>No data to display</p> <p className=''>No data to display</p>
</div> </div>
)} )}
</ul>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div> </div>
</div>
<div className='my-6 flex items-center justify-center'>
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
</div>
</>
); );
}; };
export default CardUnits export default CardUnits;

View File

@ -16,16 +16,10 @@ import {loadColumns} from "./configureUnitsCols";
import _ from 'lodash'; import _ from 'lodash';
import dataFormatter from '../../helpers/dataFormatter' import dataFormatter from '../../helpers/dataFormatter'
import {dataGridStyles} from "../../styles"; import {dataGridStyles} from "../../styles";
import CardUnits from './CardUnits';
const perPage = 10
const compactColumnVisibilityModel = {
max_occupancy_override: false,
notes: false,
availability_blocks: false,
}
const perPage = 10
const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) => { const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) => {
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"}); const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
@ -217,7 +211,7 @@ const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) =>
<div className='relative overflow-x-auto'> <div className='relative overflow-x-auto'>
<DataGrid <DataGrid
autoHeight autoHeight
rowHeight={56} rowHeight={64}
sx={dataGridStyles} sx={dataGridStyles}
className={'datagrid--table'} className={'datagrid--table'}
getRowClassName={() => `datagrid--row`} getRowClassName={() => `datagrid--row`}
@ -229,9 +223,6 @@ const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) =>
pageSize: 10, pageSize: 10,
}, },
}, },
columns: {
columnVisibilityModel: compactColumnVisibilityModel,
},
}} }}
disableRowSelectionOnClick disableRowSelectionOnClick
onProcessRowUpdateError={(params) => { onProcessRowUpdateError={(params) => {
@ -448,19 +439,11 @@ const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) =>
<p>Are you sure you want to delete this item?</p> <p>Are you sure you want to delete this item?</p>
</CardBoxModal> </CardBoxModal>
{dataGrid}
{!showGrid && (
<CardUnits
units={units ?? []}
loading={loading}
onDelete={handleDeleteModalAction}
currentPage={currentPage}
numPages={numPages}
onPageChange={onPageChange}
/>
)}
{showGrid && dataGrid}
{selectedRows.length > 0 && {selectedRows.length > 0 &&
createPortal( createPortal(

View File

@ -9,7 +9,6 @@ import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link'; import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
import { canManagePlatformUserFields, canManageUserRecord } from '../../helpers/manageableUsers';
type Props = { type Props = {
@ -39,7 +38,7 @@ const CardUsers = ({
const currentUser = useAppSelector((state) => state.auth.currentUser); const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_USERS') const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_USERS')
const canManagePlatformFields = canManagePlatformUserFields(currentUser)
return ( return (
<div className={'p-4'}> <div className={'p-4'}>
@ -48,10 +47,7 @@ const CardUsers = ({
role='list' role='list'
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8' className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
> >
{!loading && users.map((item) => { {!loading && users.map((item, index) => (
const canManageUser = hasUpdatePermission && canManageUserRecord(currentUser, item)
return (
<li <li
key={item.id} key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${ className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
@ -82,7 +78,7 @@ const CardUsers = ({
pathEdit={`/users/users-edit/?id=${item.id}`} pathEdit={`/users/users-edit/?id=${item.id}`}
pathView={`/users/users-view/?id=${item.id}`} pathView={`/users/users-view/?id=${item.id}`}
hasUpdatePermission={canManageUser} hasUpdatePermission={hasUpdatePermission}
/> />
</div> </div>
@ -194,7 +190,7 @@ const CardUsers = ({
<dt className=' text-gray-500 dark:text-dark-600'>Organizations</dt> <dt className=' text-gray-500 dark:text-dark-600'>Organizations</dt>
<dd className='flex items-start gap-x-2'> <dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'> <div className='font-medium line-clamp-4'>
{ canManagePlatformFields ? dataFormatter.organizationsOneListFormatter(item.organizations) : 'Pinned to your workspace' } { dataFormatter.organizationsOneListFormatter(item.organizations) }
</div> </div>
</dd> </dd>
</div> </div>
@ -203,8 +199,7 @@ const CardUsers = ({
</dl> </dl>
</li> </li>
) ))}
})}
{!loading && users.length === 0 && ( {!loading && users.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'> <div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p> <p className=''>No data to display</p>

View File

@ -10,7 +10,6 @@ import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link'; import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions"; import {hasPermission} from "../../helpers/userPermissions";
import { canManagePlatformUserFields, canManageUserRecord } from '../../helpers/manageableUsers';
type Props = { type Props = {
@ -26,8 +25,7 @@ const ListUsers = ({ users, loading, onDelete, currentPage, numPages, onPageChan
const currentUser = useAppSelector((state) => state.auth.currentUser); const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_USERS') const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_USERS')
const canManagePlatformFields = canManagePlatformUserFields(currentUser)
const corners = useAppSelector((state) => state.style.corners); const corners = useAppSelector((state) => state.style.corners);
const bgColor = useAppSelector((state) => state.style.cardsColor); const bgColor = useAppSelector((state) => state.style.cardsColor);
@ -36,10 +34,7 @@ const ListUsers = ({ users, loading, onDelete, currentPage, numPages, onPageChan
<> <>
<div className='relative overflow-x-auto p-4 space-y-4'> <div className='relative overflow-x-auto p-4 space-y-4'>
{loading && <LoadingSpinner />} {loading && <LoadingSpinner />}
{!loading && users.map((item) => { {!loading && users.map((item) => (
const canManageUser = hasUpdatePermission && canManageUserRecord(currentUser, item)
return (
<div key={item.id}> <div key={item.id}>
<CardBox hasTable isList className={'rounded shadow-none'}> <CardBox hasTable isList className={'rounded shadow-none'}>
<div className={`flex ${bgColor} ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 border border-gray-600 items-center overflow-hidden`}> <div className={`flex ${bgColor} ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 border border-gray-600 items-center overflow-hidden`}>
@ -121,7 +116,7 @@ const ListUsers = ({ users, loading, onDelete, currentPage, numPages, onPageChan
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Custom Permissions</p> <p className={'text-xs text-gray-500 '}>Custom Permissions</p>
<p className={'line-clamp-2'}>{canManagePlatformFields ? dataFormatter.permissionsManyListFormatter(item.custom_permissions).join(', ') : '—'}</p> <p className={'line-clamp-2'}>{ dataFormatter.permissionsManyListFormatter(item.custom_permissions).join(', ')}</p>
</div> </div>
@ -129,7 +124,7 @@ const ListUsers = ({ users, loading, onDelete, currentPage, numPages, onPageChan
<div className={'flex-1 px-3'}> <div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Organizations</p> <p className={'text-xs text-gray-500 '}>Organizations</p>
<p className={'line-clamp-2'}>{canManagePlatformFields ? dataFormatter.organizationsOneListFormatter(item.organizations) : 'Pinned to your workspace'}</p> <p className={'line-clamp-2'}>{ dataFormatter.organizationsOneListFormatter(item.organizations) }</p>
</div> </div>
@ -141,14 +136,13 @@ const ListUsers = ({ users, loading, onDelete, currentPage, numPages, onPageChan
pathEdit={`/users/users-edit/?id=${item.id}`} pathEdit={`/users/users-edit/?id=${item.id}`}
pathView={`/users/users-view/?id=${item.id}`} pathView={`/users/users-view/?id=${item.id}`}
hasUpdatePermission={canManageUser} hasUpdatePermission={hasUpdatePermission}
/> />
</div> </div>
</CardBox> </CardBox>
</div> </div>
) ))}
})}
{!loading && users.length === 0 && ( {!loading && users.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'> <div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p> <p className=''>No data to display</p>

View File

@ -16,7 +16,6 @@ import {loadColumns} from "./configureUsersCols";
import _ from 'lodash'; import _ from 'lodash';
import dataFormatter from '../../helpers/dataFormatter' import dataFormatter from '../../helpers/dataFormatter'
import {dataGridStyles} from "../../styles"; import {dataGridStyles} from "../../styles";
import { canManageUserRecord } from '../../helpers/manageableUsers'
@ -198,25 +197,8 @@ const TableSampleUsers = ({ filterItems, setFilterItems, filters, showGrid }) =>
}; };
const onDeleteRows = async (selectedRows) => { const onDeleteRows = async (selectedRows) => {
const manageableRowIds = selectedRows.filter((selectedRowId) => await dispatch(deleteItemsByIds(selectedRows));
canManageUserRecord(
currentUser,
users.find((user) => user.id === selectedRowId),
),
);
if (!manageableRowIds.length) {
notify('warning', 'Only customer and concierge accounts in your workspace can be deleted here.');
return;
}
if (manageableRowIds.length !== selectedRows.length) {
notify('warning', 'Protected users were skipped from bulk delete.');
}
await dispatch(deleteItemsByIds(manageableRowIds));
await loadData(0); await loadData(0);
setSelectedRows([]);
}; };
const controlClasses = const controlClasses =
@ -225,7 +207,6 @@ const TableSampleUsers = ({ filterItems, setFilterItems, filters, showGrid }) =>
'dark:bg-slate-800 border'; 'dark:bg-slate-800 border';
const dataGrid = ( const dataGrid = (
<div id="usersTable" className='relative overflow-x-auto'> <div id="usersTable" className='relative overflow-x-auto'>
<DataGrid <DataGrid
@ -233,7 +214,7 @@ const TableSampleUsers = ({ filterItems, setFilterItems, filters, showGrid }) =>
rowHeight={64} rowHeight={64}
sx={dataGridStyles} sx={dataGridStyles}
className={'datagrid--table'} className={'datagrid--table'}
getRowClassName={(params) => `datagrid--row ${canManageUserRecord(currentUser, params.row) ? '' : 'opacity-60'}`} getRowClassName={() => `datagrid--row`}
rows={users ?? []} rows={users ?? []}
columns={columns} columns={columns}
initialState={{ initialState={{
@ -244,17 +225,10 @@ const TableSampleUsers = ({ filterItems, setFilterItems, filters, showGrid }) =>
}, },
}} }}
disableRowSelectionOnClick disableRowSelectionOnClick
isRowSelectable={(params) => canManageUserRecord(currentUser, params.row)}
isCellEditable={(params) => Boolean(params.colDef.editable) && canManageUserRecord(currentUser, params.row)}
onProcessRowUpdateError={(params) => { onProcessRowUpdateError={(params) => {
console.error('Users grid update error:', params); console.log('Error', params);
}} }}
processRowUpdate={async (newRow, oldRow) => { processRowUpdate={async (newRow, oldRow) => {
if (!canManageUserRecord(currentUser, oldRow)) {
notify('warning', 'That account is protected and cannot be changed from this workspace.');
return oldRow;
}
const data = dataFormatter.dataGridEditFormatter(newRow); const data = dataFormatter.dataGridEditFormatter(newRow);
try { try {

Some files were not shown because too many files have changed in this diff Show More