Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8514d4078a | ||
|
|
beda38dd14 | ||
|
|
eaaae3e8f7 | ||
|
|
716d1e45e3 | ||
|
|
77b3bcf3a6 | ||
|
|
d8c8294cc5 | ||
|
|
95c088fa21 | ||
|
|
33f59460fd | ||
|
|
b4ba3c2646 | ||
|
|
9ca9df3974 | ||
|
|
77754d1430 | ||
|
|
d6ec6bb83a |
0
.perm_test_apache
Normal file
0
.perm_test_apache
Normal file
0
.perm_test_exec
Normal file
0
.perm_test_exec
Normal file
@ -38,7 +38,8 @@
|
|||||||
"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"
|
||||||
|
|||||||
@ -51,15 +51,11 @@ const config = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
roles: {
|
roles: {
|
||||||
|
|
||||||
super_admin: 'Super Administrator',
|
super_admin: 'Super Administrator',
|
||||||
|
|
||||||
admin: 'Administrator',
|
admin: 'Administrator',
|
||||||
|
concierge: 'Concierge Coordinator',
|
||||||
|
customer: 'Customer',
|
||||||
|
user: 'Customer',
|
||||||
user: 'Concierge Coordinator',
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
project_uuid: '946cafba-a21f-40cd-bb6a-bc40952c93f4',
|
project_uuid: '946cafba-a21f-40cd-bb6a-bc40952c93f4',
|
||||||
|
|||||||
@ -1,14 +1,70 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
const config = require('../../config');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
|
|
||||||
|
function isCustomerUser(currentUser) {
|
||||||
|
return currentUser?.app_role?.name === config.roles.customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManageInternalBookingRequestFields(currentUser) {
|
||||||
|
return Boolean(currentUser?.app_role?.globalAccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRequestCode() {
|
||||||
|
const dateSegment = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
const randomSegment = crypto.randomBytes(2).toString('hex').toUpperCase();
|
||||||
|
|
||||||
|
return `BR-${dateSegment}-${randomSegment}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRequestCode(data, currentUser) {
|
||||||
|
if (canManageInternalBookingRequestFields(currentUser) && data.request_code) {
|
||||||
|
return data.request_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateRequestCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStatusForCreate(data, currentUser) {
|
||||||
|
if (canManageInternalBookingRequestFields(currentUser)) {
|
||||||
|
return data.status || 'draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'submitted';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOrganizationId(data, currentUser) {
|
||||||
|
if (canManageInternalBookingRequestFields(currentUser)) {
|
||||||
|
return data.organization || currentUser.organization?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentUser.organization?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRequestedById(data, currentUser) {
|
||||||
|
if (canManageInternalBookingRequestFields(currentUser)) {
|
||||||
|
return data.requested_by || currentUser.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentUser.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeWhereWithScope(where, scope) {
|
||||||
|
if (!scope) {
|
||||||
|
return where;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
[Op.and]: [where, scope],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = class Booking_requestsDBApi {
|
module.exports = class Booking_requestsDBApi {
|
||||||
|
|
||||||
|
|
||||||
@ -21,15 +77,9 @@ module.exports = class Booking_requestsDBApi {
|
|||||||
{
|
{
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
|
|
||||||
request_code: data.request_code
|
request_code: resolveRequestCode(data, currentUser),
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
status: data.status
|
status: resolveStatusForCreate(data, currentUser),
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
check_in_at: data.check_in_at
|
check_in_at: data.check_in_at
|
||||||
||
|
||
|
||||||
@ -84,15 +134,15 @@ module.exports = class Booking_requestsDBApi {
|
|||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
await booking_requests.setTenant( data.tenant || null, {
|
await booking_requests.setTenant( canManageInternalBookingRequestFields(currentUser) ? data.tenant || null : null, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
await booking_requests.setOrganization(currentUser.organization.id || null, {
|
await booking_requests.setOrganization(resolveOrganizationId(data, currentUser), {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
await booking_requests.setRequested_by( data.requested_by || null, {
|
await booking_requests.setRequested_by(resolveRequestedById(data, currentUser), {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -211,18 +261,22 @@ module.exports = class Booking_requestsDBApi {
|
|||||||
const currentUser = (options && options.currentUser) || {id: null};
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
const globalAccess = currentUser.app_role?.globalAccess;
|
const globalAccess = currentUser.app_role?.globalAccess;
|
||||||
|
const canManageInternalFields = canManageInternalBookingRequestFields(currentUser);
|
||||||
|
|
||||||
const booking_requests = await db.booking_requests.findByPk(id, {}, {transaction});
|
const booking_requests = await db.booking_requests.findOne({
|
||||||
|
where: mergeWhereWithScope({ id }, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const updatePayload = {};
|
const updatePayload = {};
|
||||||
|
|
||||||
if (data.request_code !== undefined) updatePayload.request_code = data.request_code;
|
if (data.request_code !== undefined && canManageInternalFields) updatePayload.request_code = data.request_code;
|
||||||
|
|
||||||
|
|
||||||
if (data.status !== undefined) updatePayload.status = data.status;
|
if (data.status !== undefined && canManageInternalFields) updatePayload.status = data.status;
|
||||||
|
|
||||||
|
|
||||||
if (data.check_in_at !== undefined) updatePayload.check_in_at = data.check_in_at;
|
if (data.check_in_at !== undefined) updatePayload.check_in_at = data.check_in_at;
|
||||||
@ -258,7 +312,7 @@ module.exports = class Booking_requestsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (data.tenant !== undefined) {
|
if (data.tenant !== undefined && canManageInternalFields) {
|
||||||
await booking_requests.setTenant(
|
await booking_requests.setTenant(
|
||||||
|
|
||||||
data.tenant,
|
data.tenant,
|
||||||
@ -276,7 +330,7 @@ module.exports = class Booking_requestsDBApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.requested_by !== undefined) {
|
if (data.requested_by !== undefined && canManageInternalFields) {
|
||||||
await booking_requests.setRequested_by(
|
await booking_requests.setRequested_by(
|
||||||
|
|
||||||
data.requested_by,
|
data.requested_by,
|
||||||
@ -333,11 +387,11 @@ module.exports = class Booking_requestsDBApi {
|
|||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const booking_requests = await db.booking_requests.findAll({
|
const booking_requests = await db.booking_requests.findAll({
|
||||||
where: {
|
where: mergeWhereWithScope({
|
||||||
id: {
|
id: {
|
||||||
[Op.in]: ids,
|
[Op.in]: ids,
|
||||||
},
|
},
|
||||||
},
|
}, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -361,7 +415,14 @@ module.exports = class Booking_requestsDBApi {
|
|||||||
const currentUser = (options && options.currentUser) || {id: null};
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const booking_requests = await db.booking_requests.findByPk(id, options);
|
const booking_requests = await db.booking_requests.findOne({
|
||||||
|
where: mergeWhereWithScope({ id }, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking_requests) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
await booking_requests.update({
|
await booking_requests.update({
|
||||||
deletedBy: currentUser.id
|
deletedBy: currentUser.id
|
||||||
@ -378,11 +439,12 @@ module.exports = class Booking_requestsDBApi {
|
|||||||
|
|
||||||
static async findBy(where, options) {
|
static async findBy(where, options) {
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const currentUser = (options && options.currentUser) || null;
|
||||||
|
|
||||||
const booking_requests = await db.booking_requests.findOne(
|
const booking_requests = await db.booking_requests.findOne({
|
||||||
{ where },
|
where: mergeWhereWithScope(where, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
|
||||||
{ transaction },
|
transaction,
|
||||||
);
|
});
|
||||||
|
|
||||||
if (!booking_requests) {
|
if (!booking_requests) {
|
||||||
return booking_requests;
|
return booking_requests;
|
||||||
@ -502,21 +564,23 @@ 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.organizationsId = options.currentUser.organizationsId;
|
where.organizationId = 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 = [
|
||||||
|
|
||||||
@ -972,7 +1036,7 @@ module.exports = class Booking_requestsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
if (globalAccess) {
|
if (globalAccess) {
|
||||||
delete where.organizationsId;
|
delete where.organizationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1005,9 +1069,13 @@ module.exports = class Booking_requestsDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, currentUser) {
|
||||||
let where = {};
|
let where = {};
|
||||||
|
|
||||||
|
if (isCustomerUser(currentUser)) {
|
||||||
|
where.requested_byId = currentUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!globalAccess && organizationId) {
|
if (!globalAccess && organizationId) {
|
||||||
where.organizationId = organizationId;
|
where.organizationId = organizationId;
|
||||||
@ -1016,6 +1084,9 @@ module.exports = class Booking_requestsDBApi {
|
|||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
where = {
|
||||||
|
[Op.and]: [
|
||||||
|
where,
|
||||||
|
{
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
Utils.ilike(
|
Utils.ilike(
|
||||||
@ -1024,6 +1095,8 @@ module.exports = class Booking_requestsDBApi {
|
|||||||
query,
|
query,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,47 @@
|
|||||||
|
|
||||||
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 {
|
||||||
|
|
||||||
|
|
||||||
@ -176,7 +209,10 @@ 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.findByPk(id, {}, {transaction});
|
const documents = await db.documents.findOne({
|
||||||
|
where: mergeWhereWithScope({ id }, buildCustomerDocumentScope(currentUser)),
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -290,11 +326,11 @@ module.exports = class DocumentsDBApi {
|
|||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const documents = await db.documents.findAll({
|
const documents = await db.documents.findAll({
|
||||||
where: {
|
where: mergeWhereWithScope({
|
||||||
id: {
|
id: {
|
||||||
[Op.in]: ids,
|
[Op.in]: ids,
|
||||||
},
|
},
|
||||||
},
|
}, buildCustomerDocumentScope(currentUser)),
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -318,7 +354,10 @@ 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.findByPk(id, options);
|
const documents = await db.documents.findOne({
|
||||||
|
where: mergeWhereWithScope({ id }, buildCustomerDocumentScope(currentUser)),
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
await documents.update({
|
await documents.update({
|
||||||
deletedBy: currentUser.id
|
deletedBy: currentUser.id
|
||||||
@ -335,9 +374,10 @@ 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 },
|
{ where: mergeWhereWithScope(where, buildCustomerDocumentScope(currentUser)) },
|
||||||
{ transaction },
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -441,10 +481,6 @@ module.exports = class DocumentsDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -735,6 +771,7 @@ module.exports = class DocumentsDBApi {
|
|||||||
delete where.organizationsId;
|
delete where.organizationsId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
where = mergeWhereWithScope(where, buildCustomerDocumentScope(user));
|
||||||
|
|
||||||
const queryOptions = {
|
const queryOptions = {
|
||||||
where,
|
where,
|
||||||
@ -765,7 +802,7 @@ module.exports = class DocumentsDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, currentUser = null) {
|
||||||
let where = {};
|
let where = {};
|
||||||
|
|
||||||
|
|
||||||
@ -787,6 +824,8 @@ 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,
|
||||||
|
|||||||
398
backend/src/db/api/inventoryContext.js
Normal file
398
backend/src/db/api/inventoryContext.js
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
@ -1,10 +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');
|
||||||
|
const { resolveOrganizationIdsForTenant } = require('./inventoryContext');
|
||||||
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
@ -74,8 +71,6 @@ 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});
|
||||||
|
|
||||||
|
|
||||||
@ -322,9 +317,6 @@ module.exports = class OrganizationsDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
@ -431,17 +423,30 @@ module.exports = class OrganizationsDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, tenantId, options = {}) {
|
||||||
let where = {};
|
const filters = [];
|
||||||
|
const organizationIdsForTenant = tenantId
|
||||||
|
? await resolveOrganizationIdsForTenant(Utils.uuid(tenantId), options.transaction)
|
||||||
|
: [];
|
||||||
|
|
||||||
if (!globalAccess && organizationId) {
|
if (!globalAccess && organizationId) {
|
||||||
where.organizationId = organizationId;
|
filters.push({ id: organizationId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
if (!organizationIdsForTenant.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.push({
|
||||||
|
id: {
|
||||||
|
[Op.in]: organizationIdsForTenant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
filters.push({
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
Utils.ilike(
|
Utils.ilike(
|
||||||
@ -450,15 +455,19 @@ 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,
|
||||||
orderBy: [['name', 'ASC']],
|
order: [['name', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -16,6 +14,17 @@ 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(
|
||||||
{
|
{
|
||||||
@ -70,11 +79,11 @@ module.exports = class PropertiesDBApi {
|
|||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
await properties.setTenant( data.tenant || null, {
|
await properties.setTenant( tenantId || null, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
await properties.setOrganizations( data.organizations || null, {
|
await properties.setOrganizations( organizationId || null, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -112,83 +121,95 @@ 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 = [];
|
||||||
|
|
||||||
// Prepare data - wrapping individual data transformations in a map() method
|
for (let index = 0; index < data.length; index += 1) {
|
||||||
const propertiesData = data.map((item, index) => ({
|
const item = normalizeInventoryImportRow(data[index]);
|
||||||
id: item.id || undefined,
|
const rowNumber = index + 2;
|
||||||
|
|
||||||
name: item.name
|
try {
|
||||||
||
|
const globalAccess = Boolean(currentUser.app_role?.globalAccess);
|
||||||
null
|
let tenantId = null;
|
||||||
,
|
let organizationId = null;
|
||||||
|
|
||||||
code: item.code
|
if (globalAccess) {
|
||||||
||
|
tenantId = await resolveTenantReference(item.tenant, transaction);
|
||||||
null
|
organizationId = await resolveOrganizationReference(item.organizations, {
|
||||||
,
|
tenantId,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
address: item.address
|
if (!organizationId) {
|
||||||
||
|
throw createBadRequestError('Organization is required for property import.');
|
||||||
null
|
}
|
||||||
,
|
} else {
|
||||||
|
organizationId = getCurrentOrganizationId(currentUser);
|
||||||
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),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Bulk create items
|
|
||||||
const properties = await db.properties.bulkCreate(propertiesData, { transaction });
|
|
||||||
|
|
||||||
// For each item created, replace relation files
|
|
||||||
|
|
||||||
for (let i = 0; i < properties.length; i++) {
|
|
||||||
await FileDBApi.replaceRelationFiles(
|
|
||||||
{
|
|
||||||
belongsTo: db.properties.getTableName(),
|
|
||||||
belongsToColumn: 'images',
|
|
||||||
belongsToId: properties[i].id,
|
|
||||||
},
|
|
||||||
data[i].images,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!tenantId && organizationId) {
|
||||||
|
tenantId = await resolveTenantIdForOrganization(organizationId, transaction);
|
||||||
|
}
|
||||||
|
|
||||||
return properties;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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});
|
||||||
|
|
||||||
@ -451,10 +472,6 @@ module.exports = class PropertiesDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -763,17 +780,23 @@ module.exports = class PropertiesDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, tenantId, selectedOrganizationId) {
|
||||||
let where = {};
|
const filters = [];
|
||||||
|
|
||||||
|
|
||||||
if (!globalAccess && organizationId) {
|
if (!globalAccess && organizationId) {
|
||||||
where.organizationId = organizationId;
|
filters.push({ organizationsId: organizationId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
filters.push({ tenantId: Utils.uuid(tenantId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedOrganizationId) {
|
||||||
|
filters.push({ organizationsId: Utils.uuid(selectedOrganizationId) });
|
||||||
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
filters.push({
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
Utils.ilike(
|
Utils.ilike(
|
||||||
@ -782,15 +805,19 @@ 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,
|
||||||
orderBy: [['name', 'ASC']],
|
order: [['name', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
|
|||||||
@ -1,14 +1,36 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
const config = require('../../config');
|
||||||
|
|
||||||
|
|
||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
|
|
||||||
|
function isCustomerUser(currentUser) {
|
||||||
|
return currentUser?.app_role?.name === config.roles.customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function customerCanAccessReservation(reservation, currentUser, transaction) {
|
||||||
|
if (!reservation || !isCustomerUser(currentUser)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reservation.booking_requestId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingRequest = await db.booking_requests.findOne({
|
||||||
|
where: {
|
||||||
|
id: reservation.booking_requestId,
|
||||||
|
requested_byId: currentUser.id,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Boolean(bookingRequest);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = class ReservationsDBApi {
|
module.exports = class ReservationsDBApi {
|
||||||
|
|
||||||
|
|
||||||
@ -442,16 +464,18 @@ module.exports = class ReservationsDBApi {
|
|||||||
|
|
||||||
static async findBy(where, options) {
|
static async findBy(where, options) {
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const currentUser = (options && options.currentUser) || null;
|
||||||
|
|
||||||
const reservations = await db.reservations.findOne(
|
const reservations = await db.reservations.findOne({ where, transaction });
|
||||||
{ where },
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!reservations) {
|
if (!reservations) {
|
||||||
return reservations;
|
return reservations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await customerCanAccessReservation(reservations, currentUser, transaction))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const output = reservations.get({plain: true});
|
const output = reservations.get({plain: true});
|
||||||
|
|
||||||
|
|
||||||
@ -583,22 +607,23 @@ module.exports = class ReservationsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
const user = (options && options.currentUser) || null;
|
const user = (options && options.currentUser) || null;
|
||||||
const userOrganizations = (user && user.organizations?.id) || null;
|
const userOrganizationId =
|
||||||
|
user?.organization?.id ||
|
||||||
|
user?.organizations?.id ||
|
||||||
|
user?.organizationId ||
|
||||||
|
user?.organizationsId ||
|
||||||
|
null;
|
||||||
|
const isCustomer = isCustomerUser(user);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (userOrganizations) {
|
if (!globalAccess && userOrganizationId) {
|
||||||
if (options?.currentUser?.organizationsId) {
|
where.organizationId = userOrganizationId;
|
||||||
where.organizationsId = options.currentUser.organizationsId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
@ -628,8 +653,10 @@ module.exports = class ReservationsDBApi {
|
|||||||
{
|
{
|
||||||
model: db.booking_requests,
|
model: db.booking_requests,
|
||||||
as: 'booking_request',
|
as: 'booking_request',
|
||||||
|
required: isCustomer || Boolean(filter.booking_request),
|
||||||
where: filter.booking_request ? {
|
where: {
|
||||||
|
...(isCustomer ? { requested_byId: user.id } : {}),
|
||||||
|
...(filter.booking_request ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ id: { [Op.in]: filter.booking_request.split('|').map(term => Utils.uuid(term)) } },
|
{ id: { [Op.in]: filter.booking_request.split('|').map(term => Utils.uuid(term)) } },
|
||||||
{
|
{
|
||||||
@ -638,7 +665,8 @@ module.exports = class ReservationsDBApi {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
} : {},
|
} : {}),
|
||||||
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1171,7 +1199,7 @@ module.exports = class ReservationsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
if (globalAccess) {
|
if (globalAccess) {
|
||||||
delete where.organizationsId;
|
delete where.organizationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1204,8 +1232,9 @@ module.exports = class ReservationsDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, currentUser) {
|
||||||
let where = {};
|
let where = {};
|
||||||
|
const include = [];
|
||||||
|
|
||||||
|
|
||||||
if (!globalAccess && organizationId) {
|
if (!globalAccess && organizationId) {
|
||||||
@ -1215,6 +1244,9 @@ module.exports = class ReservationsDBApi {
|
|||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
where = {
|
||||||
|
[Op.and]: [
|
||||||
|
where,
|
||||||
|
{
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
Utils.ilike(
|
Utils.ilike(
|
||||||
@ -1223,12 +1255,24 @@ module.exports = class ReservationsDBApi {
|
|||||||
query,
|
query,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCustomerUser(currentUser)) {
|
||||||
|
include.push({
|
||||||
|
model: db.booking_requests,
|
||||||
|
as: 'booking_request',
|
||||||
|
required: true,
|
||||||
|
where: { requested_byId: currentUser.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const records = await db.reservations.findAll({
|
const records = await db.reservations.findAll({
|
||||||
attributes: [ 'id', 'reservation_code' ],
|
attributes: [ 'id', 'reservation_code' ],
|
||||||
where,
|
where,
|
||||||
|
include,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
orderBy: [['reservation_code', 'ASC']],
|
orderBy: [['reservation_code', 'ASC']],
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
@ -11,6 +9,43 @@ 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 {
|
||||||
|
|
||||||
|
|
||||||
@ -102,8 +137,6 @@ module.exports = class RolesDBApi {
|
|||||||
static async update(id, data, options) {
|
static async update(id, data, options) {
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
const globalAccess = currentUser.app_role?.globalAccess;
|
|
||||||
|
|
||||||
const roles = await db.roles.findByPk(id, {}, {transaction});
|
const roles = await db.roles.findByPk(id, {}, {transaction});
|
||||||
|
|
||||||
|
|
||||||
@ -259,17 +292,8 @@ module.exports = class RolesDBApi {
|
|||||||
const currentPage = +filter.page;
|
const currentPage = +filter.page;
|
||||||
|
|
||||||
|
|
||||||
const user = (options && options.currentUser) || null;
|
|
||||||
const userOrganizations = (user && user.organizations?.id) || null;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
@ -387,9 +411,11 @@ module.exports = class RolesDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!globalAccess) {
|
const businessOnly = filter.businessOnly === true || filter.businessOnly === 'true';
|
||||||
where = { name: { [Op.ne]: config.roles.super_admin } };
|
const assignableOnly = filter.assignableOnly === true || filter.assignableOnly === 'true';
|
||||||
}
|
const includeHighTrust = !(filter.includeHighTrust === false || filter.includeHighTrust === 'false');
|
||||||
|
|
||||||
|
where = appendRoleVisibilityScope(where, globalAccess, businessOnly, assignableOnly, includeHighTrust);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -423,15 +449,9 @@ module.exports = class RolesDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, globalAccess,) {
|
static async findAllAutocomplete(query, limit, offset, globalAccess, businessOnly = false, assignableOnly = false, includeHighTrust = true) {
|
||||||
let where = {};
|
let where = {};
|
||||||
|
|
||||||
if (!globalAccess) {
|
|
||||||
where = { name: { [Op.ne]: config.roles.super_admin } };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
where = {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -445,6 +465,14 @@ 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,
|
||||||
|
|||||||
@ -1,14 +1,42 @@
|
|||||||
|
|
||||||
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 {
|
||||||
|
|
||||||
|
|
||||||
@ -26,9 +54,7 @@ module.exports = class Service_requestsDBApi {
|
|||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
status: data.status
|
status: resolveServiceStatusForCreate(data, currentUser)
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
,
|
||||||
|
|
||||||
priority: data.priority
|
priority: data.priority
|
||||||
@ -36,9 +62,7 @@ module.exports = class Service_requestsDBApi {
|
|||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
requested_at: data.requested_at
|
requested_at: resolveRequestedAt(data, currentUser)
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
,
|
||||||
|
|
||||||
due_at: data.due_at
|
due_at: data.due_at
|
||||||
@ -46,7 +70,7 @@ module.exports = class Service_requestsDBApi {
|
|||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
completed_at: data.completed_at
|
completed_at: isCustomerUser(currentUser) ? null : data.completed_at
|
||||||
||
|
||
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
@ -66,7 +90,7 @@ module.exports = class Service_requestsDBApi {
|
|||||||
null
|
null
|
||||||
,
|
,
|
||||||
|
|
||||||
actual_cost: data.actual_cost
|
actual_cost: isCustomerUser(currentUser) ? null : data.actual_cost
|
||||||
||
|
||
|
||||||
null
|
null
|
||||||
,
|
,
|
||||||
@ -84,7 +108,7 @@ module.exports = class Service_requestsDBApi {
|
|||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
await service_requests.setTenant( data.tenant || null, {
|
await service_requests.setTenant( isCustomerUser(currentUser) ? null : data.tenant || null, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -92,15 +116,15 @@ module.exports = class Service_requestsDBApi {
|
|||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service_requests.setRequested_by( data.requested_by || null, {
|
await service_requests.setRequested_by( isCustomerUser(currentUser) ? currentUser.id : data.requested_by || null, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service_requests.setAssigned_to( data.assigned_to || null, {
|
await service_requests.setAssigned_to( isCustomerUser(currentUser) ? null : data.assigned_to || null, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service_requests.setOrganizations( data.organizations || null, {
|
await service_requests.setOrganizations( isCustomerUser(currentUser) ? currentUser.organization?.id || null : data.organizations || null, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -202,9 +226,10 @@ module.exports = class Service_requestsDBApi {
|
|||||||
static async update(id, data, options) {
|
static async update(id, data, options) {
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
const globalAccess = currentUser.app_role?.globalAccess;
|
const service_requests = await db.service_requests.findOne({
|
||||||
|
where: mergeWhereWithScope({ id }, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
|
||||||
const service_requests = await db.service_requests.findByPk(id, {}, {transaction});
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -214,7 +239,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) updatePayload.status = data.status;
|
if (data.status !== undefined && !isCustomerUser(currentUser)) updatePayload.status = data.status;
|
||||||
|
|
||||||
|
|
||||||
if (data.priority !== undefined) updatePayload.priority = data.priority;
|
if (data.priority !== undefined) updatePayload.priority = data.priority;
|
||||||
@ -226,7 +251,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) updatePayload.completed_at = data.completed_at;
|
if (data.completed_at !== undefined && !isCustomerUser(currentUser)) updatePayload.completed_at = data.completed_at;
|
||||||
|
|
||||||
|
|
||||||
if (data.summary !== undefined) updatePayload.summary = data.summary;
|
if (data.summary !== undefined) updatePayload.summary = data.summary;
|
||||||
@ -238,7 +263,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) updatePayload.actual_cost = data.actual_cost;
|
if (data.actual_cost !== undefined && !isCustomerUser(currentUser)) updatePayload.actual_cost = data.actual_cost;
|
||||||
|
|
||||||
|
|
||||||
if (data.currency !== undefined) updatePayload.currency = data.currency;
|
if (data.currency !== undefined) updatePayload.currency = data.currency;
|
||||||
@ -250,11 +275,9 @@ module.exports = class Service_requestsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (data.tenant !== undefined) {
|
if (data.tenant !== undefined && !isCustomerUser(currentUser)) {
|
||||||
await service_requests.setTenant(
|
await service_requests.setTenant(
|
||||||
|
|
||||||
data.tenant,
|
data.tenant,
|
||||||
|
|
||||||
{ transaction }
|
{ transaction }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -271,26 +294,22 @@ module.exports = class Service_requestsDBApi {
|
|||||||
if (data.requested_by !== undefined) {
|
if (data.requested_by !== undefined) {
|
||||||
await service_requests.setRequested_by(
|
await service_requests.setRequested_by(
|
||||||
|
|
||||||
data.requested_by,
|
isCustomerUser(currentUser) ? currentUser.id : data.requested_by,
|
||||||
|
|
||||||
{ transaction }
|
{ transaction }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.assigned_to !== undefined) {
|
if (data.assigned_to !== undefined && !isCustomerUser(currentUser)) {
|
||||||
await service_requests.setAssigned_to(
|
await service_requests.setAssigned_to(
|
||||||
|
|
||||||
data.assigned_to,
|
data.assigned_to,
|
||||||
|
|
||||||
{ transaction }
|
{ transaction }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.organizations !== undefined) {
|
if (data.organizations !== undefined && !isCustomerUser(currentUser)) {
|
||||||
await service_requests.setOrganizations(
|
await service_requests.setOrganizations(
|
||||||
|
|
||||||
data.organizations,
|
data.organizations,
|
||||||
|
|
||||||
{ transaction }
|
{ transaction }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -317,11 +336,11 @@ module.exports = class Service_requestsDBApi {
|
|||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const service_requests = await db.service_requests.findAll({
|
const service_requests = await db.service_requests.findAll({
|
||||||
where: {
|
where: mergeWhereWithScope({
|
||||||
id: {
|
id: {
|
||||||
[Op.in]: ids,
|
[Op.in]: ids,
|
||||||
},
|
},
|
||||||
},
|
}, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -345,7 +364,14 @@ module.exports = class Service_requestsDBApi {
|
|||||||
const currentUser = (options && options.currentUser) || {id: null};
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
|
||||||
const service_requests = await db.service_requests.findByPk(id, options);
|
const service_requests = await db.service_requests.findOne({
|
||||||
|
where: mergeWhereWithScope({ id }, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!service_requests) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
await service_requests.update({
|
await service_requests.update({
|
||||||
deletedBy: currentUser.id
|
deletedBy: currentUser.id
|
||||||
@ -362,11 +388,12 @@ module.exports = class Service_requestsDBApi {
|
|||||||
|
|
||||||
static async findBy(where, options) {
|
static async findBy(where, options) {
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const currentUser = (options && options.currentUser) || null;
|
||||||
|
|
||||||
const service_requests = await db.service_requests.findOne(
|
const service_requests = await db.service_requests.findOne({
|
||||||
{ where },
|
where: mergeWhereWithScope(where, isCustomerUser(currentUser) ? { requested_byId: currentUser.id } : null),
|
||||||
{ transaction },
|
transaction,
|
||||||
);
|
});
|
||||||
|
|
||||||
if (!service_requests) {
|
if (!service_requests) {
|
||||||
return service_requests;
|
return service_requests;
|
||||||
@ -464,6 +491,7 @@ module.exports = class Service_requestsDBApi {
|
|||||||
|
|
||||||
const user = (options && options.currentUser) || null;
|
const user = (options && options.currentUser) || null;
|
||||||
const userOrganizations = (user && user.organizations?.id) || null;
|
const userOrganizations = (user && user.organizations?.id) || null;
|
||||||
|
const customerScope = isCustomerUser(user) ? { requested_byId: user.id } : null;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -474,11 +502,12 @@ module.exports = class Service_requestsDBApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (customerScope) {
|
||||||
|
where.requested_byId = user.id;
|
||||||
|
}
|
||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
@ -901,9 +930,13 @@ module.exports = class Service_requestsDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, currentUser) {
|
||||||
let where = {};
|
let where = {};
|
||||||
|
|
||||||
|
if (isCustomerUser(currentUser)) {
|
||||||
|
where.requested_byId = currentUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!globalAccess && organizationId) {
|
if (!globalAccess && organizationId) {
|
||||||
where.organizationId = organizationId;
|
where.organizationId = organizationId;
|
||||||
@ -912,6 +945,9 @@ module.exports = class Service_requestsDBApi {
|
|||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
where = {
|
||||||
|
[Op.and]: [
|
||||||
|
where,
|
||||||
|
{
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
Utils.ilike(
|
Utils.ilike(
|
||||||
@ -920,6 +956,8 @@ module.exports = class Service_requestsDBApi {
|
|||||||
query,
|
query,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
@ -9,6 +7,149 @@ 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 {
|
||||||
|
|
||||||
|
|
||||||
@ -66,22 +207,32 @@ module.exports = class TenantsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const organizationIds = normalizeRelationIds(data.organizations);
|
||||||
|
|
||||||
await tenants.setOrganizations(data.organizations || [], {
|
await tenants.setOrganizations(organizationIds, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
await tenants.setProperties(data.properties || [], {
|
await syncTenantOwnedChildren({
|
||||||
|
tenantId: tenants.id,
|
||||||
|
model: db.properties,
|
||||||
|
selectedIds: data.properties || [],
|
||||||
|
organizationIds,
|
||||||
transaction,
|
transaction,
|
||||||
|
currentUserId: currentUser.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await tenants.setAudit_logs(data.audit_logs || [], {
|
await syncTenantOwnedChildren({
|
||||||
|
tenantId: tenants.id,
|
||||||
|
model: db.audit_logs,
|
||||||
|
selectedIds: data.audit_logs || [],
|
||||||
|
organizationIds,
|
||||||
transaction,
|
transaction,
|
||||||
|
currentUserId: currentUser.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return tenants;
|
return tenants;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,8 +299,6 @@ 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});
|
||||||
|
|
||||||
|
|
||||||
@ -185,18 +334,37 @@ module.exports = class TenantsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let organizationIds = null;
|
||||||
|
|
||||||
if (data.organizations !== undefined) {
|
if (data.organizations !== undefined) {
|
||||||
await tenants.setOrganizations(data.organizations, { transaction });
|
organizationIds = normalizeRelationIds(data.organizations);
|
||||||
|
await tenants.setOrganizations(organizationIds, { transaction });
|
||||||
|
} else {
|
||||||
|
organizationIds = normalizeRelationIds(
|
||||||
|
(await tenants.getOrganizations({ transaction })) || [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.properties !== undefined) {
|
if (data.properties !== undefined) {
|
||||||
await tenants.setProperties(data.properties, { transaction });
|
await syncTenantOwnedChildren({
|
||||||
|
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 tenants.setAudit_logs(data.audit_logs, { transaction });
|
await syncTenantOwnedChildren({
|
||||||
|
tenantId: tenants.id,
|
||||||
|
model: db.audit_logs,
|
||||||
|
selectedIds: data.audit_logs,
|
||||||
|
organizationIds,
|
||||||
|
transaction,
|
||||||
|
currentUserId: currentUser.id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -350,15 +518,9 @@ module.exports = class TenantsDBApi {
|
|||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -380,41 +542,19 @@ 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: false,
|
required: !globalAccess && Boolean(userOrganizations),
|
||||||
|
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) {
|
||||||
@ -545,7 +685,7 @@ module.exports = class TenantsDBApi {
|
|||||||
include = [
|
include = [
|
||||||
{
|
{
|
||||||
model: db.properties,
|
model: db.properties,
|
||||||
as: 'properties_filter',
|
as: 'properties_tenant',
|
||||||
required: searchTerms.length > 0,
|
required: searchTerms.length > 0,
|
||||||
where: searchTerms.length > 0 ? {
|
where: searchTerms.length > 0 ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -568,7 +708,7 @@ module.exports = class TenantsDBApi {
|
|||||||
include = [
|
include = [
|
||||||
{
|
{
|
||||||
model: db.audit_logs,
|
model: db.audit_logs,
|
||||||
as: 'audit_logs_filter',
|
as: 'audit_logs_tenant',
|
||||||
required: searchTerms.length > 0,
|
required: searchTerms.length > 0,
|
||||||
where: searchTerms.length > 0 ? {
|
where: searchTerms.length > 0 ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -613,11 +753,6 @@ module.exports = class TenantsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (globalAccess) {
|
|
||||||
delete where.organizationsId;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const queryOptions = {
|
const queryOptions = {
|
||||||
where,
|
where,
|
||||||
include,
|
include,
|
||||||
@ -649,13 +784,21 @@ 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) {
|
||||||
where.organizationId = organizationId;
|
include = [
|
||||||
|
{
|
||||||
|
model: db.organizations,
|
||||||
|
as: 'organizations_filter',
|
||||||
|
required: true,
|
||||||
|
where: {
|
||||||
|
id: Utils.uuid(organizationId),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
where = {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -672,6 +815,7 @@ 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']],
|
||||||
|
|||||||
@ -1,10 +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');
|
||||||
|
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;
|
||||||
@ -16,6 +13,17 @@ 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(
|
||||||
{
|
{
|
||||||
@ -83,7 +91,7 @@ module.exports = class Unit_typesDBApi {
|
|||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
await unit_types.setOrganizations( data.organizations || null, {
|
await unit_types.setOrganizations( organizationId || null, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -103,74 +111,76 @@ 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 = [];
|
||||||
|
|
||||||
// Prepare data - wrapping individual data transformations in a map() method
|
for (let index = 0; index < data.length; index += 1) {
|
||||||
const unit_typesData = data.map((item, index) => ({
|
const item = normalizeInventoryImportRow(data[index]);
|
||||||
id: item.id || undefined,
|
const rowNumber = index + 2;
|
||||||
|
|
||||||
name: item.name
|
try {
|
||||||
||
|
const propertyId = await resolvePropertyReference(item.property, {
|
||||||
null
|
currentUser,
|
||||||
,
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
code: item.code
|
if (!propertyId) {
|
||||||
||
|
throw createBadRequestError('Property is required for unit type import.');
|
||||||
null
|
}
|
||||||
,
|
|
||||||
|
|
||||||
max_occupancy: item.max_occupancy
|
item.property = propertyId;
|
||||||
||
|
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
bedrooms: item.bedrooms
|
const itemId = item.id || null;
|
||||||
||
|
const importHash = item.importHash || null;
|
||||||
null
|
if (options?.ignoreDuplicates) {
|
||||||
,
|
if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
bathrooms: item.bathrooms
|
const duplicateFilters = [];
|
||||||
||
|
if (itemId) {
|
||||||
null
|
duplicateFilters.push({ id: itemId });
|
||||||
,
|
}
|
||||||
|
if (importHash) {
|
||||||
|
duplicateFilters.push({ importHash });
|
||||||
|
}
|
||||||
|
|
||||||
size_sqm: item.size_sqm
|
if (duplicateFilters.length) {
|
||||||
||
|
const existingRecord = await db.unit_types.findOne({
|
||||||
null
|
where: {
|
||||||
,
|
[Op.or]: duplicateFilters,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
description: item.description
|
if (existingRecord) {
|
||||||
||
|
continue;
|
||||||
null
|
}
|
||||||
,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
base_nightly_rate: item.base_nightly_rate
|
if (itemId) {
|
||||||
||
|
seenIds.add(itemId);
|
||||||
null
|
}
|
||||||
,
|
if (importHash) {
|
||||||
|
seenImportHashes.add(importHash);
|
||||||
|
}
|
||||||
|
|
||||||
base_monthly_rate: item.base_monthly_rate
|
const createdRecord = await this.create(item, {
|
||||||
||
|
...options,
|
||||||
null
|
currentUser,
|
||||||
,
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
minimum_stay_nights: item.minimum_stay_nights
|
createdRecords.push(createdRecord);
|
||||||
||
|
} catch (error) {
|
||||||
null
|
throw prefixImportError(error, rowNumber);
|
||||||
,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
importHash: item.importHash || null,
|
return createdRecords;
|
||||||
createdById: currentUser.id,
|
|
||||||
updatedById: currentUser.id,
|
|
||||||
createdAt: new Date(Date.now() + index * 1000),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Bulk create items
|
|
||||||
const unit_types = await db.unit_types.bulkCreate(unit_typesData, { transaction });
|
|
||||||
|
|
||||||
// For each item created, replace relation files
|
|
||||||
|
|
||||||
|
|
||||||
return unit_types;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async update(id, data, options) {
|
static async update(id, data, options) {
|
||||||
@ -179,9 +189,20 @@ 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 = {};
|
||||||
|
|
||||||
@ -230,10 +251,10 @@ module.exports = class Unit_typesDBApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.organizations !== undefined) {
|
if (organizationId !== undefined) {
|
||||||
await unit_types.setOrganizations(
|
await unit_types.setOrganizations(
|
||||||
|
|
||||||
data.organizations,
|
organizationId,
|
||||||
|
|
||||||
{ transaction }
|
{ transaction }
|
||||||
);
|
);
|
||||||
@ -404,10 +425,6 @@ module.exports = class Unit_typesDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -770,17 +787,19 @@ module.exports = class Unit_typesDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, propertyId) {
|
||||||
let where = {};
|
const filters = [];
|
||||||
|
|
||||||
|
|
||||||
if (!globalAccess && organizationId) {
|
if (!globalAccess && organizationId) {
|
||||||
where.organizationId = organizationId;
|
filters.push({ organizationsId: organizationId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (propertyId) {
|
||||||
|
filters.push({ propertyId: Utils.uuid(propertyId) });
|
||||||
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
filters.push({
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
Utils.ilike(
|
Utils.ilike(
|
||||||
@ -789,15 +808,19 @@ 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,
|
||||||
orderBy: [['name', 'ASC']],
|
order: [['name', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
|
|||||||
@ -1,10 +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');
|
||||||
|
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;
|
||||||
@ -16,6 +13,31 @@ 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(
|
||||||
{
|
{
|
||||||
@ -54,7 +76,7 @@ module.exports = class UnitsDBApi {
|
|||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
await units.setProperty( data.property || null, {
|
await units.setProperty( propertyId || null, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -62,7 +84,7 @@ module.exports = class UnitsDBApi {
|
|||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
await units.setOrganizations( data.organizations || null, {
|
await units.setOrganizations( organizationId || null, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -82,49 +104,89 @@ 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 = [];
|
||||||
|
|
||||||
// Prepare data - wrapping individual data transformations in a map() method
|
for (let index = 0; index < data.length; index += 1) {
|
||||||
const unitsData = data.map((item, index) => ({
|
const item = normalizeInventoryImportRow(data[index]);
|
||||||
id: item.id || undefined,
|
const rowNumber = index + 2;
|
||||||
|
|
||||||
unit_number: item.unit_number
|
try {
|
||||||
||
|
const propertyId = await resolvePropertyReference(item.property, {
|
||||||
null
|
currentUser,
|
||||||
,
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
floor: item.floor
|
if (!propertyId) {
|
||||||
||
|
throw createBadRequestError('Property is required for unit import.');
|
||||||
null
|
}
|
||||||
,
|
|
||||||
|
|
||||||
status: item.status
|
const property = await loadPropertyContext(propertyId, transaction);
|
||||||
||
|
const unitTypeId = await resolveUnitTypeReference(item.unit_type, {
|
||||||
null
|
currentUser,
|
||||||
,
|
organizationId: property?.organizationsId || null,
|
||||||
|
propertyId,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
max_occupancy_override: item.max_occupancy_override
|
if (!unitTypeId) {
|
||||||
||
|
throw createBadRequestError('Unit type is required for unit import.');
|
||||||
null
|
}
|
||||||
,
|
|
||||||
|
|
||||||
notes: item.notes
|
item.property = propertyId;
|
||||||
||
|
item.unit_type = unitTypeId;
|
||||||
null
|
|
||||||
,
|
|
||||||
|
|
||||||
importHash: item.importHash || null,
|
const itemId = item.id || null;
|
||||||
createdById: currentUser.id,
|
const importHash = item.importHash || null;
|
||||||
updatedById: currentUser.id,
|
if (options?.ignoreDuplicates) {
|
||||||
createdAt: new Date(Date.now() + index * 1000),
|
if ((itemId && seenIds.has(itemId)) || (importHash && seenImportHashes.has(importHash))) {
|
||||||
}));
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Bulk create items
|
const duplicateFilters = [];
|
||||||
const units = await db.units.bulkCreate(unitsData, { transaction });
|
if (itemId) {
|
||||||
|
duplicateFilters.push({ id: itemId });
|
||||||
|
}
|
||||||
|
if (importHash) {
|
||||||
|
duplicateFilters.push({ importHash });
|
||||||
|
}
|
||||||
|
|
||||||
// For each item created, replace relation files
|
if (duplicateFilters.length) {
|
||||||
|
const existingRecord = await db.units.findOne({
|
||||||
|
where: {
|
||||||
|
[Op.or]: duplicateFilters,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingRecord) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return units;
|
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) {
|
||||||
@ -133,9 +195,30 @@ 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 = {};
|
||||||
|
|
||||||
@ -160,10 +243,10 @@ module.exports = class UnitsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (data.property !== undefined) {
|
if (data.property !== undefined || (data.unit_type !== undefined && propertyId !== units?.propertyId)) {
|
||||||
await units.setProperty(
|
await units.setProperty(
|
||||||
|
|
||||||
data.property,
|
propertyId,
|
||||||
|
|
||||||
{ transaction }
|
{ transaction }
|
||||||
);
|
);
|
||||||
@ -178,10 +261,10 @@ module.exports = class UnitsDBApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.organizations !== undefined) {
|
if (organizationId !== undefined) {
|
||||||
await units.setOrganizations(
|
await units.setOrganizations(
|
||||||
|
|
||||||
data.organizations,
|
organizationId,
|
||||||
|
|
||||||
{ transaction }
|
{ transaction }
|
||||||
);
|
);
|
||||||
@ -349,9 +432,6 @@ module.exports = class UnitsDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
@ -597,17 +677,23 @@ module.exports = class UnitsDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, propertyId, unitTypeId) {
|
||||||
let where = {};
|
const filters = [];
|
||||||
|
|
||||||
|
|
||||||
if (!globalAccess && organizationId) {
|
if (!globalAccess && organizationId) {
|
||||||
where.organizationId = organizationId;
|
filters.push({ organizationsId: organizationId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (propertyId) {
|
||||||
|
filters.push({ propertyId: Utils.uuid(propertyId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unitTypeId) {
|
||||||
|
filters.push({ unit_typeId: Utils.uuid(unitTypeId) });
|
||||||
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
filters.push({
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
{ ['id']: Utils.uuid(query) },
|
{ ['id']: Utils.uuid(query) },
|
||||||
Utils.ilike(
|
Utils.ilike(
|
||||||
@ -616,15 +702,19 @@ 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,
|
||||||
orderBy: [['unit_number', 'ASC']],
|
order: [['unit_number', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
return records.map((record) => ({
|
return records.map((record) => ({
|
||||||
|
|||||||
@ -12,6 +12,57 @@ 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) {
|
||||||
@ -96,7 +147,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: 'User' },
|
where: { name: config.roles?.user || 'User' },
|
||||||
});
|
});
|
||||||
if (role) {
|
if (role) {
|
||||||
await users.setApp_role(role, {
|
await users.setApp_role(role, {
|
||||||
@ -519,7 +570,7 @@ module.exports = class UsersDBApi {
|
|||||||
transaction
|
transaction
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await resolveCurrentOrganizationContext(output, transaction);
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
@ -548,10 +599,6 @@ module.exports = class UsersDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -907,19 +954,23 @@ 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" },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,89 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
'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.
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
'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.
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -1,9 +1,3 @@
|
|||||||
const config = require('../../config');
|
|
||||||
const providers = config.providers;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const moment = require('moment');
|
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const booking_requests = sequelize.define(
|
const booking_requests = sequelize.define(
|
||||||
'booking_requests',
|
'booking_requests',
|
||||||
@ -16,109 +10,57 @@ module.exports = function(sequelize, DataTypes) {
|
|||||||
|
|
||||||
request_code: {
|
request_code: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
status: {
|
status: {
|
||||||
type: DataTypes.ENUM,
|
type: DataTypes.ENUM,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
values: [
|
values: [
|
||||||
|
'draft',
|
||||||
"draft",
|
'submitted',
|
||||||
|
'in_review',
|
||||||
|
'changes_requested',
|
||||||
"submitted",
|
'approved',
|
||||||
|
'rejected',
|
||||||
|
'expired',
|
||||||
"in_review",
|
'converted_to_reservation',
|
||||||
|
'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: {
|
||||||
@ -135,97 +77,70 @@ currency: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
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_requests_travelersId',
|
name: 'booking_requestId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'booking_requestsTravelersBooking_request_travelers',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.booking_requests.belongsToMany(db.booking_request_travelers, {
|
db.booking_requests.hasMany(db.booking_request_travelers, {
|
||||||
as: 'travelers_filter',
|
as: 'travelers_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'booking_requests_travelersId',
|
name: 'booking_requestId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'booking_requestsTravelersBooking_request_travelers',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.booking_requests.belongsToMany(db.approval_steps, {
|
db.booking_requests.hasMany(db.approval_steps, {
|
||||||
as: 'approval_steps',
|
as: 'approval_steps',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'booking_requests_approval_stepsId',
|
name: 'booking_requestId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'booking_requestsApproval_stepsApproval_steps',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.booking_requests.belongsToMany(db.approval_steps, {
|
db.booking_requests.hasMany(db.approval_steps, {
|
||||||
as: 'approval_steps_filter',
|
as: 'approval_steps_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'booking_requests_approval_stepsId',
|
name: 'booking_requestId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'booking_requestsApproval_stepsApproval_steps',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.booking_requests.belongsToMany(db.documents, {
|
db.booking_requests.hasMany(db.documents, {
|
||||||
as: 'documents',
|
as: 'documents',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'booking_requests_documentsId',
|
name: 'booking_requestId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'booking_requestsDocumentsDocuments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.booking_requests.belongsToMany(db.documents, {
|
db.booking_requests.hasMany(db.documents, {
|
||||||
as: 'documents_filter',
|
as: 'documents_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'booking_requests_documentsId',
|
name: 'booking_requestId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'booking_requestsDocumentsDocuments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.booking_requests.belongsToMany(db.activity_comments, {
|
db.booking_requests.hasMany(db.activity_comments, {
|
||||||
as: 'comments',
|
as: 'comments',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'booking_requests_commentsId',
|
name: 'booking_requestId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'booking_requestsCommentsActivity_comments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.booking_requests.belongsToMany(db.activity_comments, {
|
db.booking_requests.hasMany(db.activity_comments, {
|
||||||
as: 'comments_filter',
|
as: 'comments_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'booking_requests_commentsId',
|
name: 'booking_requestId',
|
||||||
},
|
},
|
||||||
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: {
|
||||||
@ -234,7 +149,6 @@ currency: {
|
|||||||
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: {
|
||||||
@ -243,7 +157,6 @@ currency: {
|
|||||||
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: {
|
||||||
@ -252,12 +165,6 @@ currency: {
|
|||||||
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: {
|
||||||
@ -266,9 +173,6 @@ currency: {
|
|||||||
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: {
|
||||||
@ -277,15 +181,6 @@ currency: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.booking_requests.belongsTo(db.tenants, {
|
db.booking_requests.belongsTo(db.tenants, {
|
||||||
as: 'tenant',
|
as: 'tenant',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -326,9 +221,6 @@ currency: {
|
|||||||
constraints: false,
|
constraints: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
db.booking_requests.belongsTo(db.users, {
|
db.booking_requests.belongsTo(db.users, {
|
||||||
as: 'createdBy',
|
as: 'createdBy',
|
||||||
});
|
});
|
||||||
@ -338,9 +230,5 @@ currency: {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return booking_requests;
|
return booking_requests;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -134,58 +134,52 @@ notes: {
|
|||||||
|
|
||||||
invoices.associate = (db) => {
|
invoices.associate = (db) => {
|
||||||
|
|
||||||
db.invoices.belongsToMany(db.invoice_line_items, {
|
db.invoices.hasMany(db.invoice_line_items, {
|
||||||
as: 'line_items',
|
as: 'line_items',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'invoices_line_itemsId',
|
name: 'invoiceId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'invoicesLine_itemsInvoice_line_items',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.invoices.belongsToMany(db.invoice_line_items, {
|
db.invoices.hasMany(db.invoice_line_items, {
|
||||||
as: 'line_items_filter',
|
as: 'line_items_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'invoices_line_itemsId',
|
name: 'invoiceId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'invoicesLine_itemsInvoice_line_items',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.invoices.belongsToMany(db.payments, {
|
db.invoices.hasMany(db.payments, {
|
||||||
as: 'payments',
|
as: 'payments',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'invoices_paymentsId',
|
name: 'invoiceId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'invoicesPaymentsPayments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.invoices.belongsToMany(db.payments, {
|
db.invoices.hasMany(db.payments, {
|
||||||
as: 'payments_filter',
|
as: 'payments_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'invoices_paymentsId',
|
name: 'invoiceId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'invoicesPaymentsPayments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.invoices.belongsToMany(db.documents, {
|
db.invoices.hasMany(db.documents, {
|
||||||
as: 'documents',
|
as: 'documents',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'invoices_documentsId',
|
name: 'invoiceId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'invoicesDocumentsDocuments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.invoices.belongsToMany(db.documents, {
|
db.invoices.hasMany(db.documents, {
|
||||||
as: 'documents_filter',
|
as: 'documents_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'invoices_documentsId',
|
name: 'invoiceId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'invoicesDocumentsDocuments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,3 @@
|
|||||||
const config = require('../../config');
|
|
||||||
const providers = config.providers;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const moment = require('moment');
|
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const properties = sequelize.define(
|
const properties = sequelize.define(
|
||||||
@ -88,58 +83,52 @@ is_active: {
|
|||||||
|
|
||||||
properties.associate = (db) => {
|
properties.associate = (db) => {
|
||||||
|
|
||||||
db.properties.belongsToMany(db.unit_types, {
|
db.properties.hasMany(db.unit_types, {
|
||||||
as: 'unit_types',
|
as: 'unit_types',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'properties_unit_typesId',
|
name: 'propertyId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'propertiesUnit_typesUnit_types',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.properties.belongsToMany(db.unit_types, {
|
db.properties.hasMany(db.unit_types, {
|
||||||
as: 'unit_types_filter',
|
as: 'unit_types_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'properties_unit_typesId',
|
name: 'propertyId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'propertiesUnit_typesUnit_types',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.properties.belongsToMany(db.units, {
|
db.properties.hasMany(db.units, {
|
||||||
as: 'units',
|
as: 'units',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'properties_unitsId',
|
name: 'propertyId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'propertiesUnitsUnits',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.properties.belongsToMany(db.units, {
|
db.properties.hasMany(db.units, {
|
||||||
as: 'units_filter',
|
as: 'units_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'properties_unitsId',
|
name: 'propertyId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'propertiesUnitsUnits',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.properties.belongsToMany(db.amenities, {
|
db.properties.hasMany(db.amenities, {
|
||||||
as: 'amenities',
|
as: 'amenities',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'properties_amenitiesId',
|
name: 'propertyId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'propertiesAmenitiesAmenities',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.properties.belongsToMany(db.amenities, {
|
db.properties.hasMany(db.amenities, {
|
||||||
as: 'amenities_filter',
|
as: 'amenities_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'properties_amenitiesId',
|
name: 'propertyId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'propertiesAmenitiesAmenities',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,3 @@
|
|||||||
const config = require('../../config');
|
|
||||||
const providers = config.providers;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const moment = require('moment');
|
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const reservations = sequelize.define(
|
const reservations = sequelize.define(
|
||||||
'reservations',
|
'reservations',
|
||||||
@ -154,94 +148,84 @@ external_notes: {
|
|||||||
|
|
||||||
reservations.associate = (db) => {
|
reservations.associate = (db) => {
|
||||||
|
|
||||||
db.reservations.belongsToMany(db.reservation_guests, {
|
db.reservations.hasMany(db.reservation_guests, {
|
||||||
as: 'guests',
|
as: 'guests',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'reservations_guestsId',
|
name: 'reservationId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'reservationsGuestsReservation_guests',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.reservations.belongsToMany(db.reservation_guests, {
|
db.reservations.hasMany(db.reservation_guests, {
|
||||||
as: 'guests_filter',
|
as: 'guests_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'reservations_guestsId',
|
name: 'reservationId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'reservationsGuestsReservation_guests',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.reservations.belongsToMany(db.service_requests, {
|
db.reservations.hasMany(db.service_requests, {
|
||||||
as: 'service_requests',
|
as: 'service_requests',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'reservations_service_requestsId',
|
name: 'reservationId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'reservationsService_requestsService_requests',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.reservations.belongsToMany(db.service_requests, {
|
db.reservations.hasMany(db.service_requests, {
|
||||||
as: 'service_requests_filter',
|
as: 'service_requests_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'reservations_service_requestsId',
|
name: 'reservationId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'reservationsService_requestsService_requests',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.reservations.belongsToMany(db.invoices, {
|
db.reservations.hasMany(db.invoices, {
|
||||||
as: 'invoices',
|
as: 'invoices',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'reservations_invoicesId',
|
name: 'reservationId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'reservationsInvoicesInvoices',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.reservations.belongsToMany(db.invoices, {
|
db.reservations.hasMany(db.invoices, {
|
||||||
as: 'invoices_filter',
|
as: 'invoices_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'reservations_invoicesId',
|
name: 'reservationId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'reservationsInvoicesInvoices',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.reservations.belongsToMany(db.documents, {
|
db.reservations.hasMany(db.documents, {
|
||||||
as: 'documents',
|
as: 'documents',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'reservations_documentsId',
|
name: 'reservationId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'reservationsDocumentsDocuments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.reservations.belongsToMany(db.documents, {
|
db.reservations.hasMany(db.documents, {
|
||||||
as: 'documents_filter',
|
as: 'documents_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'reservations_documentsId',
|
name: 'reservationId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'reservationsDocumentsDocuments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.reservations.belongsToMany(db.activity_comments, {
|
db.reservations.hasMany(db.activity_comments, {
|
||||||
as: 'comments',
|
as: 'comments',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'reservations_commentsId',
|
name: 'reservationId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'reservationsCommentsActivity_comments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.reservations.belongsToMany(db.activity_comments, {
|
db.reservations.hasMany(db.activity_comments, {
|
||||||
as: 'comments_filter',
|
as: 'comments_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'reservations_commentsId',
|
name: 'reservationId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'reservationsCommentsActivity_comments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,3 @@
|
|||||||
const config = require('../../config');
|
|
||||||
const providers = config.providers;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const moment = require('moment');
|
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const service_requests = sequelize.define(
|
const service_requests = sequelize.define(
|
||||||
'service_requests',
|
'service_requests',
|
||||||
@ -172,40 +166,36 @@ currency: {
|
|||||||
|
|
||||||
service_requests.associate = (db) => {
|
service_requests.associate = (db) => {
|
||||||
|
|
||||||
db.service_requests.belongsToMany(db.documents, {
|
db.service_requests.hasMany(db.documents, {
|
||||||
as: 'documents',
|
as: 'documents',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'service_requests_documentsId',
|
name: 'service_requestId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'service_requestsDocumentsDocuments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.service_requests.belongsToMany(db.documents, {
|
db.service_requests.hasMany(db.documents, {
|
||||||
as: 'documents_filter',
|
as: 'documents_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'service_requests_documentsId',
|
name: 'service_requestId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'service_requestsDocumentsDocuments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.service_requests.belongsToMany(db.activity_comments, {
|
db.service_requests.hasMany(db.activity_comments, {
|
||||||
as: 'comments',
|
as: 'comments',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'service_requests_commentsId',
|
name: 'service_requestId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'service_requestsCommentsActivity_comments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.service_requests.belongsToMany(db.activity_comments, {
|
db.service_requests.hasMany(db.activity_comments, {
|
||||||
as: 'comments_filter',
|
as: 'comments_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'service_requests_commentsId',
|
name: 'service_requestId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'service_requestsCommentsActivity_comments',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,3 @@
|
|||||||
const config = require('../../config');
|
|
||||||
const providers = config.providers;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const moment = require('moment');
|
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const unit_types = sequelize.define(
|
const unit_types = sequelize.define(
|
||||||
@ -99,22 +94,20 @@ minimum_stay_nights: {
|
|||||||
|
|
||||||
unit_types.associate = (db) => {
|
unit_types.associate = (db) => {
|
||||||
|
|
||||||
db.unit_types.belongsToMany(db.units, {
|
db.unit_types.hasMany(db.units, {
|
||||||
as: 'units',
|
as: 'units',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'unit_types_unitsId',
|
name: 'unit_typeId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'unit_typesUnitsUnits',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.unit_types.belongsToMany(db.units, {
|
db.unit_types.hasMany(db.units, {
|
||||||
as: 'units_filter',
|
as: 'units_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'unit_types_unitsId',
|
name: 'unit_typeId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'unit_typesUnitsUnits',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,3 @@
|
|||||||
const config = require('../../config');
|
|
||||||
const providers = config.providers;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const moment = require('moment');
|
|
||||||
|
|
||||||
module.exports = function(sequelize, DataTypes) {
|
module.exports = function(sequelize, DataTypes) {
|
||||||
const units = sequelize.define(
|
const units = sequelize.define(
|
||||||
'units',
|
'units',
|
||||||
@ -85,22 +79,20 @@ notes: {
|
|||||||
|
|
||||||
units.associate = (db) => {
|
units.associate = (db) => {
|
||||||
|
|
||||||
db.units.belongsToMany(db.unit_availability_blocks, {
|
db.units.hasMany(db.unit_availability_blocks, {
|
||||||
as: 'availability_blocks',
|
as: 'availability_blocks',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'units_availability_blocksId',
|
name: 'unitId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'unitsAvailability_blocksUnit_availability_blocks',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
db.units.belongsToMany(db.unit_availability_blocks, {
|
db.units.hasMany(db.unit_availability_blocks, {
|
||||||
as: 'availability_blocks_filter',
|
as: 'availability_blocks_filter',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
name: 'units_availability_blocksId',
|
name: 'unitId',
|
||||||
},
|
},
|
||||||
constraints: false,
|
constraints: false,
|
||||||
through: 'unitsAvailability_blocksUnit_availability_blocks',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,246 @@
|
|||||||
|
'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.
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -81,6 +81,8 @@ 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 '';
|
||||||
@ -197,6 +199,8 @@ 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 }),
|
||||||
|
|||||||
@ -5,7 +5,6 @@ const Booking_requestsService = require('../services/booking_requests');
|
|||||||
const Booking_requestsDBApi = require('../db/api/booking_requests');
|
const Booking_requestsDBApi = require('../db/api/booking_requests');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@ -409,7 +408,7 @@ router.get('/autocomplete', async (req, res) => {
|
|||||||
req.query.query,
|
req.query.query,
|
||||||
req.query.limit,
|
req.query.limit,
|
||||||
req.query.offset,
|
req.query.offset,
|
||||||
globalAccess, organizationId,
|
globalAccess, organizationId, req.currentUser,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
@ -450,9 +449,12 @@ router.get('/autocomplete', async (req, res) => {
|
|||||||
router.get('/:id', wrapAsync(async (req, res) => {
|
router.get('/:id', wrapAsync(async (req, res) => {
|
||||||
const payload = await Booking_requestsDBApi.findBy(
|
const payload = await Booking_requestsDBApi.findBy(
|
||||||
{ id: req.params.id },
|
{ id: req.params.id },
|
||||||
|
{ currentUser: req.currentUser },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return res.status(404).send('Not found');
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}));
|
}));
|
||||||
|
|||||||
472
backend/src/routes/corporate_stay_portal.js
Normal file
472
backend/src/routes/corporate_stay_portal.js
Normal file
@ -0,0 +1,472 @@
|
|||||||
|
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;
|
||||||
@ -5,9 +5,6 @@ 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');
|
||||||
@ -404,6 +401,7 @@ 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);
|
||||||
@ -444,9 +442,12 @@ router.get('/autocomplete', async (req, res) => {
|
|||||||
router.get('/:id', wrapAsync(async (req, res) => {
|
router.get('/:id', wrapAsync(async (req, res) => {
|
||||||
const payload = await 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);
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -1,13 +1,31 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@ -33,23 +51,29 @@ const router = express.Router();
|
|||||||
* 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);
|
const payload = await OrganizationsDBApi.findAll(req.query || {}, true, {});
|
||||||
const simplifiedPayload = payload.rows.map(org => ({
|
|
||||||
id: org.id,
|
|
||||||
name: org.name
|
|
||||||
}));
|
|
||||||
res.status(200).send(simplifiedPayload);
|
|
||||||
|
|
||||||
|
const simplifiedPayload = await Promise.all(
|
||||||
|
(payload.rows || []).map(async (org) => {
|
||||||
|
const linkedTenants = await loadLinkedTenantsForOrganization(org.id);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -5,8 +5,6 @@ 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();
|
||||||
|
|
||||||
@ -377,17 +375,16 @@ 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, organizationId,
|
globalAccess,
|
||||||
|
organizationId,
|
||||||
|
req.query.tenantId || req.query.tenant,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
|
|||||||
@ -5,18 +5,34 @@ 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'));
|
||||||
|
|
||||||
|
|
||||||
@ -137,6 +153,15 @@ 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);
|
||||||
@ -394,22 +419,6 @@ 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
|
||||||
|
|||||||
@ -5,7 +5,6 @@ const ReservationsService = require('../services/reservations');
|
|||||||
const ReservationsDBApi = require('../db/api/reservations');
|
const ReservationsDBApi = require('../db/api/reservations');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@ -406,7 +405,7 @@ router.get('/autocomplete', async (req, res) => {
|
|||||||
req.query.query,
|
req.query.query,
|
||||||
req.query.limit,
|
req.query.limit,
|
||||||
req.query.offset,
|
req.query.offset,
|
||||||
globalAccess, organizationId,
|
globalAccess, organizationId, req.currentUser,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
@ -447,9 +446,12 @@ router.get('/autocomplete', async (req, res) => {
|
|||||||
router.get('/:id', wrapAsync(async (req, res) => {
|
router.get('/:id', wrapAsync(async (req, res) => {
|
||||||
const payload = await ReservationsDBApi.findBy(
|
const payload = await ReservationsDBApi.findBy(
|
||||||
{ id: req.params.id },
|
{ id: req.params.id },
|
||||||
|
{ currentUser: req.currentUser },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return res.status(404).send('Not found');
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -5,7 +5,6 @@ const RolesService = require('../services/roles');
|
|||||||
const RolesDBApi = require('../db/api/roles');
|
const RolesDBApi = require('../db/api/roles');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@ -386,6 +385,9 @@ 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);
|
||||||
|
|||||||
@ -5,7 +5,6 @@ const Service_requestsService = require('../services/service_requests');
|
|||||||
const Service_requestsDBApi = require('../db/api/service_requests');
|
const Service_requestsDBApi = require('../db/api/service_requests');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@ -402,7 +401,7 @@ router.get('/autocomplete', async (req, res) => {
|
|||||||
req.query.query,
|
req.query.query,
|
||||||
req.query.limit,
|
req.query.limit,
|
||||||
req.query.offset,
|
req.query.offset,
|
||||||
globalAccess, organizationId,
|
globalAccess, organizationId, req.currentUser,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
@ -443,9 +442,12 @@ router.get('/autocomplete', async (req, res) => {
|
|||||||
router.get('/:id', wrapAsync(async (req, res) => {
|
router.get('/:id', wrapAsync(async (req, res) => {
|
||||||
const payload = await Service_requestsDBApi.findBy(
|
const payload = await Service_requestsDBApi.findBy(
|
||||||
{ id: req.params.id },
|
{ id: req.params.id },
|
||||||
|
{ currentUser: req.currentUser },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return res.status(404).send('Not found');
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -5,18 +5,33 @@ 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'));
|
||||||
|
|
||||||
|
|
||||||
@ -146,6 +161,15 @@ 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);
|
||||||
@ -403,22 +427,6 @@ 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
|
||||||
|
|||||||
@ -5,12 +5,11 @@ 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 {
|
||||||
@ -129,6 +128,15 @@ 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);
|
||||||
@ -387,17 +395,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, organizationId,
|
globalAccess,
|
||||||
|
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);
|
||||||
|
|||||||
@ -3,8 +3,6 @@ const Booking_requestsDBApi = require('../db/api/booking_requests');
|
|||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
|
||||||
const config = require('../config');
|
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
|
||||||
@ -28,9 +26,9 @@ module.exports = class Booking_requestsService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -70,7 +68,7 @@ module.exports = class Booking_requestsService {
|
|||||||
try {
|
try {
|
||||||
let booking_requests = await Booking_requestsDBApi.findBy(
|
let booking_requests = await Booking_requestsDBApi.findBy(
|
||||||
{id},
|
{id},
|
||||||
{transaction},
|
{transaction, currentUser},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!booking_requests) {
|
if (!booking_requests) {
|
||||||
@ -95,7 +93,7 @@ module.exports = class Booking_requestsService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async deleteByIds(ids, currentUser) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
@ -117,7 +115,7 @@ module.exports = class Booking_requestsService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Booking_requestsDBApi.remove(
|
const removedBooking_requests = await Booking_requestsDBApi.remove(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
@ -125,6 +123,12 @@ module.exports = class Booking_requestsService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!removedBooking_requests) {
|
||||||
|
throw new ValidationError(
|
||||||
|
'booking_requestsNotFound',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
|
|||||||
152
backend/src/services/importer.js
Normal file
152
backend/src/services/importer.js
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
72
backend/src/services/inventoryImportTemplates.js
Normal file
72
backend/src/services/inventoryImportTemplates.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
1253
backend/src/services/portfolioWorkbookImport.js
Normal file
1253
backend/src/services/portfolioWorkbookImport.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,8 @@
|
|||||||
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 csv = require('csv-parser');
|
const { parseCsvImportFile } = require('./importer');
|
||||||
const axios = require('axios');
|
const { getInventoryImportTemplate } = require('./inventoryImportTemplates');
|
||||||
const config = require('../config');
|
|
||||||
const stream = require('stream');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -28,28 +25,17 @@ module.exports = class PropertiesService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await processFile(req, res);
|
const results = await parseCsvImportFile(
|
||||||
const bufferStream = new stream.PassThrough();
|
req,
|
||||||
const results = [];
|
res,
|
||||||
|
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,
|
||||||
@ -95,7 +81,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();
|
||||||
@ -132,7 +118,6 @@ module.exports = class PropertiesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -3,8 +3,6 @@ const Service_requestsDBApi = require('../db/api/service_requests');
|
|||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
|
||||||
const config = require('../config');
|
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
|
||||||
@ -28,9 +26,9 @@ module.exports = class Service_requestsService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -70,7 +68,7 @@ module.exports = class Service_requestsService {
|
|||||||
try {
|
try {
|
||||||
let service_requests = await Service_requestsDBApi.findBy(
|
let service_requests = await Service_requestsDBApi.findBy(
|
||||||
{id},
|
{id},
|
||||||
{transaction},
|
{transaction, currentUser},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!service_requests) {
|
if (!service_requests) {
|
||||||
@ -95,7 +93,7 @@ module.exports = class Service_requestsService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async deleteByIds(ids, currentUser) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
@ -117,7 +115,7 @@ module.exports = class Service_requestsService {
|
|||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Service_requestsDBApi.remove(
|
const removedService_requests = await Service_requestsDBApi.remove(
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
@ -125,6 +123,12 @@ module.exports = class Service_requestsService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!removedService_requests) {
|
||||||
|
throw new ValidationError(
|
||||||
|
'service_requestsNotFound',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
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 csv = require('csv-parser');
|
const { parseCsvImportFile } = require('./importer');
|
||||||
const axios = require('axios');
|
const { getInventoryImportTemplate } = require('./inventoryImportTemplates');
|
||||||
const config = require('../config');
|
|
||||||
const stream = require('stream');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -28,28 +25,17 @@ module.exports = class Unit_typesService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await processFile(req, res);
|
const results = await parseCsvImportFile(
|
||||||
const bufferStream = new stream.PassThrough();
|
req,
|
||||||
const results = [];
|
res,
|
||||||
|
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,
|
||||||
@ -95,7 +81,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();
|
||||||
@ -132,7 +118,6 @@ module.exports = class Unit_typesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
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 csv = require('csv-parser');
|
const { parseCsvImportFile } = require('./importer');
|
||||||
const axios = require('axios');
|
const { getInventoryImportTemplate } = require('./inventoryImportTemplates');
|
||||||
const config = require('../config');
|
|
||||||
const stream = require('stream');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -28,28 +25,17 @@ module.exports = class UnitsService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await processFile(req, res);
|
const results = await parseCsvImportFile(
|
||||||
const bufferStream = new stream.PassThrough();
|
req,
|
||||||
const results = [];
|
res,
|
||||||
|
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,
|
||||||
@ -95,7 +81,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();
|
||||||
@ -132,7 +118,6 @@ module.exports = class UnitsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,56 +1,143 @@
|
|||||||
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) {
|
||||||
let transaction = await db.sequelize.transaction();
|
const 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) {
|
||||||
let user = await UsersDBApi.findBy({email}, {transaction});
|
throw new ValidationError('iam.errors.emailRequired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await UsersDBApi.findBy({ email }, { transaction });
|
||||||
if (user) {
|
if (user) {
|
||||||
throw new ValidationError(
|
throw new ValidationError('iam.errors.userAlreadyExists');
|
||||||
'iam.errors.userAlreadyExists',
|
}
|
||||||
);
|
|
||||||
} else {
|
const normalizedPayload = await normalizeManagedUserPayload(data, currentUser, transaction);
|
||||||
|
|
||||||
await UsersDBApi.create(
|
await UsersDBApi.create(
|
||||||
{data},
|
{ data: normalizedPayload },
|
||||||
|
currentUser.app_role.globalAccess,
|
||||||
globalAccess,
|
|
||||||
|
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
emailsToInvite.push(email);
|
emailsToInvite.push(email);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new ValidationError('iam.errors.emailRequired')
|
|
||||||
}
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -64,21 +151,17 @@ 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")); // convert Buffer to Stream
|
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
|
||||||
|
|
||||||
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', () => {
|
.on('end', resolve)
|
||||||
console.log('results csv', results);
|
.on('error', reject);
|
||||||
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');
|
||||||
}
|
}
|
||||||
@ -87,19 +170,17 @@ module.exports = class UsersService {
|
|||||||
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 && emailsToInvite.length && !sendInvitationEmails) {
|
if (emailsToInvite.length && !sendInvitationEmails) {
|
||||||
|
|
||||||
emailsToInvite.forEach((email) => {
|
emailsToInvite.forEach((email) => {
|
||||||
AuthService.sendPasswordResetEmail(email, 'invitation', host);
|
AuthService.sendPasswordResetEmail(email, 'invitation', host);
|
||||||
});
|
});
|
||||||
@ -109,26 +190,29 @@ 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 {
|
||||||
let users = await UsersDBApi.findBy(
|
const user = await UsersDBApi.findBy({ id }, { transaction });
|
||||||
{id},
|
|
||||||
{transaction},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!users) {
|
if (!user) {
|
||||||
throw new ValidationError(
|
throw new ValidationError('iam.errors.userNotFound');
|
||||||
'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,
|
||||||
data,
|
normalizedPayload,
|
||||||
|
currentUser.app_role.globalAccess,
|
||||||
globalAccess,
|
|
||||||
|
|
||||||
{
|
{
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
@ -137,36 +221,24 @@ 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 {
|
||||||
if (currentUser.id === id) {
|
const user = await UsersDBApi.findBy({ id }, { transaction });
|
||||||
throw new ValidationError(
|
|
||||||
'iam.errors.deletingHimself',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentUser.app_role?.name !== config.roles.admin && currentUser.app_role?.name !== config.roles.super_admin ) {
|
await assertManageableExistingUser(user, currentUser);
|
||||||
throw new ValidationError(
|
|
||||||
'errors.forbidden.message',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await UsersDBApi.remove(
|
await UsersDBApi.remove(id, {
|
||||||
id,
|
|
||||||
{
|
|
||||||
currentUser,
|
currentUser,
|
||||||
transaction,
|
transaction,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -174,6 +246,35 @@ module.exports = class UsersService {
|
|||||||
throw error;
|
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(
|
||||||
|
normalizedIds.map((id) => UsersDBApi.findBy({ id }, { transaction })),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
await assertManageableExistingUser(user, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedUsers = await UsersDBApi.deleteByIds(normalizedIds, {
|
||||||
|
currentUser,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
return deletedUsers;
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1387
backend/yarn.lock
1387
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -3,8 +3,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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: "",
|
||||||
|
|||||||
@ -15,66 +15,58 @@ 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(false)
|
const [isDropdownActive, setIsDropdownActive] = useState(!isDropdownList && !!item.menu)
|
||||||
|
|
||||||
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(
|
const activeLinkColor = useAppSelector((state) => state.style.activeLinkColor)
|
||||||
(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={`flex-none mx-3 ${activeClassAddon}`} size="18" />
|
<BaseIcon path={item.icon} className={`mt-0.5 flex-none ${activeClassAddon}`} size="18" />
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={`grow text-ellipsis line-clamp-1 ${
|
className={`grow break-words text-sm leading-5 ${item.menu ? '' : 'pr-3'} ${activeClassAddon}`}
|
||||||
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={`flex-none ${activeClassAddon}`}
|
className={`mt-0.5 flex-none ${activeClassAddon}`}
|
||||||
w="w-12"
|
size="18"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
const componentClass = [
|
const componentClass = [
|
||||||
'flex cursor-pointer py-1.5 ',
|
'flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3 transition-colors',
|
||||||
isDropdownList ? 'px-6 text-sm' : '',
|
isDropdownList ? 'ml-2 text-sm' : '',
|
||||||
item.color
|
item.color ? getButtonColor(item.color, false, true) : `${asideMenuItemStyle}`,
|
||||||
? getButtonColor(item.color, false, true)
|
isLinkActive ? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800` : '',
|
||||||
: `${asideMenuItemStyle}`,
|
].join(' ')
|
||||||
isLinkActive
|
|
||||||
? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800`
|
|
||||||
: '',
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={'px-3 py-1.5'}>
|
<li className={'px-2 py-1'}>
|
||||||
{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}>
|
||||||
@ -89,8 +81,8 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
|
|||||||
{item.menu && (
|
{item.menu && (
|
||||||
<AsideMenuList
|
<AsideMenuList
|
||||||
menu={item.menu}
|
menu={item.menu}
|
||||||
className={`${asideMenuDropdownStyle} ${
|
className={`${asideMenuDropdownStyle} mt-2 rounded-xl border border-white/5 p-1 ${
|
||||||
isDropdownActive ? 'block dark:bg-slate-800/50' : 'hidden'
|
isDropdownActive ? 'block dark:bg-slate-800/40' : 'hidden'
|
||||||
}`}
|
}`}
|
||||||
isDropdownList
|
isDropdownList
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,88 +1,165 @@
|
|||||||
import React from 'react'
|
import React, { useEffect, useState } from 'react';
|
||||||
import { mdiLogout, mdiClose } from '@mdi/js'
|
import { mdiClose, mdiMinus, mdiPlus } 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 Link from 'next/link';
|
import { loadLinkedTenantSummary } from '../helpers/organizationTenants';
|
||||||
|
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[]>([]);
|
||||||
|
|
||||||
const handleAsideLgCloseClick = (e: React.MouseEvent) => {
|
useEffect(() => {
|
||||||
e.preventDefault()
|
if (typeof window === 'undefined') return;
|
||||||
props.onAsideLgCloseClick()
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
let isMounted = true;
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
|
||||||
const organizationsId = currentUser?.organizations?.id;
|
|
||||||
const [organizations, setOrganizations] = React.useState(null);
|
|
||||||
|
|
||||||
const fetchOrganizations = createAsyncThunk('/org-for-auth', async () => {
|
loadLinkedTenantSummary(organizationId)
|
||||||
try {
|
.then((summary) => {
|
||||||
const response = await axios.get('/org-for-auth');
|
if (isMounted) {
|
||||||
setOrganizations(response.data);
|
setLinkedTenants(summary.rows.slice(0, 2));
|
||||||
return response.data;
|
}
|
||||||
} catch (error) {
|
})
|
||||||
console.error(error.response);
|
.catch((error) => {
|
||||||
throw error;
|
console.error('Failed to load sidebar tenant context:', error);
|
||||||
|
if (isMounted) {
|
||||||
|
setLinkedTenants([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
return () => {
|
||||||
dispatch(fetchOrganizations());
|
isMounted = false;
|
||||||
}, [dispatch]);
|
};
|
||||||
|
}, [
|
||||||
|
currentUser?.organizations?.id,
|
||||||
|
currentUser?.organization?.id,
|
||||||
|
currentUser?.organizationsId,
|
||||||
|
currentUser?.organizationId,
|
||||||
|
]);
|
||||||
|
|
||||||
let organizationName = organizations?.find(item => item.id === organizationsId)?.name;
|
const handleAsideLgCloseClick = (e: React.MouseEvent) => {
|
||||||
if(organizationName?.length > 25){
|
e.preventDefault();
|
||||||
organizationName = organizationName?.substring(0, 25) + '...';
|
props.onAsideLgCloseClick();
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const resizeAside = (direction: 'narrower' | 'wider') => {
|
||||||
|
setAsideWidth((currentWidth) => {
|
||||||
|
const nextWidth =
|
||||||
|
direction === 'wider' ? currentWidth + ASIDE_WIDTH_STEP : currentWidth - ASIDE_WIDTH_STEP;
|
||||||
|
|
||||||
|
return Math.min(MAX_ASIDE_WIDTH, Math.max(MIN_ASIDE_WIDTH, nextWidth));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
|
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)]`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`}
|
className={`flex flex-1 flex-col overflow-hidden border border-white/10 shadow-2xl dark:bg-dark-900 ${asideStyle} ${corners}`}
|
||||||
>
|
>
|
||||||
<div
|
<div className={`flex min-h-20 items-start justify-between gap-3 px-4 py-4 ${asideBrandStyle}`}>
|
||||||
className={`flex flex-row h-14 items-center justify-between ${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"
|
||||||
>
|
>
|
||||||
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
<BaseIcon path={mdiMinus} size="16" />
|
||||||
|
</button>
|
||||||
<b className="font-black">Gracey Corporate Stay Portal</b>
|
<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"
|
||||||
{organizationName && <p>{organizationName}</p>}
|
onClick={() => resizeAside('wider')}
|
||||||
|
disabled={asideWidth >= MAX_ASIDE_WIDTH}
|
||||||
|
aria-label="Make sidebar wider"
|
||||||
|
title="Make sidebar wider"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiPlus} size="16" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="hidden lg:inline-block xl:hidden p-3"
|
className="hidden p-2 text-slate-300 transition hover:text-white lg:inline-block xl:hidden"
|
||||||
onClick={handleAsideLgCloseClick}
|
onClick={handleAsideLgCloseClick}
|
||||||
|
type="button"
|
||||||
|
aria-label="Close sidebar"
|
||||||
>
|
>
|
||||||
<BaseIcon path={mdiClose} />
|
<BaseIcon path={mdiClose} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex-1 overflow-y-auto overflow-x-hidden ${
|
className={`flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 ${
|
||||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -90,5 +167,5 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,21 @@
|
|||||||
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
|
EventProps,
|
||||||
|
SlotInfo,
|
||||||
Views,
|
Views,
|
||||||
momentLocalizer,
|
momentLocalizer,
|
||||||
SlotInfo,
|
|
||||||
EventProps,
|
|
||||||
} from 'react-big-calendar';
|
} from 'react-big-calendar';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
|
||||||
import ListActionsPopover from './ListActionsPopover';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||||||
|
import BaseButton from './BaseButton';
|
||||||
|
import ListActionsPopover from './ListActionsPopover';
|
||||||
|
import LoadingSpinner from './LoadingSpinner';
|
||||||
|
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 = {
|
||||||
@ -22,6 +23,7 @@ type TEvent = {
|
|||||||
title: string;
|
title: string;
|
||||||
start: Date;
|
start: Date;
|
||||||
end: Date;
|
end: Date;
|
||||||
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -33,10 +35,40 @@ type Props = {
|
|||||||
showField: string;
|
showField: string;
|
||||||
pathEdit?: string;
|
pathEdit?: string;
|
||||||
pathView?: string;
|
pathView?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
emptyTitle?: string;
|
||||||
|
emptyDescription?: string;
|
||||||
'start-data-key': string;
|
'start-data-key': string;
|
||||||
'end-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,
|
||||||
@ -46,32 +78,34 @@ const BigCalendar = ({
|
|||||||
showField,
|
showField,
|
||||||
pathEdit,
|
pathEdit,
|
||||||
pathView,
|
pathView,
|
||||||
|
isLoading = false,
|
||||||
|
emptyTitle = 'Nothing is scheduled in this window',
|
||||||
|
emptyDescription = 'Adjust the date range or create a new item to start filling this calendar.',
|
||||||
'start-data-key': startDataKey,
|
'start-data-key': startDataKey,
|
||||||
'end-data-key': endDataKey,
|
'end-data-key': endDataKey,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [myEvents, setMyEvents] = useState<TEvent[]>([]);
|
const [myEvents, setMyEvents] = useState<TEvent[]>([]);
|
||||||
const prevRange = useRef<{ start: string; end: string } | null>(null);
|
const prevRange = useRef<{ start: string; end: string } | null>(null);
|
||||||
|
|
||||||
|
|
||||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||||
const hasUpdatePermission =
|
const hasUpdatePermission =
|
||||||
currentUser &&
|
currentUser && hasPermission(currentUser, `UPDATE_${entityName.toUpperCase()}`);
|
||||||
hasPermission(currentUser, `UPDATE_${entityName.toUpperCase()}`);
|
|
||||||
const hasCreatePermission =
|
const hasCreatePermission =
|
||||||
currentUser &&
|
currentUser && hasPermission(currentUser, `CREATE_${entityName.toUpperCase()}`);
|
||||||
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, 6),
|
scrollToTime: new Date(1970, 1, 1, 8),
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!events || !Array.isArray(events) || !events?.length) return;
|
if (!Array.isArray(events) || !events.length) {
|
||||||
|
setMyEvents([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const formattedEvents = events.map((event) => ({
|
const formattedEvents = events.map((event) => ({
|
||||||
...event,
|
...event,
|
||||||
@ -81,11 +115,9 @@ const BigCalendar = ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
setMyEvents(formattedEvents);
|
setMyEvents(formattedEvents);
|
||||||
}, [endDataKey, events, startDataKey, showField]);
|
}, [endDataKey, events, showField, startDataKey]);
|
||||||
|
|
||||||
const onRangeChange = (
|
const onRangeChange = (range: Date[] | { start: Date; end: Date }) => {
|
||||||
range: Date[] | { start: Date; end: Date },
|
|
||||||
) => {
|
|
||||||
const newRange = { start: '', end: '' };
|
const newRange = { start: '', end: '' };
|
||||||
const format = 'YYYY-MM-DDTHH:mm';
|
const format = 'YYYY-MM-DDTHH:mm';
|
||||||
|
|
||||||
@ -101,7 +133,6 @@ const BigCalendar = ({
|
|||||||
newRange.end = moment(newRange.end).add(1, 'days').format(format);
|
newRange.end = moment(newRange.end).add(1, 'days').format(format);
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if the range fits in the previous range
|
|
||||||
if (
|
if (
|
||||||
prevRange.current &&
|
prevRange.current &&
|
||||||
prevRange.current.start <= newRange.start &&
|
prevRange.current.start <= newRange.start &&
|
||||||
@ -115,8 +146,26 @@ const BigCalendar = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='h-[600px] p-4'>
|
<div className='rounded-2xl border border-gray-200/80 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800/80'>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingSpinner
|
||||||
|
compact
|
||||||
|
label='Loading calendar view'
|
||||||
|
detail='Syncing the current date range and live reservations.'
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && myEvents.length === 0 ? (
|
||||||
|
<CardBoxComponentEmpty
|
||||||
|
compact
|
||||||
|
title={emptyTitle}
|
||||||
|
description={emptyDescription}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={isLoading ? 'pointer-events-none opacity-40' : ''}>
|
||||||
<Calendar
|
<Calendar
|
||||||
|
className='min-h-[680px]'
|
||||||
defaultDate={defaultDate}
|
defaultDate={defaultDate}
|
||||||
defaultView={Views.MONTH}
|
defaultView={Views.MONTH}
|
||||||
events={myEvents}
|
events={myEvents}
|
||||||
@ -125,12 +174,28 @@ const BigCalendar = ({
|
|||||||
onSelectSlot={handleCreateEventAction}
|
onSelectSlot={handleCreateEventAction}
|
||||||
onRangeChange={onRangeChange}
|
onRangeChange={onRangeChange}
|
||||||
scrollToTime={scrollToTime}
|
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={{
|
components={{
|
||||||
|
toolbar: (toolbarProps: any) => <CalendarToolbar {...toolbarProps} />,
|
||||||
event: (props) => (
|
event: (props) => (
|
||||||
<MyCustomEvent
|
<MyCustomEvent
|
||||||
{...props}
|
{...props}
|
||||||
onDelete={handleDeleteAction}
|
onDelete={handleDeleteAction}
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
hasUpdatePermission={!!hasUpdatePermission}
|
||||||
pathEdit={pathEdit}
|
pathEdit={pathEdit}
|
||||||
pathView={pathView}
|
pathView={pathView}
|
||||||
/>
|
/>
|
||||||
@ -138,6 +203,7 @@ const BigCalendar = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -152,16 +218,19 @@ const MyCustomEvent = (
|
|||||||
const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } = props;
|
const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex items-center justify-between relative'}>
|
<div className='flex items-start justify-between gap-2 overflow-hidden rounded-xl'>
|
||||||
|
<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>
|
||||||
|
<p className='truncate text-[11px] text-blue-700/80'>{formatEventWindow(event)}</p>
|
||||||
|
</div>
|
||||||
<ListActionsPopover
|
<ListActionsPopover
|
||||||
className={'w-2 h-2 text-white'}
|
className='h-5 w-5 text-blue-700'
|
||||||
iconClassName={'text-white w-5'}
|
iconClassName='w-4 text-blue-700'
|
||||||
itemId={event.id}
|
itemId={event.id}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
pathEdit={`${pathEdit}${event.id}`}
|
pathEdit={`${pathEdit}${event.id}`}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ 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";
|
||||||
|
|
||||||
|
|
||||||
@ -24,6 +25,22 @@ 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"});
|
||||||
|
|
||||||
@ -83,6 +100,14 @@ 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)
|
||||||
|
|
||||||
@ -97,25 +122,15 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
|
|
||||||
|
|
||||||
setKanbanColumns([
|
setKanbanColumns([
|
||||||
|
{ id: "draft", label: _.startCase("draft") },
|
||||||
{ id: "draft", label: "draft" },
|
{ id: "submitted", label: _.startCase("submitted") },
|
||||||
|
{ id: "in_review", label: _.startCase("in_review") },
|
||||||
{ id: "submitted", label: "submitted" },
|
{ id: "changes_requested", label: _.startCase("changes_requested") },
|
||||||
|
{ id: "approved", label: _.startCase("approved") },
|
||||||
{ id: "in_review", label: "in_review" },
|
{ id: "rejected", label: _.startCase("rejected") },
|
||||||
|
{ id: "expired", label: _.startCase("expired") },
|
||||||
{ id: "changes_requested", label: "changes_requested" },
|
{ id: "converted_to_reservation", label: _.startCase("converted_to_reservation") },
|
||||||
|
{ 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" },
|
|
||||||
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
@ -136,9 +151,56 @@ 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 = '&';
|
||||||
filterItems.forEach((item) => {
|
validFilterItems.forEach((item) => {
|
||||||
const isRangeFilter = filters.find(
|
const isRangeFilter = filters.find(
|
||||||
(filter) =>
|
(filter) =>
|
||||||
filter.title === item.fields.selectedField &&
|
filter.title === item.fields.selectedField &&
|
||||||
@ -162,10 +224,10 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return request;
|
return request;
|
||||||
}, [filterItems, filters]);
|
}, [filters, validFilterItems]);
|
||||||
|
|
||||||
const deleteFilter = (value) => {
|
const deleteFilter = (value) => {
|
||||||
const newItems = filterItems.filter((item) => item.id !== value);
|
const newItems = validFilterItems.filter((item) => item.id !== value);
|
||||||
|
|
||||||
if (newItems.length) {
|
if (newItems.length) {
|
||||||
setFilterItems(newItems);
|
setFilterItems(newItems);
|
||||||
@ -190,7 +252,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
const name = e.target.name;
|
const name = e.target.name;
|
||||||
|
|
||||||
setFilterItems(
|
setFilterItems(
|
||||||
filterItems.map((item) => {
|
validFilterItems.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 } };
|
||||||
|
|
||||||
@ -243,16 +305,16 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
};
|
};
|
||||||
|
|
||||||
const controlClasses =
|
const controlClasses =
|
||||||
'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' +
|
'w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-400 ' +
|
||||||
` ${bgColor} ${focusRing} ${corners} ` +
|
' ' + bgColor + ' ' + focusRing + ' ' + corners + ' ' +
|
||||||
'dark:bg-slate-800 border';
|
'dark:bg-slate-800/80 my-1';
|
||||||
|
|
||||||
|
|
||||||
const dataGrid = (
|
const dataGrid = (
|
||||||
<div className='relative overflow-x-auto'>
|
<div className='relative overflow-x-auto'>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
autoHeight
|
autoHeight
|
||||||
rowHeight={64}
|
rowHeight={56}
|
||||||
sx={dataGridStyles}
|
sx={dataGridStyles}
|
||||||
className={'datagrid--table'}
|
className={'datagrid--table'}
|
||||||
getRowClassName={() => `datagrid--row`}
|
getRowClassName={() => `datagrid--row`}
|
||||||
@ -264,6 +326,9 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
columns: {
|
||||||
|
columnVisibilityModel: compactColumnVisibilityModel,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
disableRowSelectionOnClick
|
disableRowSelectionOnClick
|
||||||
onProcessRowUpdateError={(params) => {
|
onProcessRowUpdateError={(params) => {
|
||||||
@ -302,8 +367,8 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
{validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ?
|
||||||
<CardBox>
|
<CardBox className='mb-6 border border-white/10 shadow-none'>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
checkboxes: ['lorem'],
|
checkboxes: ['lorem'],
|
||||||
@ -314,11 +379,14 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
>
|
>
|
||||||
<Form>
|
<Form>
|
||||||
<>
|
<>
|
||||||
{filterItems && filterItems.map((filterItem) => {
|
{validFilterItems && validFilterItems.map((filterItem) => {
|
||||||
|
const showIncompleteHint = isFilterItemIncomplete(filterItem, filters)
|
||||||
|
const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={filterItem.id} className="flex mb-4">
|
<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 className="flex flex-col w-full mr-3">
|
<div className="flex min-w-0 flex-col">
|
||||||
<div className=" text-gray-500 font-bold">Filter</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Filter</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='selectedField'
|
name='selectedField'
|
||||||
@ -340,8 +408,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 flex-col w-full mr-3">
|
<div className="flex min-w-0 flex-col">
|
||||||
<div className="text-gray-500 font-bold">
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||||
Value
|
Value
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<Field
|
||||||
@ -365,9 +433,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="flex flex-row w-full mr-3">
|
<div className="grid min-w-0 gap-3 md:grid-cols-2">
|
||||||
<div className="flex flex-col w-full mr-3">
|
<div className="flex min-w-0 flex-col">
|
||||||
<div className=" text-gray-500 font-bold">From</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">From</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='filterValueFrom'
|
name='filterValueFrom'
|
||||||
@ -378,7 +446,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-gray-500 font-bold">To</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">To</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='filterValueTo'
|
name='filterValueTo'
|
||||||
@ -394,9 +462,9 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
filter.title ===
|
filter.title ===
|
||||||
filterItem?.fields?.selectedField
|
filterItem?.fields?.selectedField
|
||||||
)?.date ? (
|
)?.date ? (
|
||||||
<div className='flex flex-row w-full mr-3'>
|
<div className='grid min-w-0 gap-3 md:grid-cols-2'>
|
||||||
<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-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>
|
||||||
From
|
From
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<Field
|
||||||
@ -410,7 +478,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-gray-500 font-bold'>To</div>
|
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>To</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='filterValueTo'
|
name='filterValueTo'
|
||||||
@ -423,8 +491,8 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col w-full mr-3">
|
<div className="flex min-w-0 flex-col">
|
||||||
<div className=" text-gray-500 font-bold">Contains</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Contains</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='filterValue'
|
name='filterValue'
|
||||||
@ -435,31 +503,37 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-2">
|
||||||
<div className=" text-gray-500 font-bold">Action</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Action</div>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2"
|
className="my-1 w-full md:w-auto"
|
||||||
type='reset'
|
type='reset'
|
||||||
color='danger'
|
color='whiteDark'
|
||||||
|
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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2 mr-3"
|
className="my-1 mr-0"
|
||||||
type='submit' color='info'
|
type='submit' color='info'
|
||||||
label='Apply'
|
label='Apply'
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2"
|
className="my-1"
|
||||||
type='reset' color='info' outline
|
type='reset' color='whiteDark' outline
|
||||||
label='Cancel'
|
label='Cancel'
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
/>
|
/>
|
||||||
@ -483,6 +557,22 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
|
|
||||||
|
|
||||||
{!showGrid && kanbanColumns && (
|
{!showGrid && kanbanColumns && (
|
||||||
|
<>
|
||||||
|
<div className='mb-6 grid gap-3 md:grid-cols-4'>
|
||||||
|
{bookingKanbanOverview.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>
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
columnFieldName={'status'}
|
columnFieldName={'status'}
|
||||||
showFieldName={'request_code'}
|
showFieldName={'request_code'}
|
||||||
@ -492,6 +582,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
updateThunk={update}
|
updateThunk={update}
|
||||||
columns={kanbanColumns}
|
columns={kanbanColumns}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ 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;
|
||||||
|
|
||||||
@ -38,8 +39,15 @@ 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']),
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
const columns = [
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'tenant',
|
field: 'tenant',
|
||||||
@ -429,4 +437,6 @@ export const loadColumns = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field));
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,9 +1,27 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
const CardBoxComponentEmpty = () => {
|
type Props = {
|
||||||
|
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 className="text-center py-24 text-gray-500 dark:text-slate-400">
|
<div
|
||||||
<p>Nothing's here…</p>
|
className={`flex items-center justify-center px-4 text-center ${compact ? 'py-10' : 'py-24'}`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
110
frontend/src/components/ConnectedEntityCard.tsx
Normal file
110
frontend/src/components/ConnectedEntityCard.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
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
|
||||||
106
frontend/src/components/ConnectedEntityNotice.tsx
Normal file
106
frontend/src/components/ConnectedEntityNotice.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
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
|
||||||
486
frontend/src/components/CurrentWorkspaceChip.tsx
Normal file
486
frontend/src/components/CurrentWorkspaceChip.tsx
Normal file
@ -0,0 +1,486 @@
|
|||||||
|
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
|
||||||
@ -15,6 +15,7 @@ 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";
|
||||||
|
|
||||||
|
|
||||||
@ -77,6 +78,14 @@ 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)
|
||||||
|
|
||||||
@ -103,7 +112,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
|||||||
|
|
||||||
const generateFilterRequests = useMemo(() => {
|
const generateFilterRequests = useMemo(() => {
|
||||||
let request = '&';
|
let request = '&';
|
||||||
filterItems.forEach((item) => {
|
validFilterItems.forEach((item) => {
|
||||||
const isRangeFilter = filters.find(
|
const isRangeFilter = filters.find(
|
||||||
(filter) =>
|
(filter) =>
|
||||||
filter.title === item.fields.selectedField &&
|
filter.title === item.fields.selectedField &&
|
||||||
@ -127,10 +136,10 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return request;
|
return request;
|
||||||
}, [filterItems, filters]);
|
}, [filters, validFilterItems]);
|
||||||
|
|
||||||
const deleteFilter = (value) => {
|
const deleteFilter = (value) => {
|
||||||
const newItems = filterItems.filter((item) => item.id !== value);
|
const newItems = validFilterItems.filter((item) => item.id !== value);
|
||||||
|
|
||||||
if (newItems.length) {
|
if (newItems.length) {
|
||||||
setFilterItems(newItems);
|
setFilterItems(newItems);
|
||||||
@ -151,7 +160,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
|||||||
const name = e.target.name;
|
const name = e.target.name;
|
||||||
|
|
||||||
setFilterItems(
|
setFilterItems(
|
||||||
filterItems.map((item) => {
|
validFilterItems.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 } };
|
||||||
|
|
||||||
@ -261,7 +270,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
{validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ?
|
||||||
<CardBox>
|
<CardBox>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
@ -273,10 +282,13 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
|||||||
>
|
>
|
||||||
<Form>
|
<Form>
|
||||||
<>
|
<>
|
||||||
{filterItems && filterItems.map((filterItem) => {
|
{validFilterItems && validFilterItems.map((filterItem) => {
|
||||||
|
const showIncompleteHint = isFilterItemIncomplete(filterItem, filters)
|
||||||
|
const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={filterItem.id} className="flex mb-4">
|
<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 className="flex flex-col w-full mr-3">
|
<div className="flex w-full flex-col md: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}
|
||||||
@ -299,7 +311,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 flex-col w-full mr-3">
|
<div className="flex w-full flex-col md:mr-3">
|
||||||
<div className="text-gray-500 font-bold">
|
<div className="text-gray-500 font-bold">
|
||||||
Value
|
Value
|
||||||
</div>
|
</div>
|
||||||
@ -324,8 +336,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 flex-row w-full mr-3">
|
<div className="flex w-full flex-col gap-3 sm:flex-row md:mr-3">
|
||||||
<div className="flex flex-col w-full mr-3">
|
<div className="flex w-full flex-col md: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}
|
||||||
@ -353,7 +365,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
|||||||
filter.title ===
|
filter.title ===
|
||||||
filterItem?.fields?.selectedField
|
filterItem?.fields?.selectedField
|
||||||
)?.date ? (
|
)?.date ? (
|
||||||
<div className='flex flex-row w-full mr-3'>
|
<div className='flex w-full flex-col gap-3 sm:flex-row md: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
|
||||||
@ -382,7 +394,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col w-full mr-3">
|
<div className="flex w-full flex-col md: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}
|
||||||
@ -394,10 +406,10 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-2">
|
||||||
<div className=" text-gray-500 font-bold">Action</div>
|
<div className=" text-gray-500 font-bold">Action</div>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2"
|
className="my-1 w-full sm:my-2 sm:w-auto"
|
||||||
type='reset'
|
type='reset'
|
||||||
color='danger'
|
color='danger'
|
||||||
label='Delete'
|
label='Delete'
|
||||||
@ -405,19 +417,24 @@ 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">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2 mr-3"
|
className="my-1 w-full sm:my-2 sm:mr-3 sm:w-auto"
|
||||||
type='submit' color='info'
|
type='submit' color='info'
|
||||||
label='Apply'
|
label='Apply'
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2"
|
className="my-1 w-full sm:my-2 sm:w-auto"
|
||||||
type='reset' color='info' outline
|
type='reset' color='info' outline
|
||||||
label='Cancel'
|
label='Cancel'
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ 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;
|
||||||
|
|
||||||
@ -38,8 +39,15 @@ 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']),
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
const columns = [
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'tenant',
|
field: 'tenant',
|
||||||
@ -341,4 +349,6 @@ export const loadColumns = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field));
|
||||||
};
|
};
|
||||||
|
|||||||
@ -34,7 +34,7 @@ const FormField = ({ icons = [], ...props }: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const controlClassName = [
|
const controlClassName = [
|
||||||
`px-3 py-2 max-w-full border-gray-300 dark:border-dark-700 ${corners} w-full dark:placeholder-gray-400`,
|
`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`,
|
||||||
`${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,10 +54,13 @@ 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} ${icons[index] ? 'pl-10' : ''}`,
|
className: `${controlClassName} ${childClassName} ${icons[index] ? 'pl-10' : ''}`.trim(),
|
||||||
})}
|
})}
|
||||||
{icons[index] && (
|
{icons[index] && (
|
||||||
<BaseIcon
|
<BaseIcon
|
||||||
@ -68,7 +71,8 @@ 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>
|
||||||
|
|||||||
57
frontend/src/components/InventoryImportGuidance.tsx
Normal file
57
frontend/src/components/InventoryImportGuidance.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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;
|
||||||
@ -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,15 +24,12 @@ const KanbanBoard = ({
|
|||||||
updateThunk,
|
updateThunk,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'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'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
|
<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'>
|
||||||
|
<div className='flex min-h-[420px] flex-1 gap-4 overflow-x-auto overflow-y-hidden pb-2'>
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<div key={column.id}>
|
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
|
key={column.id}
|
||||||
entityName={entityName}
|
entityName={entityName}
|
||||||
columnFieldName={columnFieldName}
|
columnFieldName={columnFieldName}
|
||||||
showFieldName={showFieldName}
|
showFieldName={showFieldName}
|
||||||
@ -41,10 +38,10 @@ const KanbanBoard = ({
|
|||||||
deleteThunk={deleteThunk}
|
deleteThunk={deleteThunk}
|
||||||
updateThunk={updateThunk}
|
updateThunk={updateThunk}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</DndProvider>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</DndProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
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 ListActionsPopover from '../ListActionsPopover';
|
|
||||||
import { DragSourceMonitor, useDrag } from 'react-dnd';
|
import { DragSourceMonitor, useDrag } from 'react-dnd';
|
||||||
|
import ListActionsPopover from '../ListActionsPopover';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: any;
|
item: any;
|
||||||
@ -12,6 +12,22 @@ type Props = {
|
|||||||
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,
|
||||||
@ -27,36 +43,89 @@ const KanbanCard = ({
|
|||||||
isDragging: monitor.isDragging(),
|
isDragging: monitor.isDragging(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
[item],
|
[item, column],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const title = item?.[showFieldName] ?? 'Untitled';
|
||||||
|
const dateRange = formatDateRange(item?.check_in_at || item?.start_at, item?.check_out_at || item?.end_at);
|
||||||
|
const locationLabel =
|
||||||
|
item?.preferred_property?.name ||
|
||||||
|
item?.property?.name ||
|
||||||
|
item?.unit?.unit_number ||
|
||||||
|
item?.unit_type?.name ||
|
||||||
|
'';
|
||||||
|
const supportingLabel =
|
||||||
|
item?.requested_by?.email ||
|
||||||
|
item?.requested_by?.firstName ||
|
||||||
|
item?.organization?.name ||
|
||||||
|
item?.organizations?.name ||
|
||||||
|
'';
|
||||||
|
const updatedAt = item?.updatedAt || item?.createdAt;
|
||||||
|
const stats = [
|
||||||
|
item?.guest_count
|
||||||
|
? { label: 'Guests', value: String(item.guest_count) }
|
||||||
|
: null,
|
||||||
|
item?.priority
|
||||||
|
? { label: 'Priority', value: humanize(item.priority) }
|
||||||
|
: null,
|
||||||
|
dateRange
|
||||||
|
? { label: entityName === 'booking_requests' ? 'Stay' : 'Dates', value: dateRange }
|
||||||
|
: null,
|
||||||
|
locationLabel ? { label: 'Location', value: locationLabel } : null,
|
||||||
|
].filter(Boolean) as Array<{ label: string; value: string }>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={drag}
|
ref={drag}
|
||||||
className={
|
className={`rounded-2xl border border-gray-200/80 bg-white p-4 shadow-sm transition ${
|
||||||
`bg-midnightBlueTheme-cardColor dark:bg-dark-800 rounded-md space-y-2 p-4 relative ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`
|
isDragging ? 'cursor-grabbing opacity-70' : 'cursor-grab hover:-translate-y-0.5'
|
||||||
}
|
} dark:border-dark-700 dark:bg-dark-900`}
|
||||||
>
|
>
|
||||||
<div className={'flex items-center justify-between'}>
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div className='min-w-0'>
|
||||||
<Link
|
<Link
|
||||||
href={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
href={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
||||||
className={'text-base font-semibold'}
|
className='block truncate text-sm font-semibold text-gray-900 dark:text-white'
|
||||||
>
|
>
|
||||||
{item[showFieldName] ?? 'No data'}
|
{title}
|
||||||
</Link>
|
</Link>
|
||||||
|
{supportingLabel && (
|
||||||
|
<p className='mt-1 truncate text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
{supportingLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={'flex items-center justify-between'}>
|
|
||||||
<p>{moment(item.createdAt).format('MMM DD hh:mm a')}</p>
|
|
||||||
<ListActionsPopover
|
<ListActionsPopover
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
pathEdit={`/${entityName}/${entityName}-edit/?id=${item.id}`}
|
pathEdit={`/${entityName}/${entityName}-edit/?id=${item.id}`}
|
||||||
pathView={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
pathView={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
||||||
onDelete={(id) => setItemIdToDelete(id)}
|
onDelete={(id) => setItemIdToDelete(id)}
|
||||||
hasUpdatePermission={true}
|
hasUpdatePermission={true}
|
||||||
className={'w-2 h-2 text-white'}
|
className='h-5 w-5 text-gray-400 dark:text-gray-500'
|
||||||
iconClassName={'w-5'}
|
iconClassName='w-5 text-gray-400 dark:text-gray-500'
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,18 +1,20 @@
|
|||||||
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 CardBox from '../CardBox';
|
|
||||||
import CardBoxModal from '../CardBoxModal';
|
|
||||||
import { AsyncThunk } from '@reduxjs/toolkit';
|
import { AsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { useDrop } from 'react-dnd';
|
import { useDrop } from 'react-dnd';
|
||||||
|
import CardBox from '../CardBox';
|
||||||
|
import CardBoxModal from '../CardBoxModal';
|
||||||
|
import LoadingSpinner from '../LoadingSpinner';
|
||||||
|
import CardBoxComponentEmpty from '../CardBoxComponentEmpty';
|
||||||
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: any;
|
filtersQuery: string;
|
||||||
deleteThunk: AsyncThunk<any, any, any>;
|
deleteThunk: AsyncThunk<any, any, any>;
|
||||||
updateThunk: AsyncThunk<any, any, any>;
|
updateThunk: AsyncThunk<any, any, any>;
|
||||||
};
|
};
|
||||||
@ -33,37 +35,20 @@ const KanbanColumn = ({
|
|||||||
deleteThunk,
|
deleteThunk,
|
||||||
updateThunk,
|
updateThunk,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
const dispatch = useAppDispatch();
|
||||||
const [count, setCount] = useState(0);
|
|
||||||
const [data, setData] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [itemIdToDelete, setItemIdToDelete] = useState('');
|
|
||||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||||
const listInnerRef = useRef<HTMLDivElement | null>(null);
|
const listInnerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const [{ dropResult }, drop] = useDrop<
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
{
|
const [count, setCount] = useState(0);
|
||||||
item: any;
|
const [data, setData] = useState<any[]>([]);
|
||||||
column: {
|
const [loading, setLoading] = useState(false);
|
||||||
id: string;
|
const [itemIdToDelete, setItemIdToDelete] = useState('');
|
||||||
label: string;
|
|
||||||
};
|
const [{ dropResult }, drop] = useDrop(
|
||||||
},
|
|
||||||
unknown,
|
|
||||||
{
|
|
||||||
dropResult: DropResult;
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
() => ({
|
() => ({
|
||||||
accept: 'box',
|
accept: 'box',
|
||||||
drop: ({
|
drop: ({ item, column: sourceColumn }: { item: any; column: { id: string; label: string } }) => {
|
||||||
item,
|
|
||||||
column: sourceColumn,
|
|
||||||
}: {
|
|
||||||
item: any;
|
|
||||||
column: { id: string; label: string };
|
|
||||||
}) => {
|
|
||||||
if (sourceColumn.id === column.id) return;
|
if (sourceColumn.id === column.id) return;
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
@ -73,40 +58,42 @@ const KanbanColumn = ({
|
|||||||
[columnFieldName]: column.id,
|
[columnFieldName]: column.id,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).then((res) => {
|
).then(() => {
|
||||||
setData((prevState) => (prevState ? [...prevState, item] : [item]));
|
const movedItem = {
|
||||||
|
...item,
|
||||||
|
[columnFieldName]: column.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
setData((prevState) => [movedItem, ...prevState.filter((record) => record.id !== item.id)]);
|
||||||
setCount((prevState) => prevState + 1);
|
setCount((prevState) => prevState + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { sourceColumn, item };
|
return { sourceColumn, item };
|
||||||
},
|
},
|
||||||
collect: (monitor) => ({
|
collect: (monitor) => ({
|
||||||
dropResult: monitor.getDropResult(),
|
dropResult: monitor.getDropResult() as DropResult | null,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
[],
|
[column.id, columnFieldName, dispatch, updateThunk],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadData = useCallback(
|
const loadData = useCallback(
|
||||||
(page: number, filters = '') => {
|
async (page: number, filters = '') => {
|
||||||
const query = `?page=${page}&limit=${perPage}&field=createdAt&sort=desc&${columnFieldName}=${column.id}&${filters}`;
|
const query = `?page=${page}&limit=${perPage}&field=createdAt&sort=desc&${columnFieldName}=${column.id}&${filters}`;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Axios.get(`${entityName}${query}`)
|
|
||||||
.then((res) => {
|
try {
|
||||||
setData((prevState) =>
|
const res = await Axios.get(`/${entityName}${query}`);
|
||||||
page === 0 ? res.data.rows : [...prevState, ...res.data.rows],
|
setData((prevState) => (page === 0 ? res.data.rows : [...prevState, ...res.data.rows]));
|
||||||
);
|
|
||||||
setCount(res.data.count);
|
setCount(res.data.count);
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
})
|
} catch (error) {
|
||||||
.catch((err) => {
|
console.error(`Failed to load ${entityName} kanban column ${column.id}`, error);
|
||||||
console.error(err);
|
} finally {
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
}
|
||||||
},
|
},
|
||||||
[currentUser, column],
|
[column.id, columnFieldName, entityName],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -115,27 +102,21 @@ const KanbanColumn = ({
|
|||||||
}, [currentUser, loadData, filtersQuery]);
|
}, [currentUser, loadData, filtersQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData(0, filtersQuery);
|
if (!dropResult?.sourceColumn || dropResult.sourceColumn.id !== column.id) {
|
||||||
}, [loadData, filtersQuery]);
|
return;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (dropResult?.sourceColumn && dropResult.sourceColumn.id === column.id) {
|
|
||||||
setData((prevState) =>
|
|
||||||
prevState.filter((item) => item.id !== dropResult.item.id),
|
|
||||||
);
|
|
||||||
setCount((prevState) => prevState - 1);
|
|
||||||
}
|
}
|
||||||
}, [dropResult]);
|
|
||||||
|
setData((prevState) => prevState.filter((item) => item.id !== dropResult.item.id));
|
||||||
|
setCount((prevState) => Math.max(prevState - 1, 0));
|
||||||
|
}, [column.id, dropResult]);
|
||||||
|
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (listInnerRef.current) {
|
if (!listInnerRef.current || loading) return;
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = listInnerRef.current;
|
const { scrollTop, scrollHeight, clientHeight } = listInnerRef.current;
|
||||||
if (Math.floor(scrollTop + clientHeight) === scrollHeight) {
|
if (scrollTop + clientHeight >= scrollHeight - 12 && data.length < count) {
|
||||||
if (data.length < count && !loading) {
|
|
||||||
loadData(currentPage + 1, filtersQuery);
|
loadData(currentPage + 1, filtersQuery);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDeleteConfirm = () => {
|
const onDeleteConfirm = () => {
|
||||||
@ -148,8 +129,8 @@ const KanbanColumn = ({
|
|||||||
loadData(0, filtersQuery);
|
loadData(0, filtersQuery);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((error) => {
|
||||||
console.error(err);
|
console.error(`Failed to delete ${entityName} item ${itemIdToDelete}`, error);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setItemIdToDelete('');
|
setItemIdToDelete('');
|
||||||
@ -158,37 +139,51 @@ const KanbanColumn = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardBox
|
<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'>
|
||||||
hasComponentLayout
|
<div className='flex items-center justify-between border-b border-gray-200/80 px-4 py-4 dark:border-dark-700'>
|
||||||
className={
|
<div>
|
||||||
'w-72 rounded-md h-fit max-h-full overflow-hidden flex flex-col'
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500'>
|
||||||
}
|
Stage
|
||||||
>
|
</p>
|
||||||
<div className={'flex items-center justify-between p-3'}>
|
<p className='mt-1 text-sm font-semibold text-gray-900 dark:text-white'>{column.label}</p>
|
||||||
<p className={'uppercase'}>{column.label}</p>
|
</div>
|
||||||
<p>{count}</p>
|
<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>
|
||||||
<div
|
<div
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
drop(node);
|
drop(node);
|
||||||
listInnerRef.current = node;
|
listInnerRef.current = node;
|
||||||
}}
|
}}
|
||||||
className={'p-3 space-y-3 flex-1 overflow-y-auto max-h-[400px]'}
|
className='max-h-[560px] flex-1 space-y-3 overflow-y-auto p-3'
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
>
|
>
|
||||||
{data?.map((item) => (
|
{data.map((item) => (
|
||||||
<div key={item.id}>
|
|
||||||
<KanbanCard
|
<KanbanCard
|
||||||
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
column={column}
|
column={column}
|
||||||
showFieldName={showFieldName}
|
showFieldName={showFieldName}
|
||||||
entityName={entityName}
|
entityName={entityName}
|
||||||
setItemIdToDelete={setItemIdToDelete}
|
setItemIdToDelete={setItemIdToDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
{!data?.length && (
|
|
||||||
<p className={'text-center py-8 bg-midnightBlueTheme-cardColor dark:bg-dark-800'}>No data</p>
|
{!loading && !data.length && (
|
||||||
|
<CardBoxComponentEmpty
|
||||||
|
compact
|
||||||
|
title={`No ${entityName.replace(/_/g, ' ')} in ${column.label}`}
|
||||||
|
description='When records move into this stage, they will appear here automatically.'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<LoadingSpinner
|
||||||
|
compact
|
||||||
|
label={`Loading ${column.label.toLowerCase()} stage`}
|
||||||
|
detail='Pulling the latest items for this lane.'
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|||||||
@ -1,15 +1,27 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const LoadingSpinner = () => {
|
type Props = {
|
||||||
|
label?: string;
|
||||||
|
detail?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoadingSpinner = ({
|
||||||
|
label = 'Loading your workspace',
|
||||||
|
detail = 'Refreshing the latest operational data.',
|
||||||
|
compact = false,
|
||||||
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center justify-center h-40'>
|
|
||||||
<div className='relative w-12'>
|
|
||||||
<div
|
<div
|
||||||
className='w-12 h-12 rounded-full absolute border-4 border-solid border-gray-200 dark:border-slate-800'
|
className={`flex items-center justify-center ${compact ? 'min-h-[140px]' : 'min-h-[220px]'} px-4 py-6`}
|
||||||
></div>
|
>
|
||||||
<div
|
<div className="flex max-w-md flex-col items-center text-center">
|
||||||
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 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 className="absolute inset-0 animate-spin rounded-full border-4 border-solid border-midnightBlueTheme-iconsColor border-t-transparent dark:border-blue-500" />
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
203
frontend/src/components/MyOrgTenantSummary.tsx
Normal file
203
frontend/src/components/MyOrgTenantSummary.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
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
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } 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'
|
||||||
|
|||||||
@ -1,24 +1,22 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react'
|
||||||
import ImageField from '../ImageField';
|
import Link from 'next/link'
|
||||||
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,
|
||||||
@ -28,39 +26,82 @@ const CardOrganizations = ({
|
|||||||
numPages,
|
numPages,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const asideScrollbarsStyle = useAppSelector(
|
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
|
||||||
(state) => state.style.asideScrollbarsStyle,
|
const bgColor = useAppSelector((state) => state.style.cardsColor)
|
||||||
);
|
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
const corners = useAppSelector((state) => state.style.corners)
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
const focusRing = useAppSelector((state) => state.style.focusRingColor)
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
|
||||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
|
||||||
|
|
||||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
const currentUser = useAppSelector((state) => state.auth.currentUser)
|
||||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS')
|
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS')
|
||||||
|
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS')
|
||||||
|
const [linkedTenantSummaries, setLinkedTenantSummaries] = useState<OrganizationTenantSummaryMap>({})
|
||||||
|
|
||||||
|
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
|
<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'>
|
||||||
role='list'
|
{!loading &&
|
||||||
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
|
organizations.map((item) => {
|
||||||
>
|
const linkedSummary = linkedTenantSummaries[item.id]
|
||||||
{!loading && organizations.map((item, index) => (
|
const linkedCount = linkedSummary?.count || 0
|
||||||
|
const linkedTenantLabel = !canViewTenants
|
||||||
|
? 'Tenant access restricted'
|
||||||
|
: linkedCount
|
||||||
|
? `${linkedCount} linked tenant${linkedCount === 1 ? '' : 's'}`
|
||||||
|
: 'No tenant link'
|
||||||
|
|
||||||
|
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 ${
|
||||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
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 className='min-w-0 flex-1'>
|
||||||
|
<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>
|
||||||
|
|
||||||
<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='mt-3 block line-clamp-2 text-lg font-bold leading-6'>
|
||||||
|
{item.name || 'Unnamed organization'}
|
||||||
<Link href={`/organizations/organizations-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='ml-auto'>
|
<div className='ml-auto'>
|
||||||
<ListActionsPopover
|
<ListActionsPopover
|
||||||
@ -68,44 +109,39 @@ const CardOrganizations = ({
|
|||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
|
pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
|
||||||
pathView={`/organizations/organizations-view/?id=${item.id}`}
|
pathView={`/organizations/organizations-view/?id=${item.id}`}
|
||||||
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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'>
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Name</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.name }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</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'>
|
||||||
|
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>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{!loading && organizations.length === 0 && (
|
{!loading && organizations.length === 0 && (
|
||||||
<div className='col-span-full flex items-center justify-center h-40'>
|
<div className='col-span-full flex h-40 items-center justify-center'>
|
||||||
<p className=''>No data to display</p>
|
<p>No data to display</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
<div className={'flex items-center justify-center my-6'}>
|
<div className='my-6 flex items-center justify-center'>
|
||||||
<Pagination
|
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
|
||||||
currentPage={currentPage}
|
|
||||||
numPages={numPages}
|
|
||||||
setCurrentPage={onPageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default CardOrganizations;
|
export default CardOrganizations
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
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
|
||||||
@ -1,88 +1,133 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react'
|
||||||
import CardBox from '../CardBox';
|
import Link from 'next/link'
|
||||||
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 currentUser = useAppSelector((state) => state.auth.currentUser);
|
|
||||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS')
|
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS')
|
||||||
|
|
||||||
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)
|
||||||
|
const [linkedTenantSummaries, setLinkedTenantSummaries] = useState<OrganizationTenantSummaryMap>({})
|
||||||
|
|
||||||
|
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 list rows:', error)
|
||||||
|
if (isActive) {
|
||||||
|
setLinkedTenantSummaries({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false
|
||||||
|
}
|
||||||
|
}, [canViewTenants, organizations])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='relative overflow-x-auto p-4 space-y-4'>
|
<div className='relative space-y-4 overflow-x-auto p-4'>
|
||||||
{loading && <LoadingSpinner />}
|
{loading && <LoadingSpinner />}
|
||||||
{!loading && organizations.map((item) => (
|
{!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}>
|
<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 items-start overflow-hidden border border-gray-600 ${bgColor} ${
|
||||||
<Link
|
corners !== 'rounded-full' ? corners : 'rounded-3xl'
|
||||||
href={`/organizations/organizations-view/?id=${item.id}`}
|
} dark:bg-dark-900`}
|
||||||
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'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
|
<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'>
|
||||||
<div className={'flex-1 px-3'}>
|
<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'>
|
||||||
<p className={'text-xs text-gray-500 '}>Name</p>
|
Organization
|
||||||
<p className={'line-clamp-2'}>{ item.name }</p>
|
</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>
|
</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>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<div className='flex shrink-0 items-start p-4'>
|
||||||
<ListActionsPopover
|
<ListActionsPopover
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
|
pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
|
||||||
pathView={`/organizations/organizations-view/?id=${item.id}`}
|
pathView={`/organizations/organizations-view/?id=${item.id}`}
|
||||||
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{!loading && organizations.length === 0 && (
|
{!loading && organizations.length === 0 && (
|
||||||
<div className='col-span-full flex items-center justify-center h-40'>
|
<div className='col-span-full flex h-40 items-center justify-center'>
|
||||||
<p className=''>No data to display</p>
|
<p>No data to display</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={'flex items-center justify-center my-6'}>
|
<div className='my-6 flex items-center justify-center'>
|
||||||
<Pagination
|
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
|
||||||
currentPage={currentPage}
|
|
||||||
numPages={numPages}
|
|
||||||
setCurrentPage={onPageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ListOrganizations
|
export default ListOrganizations
|
||||||
@ -13,6 +13,8 @@ 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";
|
||||||
@ -33,6 +35,7 @@ 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: '',
|
||||||
@ -42,6 +45,7 @@ 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);
|
||||||
@ -170,7 +174,33 @@ 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({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isActive = true;
|
||||||
|
|
||||||
|
loadLinkedTenantSummaries(organizationRows.map((item: any) => item?.id))
|
||||||
|
.then((summaries) => {
|
||||||
|
if (isActive) {
|
||||||
|
setLinkedTenantSummaries(summaries);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load linked tenants for organization table:', error);
|
||||||
|
if (isActive) {
|
||||||
|
setLinkedTenantSummaries({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
};
|
||||||
|
}, [canViewTenants, organizations]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
@ -179,8 +209,10 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
|
|||||||
handleDeleteModalAction,
|
handleDeleteModalAction,
|
||||||
`organizations`,
|
`organizations`,
|
||||||
currentUser,
|
currentUser,
|
||||||
|
linkedTenantSummaries,
|
||||||
|
canViewTenants,
|
||||||
).then((newCols) => setColumns(newCols));
|
).then((newCols) => setColumns(newCols));
|
||||||
}, [currentUser]);
|
}, [canViewTenants, currentUser, linkedTenantSummaries]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -211,7 +243,7 @@ const TableSampleOrganizations = ({ filterItems, setFilterItems, filters, showGr
|
|||||||
<div className='relative overflow-x-auto'>
|
<div className='relative overflow-x-auto'>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
autoHeight
|
autoHeight
|
||||||
rowHeight={64}
|
rowHeight={78}
|
||||||
sx={dataGridStyles}
|
sx={dataGridStyles}
|
||||||
className={'datagrid--table'}
|
className={'datagrid--table'}
|
||||||
getRowClassName={() => `datagrid--row`}
|
getRowClassName={() => `datagrid--row`}
|
||||||
|
|||||||
@ -1,83 +1,70 @@
|
|||||||
import React from 'react';
|
import React from 'react'
|
||||||
import BaseIcon from '../BaseIcon';
|
import { GridRowParams } from '@mui/x-data-grid'
|
||||||
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
|
|
||||||
import axios from 'axios';
|
|
||||||
import {
|
|
||||||
GridActionsCellItem,
|
|
||||||
GridRowParams,
|
|
||||||
GridValueGetterParams,
|
|
||||||
} from '@mui/x-data-grid';
|
|
||||||
import ImageField from '../ImageField';
|
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
|
||||||
import dataFormatter from '../../helpers/dataFormatter'
|
|
||||||
import DataGridMultiSelect from "../DataGridMultiSelect";
|
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
|
||||||
|
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
import LinkedTenantsPreview from './LinkedTenantsPreview'
|
||||||
|
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,
|
||||||
user
|
linkedTenantSummaries: OrganizationTenantSummaryMap = {},
|
||||||
|
canViewTenants = true,
|
||||||
) => {
|
) => {
|
||||||
async function callOptionsApi(entityName: string) {
|
|
||||||
|
|
||||||
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await axios(`/${entityName}/autocomplete?limit=100`);
|
|
||||||
return data.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_ORGANIZATIONS')
|
const hasUpdatePermission = hasPermission(user, 'UPDATE_ORGANIZATIONS')
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: 'name',
|
||||||
headerName: 'Name',
|
headerName: 'Name',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 180,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
editable: hasUpdatePermission,
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'linkedTenants',
|
||||||
|
headerName: 'Linked tenants',
|
||||||
|
flex: 1.2,
|
||||||
|
minWidth: 220,
|
||||||
|
filterable: false,
|
||||||
|
sortable: false,
|
||||||
|
editable: false,
|
||||||
|
headerClassName: 'datagrid--header',
|
||||||
|
cellClassName: 'datagrid--cell',
|
||||||
|
renderCell: (params: any) => (
|
||||||
|
<div className='flex min-h-[52px] items-center py-2'>
|
||||||
|
<LinkedTenantsPreview
|
||||||
|
summary={linkedTenantSummaries[params?.row?.id]}
|
||||||
|
compact
|
||||||
|
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) => [
|
||||||
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
<div key={params?.row?.id}>
|
||||||
<ListActionsPopover
|
<ListActionsPopover
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
itemId={params?.row?.id}
|
itemId={params?.row?.id}
|
||||||
pathEdit={`/organizations/organizations-edit/?id=${params?.row?.id}`}
|
pathEdit={`/organizations/organizations-edit/?id=${params?.row?.id}`}
|
||||||
pathView={`/organizations/organizations-view/?id=${params?.row?.id}`}
|
pathView={`/organizations/organizations-view/?id=${params?.row?.id}`}
|
||||||
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,15 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ImageField from '../ImageField';
|
|
||||||
import ListActionsPopover from '../ListActionsPopover';
|
|
||||||
import { useAppSelector } from '../../stores/hooks';
|
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
|
||||||
import { Pagination } from '../Pagination';
|
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
|
||||||
import LoadingSpinner from "../LoadingSpinner";
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import CardBox from '../CardBox';
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
import ListActionsPopover from '../ListActionsPopover';
|
||||||
|
import { Pagination } from '../Pagination';
|
||||||
|
import LoadingSpinner from '../LoadingSpinner';
|
||||||
|
import CardBoxComponentEmpty from '../CardBoxComponentEmpty';
|
||||||
|
import { useAppSelector } from '../../stores/hooks';
|
||||||
|
import { hasPermission } from '../../helpers/userPermissions';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
properties: any[];
|
properties: any[];
|
||||||
@ -28,241 +25,110 @@ const CardProperties = ({
|
|||||||
numPages,
|
numPages,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const asideScrollbarsStyle = useAppSelector(
|
|
||||||
(state) => state.style.asideScrollbarsStyle,
|
|
||||||
);
|
|
||||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
|
||||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
|
||||||
|
|
||||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PROPERTIES')
|
const corners = useAppSelector((state) => state.style.corners);
|
||||||
|
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
||||||
|
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PROPERTIES');
|
||||||
|
const cardRadius = corners !== 'rounded-full' ? corners : 'rounded-3xl';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'p-4'}>
|
<>
|
||||||
{loading && <LoadingSpinner />}
|
<div className='space-y-4 p-4'>
|
||||||
<ul
|
{loading && (
|
||||||
role='list'
|
<LoadingSpinner
|
||||||
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
|
label='Loading properties'
|
||||||
>
|
detail='Preparing the current portfolio snapshot and live inventory.'
|
||||||
{!loading && properties.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 md:p-0 md:block gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
|
{!loading &&
|
||||||
|
properties.map((item) => {
|
||||||
|
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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBox key={item.id} className='shadow-none'>
|
||||||
|
<div
|
||||||
|
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'>
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<div className='flex flex-wrap items-center gap-3'>
|
||||||
<Link
|
<Link
|
||||||
href={`/properties/properties-view/?id=${item.id}`}
|
href={`/properties/properties-view/?id=${item.id}`}
|
||||||
className={'cursor-pointer'}
|
className='text-lg font-semibold leading-6 text-gray-900 dark:text-white'
|
||||||
>
|
>
|
||||||
<ImageField
|
{item.name || item.code || 'Untitled property'}
|
||||||
name={'Avatar'}
|
|
||||||
image={item.images}
|
|
||||||
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'
|
|
||||||
imageClassName='h-full w-full flex-none rounded-lg md:rounded-b-none bg-white object-cover'
|
|
||||||
/>
|
|
||||||
<p className={'px-6 py-2 font-semibold'}>{item.name}</p>
|
|
||||||
</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>
|
||||||
<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='mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
||||||
<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'>Code</p>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<p className='mt-1 text-sm font-medium'>{item.code || '—'}</p>
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ dataFormatter.tenantsOneListFormatter(item.tenant) }
|
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
<div>
|
||||||
|
<p className='text-xs uppercase tracking-wide text-gray-500'>Timezone</p>
|
||||||
|
<p className='mt-1 text-sm font-medium'>{item.timezone || '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs uppercase tracking-wide text-gray-500'>Units</p>
|
||||||
|
<p className='mt-1 text-sm font-medium'>{unitsCount}</p>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</dd>
|
<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 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>
|
</div>
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<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 && (
|
||||||
<div className='col-span-full flex items-center justify-center h-40'>
|
<CardBoxComponentEmpty
|
||||||
<p className=''>No data to display</p>
|
title='No properties match this view'
|
||||||
</div>
|
description='Try clearing filters or add a property to bring the portfolio into view.'
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
<div className={'flex items-center justify-center my-6'}>
|
|
||||||
<Pagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
numPages={numPages}
|
|
||||||
setCurrentPage={onPageChange}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='my-6 flex items-center justify-center'>
|
||||||
|
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CardProperties;
|
export default CardProperties
|
||||||
|
|||||||
@ -23,6 +23,16 @@ 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"});
|
||||||
|
|
||||||
@ -204,16 +214,16 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
|||||||
};
|
};
|
||||||
|
|
||||||
const controlClasses =
|
const controlClasses =
|
||||||
'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' +
|
'w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-400 ' +
|
||||||
` ${bgColor} ${focusRing} ${corners} ` +
|
' ' + bgColor + ' ' + focusRing + ' ' + corners + ' ' +
|
||||||
'dark:bg-slate-800 border';
|
'dark:bg-slate-800/80 my-1';
|
||||||
|
|
||||||
|
|
||||||
const dataGrid = (
|
const dataGrid = (
|
||||||
<div className='relative overflow-x-auto'>
|
<div className='relative overflow-x-auto'>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
autoHeight
|
autoHeight
|
||||||
rowHeight={64}
|
rowHeight={56}
|
||||||
sx={dataGridStyles}
|
sx={dataGridStyles}
|
||||||
className={'datagrid--table'}
|
className={'datagrid--table'}
|
||||||
getRowClassName={() => `datagrid--row`}
|
getRowClassName={() => `datagrid--row`}
|
||||||
@ -225,6 +235,9 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
|||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
columns: {
|
||||||
|
columnVisibilityModel: compactColumnVisibilityModel,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
disableRowSelectionOnClick
|
disableRowSelectionOnClick
|
||||||
onProcessRowUpdateError={(params) => {
|
onProcessRowUpdateError={(params) => {
|
||||||
@ -264,7 +277,7 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
||||||
<CardBox>
|
<CardBox className='mb-6 border border-white/10 shadow-none'>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
checkboxes: ['lorem'],
|
checkboxes: ['lorem'],
|
||||||
@ -277,9 +290,9 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
|||||||
<>
|
<>
|
||||||
{filterItems && filterItems.map((filterItem) => {
|
{filterItems && filterItems.map((filterItem) => {
|
||||||
return (
|
return (
|
||||||
<div key={filterItem.id} className="flex mb-4">
|
<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 className="flex flex-col w-full mr-3">
|
<div className="flex min-w-0 flex-col">
|
||||||
<div className=" text-gray-500 font-bold">Filter</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Filter</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='selectedField'
|
name='selectedField'
|
||||||
@ -301,8 +314,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 flex-col w-full mr-3">
|
<div className="flex min-w-0 flex-col">
|
||||||
<div className="text-gray-500 font-bold">
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||||
Value
|
Value
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<Field
|
||||||
@ -326,9 +339,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="flex flex-row w-full mr-3">
|
<div className="grid min-w-0 gap-3 md:grid-cols-2">
|
||||||
<div className="flex flex-col w-full mr-3">
|
<div className="flex min-w-0 flex-col">
|
||||||
<div className=" text-gray-500 font-bold">From</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">From</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='filterValueFrom'
|
name='filterValueFrom'
|
||||||
@ -339,7 +352,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-gray-500 font-bold">To</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">To</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='filterValueTo'
|
name='filterValueTo'
|
||||||
@ -355,9 +368,9 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
|||||||
filter.title ===
|
filter.title ===
|
||||||
filterItem?.fields?.selectedField
|
filterItem?.fields?.selectedField
|
||||||
)?.date ? (
|
)?.date ? (
|
||||||
<div className='flex flex-row w-full mr-3'>
|
<div className='grid min-w-0 gap-3 md:grid-cols-2'>
|
||||||
<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-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>
|
||||||
From
|
From
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<Field
|
||||||
@ -371,7 +384,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-gray-500 font-bold'>To</div>
|
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>To</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='filterValueTo'
|
name='filterValueTo'
|
||||||
@ -384,8 +397,8 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col w-full mr-3">
|
<div className="flex min-w-0 flex-col">
|
||||||
<div className=" text-gray-500 font-bold">Contains</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Contains</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='filterValue'
|
name='filterValue'
|
||||||
@ -397,11 +410,12 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className=" text-gray-500 font-bold">Action</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Action</div>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2"
|
className="my-1"
|
||||||
type='reset'
|
type='reset'
|
||||||
color='danger'
|
color='whiteDark'
|
||||||
|
outline
|
||||||
label='Delete'
|
label='Delete'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
deleteFilter(filterItem.id)
|
deleteFilter(filterItem.id)
|
||||||
@ -411,16 +425,16 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<div className="flex">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2 mr-3"
|
className="my-1 mr-0"
|
||||||
type='submit' color='info'
|
type='submit' color='info'
|
||||||
label='Apply'
|
label='Apply'
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2"
|
className="my-1"
|
||||||
type='reset' color='info' outline
|
type='reset' color='whiteDark' outline
|
||||||
label='Cancel'
|
label='Cancel'
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
/>
|
/>
|
||||||
|
|||||||
44
frontend/src/components/PublicSiteFooter.tsx
Normal file
44
frontend/src/components/PublicSiteFooter.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/components/PublicSiteHeader.tsx
Normal file
41
frontend/src/components/PublicSiteHeader.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ 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";
|
||||||
|
|
||||||
|
|
||||||
@ -22,7 +23,28 @@ import BigCalendar from "../BigCalendar";
|
|||||||
import { SlotInfo } from 'react-big-calendar';
|
import { SlotInfo } from 'react-big-calendar';
|
||||||
|
|
||||||
|
|
||||||
const perPage = 100
|
const perPage = 25
|
||||||
|
|
||||||
|
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"});
|
||||||
@ -80,6 +102,14 @@ 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)
|
||||||
|
|
||||||
@ -110,9 +140,66 @@ 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 = '&';
|
||||||
filterItems.forEach((item) => {
|
validFilterItems.forEach((item) => {
|
||||||
const isRangeFilter = filters.find(
|
const isRangeFilter = filters.find(
|
||||||
(filter) =>
|
(filter) =>
|
||||||
filter.title === item.fields.selectedField &&
|
filter.title === item.fields.selectedField &&
|
||||||
@ -136,10 +223,10 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return request;
|
return request;
|
||||||
}, [filterItems, filters]);
|
}, [filters, validFilterItems]);
|
||||||
|
|
||||||
const deleteFilter = (value) => {
|
const deleteFilter = (value) => {
|
||||||
const newItems = filterItems.filter((item) => item.id !== value);
|
const newItems = validFilterItems.filter((item) => item.id !== value);
|
||||||
|
|
||||||
if (newItems.length) {
|
if (newItems.length) {
|
||||||
setFilterItems(newItems);
|
setFilterItems(newItems);
|
||||||
@ -160,7 +247,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
|||||||
const name = e.target.name;
|
const name = e.target.name;
|
||||||
|
|
||||||
setFilterItems(
|
setFilterItems(
|
||||||
filterItems.map((item) => {
|
validFilterItems.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 } };
|
||||||
|
|
||||||
@ -211,16 +298,16 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
|||||||
};
|
};
|
||||||
|
|
||||||
const controlClasses =
|
const controlClasses =
|
||||||
'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' +
|
'w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-400 ' +
|
||||||
` ${bgColor} ${focusRing} ${corners} ` +
|
' ' + bgColor + ' ' + focusRing + ' ' + corners + ' ' +
|
||||||
'dark:bg-slate-800 border';
|
'dark:bg-slate-800/80 my-1';
|
||||||
|
|
||||||
|
|
||||||
const dataGrid = (
|
const dataGrid = (
|
||||||
<div className='relative overflow-x-auto'>
|
<div className='relative overflow-x-auto'>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
autoHeight
|
autoHeight
|
||||||
rowHeight={64}
|
rowHeight={56}
|
||||||
sx={dataGridStyles}
|
sx={dataGridStyles}
|
||||||
className={'datagrid--table'}
|
className={'datagrid--table'}
|
||||||
getRowClassName={() => `datagrid--row`}
|
getRowClassName={() => `datagrid--row`}
|
||||||
@ -229,9 +316,12 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
|||||||
initialState={{
|
initialState={{
|
||||||
pagination: {
|
pagination: {
|
||||||
paginationModel: {
|
paginationModel: {
|
||||||
pageSize: 10,
|
pageSize: 25,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
columns: {
|
||||||
|
columnVisibilityModel: compactColumnVisibilityModel,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
disableRowSelectionOnClick
|
disableRowSelectionOnClick
|
||||||
onProcessRowUpdateError={(params) => {
|
onProcessRowUpdateError={(params) => {
|
||||||
@ -258,7 +348,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
|||||||
: setSortModel([{ field: '', sort: 'desc' }]);
|
: setSortModel([{ field: '', sort: 'desc' }]);
|
||||||
}}
|
}}
|
||||||
rowCount={count}
|
rowCount={count}
|
||||||
pageSizeOptions={[10]}
|
pageSizeOptions={[25]}
|
||||||
paginationMode={'server'}
|
paginationMode={'server'}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onPaginationModelChange={(params) => {
|
onPaginationModelChange={(params) => {
|
||||||
@ -270,8 +360,8 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
{validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ?
|
||||||
<CardBox>
|
<CardBox className='mb-6 border border-white/10 shadow-none'>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
checkboxes: ['lorem'],
|
checkboxes: ['lorem'],
|
||||||
@ -282,11 +372,14 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
|||||||
>
|
>
|
||||||
<Form>
|
<Form>
|
||||||
<>
|
<>
|
||||||
{filterItems && filterItems.map((filterItem) => {
|
{validFilterItems && validFilterItems.map((filterItem) => {
|
||||||
|
const showIncompleteHint = isFilterItemIncomplete(filterItem, filters)
|
||||||
|
const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={filterItem.id} className="flex mb-4">
|
<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 className="flex flex-col w-full mr-3">
|
<div className="flex min-w-0 flex-col">
|
||||||
<div className=" text-gray-500 font-bold">Filter</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Filter</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='selectedField'
|
name='selectedField'
|
||||||
@ -308,8 +401,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 flex-col w-full mr-3">
|
<div className="flex min-w-0 flex-col">
|
||||||
<div className="text-gray-500 font-bold">
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||||
Value
|
Value
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<Field
|
||||||
@ -333,9 +426,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="flex flex-row w-full mr-3">
|
<div className="grid min-w-0 gap-3 md:grid-cols-2">
|
||||||
<div className="flex flex-col w-full mr-3">
|
<div className="flex min-w-0 flex-col">
|
||||||
<div className=" text-gray-500 font-bold">From</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">From</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='filterValueFrom'
|
name='filterValueFrom'
|
||||||
@ -346,7 +439,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-gray-500 font-bold">To</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">To</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='filterValueTo'
|
name='filterValueTo'
|
||||||
@ -362,9 +455,9 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
|||||||
filter.title ===
|
filter.title ===
|
||||||
filterItem?.fields?.selectedField
|
filterItem?.fields?.selectedField
|
||||||
)?.date ? (
|
)?.date ? (
|
||||||
<div className='flex flex-row w-full mr-3'>
|
<div className='grid min-w-0 gap-3 md:grid-cols-2'>
|
||||||
<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-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>
|
||||||
From
|
From
|
||||||
</div>
|
</div>
|
||||||
<Field
|
<Field
|
||||||
@ -378,7 +471,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-gray-500 font-bold'>To</div>
|
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>To</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='filterValueTo'
|
name='filterValueTo'
|
||||||
@ -391,8 +484,8 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col w-full mr-3">
|
<div className="flex min-w-0 flex-col">
|
||||||
<div className=" text-gray-500 font-bold">Contains</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Contains</div>
|
||||||
<Field
|
<Field
|
||||||
className={controlClasses}
|
className={controlClasses}
|
||||||
name='filterValue'
|
name='filterValue'
|
||||||
@ -403,31 +496,37 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-2">
|
||||||
<div className=" text-gray-500 font-bold">Action</div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Action</div>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2"
|
className="my-1 w-full md:w-auto"
|
||||||
type='reset'
|
type='reset'
|
||||||
color='danger'
|
color='whiteDark'
|
||||||
|
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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2 mr-3"
|
className="my-1 mr-0"
|
||||||
type='submit' color='info'
|
type='submit' color='info'
|
||||||
label='Apply'
|
label='Apply'
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2"
|
className="my-1"
|
||||||
type='reset' color='info' outline
|
type='reset' color='whiteDark' outline
|
||||||
label='Cancel'
|
label='Cancel'
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
/>
|
/>
|
||||||
@ -450,6 +549,22 @@ 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'}
|
||||||
@ -462,8 +577,12 @@ 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'}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ 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;
|
||||||
|
|
||||||
@ -38,8 +39,15 @@ 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']),
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
const columns = [
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'tenant',
|
field: 'tenant',
|
||||||
@ -524,4 +532,6 @@ export const loadColumns = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field));
|
||||||
};
|
};
|
||||||
|
|||||||
@ -55,7 +55,7 @@ const TableSampleRoles = ({ filterItems, setFilterItems, filters, showGrid }) =>
|
|||||||
if (request !== filterRequest) setFilterRequest(request);
|
if (request !== filterRequest) setFilterRequest(request);
|
||||||
const { sort, field } = sortModel[0];
|
const { sort, field } = sortModel[0];
|
||||||
|
|
||||||
const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`;
|
const query = `?page=${page}&limit=${perPage}&businessOnly=true${request}&sort=${sort}&field=${field}`;
|
||||||
dispatch(fetch({ limit: perPage, page, query }));
|
dispatch(fetch({ limit: perPage, page, query }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -6,5 +6,5 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SectionMain({ children }: Props) {
|
export default function SectionMain({ children }: Props) {
|
||||||
return <section className={`p-6 ${containerMaxW}`}>{children}</section>
|
return <section className={`px-5 py-6 md:px-8 md:py-8 ${containerMaxW}`}>{children}</section>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
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';
|
||||||
@ -9,21 +7,35 @@ type Props = {
|
|||||||
icon: string
|
icon: string
|
||||||
title: string
|
title: string
|
||||||
main?: boolean
|
main?: boolean
|
||||||
|
subtitle?: string
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SectionTitleLineWithButton({ icon, title, main = false, children }: Props) {
|
export default function SectionTitleLineWithButton({
|
||||||
const hasChildren = !!Children.count(children)
|
icon,
|
||||||
|
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 className={`${main ? '' : 'pt-6'} mb-6 flex items-center justify-between`}>
|
<section
|
||||||
<div className="flex items-center justify-start">
|
className={`${main ? '' : 'pt-6'} mb-6 flex flex-col gap-4 md:flex-row md:items-start md:justify-between`}
|
||||||
{icon && main && <IconRounded icon={icon} color="light" className="mr-3" bg />}
|
>
|
||||||
{icon && !main && <BaseIcon path={icon} className="mr-2" size="20" />}
|
<div className="flex min-w-0 items-start justify-start gap-3">
|
||||||
|
{icon && main && <IconRounded icon={icon} color="light" className="mt-1" bg />}
|
||||||
|
{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>
|
<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>
|
||||||
{children}
|
</div>
|
||||||
{!hasChildren && <BaseButton icon={mdiCog} color="whiteDark" />}
|
{hasChildren ? <div className="flex flex-wrap items-center gap-3">{childArray}</div> : null}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,38 +1,91 @@
|
|||||||
import React, {useEffect, useId, useState} from 'react';
|
import React, { useEffect, useId, useMemo, 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'
|
||||||
|
|
||||||
export const SelectField = ({ options, field, form, itemRef, showField, disabled }) => {
|
const buildQueryString = (itemRef, queryParams) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (itemRef === 'roles') {
|
||||||
|
params.set('businessOnly', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof queryParams === 'string') {
|
||||||
|
const extraParams = new URLSearchParams(queryParams);
|
||||||
|
extraParams.forEach((value, key) => params.set(key, value));
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryParams && typeof queryParams === 'object') {
|
||||||
|
Object.entries(queryParams).forEach(([key, value]) => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
params.set(key, String(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return params.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectField = ({ options, field, form, itemRef, showField, disabled, queryParams, 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(options?.id && field?.value?.id) {
|
if (field?.value?.id) {
|
||||||
setValue({value: field.value?.id, label: field.value[showField]})
|
setValue({ value: field.value.id, label: field.value[showField || 'name'] || field.value.label });
|
||||||
form.setFieldValue(field.name, field.value?.id);
|
form.setFieldValue(field.name, field.value.id);
|
||||||
} else if (!field.value) {
|
} else if (options?.id) {
|
||||||
|
const label = options[showField || 'name'] || options.label || options.firstName || options.email || options.request_code || options.reservation_code || options.summary;
|
||||||
|
setValue({ value: options.id, label });
|
||||||
|
if (field?.value !== options.id) {
|
||||||
|
form.setFieldValue(field.name, options.id);
|
||||||
|
}
|
||||||
|
} else if (!field?.value) {
|
||||||
setValue(null);
|
setValue(null);
|
||||||
}
|
}
|
||||||
}, [options?.id, field?.value?.id, field?.value])
|
}, [options, field?.value, form, field?.name, showField])
|
||||||
|
|
||||||
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 path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}`;
|
const params = new URLSearchParams(extraQueryString);
|
||||||
|
params.set('limit', String(PAGE_SIZE));
|
||||||
|
params.set('offset', String(loadedOptions.length));
|
||||||
|
if (inputValue) {
|
||||||
|
params.set('query', inputValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `/${itemRef}/autocomplete?${params.toString()}`;
|
||||||
const { data } = await axios(path);
|
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={{
|
||||||
@ -47,7 +100,8 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled
|
|||||||
defaultOptions
|
defaultOptions
|
||||||
isDisabled={disabled}
|
isDisabled={disabled}
|
||||||
isClearable
|
isClearable
|
||||||
|
placeholder={placeholder || 'Select...'}
|
||||||
|
noOptionsMessage={getNoOptionsMessage}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,30 +2,94 @@ 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 (field.value?.[0] && typeof field.value[0] !== 'string') {
|
if (!Array.isArray(field?.value) || field.value.length === 0) {
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const nextValue = field.value
|
||||||
if (options) {
|
.map((item) => {
|
||||||
setValue(options.map((el) => ({ value: el.id, label: el[showField] })));
|
if (typeof item === 'string') {
|
||||||
form.setFieldValue(
|
const matchingOption = Array.isArray(options)
|
||||||
field.name,
|
? options.find((option) => option?.id === item || option?.value === item)
|
||||||
options.map((el) => ({ value: el.id, label: el[showField] })),
|
: null;
|
||||||
);
|
|
||||||
|
return {
|
||||||
|
value: item,
|
||||||
|
label: getOptionLabel(matchingOption, showField) || item,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [options]);
|
|
||||||
|
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]);
|
||||||
|
|
||||||
const mapResponseToValuesAndLabels = (data) => ({
|
const mapResponseToValuesAndLabels = (data) => ({
|
||||||
value: data.id,
|
value: data.id,
|
||||||
@ -33,10 +97,12 @@ export const SelectFieldMany = ({ options, field, form, itemRef, showField }) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleChange = (data: any) => {
|
const handleChange = (data: any) => {
|
||||||
setValue(data)
|
const nextValue = Array.isArray(data) ? data : [];
|
||||||
|
|
||||||
|
setValue(nextValue);
|
||||||
form.setFieldValue(
|
form.setFieldValue(
|
||||||
field.name,
|
field.name,
|
||||||
data.map(el => (el?.value || null)),
|
nextValue.map((item) => item?.value).filter(Boolean),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -46,8 +112,9 @@ 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={{
|
||||||
|
|||||||
@ -15,6 +15,7 @@ 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";
|
||||||
|
|
||||||
|
|
||||||
@ -83,6 +84,14 @@ 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)
|
||||||
|
|
||||||
@ -134,7 +143,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
|
|
||||||
const generateFilterRequests = useMemo(() => {
|
const generateFilterRequests = useMemo(() => {
|
||||||
let request = '&';
|
let request = '&';
|
||||||
filterItems.forEach((item) => {
|
validFilterItems.forEach((item) => {
|
||||||
const isRangeFilter = filters.find(
|
const isRangeFilter = filters.find(
|
||||||
(filter) =>
|
(filter) =>
|
||||||
filter.title === item.fields.selectedField &&
|
filter.title === item.fields.selectedField &&
|
||||||
@ -158,10 +167,10 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return request;
|
return request;
|
||||||
}, [filterItems, filters]);
|
}, [filters, validFilterItems]);
|
||||||
|
|
||||||
const deleteFilter = (value) => {
|
const deleteFilter = (value) => {
|
||||||
const newItems = filterItems.filter((item) => item.id !== value);
|
const newItems = validFilterItems.filter((item) => item.id !== value);
|
||||||
|
|
||||||
if (newItems.length) {
|
if (newItems.length) {
|
||||||
setFilterItems(newItems);
|
setFilterItems(newItems);
|
||||||
@ -186,7 +195,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
const name = e.target.name;
|
const name = e.target.name;
|
||||||
|
|
||||||
setFilterItems(
|
setFilterItems(
|
||||||
filterItems.map((item) => {
|
validFilterItems.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,7 +307,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
{validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ?
|
||||||
<CardBox>
|
<CardBox>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
@ -310,10 +319,13 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
>
|
>
|
||||||
<Form>
|
<Form>
|
||||||
<>
|
<>
|
||||||
{filterItems && filterItems.map((filterItem) => {
|
{validFilterItems && validFilterItems.map((filterItem) => {
|
||||||
|
const showIncompleteHint = isFilterItemIncomplete(filterItem, filters)
|
||||||
|
const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={filterItem.id} className="flex mb-4">
|
<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 className="flex flex-col w-full mr-3">
|
<div className="flex w-full flex-col md: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}
|
||||||
@ -336,7 +348,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 flex-col w-full mr-3">
|
<div className="flex w-full flex-col md:mr-3">
|
||||||
<div className="text-gray-500 font-bold">
|
<div className="text-gray-500 font-bold">
|
||||||
Value
|
Value
|
||||||
</div>
|
</div>
|
||||||
@ -361,8 +373,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 flex-row w-full mr-3">
|
<div className="flex w-full flex-col gap-3 sm:flex-row md:mr-3">
|
||||||
<div className="flex flex-col w-full mr-3">
|
<div className="flex w-full flex-col md: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}
|
||||||
@ -390,7 +402,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
filter.title ===
|
filter.title ===
|
||||||
filterItem?.fields?.selectedField
|
filterItem?.fields?.selectedField
|
||||||
)?.date ? (
|
)?.date ? (
|
||||||
<div className='flex flex-row w-full mr-3'>
|
<div className='flex w-full flex-col gap-3 sm:flex-row md: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
|
||||||
@ -419,7 +431,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col w-full mr-3">
|
<div className="flex w-full flex-col md: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}
|
||||||
@ -431,10 +443,10 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-2">
|
||||||
<div className=" text-gray-500 font-bold">Action</div>
|
<div className=" text-gray-500 font-bold">Action</div>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2"
|
className="my-1 w-full sm:my-2 sm:w-auto"
|
||||||
type='reset'
|
type='reset'
|
||||||
color='danger'
|
color='danger'
|
||||||
label='Delete'
|
label='Delete'
|
||||||
@ -442,19 +454,24 @@ 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">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2 mr-3"
|
className="my-1 w-full sm:my-2 sm:mr-3 sm:w-auto"
|
||||||
type='submit' color='info'
|
type='submit' color='info'
|
||||||
label='Apply'
|
label='Apply'
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className="my-2"
|
className="my-1 w-full sm:my-2 sm:w-auto"
|
||||||
type='reset' color='info' outline
|
type='reset' color='info' outline
|
||||||
label='Cancel'
|
label='Cancel'
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ 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;
|
||||||
|
|
||||||
@ -38,8 +39,15 @@ 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']),
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
const columns = [
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'tenant',
|
field: 'tenant',
|
||||||
@ -369,4 +377,6 @@ export const loadColumns = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field));
|
||||||
};
|
};
|
||||||
|
|||||||
23
frontend/src/components/TenantStatusChip.tsx
Normal file
23
frontend/src/components/TenantStatusChip.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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
|
||||||
@ -1,24 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react'
|
||||||
import ImageField from '../ImageField';
|
import Link from 'next/link'
|
||||||
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,
|
||||||
@ -28,39 +26,49 @@ const CardTenants = ({
|
|||||||
numPages,
|
numPages,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const asideScrollbarsStyle = useAppSelector(
|
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
|
||||||
(state) => state.style.asideScrollbarsStyle,
|
const bgColor = useAppSelector((state) => state.style.cardsColor)
|
||||||
);
|
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
const corners = useAppSelector((state) => state.style.corners)
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
const focusRing = useAppSelector((state) => state.style.focusRingColor)
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
|
||||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
|
||||||
|
|
||||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
const currentUser = useAppSelector((state) => state.auth.currentUser)
|
||||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TENANTS')
|
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TENANTS')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='p-4'>
|
||||||
|
{loading && <LoadingSpinner />}
|
||||||
|
<ul role='list' className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'>
|
||||||
|
{!loading &&
|
||||||
|
tenants.map((item) => {
|
||||||
|
const linkedOrganizations = Array.isArray(item.organizations) ? item.organizations : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'p-4'}>
|
|
||||||
{loading && <LoadingSpinner />}
|
|
||||||
<ul
|
|
||||||
role='list'
|
|
||||||
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
|
|
||||||
>
|
|
||||||
{!loading && tenants.map((item, index) => (
|
|
||||||
<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 ${
|
||||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<div className={`relative border-b border-gray-900/5 bg-gray-50 p-6 dark:bg-dark-800 ${bgColor}`}>
|
||||||
|
<div className='flex items-start gap-4'>
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<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>
|
||||||
|
|
||||||
<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='mt-3 block line-clamp-2 text-lg font-bold leading-6'>
|
||||||
|
{item.name || 'Unnamed tenant'}
|
||||||
<Link href={`/tenants/tenants-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='ml-auto'>
|
<div className='ml-auto'>
|
||||||
<ListActionsPopover
|
<ListActionsPopover
|
||||||
@ -68,152 +76,99 @@ const CardTenants = ({
|
|||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
pathEdit={`/tenants/tenants-edit/?id=${item.id}`}
|
pathEdit={`/tenants/tenants-edit/?id=${item.id}`}
|
||||||
pathView={`/tenants/tenants-view/?id=${item.id}`}
|
pathView={`/tenants/tenants-view/?id=${item.id}`}
|
||||||
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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'>
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Tenantname</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.name }
|
|
||||||
</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>
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Tenantslug</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.slug }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Legalname</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.legal_name }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</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>
|
<dt className='text-gray-500 dark:text-dark-600'>Properties</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='mt-1 font-medium line-clamp-3'>
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{dataFormatter.propertiesManyListFormatter(item.properties).join(', ')}
|
{dataFormatter.propertiesManyListFormatter(item.properties).join(', ')}
|
||||||
</div>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{dataFormatter.audit_logsManyListFormatter(item.audit_logs).length ? (
|
||||||
|
<div>
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className='text-gray-500 dark:text-dark-600'>Audit logs</dt>
|
<dt className='text-gray-500 dark:text-dark-600'>Audit logs</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='mt-1 font-medium line-clamp-3'>
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{dataFormatter.audit_logsManyListFormatter(item.audit_logs).join(', ')}
|
{dataFormatter.audit_logsManyListFormatter(item.audit_logs).join(', ')}
|
||||||
</div>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{!loading && tenants.length === 0 && (
|
{!loading && tenants.length === 0 && (
|
||||||
<div className='col-span-full flex items-center justify-center h-40'>
|
<div className='col-span-full flex h-40 items-center justify-center'>
|
||||||
<p className=''>No data to display</p>
|
<p>No data to display</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
<div className={'flex items-center justify-center my-6'}>
|
<div className='my-6 flex items-center justify-center'>
|
||||||
<Pagination
|
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
|
||||||
currentPage={currentPage}
|
|
||||||
numPages={numPages}
|
|
||||||
setCurrentPage={onPageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default CardTenants;
|
export default CardTenants
|
||||||
|
|||||||
@ -1,160 +1,138 @@
|
|||||||
import React from 'react';
|
import React from 'react'
|
||||||
import CardBox from '../CardBox';
|
import Link from 'next/link'
|
||||||
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 currentUser = useAppSelector((state) => state.auth.currentUser);
|
|
||||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TENANTS')
|
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TENANTS')
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='relative overflow-x-auto p-4 space-y-4'>
|
<div className='relative space-y-4 overflow-x-auto p-4'>
|
||||||
{loading && <LoadingSpinner />}
|
{loading && <LoadingSpinner />}
|
||||||
{!loading && tenants.map((item) => (
|
{!loading &&
|
||||||
|
tenants.map((item) => {
|
||||||
|
const linkedOrganizations = Array.isArray(item.organizations) ? item.organizations : []
|
||||||
|
|
||||||
|
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 items-start overflow-hidden border border-gray-600 ${bgColor} ${
|
||||||
<Link
|
corners !== 'rounded-full' ? corners : 'rounded-3xl'
|
||||||
href={`/tenants/tenants-view/?id=${item.id}`}
|
} dark:bg-dark-900`}
|
||||||
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'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
|
<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'>
|
||||||
<div className={'flex-1 px-3'}>
|
<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'>
|
||||||
<p className={'text-xs text-gray-500 '}>Tenantname</p>
|
Tenant
|
||||||
<p className={'line-clamp-2'}>{ item.name }</p>
|
</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>
|
</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='mt-3 flex flex-wrap gap-2'>
|
||||||
|
{item.slug ? (
|
||||||
<div className={'flex-1 px-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'>
|
||||||
<p className={'text-xs text-gray-500 '}>Tenantslug</p>
|
<span className='font-semibold text-gray-900 dark:text-gray-100'>Slug:</span> {item.slug}
|
||||||
<p className={'line-clamp-2'}>{ item.slug }</p>
|
</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}
|
||||||
</div>
|
</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 '}>Legalname</p>
|
<div className='mt-2 flex flex-wrap items-center gap-2'>
|
||||||
<p className={'line-clamp-2'}>{ item.legal_name }</p>
|
{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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className='text-sm text-gray-500 dark:text-dark-600'>No linked organizations</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
|
||||||
<p className={'text-xs text-gray-500 '}>Primarydomain</p>
|
|
||||||
<p className={'line-clamp-2'}>{ item.primary_domain }</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
|
||||||
<p className={'text-xs text-gray-500 '}>Timezone</p>
|
|
||||||
<p className={'line-clamp-2'}>{ item.timezone }</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className={'flex-1 px-3'}>
|
|
||||||
<p className={'text-xs text-gray-500 '}>Defaultcurrency</p>
|
|
||||||
<p className={'line-clamp-2'}>{ item.default_currency }</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</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>
|
</Link>
|
||||||
|
|
||||||
|
<div className='flex shrink-0 items-start p-4'>
|
||||||
<ListActionsPopover
|
<ListActionsPopover
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
pathEdit={`/tenants/tenants-edit/?id=${item.id}`}
|
pathEdit={`/tenants/tenants-edit/?id=${item.id}`}
|
||||||
pathView={`/tenants/tenants-view/?id=${item.id}`}
|
pathView={`/tenants/tenants-view/?id=${item.id}`}
|
||||||
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{!loading && tenants.length === 0 && (
|
{!loading && tenants.length === 0 && (
|
||||||
<div className='col-span-full flex items-center justify-center h-40'>
|
<div className='col-span-full flex h-40 items-center justify-center'>
|
||||||
<p className=''>No data to display</p>
|
<p>No data to display</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={'flex items-center justify-center my-6'}>
|
<div className='my-6 flex items-center justify-center'>
|
||||||
<Pagination
|
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
|
||||||
currentPage={currentPage}
|
|
||||||
numPages={numPages}
|
|
||||||
setCurrentPage={onPageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ListTenants
|
export default ListTenants
|
||||||
@ -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={64}
|
rowHeight={72}
|
||||||
sx={dataGridStyles}
|
sx={dataGridStyles}
|
||||||
className={'datagrid--table'}
|
className={'datagrid--table'}
|
||||||
getRowClassName={() => `datagrid--row`}
|
getRowClassName={() => `datagrid--row`}
|
||||||
|
|||||||
@ -149,9 +149,9 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'organizations',
|
field: 'organizations',
|
||||||
headerName: 'Organizations',
|
headerName: 'Linked organizations',
|
||||||
flex: 1,
|
flex: 1.2,
|
||||||
minWidth: 120,
|
minWidth: 220,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
@ -161,6 +161,42 @@ 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'}/>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -462,6 +462,7 @@ 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'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,15 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ImageField from '../ImageField';
|
import Link from 'next/link';
|
||||||
|
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 { Pagination } from '../Pagination';
|
import { hasPermission } from '../../helpers/userPermissions';
|
||||||
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[];
|
||||||
@ -20,176 +17,121 @@ type Props = {
|
|||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CardUnits = ({
|
const statusTone = (status?: string) => {
|
||||||
units,
|
switch (status) {
|
||||||
loading,
|
case 'available':
|
||||||
onDelete,
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300';
|
||||||
currentPage,
|
case 'occupied':
|
||||||
numPages,
|
case 'reserved':
|
||||||
onPageChange,
|
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-300';
|
||||||
}: Props) => {
|
case 'maintenance':
|
||||||
const asideScrollbarsStyle = useAppSelector(
|
case 'out_of_service':
|
||||||
(state) => state.style.asideScrollbarsStyle,
|
return 'bg-rose-100 text-rose-700 dark:bg-rose-500/10 dark:text-rose-300';
|
||||||
);
|
default:
|
||||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
return 'bg-gray-100 text-gray-700 dark:bg-slate-700 dark:text-slate-200';
|
||||||
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 hasUpdatePermission = hasPermission(currentUser, 'UPDATE_UNITS')
|
const corners = useAppSelector((state) => state.style.corners);
|
||||||
|
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`}>
|
{!loading &&
|
||||||
|
units.map((item) => {
|
||||||
|
const property = dataFormatter.propertiesOneListFormatter(item.property) || 'Unassigned property';
|
||||||
|
const unitType = dataFormatter.unit_typesOneListFormatter(item.unit_type) || 'No unit type';
|
||||||
|
const availabilityCount = Array.isArray(item.availability_blocks)
|
||||||
|
? item.availability_blocks.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
<Link href={`/units/units-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
|
return (
|
||||||
{item.unit_number}
|
<CardBox key={item.id} className='shadow-none'>
|
||||||
|
<div
|
||||||
|
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'>
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<div className='flex flex-wrap items-center gap-3'>
|
||||||
|
<Link
|
||||||
|
href={`/units/units-view/?id=${item.id}`}
|
||||||
|
className='text-lg font-semibold leading-6 text-gray-900 dark:text-white'
|
||||||
|
>
|
||||||
|
{item.unit_number || 'Untitled unit'}
|
||||||
</Link>
|
</Link>
|
||||||
|
<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'>
|
||||||
<div className='ml-auto '>
|
|
||||||
<ListActionsPopover
|
<ListActionsPopover
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
pathEdit={`/units/units-edit/?id=${item.id}`}
|
pathEdit={`/units/units-edit/?id=${item.id}`}
|
||||||
pathView={`/units/units-view/?id=${item.id}`}
|
pathView={`/units/units-view/?id=${item.id}`}
|
||||||
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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'>
|
|
||||||
|
|
||||||
|
<div className='mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Property</dt>
|
<p className='text-xs uppercase tracking-wide text-gray-500'>Floor</p>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<p className='mt-1 text-sm font-medium'>{item.floor || '—'}</p>
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ dataFormatter.propertiesOneListFormatter(item.property) }
|
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
<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>
|
||||||
|
<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 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>
|
</div>
|
||||||
</dd>
|
<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 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>
|
</div>
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<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='col-span-full flex items-center justify-center h-40'>
|
<div className='flex h-40 items-center justify-center'>
|
||||||
<p className=''>No data to display</p>
|
<p>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 className='my-6 flex items-center justify-center'>
|
||||||
|
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CardUnits;
|
export default CardUnits
|
||||||
|
|||||||
@ -16,11 +16,17 @@ 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 perPage = 10
|
||||||
|
|
||||||
|
const compactColumnVisibilityModel = {
|
||||||
|
max_occupancy_override: false,
|
||||||
|
notes: false,
|
||||||
|
availability_blocks: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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"});
|
||||||
|
|
||||||
@ -211,7 +217,7 @@ const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) =>
|
|||||||
<div className='relative overflow-x-auto'>
|
<div className='relative overflow-x-auto'>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
autoHeight
|
autoHeight
|
||||||
rowHeight={64}
|
rowHeight={56}
|
||||||
sx={dataGridStyles}
|
sx={dataGridStyles}
|
||||||
className={'datagrid--table'}
|
className={'datagrid--table'}
|
||||||
getRowClassName={() => `datagrid--row`}
|
getRowClassName={() => `datagrid--row`}
|
||||||
@ -223,6 +229,9 @@ const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) =>
|
|||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
columns: {
|
||||||
|
columnVisibilityModel: compactColumnVisibilityModel,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
disableRowSelectionOnClick
|
disableRowSelectionOnClick
|
||||||
onProcessRowUpdateError={(params) => {
|
onProcessRowUpdateError={(params) => {
|
||||||
@ -440,10 +449,18 @@ const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) =>
|
|||||||
</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(
|
||||||
|
|||||||
@ -9,6 +9,7 @@ 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 = {
|
||||||
@ -38,7 +39,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'}>
|
||||||
@ -47,7 +48,10 @@ 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, index) => (
|
{!loading && users.map((item) => {
|
||||||
|
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 ${
|
||||||
@ -78,7 +82,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={hasUpdatePermission}
|
hasUpdatePermission={canManageUser}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -190,7 +194,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'>
|
||||||
{ dataFormatter.organizationsOneListFormatter(item.organizations) }
|
{ canManagePlatformFields ? dataFormatter.organizationsOneListFormatter(item.organizations) : 'Pinned to your workspace' }
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@ -199,7 +203,8 @@ 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>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ 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 = {
|
||||||
@ -25,6 +26,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);
|
||||||
@ -34,7 +36,10 @@ 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`}>
|
||||||
@ -116,7 +121,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'}>{ dataFormatter.permissionsManyListFormatter(item.custom_permissions).join(', ')}</p>
|
<p className={'line-clamp-2'}>{canManagePlatformFields ? dataFormatter.permissionsManyListFormatter(item.custom_permissions).join(', ') : '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@ -124,7 +129,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'}>{ dataFormatter.organizationsOneListFormatter(item.organizations) }</p>
|
<p className={'line-clamp-2'}>{canManagePlatformFields ? dataFormatter.organizationsOneListFormatter(item.organizations) : 'Pinned to your workspace'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@ -136,13 +141,14 @@ 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={hasUpdatePermission}
|
hasUpdatePermission={canManageUser}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ 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'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -197,8 +198,25 @@ const TableSampleUsers = ({ filterItems, setFilterItems, filters, showGrid }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onDeleteRows = async (selectedRows) => {
|
const onDeleteRows = async (selectedRows) => {
|
||||||
await dispatch(deleteItemsByIds(selectedRows));
|
const manageableRowIds = selectedRows.filter((selectedRowId) =>
|
||||||
|
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 =
|
||||||
@ -207,6 +225,7 @@ 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
|
||||||
@ -214,7 +233,7 @@ const TableSampleUsers = ({ filterItems, setFilterItems, filters, showGrid }) =>
|
|||||||
rowHeight={64}
|
rowHeight={64}
|
||||||
sx={dataGridStyles}
|
sx={dataGridStyles}
|
||||||
className={'datagrid--table'}
|
className={'datagrid--table'}
|
||||||
getRowClassName={() => `datagrid--row`}
|
getRowClassName={(params) => `datagrid--row ${canManageUserRecord(currentUser, params.row) ? '' : 'opacity-60'}`}
|
||||||
rows={users ?? []}
|
rows={users ?? []}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
initialState={{
|
initialState={{
|
||||||
@ -225,10 +244,17 @@ 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.log('Error', params);
|
console.error('Users grid update 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
Loading…
x
Reference in New Issue
Block a user