Autosave: 20260403-220323
This commit is contained in:
parent
77754d1430
commit
9ca9df3974
@ -1,14 +1,47 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
const Utils = require('../utils');
|
||||
|
||||
const config = require('../../config');
|
||||
|
||||
|
||||
const Sequelize = db.Sequelize;
|
||||
const Op = Sequelize.Op;
|
||||
|
||||
function isCustomerUser(currentUser) {
|
||||
return currentUser?.app_role?.name === config.roles.customer;
|
||||
}
|
||||
|
||||
function quoteSqlString(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function buildCustomerDocumentScope(currentUser) {
|
||||
if (!isCustomerUser(currentUser)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = quoteSqlString(currentUser.id);
|
||||
|
||||
return {
|
||||
[Op.or]: [
|
||||
{ uploaded_byId: currentUser.id },
|
||||
Sequelize.literal(`EXISTS (SELECT 1 FROM "booking_requests" br WHERE br."id" = "documents"."booking_requestId" AND br."requested_byId" = ${userId})`),
|
||||
Sequelize.literal(`EXISTS (SELECT 1 FROM "service_requests" sr WHERE sr."id" = "documents"."service_requestId" AND sr."requested_byId" = ${userId})`),
|
||||
Sequelize.literal(`EXISTS (SELECT 1 FROM "reservations" r JOIN "booking_requests" br ON br."id" = r."booking_requestId" WHERE r."id" = "documents"."reservationId" AND br."requested_byId" = ${userId})`),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function mergeWhereWithScope(where, scope) {
|
||||
if (!scope) {
|
||||
return where;
|
||||
}
|
||||
|
||||
return {
|
||||
[Op.and]: [where, scope],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = class DocumentsDBApi {
|
||||
|
||||
|
||||
@ -176,7 +209,10 @@ module.exports = class DocumentsDBApi {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const globalAccess = currentUser.app_role?.globalAccess;
|
||||
|
||||
const documents = await db.documents.findByPk(id, {}, {transaction});
|
||||
const documents = await db.documents.findOne({
|
||||
where: mergeWhereWithScope({ id }, buildCustomerDocumentScope(currentUser)),
|
||||
transaction,
|
||||
});
|
||||
|
||||
|
||||
|
||||
@ -290,11 +326,11 @@ module.exports = class DocumentsDBApi {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const documents = await db.documents.findAll({
|
||||
where: {
|
||||
where: mergeWhereWithScope({
|
||||
id: {
|
||||
[Op.in]: ids,
|
||||
},
|
||||
},
|
||||
}, buildCustomerDocumentScope(currentUser)),
|
||||
transaction,
|
||||
});
|
||||
|
||||
@ -318,7 +354,10 @@ module.exports = class DocumentsDBApi {
|
||||
const currentUser = (options && options.currentUser) || {id: null};
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
const documents = await db.documents.findByPk(id, options);
|
||||
const documents = await db.documents.findOne({
|
||||
where: mergeWhereWithScope({ id }, buildCustomerDocumentScope(currentUser)),
|
||||
transaction,
|
||||
});
|
||||
|
||||
await documents.update({
|
||||
deletedBy: currentUser.id
|
||||
@ -335,9 +374,10 @@ module.exports = class DocumentsDBApi {
|
||||
|
||||
static async findBy(where, options) {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const currentUser = (options && options.currentUser) || null;
|
||||
|
||||
const documents = await db.documents.findOne(
|
||||
{ where },
|
||||
{ where: mergeWhereWithScope(where, buildCustomerDocumentScope(currentUser)) },
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
@ -441,11 +481,7 @@ module.exports = class DocumentsDBApi {
|
||||
|
||||
offset = currentPage * limit;
|
||||
|
||||
const orderBy = null;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
let include = [
|
||||
let include = [
|
||||
|
||||
{
|
||||
model: db.tenants,
|
||||
@ -734,7 +770,8 @@ module.exports = class DocumentsDBApi {
|
||||
if (globalAccess) {
|
||||
delete where.organizationsId;
|
||||
}
|
||||
|
||||
|
||||
where = mergeWhereWithScope(where, buildCustomerDocumentScope(user));
|
||||
|
||||
const queryOptions = {
|
||||
where,
|
||||
@ -765,7 +802,7 @@ module.exports = class DocumentsDBApi {
|
||||
}
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId, currentUser = null) {
|
||||
let where = {};
|
||||
|
||||
|
||||
@ -787,6 +824,8 @@ module.exports = class DocumentsDBApi {
|
||||
};
|
||||
}
|
||||
|
||||
where = mergeWhereWithScope(where, buildCustomerDocumentScope(currentUser));
|
||||
|
||||
const records = await db.documents.findAll({
|
||||
attributes: [ 'id', 'file_name' ],
|
||||
where,
|
||||
|
||||
@ -16,17 +16,26 @@ const BUSINESS_ROLE_NAMES = [
|
||||
config.roles.customer,
|
||||
];
|
||||
|
||||
function appendRoleVisibilityScope(where, globalAccess, businessOnly = false) {
|
||||
const HIGH_TRUST_ROLE_NAMES = [
|
||||
config.roles.super_admin,
|
||||
config.roles.admin,
|
||||
];
|
||||
|
||||
function appendRoleVisibilityScope(where, globalAccess, businessOnly = false, assignableOnly = false, includeHighTrust = true) {
|
||||
const scopes = [];
|
||||
|
||||
if (!globalAccess) {
|
||||
scopes.push({ name: { [Op.ne]: config.roles.super_admin } });
|
||||
}
|
||||
|
||||
if (businessOnly) {
|
||||
if (businessOnly || assignableOnly) {
|
||||
scopes.push({ name: { [Op.in]: BUSINESS_ROLE_NAMES } });
|
||||
}
|
||||
|
||||
if (!includeHighTrust) {
|
||||
scopes.push({ name: { [Op.notIn]: HIGH_TRUST_ROLE_NAMES } });
|
||||
}
|
||||
|
||||
if (!scopes.length) {
|
||||
return where;
|
||||
}
|
||||
@ -403,8 +412,10 @@ module.exports = class RolesDBApi {
|
||||
}
|
||||
|
||||
const businessOnly = filter.businessOnly === true || filter.businessOnly === 'true';
|
||||
const assignableOnly = filter.assignableOnly === true || filter.assignableOnly === 'true';
|
||||
const includeHighTrust = !(filter.includeHighTrust === false || filter.includeHighTrust === 'false');
|
||||
|
||||
where = appendRoleVisibilityScope(where, globalAccess, businessOnly);
|
||||
where = appendRoleVisibilityScope(where, globalAccess, businessOnly, assignableOnly, includeHighTrust);
|
||||
|
||||
|
||||
|
||||
@ -438,7 +449,7 @@ module.exports = class RolesDBApi {
|
||||
}
|
||||
}
|
||||
|
||||
static async findAllAutocomplete(query, limit, offset, globalAccess, businessOnly = false) {
|
||||
static async findAllAutocomplete(query, limit, offset, globalAccess, businessOnly = false, assignableOnly = false, includeHighTrust = true) {
|
||||
let where = {};
|
||||
|
||||
if (query) {
|
||||
@ -454,7 +465,13 @@ module.exports = class RolesDBApi {
|
||||
};
|
||||
}
|
||||
|
||||
where = appendRoleVisibilityScope(where, globalAccess, businessOnly === true || businessOnly === 'true');
|
||||
where = appendRoleVisibilityScope(
|
||||
where,
|
||||
globalAccess,
|
||||
businessOnly === true || businessOnly === 'true',
|
||||
assignableOnly === true || assignableOnly === 'true',
|
||||
!(includeHighTrust === false || includeHighTrust === 'false'),
|
||||
);
|
||||
|
||||
const records = await db.roles.findAll({
|
||||
attributes: [ 'id', 'name' ],
|
||||
|
||||
@ -21,6 +21,22 @@ function mergeWhereWithScope(where, scope) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveServiceStatusForCreate(data, currentUser) {
|
||||
if (isCustomerUser(currentUser)) {
|
||||
return 'new';
|
||||
}
|
||||
|
||||
return data.status || 'new';
|
||||
}
|
||||
|
||||
function resolveRequestedAt(data, currentUser) {
|
||||
if (isCustomerUser(currentUser)) {
|
||||
return data.requested_at || new Date();
|
||||
}
|
||||
|
||||
return data.requested_at || null;
|
||||
}
|
||||
|
||||
module.exports = class Service_requestsDBApi {
|
||||
|
||||
|
||||
@ -38,9 +54,7 @@ module.exports = class Service_requestsDBApi {
|
||||
null
|
||||
,
|
||||
|
||||
status: data.status
|
||||
||
|
||||
null
|
||||
status: resolveServiceStatusForCreate(data, currentUser)
|
||||
,
|
||||
|
||||
priority: data.priority
|
||||
@ -48,9 +62,7 @@ module.exports = class Service_requestsDBApi {
|
||||
null
|
||||
,
|
||||
|
||||
requested_at: data.requested_at
|
||||
||
|
||||
null
|
||||
requested_at: resolveRequestedAt(data, currentUser)
|
||||
,
|
||||
|
||||
due_at: data.due_at
|
||||
@ -58,7 +70,7 @@ module.exports = class Service_requestsDBApi {
|
||||
null
|
||||
,
|
||||
|
||||
completed_at: data.completed_at
|
||||
completed_at: isCustomerUser(currentUser) ? null : data.completed_at
|
||||
||
|
||||
null
|
||||
,
|
||||
@ -78,7 +90,7 @@ module.exports = class Service_requestsDBApi {
|
||||
null
|
||||
,
|
||||
|
||||
actual_cost: data.actual_cost
|
||||
actual_cost: isCustomerUser(currentUser) ? null : data.actual_cost
|
||||
||
|
||||
null
|
||||
,
|
||||
@ -96,7 +108,7 @@ module.exports = class Service_requestsDBApi {
|
||||
);
|
||||
|
||||
|
||||
await service_requests.setTenant( data.tenant || null, {
|
||||
await service_requests.setTenant( isCustomerUser(currentUser) ? null : data.tenant || null, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
@ -108,11 +120,11 @@ module.exports = class Service_requestsDBApi {
|
||||
transaction,
|
||||
});
|
||||
|
||||
await service_requests.setAssigned_to( data.assigned_to || null, {
|
||||
await service_requests.setAssigned_to( isCustomerUser(currentUser) ? null : data.assigned_to || null, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
await service_requests.setOrganizations( data.organizations || null, {
|
||||
await service_requests.setOrganizations( isCustomerUser(currentUser) ? currentUser.organization?.id || null : data.organizations || null, {
|
||||
transaction,
|
||||
});
|
||||
|
||||
@ -227,7 +239,7 @@ module.exports = class Service_requestsDBApi {
|
||||
if (data.request_type !== undefined) updatePayload.request_type = data.request_type;
|
||||
|
||||
|
||||
if (data.status !== undefined) updatePayload.status = data.status;
|
||||
if (data.status !== undefined && !isCustomerUser(currentUser)) updatePayload.status = data.status;
|
||||
|
||||
|
||||
if (data.priority !== undefined) updatePayload.priority = data.priority;
|
||||
@ -239,7 +251,7 @@ module.exports = class Service_requestsDBApi {
|
||||
if (data.due_at !== undefined) updatePayload.due_at = data.due_at;
|
||||
|
||||
|
||||
if (data.completed_at !== undefined) updatePayload.completed_at = data.completed_at;
|
||||
if (data.completed_at !== undefined && !isCustomerUser(currentUser)) updatePayload.completed_at = data.completed_at;
|
||||
|
||||
|
||||
if (data.summary !== undefined) updatePayload.summary = data.summary;
|
||||
@ -251,7 +263,7 @@ module.exports = class Service_requestsDBApi {
|
||||
if (data.estimated_cost !== undefined) updatePayload.estimated_cost = data.estimated_cost;
|
||||
|
||||
|
||||
if (data.actual_cost !== undefined) updatePayload.actual_cost = data.actual_cost;
|
||||
if (data.actual_cost !== undefined && !isCustomerUser(currentUser)) updatePayload.actual_cost = data.actual_cost;
|
||||
|
||||
|
||||
if (data.currency !== undefined) updatePayload.currency = data.currency;
|
||||
@ -263,11 +275,9 @@ module.exports = class Service_requestsDBApi {
|
||||
|
||||
|
||||
|
||||
if (data.tenant !== undefined) {
|
||||
if (data.tenant !== undefined && !isCustomerUser(currentUser)) {
|
||||
await service_requests.setTenant(
|
||||
|
||||
data.tenant,
|
||||
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
@ -290,20 +300,16 @@ module.exports = class Service_requestsDBApi {
|
||||
);
|
||||
}
|
||||
|
||||
if (data.assigned_to !== undefined) {
|
||||
if (data.assigned_to !== undefined && !isCustomerUser(currentUser)) {
|
||||
await service_requests.setAssigned_to(
|
||||
|
||||
data.assigned_to,
|
||||
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
if (data.organizations !== undefined) {
|
||||
if (data.organizations !== undefined && !isCustomerUser(currentUser)) {
|
||||
await service_requests.setOrganizations(
|
||||
|
||||
data.organizations,
|
||||
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
@ -96,7 +96,7 @@ module.exports = class UsersDBApi {
|
||||
|
||||
if (!data.data.app_role) {
|
||||
const role = await db.roles.findOne({
|
||||
where: { name: 'User' },
|
||||
where: { name: config.roles?.user || 'User' },
|
||||
});
|
||||
if (role) {
|
||||
await users.setApp_role(role, {
|
||||
@ -548,11 +548,7 @@ module.exports = class UsersDBApi {
|
||||
|
||||
offset = currentPage * limit;
|
||||
|
||||
const orderBy = null;
|
||||
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
|
||||
let include = [
|
||||
let include = [
|
||||
|
||||
{
|
||||
model: db.roles,
|
||||
|
||||
@ -5,9 +5,6 @@ const DocumentsService = require('../services/documents');
|
||||
const DocumentsDBApi = require('../db/api/documents');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
const config = require('../config');
|
||||
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const { parse } = require('json2csv');
|
||||
@ -404,6 +401,7 @@ router.get('/autocomplete', async (req, res) => {
|
||||
req.query.limit,
|
||||
req.query.offset,
|
||||
globalAccess, organizationId,
|
||||
req.currentUser,
|
||||
);
|
||||
|
||||
res.status(200).send(payload);
|
||||
@ -444,9 +442,12 @@ router.get('/autocomplete', async (req, res) => {
|
||||
router.get('/:id', wrapAsync(async (req, res) => {
|
||||
const payload = await DocumentsDBApi.findBy(
|
||||
{ id: req.params.id },
|
||||
{ currentUser: req.currentUser },
|
||||
);
|
||||
|
||||
|
||||
|
||||
if (!payload) {
|
||||
return res.status(404).send('Not found');
|
||||
}
|
||||
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
@ -386,6 +386,8 @@ router.get('/autocomplete', async (req, res) => {
|
||||
req.query.offset,
|
||||
globalAccess,
|
||||
req.query.businessOnly,
|
||||
req.query.assignableOnly,
|
||||
req.query.includeHighTrust,
|
||||
);
|
||||
|
||||
res.status(200).send(payload);
|
||||
|
||||
@ -3,54 +3,115 @@ const UsersDBApi = require('../db/api/users');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
|
||||
|
||||
const InvitationEmail = require('./email/list/invitation');
|
||||
const EmailSender = require('./email');
|
||||
const AuthService = require('./auth');
|
||||
|
||||
const BUSINESS_ASSIGNABLE_ROLE_NAMES = [
|
||||
config.roles.super_admin,
|
||||
config.roles.admin,
|
||||
config.roles.concierge,
|
||||
config.roles.customer,
|
||||
];
|
||||
|
||||
const HIGH_TRUST_ROLE_NAMES = [
|
||||
config.roles.super_admin,
|
||||
config.roles.admin,
|
||||
];
|
||||
|
||||
async function findRoleById(roleId, transaction) {
|
||||
if (!roleId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return db.roles.findByPk(roleId, { transaction });
|
||||
}
|
||||
|
||||
async function findDefaultRole(transaction) {
|
||||
return db.roles.findOne({
|
||||
where: { name: config.roles.user },
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
async function normalizeManagedUserPayload(data, currentUser, transaction, existingUser = null) {
|
||||
const payload = { ...data };
|
||||
const isSuperAdmin = Boolean(currentUser?.app_role?.globalAccess);
|
||||
const currentOrganizationId = currentUser?.organization?.id || currentUser?.organizationId || null;
|
||||
|
||||
if (!payload.app_role) {
|
||||
payload.app_role = existingUser?.app_role?.id || null;
|
||||
}
|
||||
|
||||
let selectedRole = await findRoleById(payload.app_role, transaction);
|
||||
|
||||
if (!selectedRole && !existingUser) {
|
||||
selectedRole = await findDefaultRole(transaction);
|
||||
if (selectedRole) {
|
||||
payload.app_role = selectedRole.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedRole && !BUSINESS_ASSIGNABLE_ROLE_NAMES.includes(selectedRole.name)) {
|
||||
throw new ValidationError('errors.forbidden.message');
|
||||
}
|
||||
|
||||
if (existingUser?.app_role?.name && !isSuperAdmin && HIGH_TRUST_ROLE_NAMES.includes(existingUser.app_role.name)) {
|
||||
throw new ValidationError('errors.forbidden.message');
|
||||
}
|
||||
|
||||
if (selectedRole?.name && !isSuperAdmin && HIGH_TRUST_ROLE_NAMES.includes(selectedRole.name)) {
|
||||
throw new ValidationError('errors.forbidden.message');
|
||||
}
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
payload.custom_permissions = existingUser?.custom_permissions?.map((permission) => permission.id) || [];
|
||||
payload.organizations = currentOrganizationId || existingUser?.organizations?.id || null;
|
||||
}
|
||||
|
||||
if (!payload.organizations && existingUser?.organizations?.id) {
|
||||
payload.organizations = existingUser.organizations.id;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
module.exports = class UsersService {
|
||||
static async create(data, currentUser, sendInvitationEmails = true, host) {
|
||||
let transaction = await db.sequelize.transaction();
|
||||
|
||||
const globalAccess = currentUser.app_role.globalAccess;
|
||||
|
||||
const transaction = await db.sequelize.transaction();
|
||||
let email = data.email;
|
||||
let emailsToInvite = [];
|
||||
|
||||
try {
|
||||
if (email) {
|
||||
let user = await UsersDBApi.findBy({email}, {transaction});
|
||||
if (user) {
|
||||
throw new ValidationError(
|
||||
'iam.errors.userAlreadyExists',
|
||||
);
|
||||
} else {
|
||||
await UsersDBApi.create(
|
||||
{data},
|
||||
|
||||
globalAccess,
|
||||
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
emailsToInvite.push(email);
|
||||
}
|
||||
} else {
|
||||
throw new ValidationError('iam.errors.emailRequired')
|
||||
if (!email) {
|
||||
throw new ValidationError('iam.errors.emailRequired');
|
||||
}
|
||||
|
||||
const user = await UsersDBApi.findBy({ email }, { transaction });
|
||||
if (user) {
|
||||
throw new ValidationError('iam.errors.userAlreadyExists');
|
||||
}
|
||||
|
||||
const normalizedPayload = await normalizeManagedUserPayload(data, currentUser, transaction);
|
||||
|
||||
await UsersDBApi.create(
|
||||
{ data: normalizedPayload },
|
||||
currentUser.app_role.globalAccess,
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
emailsToInvite.push(email);
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
if (emailsToInvite && emailsToInvite.length) {
|
||||
if (!sendInvitationEmails) return;
|
||||
|
||||
if (emailsToInvite.length && sendInvitationEmails) {
|
||||
AuthService.sendPasswordResetEmail(email, 'invitation', host);
|
||||
}
|
||||
}
|
||||
@ -64,42 +125,36 @@ module.exports = class UsersService {
|
||||
const bufferStream = new stream.PassThrough();
|
||||
const results = [];
|
||||
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
bufferStream
|
||||
.pipe(csv())
|
||||
.on('data', (data) => results.push(data))
|
||||
.on('end', () => {
|
||||
console.log('results csv', results);
|
||||
resolve();
|
||||
})
|
||||
.on('error', (error) => reject(error));
|
||||
.on('end', resolve)
|
||||
.on('error', reject);
|
||||
});
|
||||
|
||||
const hasAllEmails = results.every((result) => result.email);
|
||||
|
||||
if (!hasAllEmails) {
|
||||
throw new ValidationError('importer.errors.userEmailMissing');
|
||||
}
|
||||
|
||||
await UsersDBApi.bulkImport(results, {
|
||||
transaction,
|
||||
ignoreDuplicates: true,
|
||||
validate: true,
|
||||
currentUser: req.currentUser
|
||||
transaction,
|
||||
ignoreDuplicates: true,
|
||||
validate: true,
|
||||
currentUser: req.currentUser,
|
||||
});
|
||||
|
||||
emailsToInvite = results.map((result) => result.email);
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (emailsToInvite && emailsToInvite.length && !sendInvitationEmails) {
|
||||
|
||||
if (emailsToInvite.length && !sendInvitationEmails) {
|
||||
emailsToInvite.forEach((email) => {
|
||||
AuthService.sendPasswordResetEmail(email, 'invitation', host);
|
||||
});
|
||||
@ -108,27 +163,20 @@ module.exports = class UsersService {
|
||||
|
||||
static async update(data, id, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
const globalAccess = currentUser.app_role.globalAccess;
|
||||
|
||||
try {
|
||||
let users = await UsersDBApi.findBy(
|
||||
{id},
|
||||
{transaction},
|
||||
);
|
||||
|
||||
if (!users) {
|
||||
throw new ValidationError(
|
||||
'iam.errors.userNotFound',
|
||||
);
|
||||
try {
|
||||
const user = await UsersDBApi.findBy({ id }, { transaction });
|
||||
|
||||
if (!user) {
|
||||
throw new ValidationError('iam.errors.userNotFound');
|
||||
}
|
||||
|
||||
const normalizedPayload = await normalizeManagedUserPayload(data, currentUser, transaction, user);
|
||||
|
||||
const updatedUser = await UsersDBApi.update(
|
||||
id,
|
||||
data,
|
||||
|
||||
globalAccess,
|
||||
|
||||
normalizedPayload,
|
||||
currentUser.app_role.globalAccess,
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
@ -137,36 +185,37 @@ module.exports = class UsersService {
|
||||
|
||||
await transaction.commit();
|
||||
return updatedUser;
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async remove(id, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
if (currentUser.id === id) {
|
||||
throw new ValidationError(
|
||||
'iam.errors.deletingHimself',
|
||||
);
|
||||
throw new ValidationError('iam.errors.deletingHimself');
|
||||
}
|
||||
|
||||
if (currentUser.app_role?.name !== config.roles.admin && currentUser.app_role?.name !== config.roles.super_admin ) {
|
||||
throw new ValidationError(
|
||||
'errors.forbidden.message',
|
||||
);
|
||||
if (currentUser.app_role?.name !== config.roles.admin && currentUser.app_role?.name !== config.roles.super_admin) {
|
||||
throw new ValidationError('errors.forbidden.message');
|
||||
}
|
||||
|
||||
await UsersDBApi.remove(
|
||||
id,
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
const user = await UsersDBApi.findBy({ id }, { transaction });
|
||||
if (!user) {
|
||||
throw new ValidationError('iam.errors.userNotFound');
|
||||
}
|
||||
|
||||
if (!currentUser.app_role?.globalAccess && HIGH_TRUST_ROLE_NAMES.includes(user?.app_role?.name)) {
|
||||
throw new ValidationError('errors.forbidden.message');
|
||||
}
|
||||
|
||||
await UsersDBApi.remove(id, {
|
||||
currentUser,
|
||||
transaction,
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
@ -175,5 +224,3 @@ module.exports = class UsersService {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
import {loadColumns} from "./configureBooking_requestsCols";
|
||||
import _ from 'lodash';
|
||||
import dataFormatter from '../../helpers/dataFormatter'
|
||||
import { getIncompleteFilterHint, isFilterItemIncomplete, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
|
||||
import {dataGridStyles} from "../../styles";
|
||||
|
||||
|
||||
@ -99,6 +100,14 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
}
|
||||
}, [refetch, dispatch]);
|
||||
|
||||
const validFilterItems = useMemo(() => pruneHiddenFilterItems(filterItems, filters), [filterItems, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (validFilterItems !== filterItems) {
|
||||
setFilterItems(validFilterItems);
|
||||
}
|
||||
}, [filterItems, setFilterItems, validFilterItems]);
|
||||
|
||||
const [isModalInfoActive, setIsModalInfoActive] = useState(false)
|
||||
const [isModalTrashActive, setIsModalTrashActive] = useState(false)
|
||||
|
||||
@ -191,7 +200,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
|
||||
const generateFilterRequests = useMemo(() => {
|
||||
let request = '&';
|
||||
filterItems.forEach((item) => {
|
||||
validFilterItems.forEach((item) => {
|
||||
const isRangeFilter = filters.find(
|
||||
(filter) =>
|
||||
filter.title === item.fields.selectedField &&
|
||||
@ -215,10 +224,10 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
}
|
||||
});
|
||||
return request;
|
||||
}, [filterItems, filters]);
|
||||
}, [filters, validFilterItems]);
|
||||
|
||||
const deleteFilter = (value) => {
|
||||
const newItems = filterItems.filter((item) => item.id !== value);
|
||||
const newItems = validFilterItems.filter((item) => item.id !== value);
|
||||
|
||||
if (newItems.length) {
|
||||
setFilterItems(newItems);
|
||||
@ -243,7 +252,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
const name = e.target.name;
|
||||
|
||||
setFilterItems(
|
||||
filterItems.map((item) => {
|
||||
validFilterItems.map((item) => {
|
||||
if (item.id !== id) return item;
|
||||
if (name === 'selectedField') return { id, fields: { [name]: value } };
|
||||
|
||||
@ -358,7 +367,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
|
||||
return (
|
||||
<>
|
||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
||||
{validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ?
|
||||
<CardBox className='mb-6 border border-white/10 shadow-none'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
@ -370,9 +379,12 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
>
|
||||
<Form>
|
||||
<>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
{validFilterItems && validFilterItems.map((filterItem) => {
|
||||
const showIncompleteHint = isFilterItemIncomplete(filterItem, filters)
|
||||
const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null
|
||||
|
||||
return (
|
||||
<div key={filterItem.id} className="mb-3 grid gap-3 rounded-2xl border border-white/10 bg-white/5 p-4 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_auto]">
|
||||
<div key={filterItem.id} className="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 min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Filter</div>
|
||||
<Field
|
||||
@ -491,10 +503,10 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Action</div>
|
||||
<BaseButton
|
||||
className="my-1"
|
||||
className="my-1 w-full md:w-auto"
|
||||
type='reset'
|
||||
color='whiteDark'
|
||||
outline
|
||||
@ -503,6 +515,11 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
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>
|
||||
)
|
||||
|
||||
@ -14,6 +14,7 @@ import DataGridMultiSelect from "../DataGridMultiSelect";
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
import { getRoleLaneFromUser } from "../../helpers/roleLanes";
|
||||
|
||||
type Params = (id: string) => void;
|
||||
|
||||
@ -38,8 +39,15 @@ export const loadColumns = async (
|
||||
}
|
||||
|
||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_BOOKING_REQUESTS')
|
||||
const roleLane = getRoleLaneFromUser(user)
|
||||
const hiddenFieldsByLane = {
|
||||
super_admin: new Set([]),
|
||||
admin: new Set(['tenant', 'organization', 'requested_by']),
|
||||
concierge: new Set(['tenant', 'organization', 'requested_by', 'approval_steps', 'comments']),
|
||||
customer: new Set(['tenant', 'organization', 'requested_by', 'approval_steps', 'comments', 'budget_code', 'max_budget_amount', 'currency']),
|
||||
}
|
||||
|
||||
return [
|
||||
const columns = [
|
||||
|
||||
{
|
||||
field: 'tenant',
|
||||
@ -429,4 +437,6 @@ export const loadColumns = async (
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field));
|
||||
};
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
import {loadColumns} from "./configureDocumentsCols";
|
||||
import _ from 'lodash';
|
||||
import dataFormatter from '../../helpers/dataFormatter'
|
||||
import { getIncompleteFilterHint, isFilterItemIncomplete, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
|
||||
import {dataGridStyles} from "../../styles";
|
||||
|
||||
|
||||
@ -77,6 +78,14 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
||||
}
|
||||
}, [refetch, dispatch]);
|
||||
|
||||
const validFilterItems = useMemo(() => pruneHiddenFilterItems(filterItems, filters), [filterItems, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (validFilterItems !== filterItems) {
|
||||
setFilterItems(validFilterItems);
|
||||
}
|
||||
}, [filterItems, setFilterItems, validFilterItems]);
|
||||
|
||||
const [isModalInfoActive, setIsModalInfoActive] = useState(false)
|
||||
const [isModalTrashActive, setIsModalTrashActive] = useState(false)
|
||||
|
||||
@ -103,7 +112,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
||||
|
||||
const generateFilterRequests = useMemo(() => {
|
||||
let request = '&';
|
||||
filterItems.forEach((item) => {
|
||||
validFilterItems.forEach((item) => {
|
||||
const isRangeFilter = filters.find(
|
||||
(filter) =>
|
||||
filter.title === item.fields.selectedField &&
|
||||
@ -127,10 +136,10 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
||||
}
|
||||
});
|
||||
return request;
|
||||
}, [filterItems, filters]);
|
||||
}, [filters, validFilterItems]);
|
||||
|
||||
const deleteFilter = (value) => {
|
||||
const newItems = filterItems.filter((item) => item.id !== value);
|
||||
const newItems = validFilterItems.filter((item) => item.id !== value);
|
||||
|
||||
if (newItems.length) {
|
||||
setFilterItems(newItems);
|
||||
@ -151,7 +160,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
||||
const name = e.target.name;
|
||||
|
||||
setFilterItems(
|
||||
filterItems.map((item) => {
|
||||
validFilterItems.map((item) => {
|
||||
if (item.id !== id) return item;
|
||||
if (name === 'selectedField') return { id, fields: { [name]: value } };
|
||||
|
||||
@ -261,7 +270,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
||||
|
||||
return (
|
||||
<>
|
||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
||||
{validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ?
|
||||
<CardBox>
|
||||
<Formik
|
||||
initialValues={{
|
||||
@ -273,10 +282,13 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
||||
>
|
||||
<Form>
|
||||
<>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
{validFilterItems && validFilterItems.map((filterItem) => {
|
||||
const showIncompleteHint = isFilterItemIncomplete(filterItem, filters)
|
||||
const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null
|
||||
|
||||
return (
|
||||
<div key={filterItem.id} className="flex mb-4">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<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 w-full flex-col md:mr-3">
|
||||
<div className=" text-gray-500 font-bold">Filter</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
@ -299,7 +311,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
||||
{filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.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">
|
||||
Value
|
||||
</div>
|
||||
@ -324,8 +336,8 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
||||
) : filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.number ? (
|
||||
<div className="flex flex-row w-full mr-3">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row md:mr-3">
|
||||
<div className="flex w-full flex-col md:mr-3">
|
||||
<div className=" text-gray-500 font-bold">From</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
@ -353,7 +365,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
||||
filter.title ===
|
||||
filterItem?.fields?.selectedField
|
||||
)?.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=' text-gray-500 font-bold'>
|
||||
From
|
||||
@ -382,7 +394,7 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
||||
</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>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
@ -394,10 +406,10 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className=" text-gray-500 font-bold">Action</div>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
className="my-1 w-full sm:my-2 sm:w-auto"
|
||||
type='reset'
|
||||
color='danger'
|
||||
label='Delete'
|
||||
@ -405,19 +417,24 @@ const TableSampleDocuments = ({ filterItems, setFilterItems, filters, showGrid }
|
||||
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 className="flex">
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<BaseButton
|
||||
className="my-2 mr-3"
|
||||
className="my-1 w-full sm:my-2 sm:mr-3 sm:w-auto"
|
||||
type='submit' color='info'
|
||||
label='Apply'
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
className="my-1 w-full sm:my-2 sm:w-auto"
|
||||
type='reset' color='info' outline
|
||||
label='Cancel'
|
||||
onClick={handleReset}
|
||||
|
||||
@ -14,6 +14,7 @@ import DataGridMultiSelect from "../DataGridMultiSelect";
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
import { getRoleLaneFromUser } from "../../helpers/roleLanes";
|
||||
|
||||
type Params = (id: string) => void;
|
||||
|
||||
@ -38,8 +39,15 @@ export const loadColumns = async (
|
||||
}
|
||||
|
||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_DOCUMENTS')
|
||||
const roleLane = getRoleLaneFromUser(user)
|
||||
const hiddenFieldsByLane = {
|
||||
super_admin: new Set([]),
|
||||
admin: new Set(['tenant', 'organization', 'storage_key', 'public_url']),
|
||||
concierge: new Set(['tenant', 'organization', 'storage_key', 'public_url', 'mime_type', 'file_size_bytes', 'invoice', 'is_private']),
|
||||
customer: new Set(['tenant', 'organization', 'storage_key', 'public_url', 'mime_type', 'file_size_bytes', 'uploaded_by', 'invoice', 'is_private', 'notes']),
|
||||
}
|
||||
|
||||
return [
|
||||
const columns = [
|
||||
|
||||
{
|
||||
field: 'tenant',
|
||||
@ -341,4 +349,6 @@ export const loadColumns = async (
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field));
|
||||
};
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
import {loadColumns} from "./configureReservationsCols";
|
||||
import _ from 'lodash';
|
||||
import dataFormatter from '../../helpers/dataFormatter'
|
||||
import { getIncompleteFilterHint, isFilterItemIncomplete, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
|
||||
import {dataGridStyles} from "../../styles";
|
||||
|
||||
|
||||
@ -101,6 +102,14 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
}
|
||||
}, [refetch, dispatch]);
|
||||
|
||||
const validFilterItems = useMemo(() => pruneHiddenFilterItems(filterItems, filters), [filterItems, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (validFilterItems !== filterItems) {
|
||||
setFilterItems(validFilterItems);
|
||||
}
|
||||
}, [filterItems, setFilterItems, validFilterItems]);
|
||||
|
||||
const [isModalInfoActive, setIsModalInfoActive] = useState(false)
|
||||
const [isModalTrashActive, setIsModalTrashActive] = useState(false)
|
||||
|
||||
@ -190,7 +199,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
|
||||
const generateFilterRequests = useMemo(() => {
|
||||
let request = '&';
|
||||
filterItems.forEach((item) => {
|
||||
validFilterItems.forEach((item) => {
|
||||
const isRangeFilter = filters.find(
|
||||
(filter) =>
|
||||
filter.title === item.fields.selectedField &&
|
||||
@ -214,10 +223,10 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
}
|
||||
});
|
||||
return request;
|
||||
}, [filterItems, filters]);
|
||||
}, [filters, validFilterItems]);
|
||||
|
||||
const deleteFilter = (value) => {
|
||||
const newItems = filterItems.filter((item) => item.id !== value);
|
||||
const newItems = validFilterItems.filter((item) => item.id !== value);
|
||||
|
||||
if (newItems.length) {
|
||||
setFilterItems(newItems);
|
||||
@ -238,7 +247,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
const name = e.target.name;
|
||||
|
||||
setFilterItems(
|
||||
filterItems.map((item) => {
|
||||
validFilterItems.map((item) => {
|
||||
if (item.id !== id) return item;
|
||||
if (name === 'selectedField') return { id, fields: { [name]: value } };
|
||||
|
||||
@ -351,7 +360,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
|
||||
return (
|
||||
<>
|
||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
||||
{validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ?
|
||||
<CardBox className='mb-6 border border-white/10 shadow-none'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
@ -363,9 +372,12 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
>
|
||||
<Form>
|
||||
<>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
{validFilterItems && validFilterItems.map((filterItem) => {
|
||||
const showIncompleteHint = isFilterItemIncomplete(filterItem, filters)
|
||||
const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null
|
||||
|
||||
return (
|
||||
<div key={filterItem.id} className="mb-3 grid gap-3 rounded-2xl border border-white/10 bg-white/5 p-4 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_auto]">
|
||||
<div key={filterItem.id} className="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 min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Filter</div>
|
||||
<Field
|
||||
@ -484,10 +496,10 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Action</div>
|
||||
<BaseButton
|
||||
className="my-1"
|
||||
className="my-1 w-full md:w-auto"
|
||||
type='reset'
|
||||
color='whiteDark'
|
||||
outline
|
||||
@ -496,6 +508,11 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
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>
|
||||
)
|
||||
|
||||
@ -14,6 +14,7 @@ import DataGridMultiSelect from "../DataGridMultiSelect";
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
import { getRoleLaneFromUser } from "../../helpers/roleLanes";
|
||||
|
||||
type Params = (id: string) => void;
|
||||
|
||||
@ -38,8 +39,15 @@ export const loadColumns = async (
|
||||
}
|
||||
|
||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_RESERVATIONS')
|
||||
const roleLane = getRoleLaneFromUser(user)
|
||||
const hiddenFieldsByLane = {
|
||||
super_admin: new Set([]),
|
||||
admin: new Set(['tenant', 'organization']),
|
||||
concierge: new Set(['tenant', 'organization', 'nightly_rate', 'monthly_rate', 'currency', 'internal_notes', 'invoices', 'comments']),
|
||||
customer: new Set(['tenant', 'organization', 'booking_request', 'nightly_rate', 'monthly_rate', 'currency', 'internal_notes', 'invoices', 'comments', 'service_requests']),
|
||||
}
|
||||
|
||||
return [
|
||||
const columns = [
|
||||
|
||||
{
|
||||
field: 'tenant',
|
||||
@ -524,4 +532,6 @@ export const loadColumns = async (
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field));
|
||||
};
|
||||
|
||||
@ -1,39 +1,79 @@
|
||||
import React, {useEffect, useId, useState} from 'react';
|
||||
import React, { useEffect, useId, useMemo, useState } from 'react';
|
||||
import { AsyncPaginate } from 'react-select-async-paginate';
|
||||
import axios from 'axios'
|
||||
|
||||
export const SelectField = ({ options, field, form, itemRef, showField, disabled }) => {
|
||||
const buildQueryString = (itemRef, queryParams) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (itemRef === 'roles') {
|
||||
params.set('businessOnly', 'true');
|
||||
}
|
||||
|
||||
if (typeof queryParams === 'string') {
|
||||
const extraParams = new URLSearchParams(queryParams);
|
||||
extraParams.forEach((value, key) => params.set(key, value));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
if (queryParams && typeof queryParams === 'object') {
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
params.set(key, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export const SelectField = ({ options, field, form, itemRef, showField, disabled, queryParams }) => {
|
||||
const [value, setValue] = useState(null)
|
||||
const PAGE_SIZE = 100;
|
||||
const extraQueryString = useMemo(() => buildQueryString(itemRef, queryParams), [itemRef, queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if(options?.id && field?.value?.id) {
|
||||
setValue({value: field.value?.id, label: field.value[showField]})
|
||||
form.setFieldValue(field.name, field.value?.id);
|
||||
} else if (!field.value) {
|
||||
if (field?.value?.id) {
|
||||
setValue({ value: field.value.id, label: field.value[showField || 'name'] || field.value.label });
|
||||
form.setFieldValue(field.name, field.value.id);
|
||||
} else if (options?.id) {
|
||||
const label = options[showField || 'name'] || options.label || options.firstName || options.email || options.request_code || options.reservation_code || options.summary;
|
||||
setValue({ value: options.id, label });
|
||||
if (field?.value !== options.id) {
|
||||
form.setFieldValue(field.name, options.id);
|
||||
}
|
||||
} else if (!field?.value) {
|
||||
setValue(null);
|
||||
}
|
||||
}, [options?.id, field?.value?.id, field?.value])
|
||||
}, [options, field?.value, form, field?.name, showField])
|
||||
|
||||
const mapResponseToValuesAndLabels = (data) => ({
|
||||
value: data.id,
|
||||
label: data.label,
|
||||
})
|
||||
|
||||
const handleChange = (option) => {
|
||||
form.setFieldValue(field.name, option?.value || null)
|
||||
setValue(option)
|
||||
}
|
||||
|
||||
async function callApi(inputValue: string, loadedOptions: any[]) {
|
||||
const businessOnly = itemRef === 'roles' ? '&businessOnly=true' : '';
|
||||
const path = `/${itemRef}/autocomplete?limit=${PAGE_SIZE}&offset=${loadedOptions.length}${inputValue ? `&query=${inputValue}` : ''}${businessOnly}`;
|
||||
const params = new URLSearchParams(extraQueryString);
|
||||
params.set('limit', String(PAGE_SIZE));
|
||||
params.set('offset', String(loadedOptions.length));
|
||||
if (inputValue) {
|
||||
params.set('query', inputValue);
|
||||
}
|
||||
|
||||
const path = `/${itemRef}/autocomplete?${params.toString()}`;
|
||||
const { data } = await axios(path);
|
||||
return {
|
||||
options: data.map(mapResponseToValuesAndLabels),
|
||||
hasMore: data.length === PAGE_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AsyncPaginate
|
||||
classNames={{
|
||||
@ -51,4 +91,3 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
import {loadColumns} from "./configureService_requestsCols";
|
||||
import _ from 'lodash';
|
||||
import dataFormatter from '../../helpers/dataFormatter'
|
||||
import { getIncompleteFilterHint, isFilterItemIncomplete, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
|
||||
import {dataGridStyles} from "../../styles";
|
||||
|
||||
|
||||
@ -83,6 +84,14 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
}
|
||||
}, [refetch, dispatch]);
|
||||
|
||||
const validFilterItems = useMemo(() => pruneHiddenFilterItems(filterItems, filters), [filterItems, filters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (validFilterItems !== filterItems) {
|
||||
setFilterItems(validFilterItems);
|
||||
}
|
||||
}, [filterItems, setFilterItems, validFilterItems]);
|
||||
|
||||
const [isModalInfoActive, setIsModalInfoActive] = useState(false)
|
||||
const [isModalTrashActive, setIsModalTrashActive] = useState(false)
|
||||
|
||||
@ -134,7 +143,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
|
||||
const generateFilterRequests = useMemo(() => {
|
||||
let request = '&';
|
||||
filterItems.forEach((item) => {
|
||||
validFilterItems.forEach((item) => {
|
||||
const isRangeFilter = filters.find(
|
||||
(filter) =>
|
||||
filter.title === item.fields.selectedField &&
|
||||
@ -158,10 +167,10 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
}
|
||||
});
|
||||
return request;
|
||||
}, [filterItems, filters]);
|
||||
}, [filters, validFilterItems]);
|
||||
|
||||
const deleteFilter = (value) => {
|
||||
const newItems = filterItems.filter((item) => item.id !== value);
|
||||
const newItems = validFilterItems.filter((item) => item.id !== value);
|
||||
|
||||
if (newItems.length) {
|
||||
setFilterItems(newItems);
|
||||
@ -186,7 +195,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
const name = e.target.name;
|
||||
|
||||
setFilterItems(
|
||||
filterItems.map((item) => {
|
||||
validFilterItems.map((item) => {
|
||||
if (item.id !== id) return item;
|
||||
if (name === 'selectedField') return { id, fields: { [name]: value } };
|
||||
|
||||
@ -298,7 +307,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
|
||||
return (
|
||||
<>
|
||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
||||
{validFilterItems && Array.isArray(validFilterItems) && validFilterItems.length ?
|
||||
<CardBox>
|
||||
<Formik
|
||||
initialValues={{
|
||||
@ -310,10 +319,13 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
>
|
||||
<Form>
|
||||
<>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
{validFilterItems && validFilterItems.map((filterItem) => {
|
||||
const showIncompleteHint = isFilterItemIncomplete(filterItem, filters)
|
||||
const incompleteHint = showIncompleteHint ? getIncompleteFilterHint(filterItem, filters) : null
|
||||
|
||||
return (
|
||||
<div key={filterItem.id} className="flex mb-4">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<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 w-full flex-col md:mr-3">
|
||||
<div className=" text-gray-500 font-bold">Filter</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
@ -336,7 +348,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
{filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.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">
|
||||
Value
|
||||
</div>
|
||||
@ -361,8 +373,8 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
) : filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.number ? (
|
||||
<div className="flex flex-row w-full mr-3">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row md:mr-3">
|
||||
<div className="flex w-full flex-col md:mr-3">
|
||||
<div className=" text-gray-500 font-bold">From</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
@ -390,7 +402,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
filter.title ===
|
||||
filterItem?.fields?.selectedField
|
||||
)?.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=' text-gray-500 font-bold'>
|
||||
From
|
||||
@ -419,7 +431,7 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
</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>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
@ -431,10 +443,10 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className=" text-gray-500 font-bold">Action</div>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
className="my-1 w-full sm:my-2 sm:w-auto"
|
||||
type='reset'
|
||||
color='danger'
|
||||
label='Delete'
|
||||
@ -442,19 +454,24 @@ const TableSampleService_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
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 className="flex">
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<BaseButton
|
||||
className="my-2 mr-3"
|
||||
className="my-1 w-full sm:my-2 sm:mr-3 sm:w-auto"
|
||||
type='submit' color='info'
|
||||
label='Apply'
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
className="my-1 w-full sm:my-2 sm:w-auto"
|
||||
type='reset' color='info' outline
|
||||
label='Cancel'
|
||||
onClick={handleReset}
|
||||
|
||||
@ -14,6 +14,7 @@ import DataGridMultiSelect from "../DataGridMultiSelect";
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
import { getRoleLaneFromUser } from "../../helpers/roleLanes";
|
||||
|
||||
type Params = (id: string) => void;
|
||||
|
||||
@ -38,8 +39,15 @@ export const loadColumns = async (
|
||||
}
|
||||
|
||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_SERVICE_REQUESTS')
|
||||
const roleLane = getRoleLaneFromUser(user)
|
||||
const hiddenFieldsByLane = {
|
||||
super_admin: new Set([]),
|
||||
admin: new Set(['tenant']),
|
||||
concierge: new Set(['tenant', 'estimated_cost', 'actual_cost', 'currency', 'comments']),
|
||||
customer: new Set(['tenant', 'requested_by', 'assigned_to', 'estimated_cost', 'actual_cost', 'currency', 'comments']),
|
||||
}
|
||||
|
||||
return [
|
||||
const columns = [
|
||||
|
||||
{
|
||||
field: 'tenant',
|
||||
@ -369,4 +377,6 @@ export const loadColumns = async (
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return columns.filter((column) => !hiddenFieldsByLane[roleLane]?.has(column.field));
|
||||
};
|
||||
|
||||
207
frontend/src/helpers/entityVisibility.ts
Normal file
207
frontend/src/helpers/entityVisibility.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import { RoleLane } from './roleLanes'
|
||||
|
||||
type FilterDefinition = {
|
||||
title: string
|
||||
label?: string
|
||||
type?: string
|
||||
number?: string
|
||||
date?: string
|
||||
}
|
||||
|
||||
type FilterItemFields = {
|
||||
selectedField?: string
|
||||
filterValue?: string
|
||||
filterValueFrom?: string
|
||||
filterValueTo?: string
|
||||
}
|
||||
|
||||
type FilterItemLike = {
|
||||
fields?: FilterItemFields
|
||||
}
|
||||
|
||||
const hiddenFieldsByEntityAndLane: Record<string, Record<RoleLane, Set<string>>> = {
|
||||
booking_requests: {
|
||||
super_admin: new Set([]),
|
||||
admin: new Set(['tenant', 'organization', 'requested_by']),
|
||||
concierge: new Set(['tenant', 'organization', 'requested_by', 'approval_steps', 'comments']),
|
||||
customer: new Set(['tenant', 'organization', 'requested_by', 'approval_steps', 'comments', 'budget_code', 'max_budget_amount', 'currency']),
|
||||
},
|
||||
reservations: {
|
||||
super_admin: new Set([]),
|
||||
admin: new Set(['tenant', 'organization']),
|
||||
concierge: new Set(['tenant', 'organization', 'nightly_rate', 'monthly_rate', 'currency', 'internal_notes', 'invoices', 'comments']),
|
||||
customer: new Set(['tenant', 'organization', 'booking_request', 'nightly_rate', 'monthly_rate', 'currency', 'internal_notes', 'invoices', 'comments', 'service_requests']),
|
||||
},
|
||||
service_requests: {
|
||||
super_admin: new Set([]),
|
||||
admin: new Set(['tenant']),
|
||||
concierge: new Set(['tenant', 'estimated_cost', 'actual_cost', 'currency', 'comments']),
|
||||
customer: new Set(['tenant', 'requested_by', 'assigned_to', 'estimated_cost', 'actual_cost', 'currency', 'comments']),
|
||||
},
|
||||
documents: {
|
||||
super_admin: new Set([]),
|
||||
admin: new Set(['tenant', 'organization', 'storage_key', 'public_url']),
|
||||
concierge: new Set(['tenant', 'organization', 'storage_key', 'public_url', 'mime_type', 'file_size_bytes', 'invoice', 'is_private']),
|
||||
customer: new Set(['tenant', 'organization', 'storage_key', 'public_url', 'mime_type', 'file_size_bytes', 'uploaded_by', 'invoice', 'is_private', 'notes']),
|
||||
},
|
||||
}
|
||||
|
||||
const isRangeFilter = (filter?: FilterDefinition) => Boolean(filter?.number || filter?.date)
|
||||
|
||||
const getFilterDisplayLabel = (filter?: FilterDefinition) => {
|
||||
const rawLabel = `${filter?.label ?? filter?.title ?? ""}`.trim()
|
||||
|
||||
if (!rawLabel) {
|
||||
return 'this field'
|
||||
}
|
||||
|
||||
return rawLabel.charAt(0).toLowerCase() + rawLabel.slice(1)
|
||||
}
|
||||
|
||||
export const getHiddenFieldsForRole = (entityName: string, roleLane: RoleLane) => {
|
||||
return hiddenFieldsByEntityAndLane[entityName]?.[roleLane] ?? new Set<string>()
|
||||
}
|
||||
|
||||
export const filterEntityFieldsForRole = <T extends { title: string }>(
|
||||
entityName: string,
|
||||
roleLane: RoleLane,
|
||||
fields: T[],
|
||||
) => {
|
||||
const hiddenFields = getHiddenFieldsForRole(entityName, roleLane)
|
||||
|
||||
return fields.filter((field) => !hiddenFields.has(field.title))
|
||||
}
|
||||
|
||||
export const pruneHiddenFilterItems = <
|
||||
TFilter extends { title: string },
|
||||
TFilterItem extends { fields?: { selectedField?: string } },
|
||||
>(
|
||||
filterItems: TFilterItem[],
|
||||
filters: TFilter[],
|
||||
) => {
|
||||
if (!Array.isArray(filterItems) || !filterItems.length) {
|
||||
return filterItems
|
||||
}
|
||||
|
||||
const availableFields = new Set(filters.map((filter) => filter.title))
|
||||
const nextFilterItems = filterItems.filter((item) => availableFields.has(item?.fields?.selectedField || ''))
|
||||
|
||||
return nextFilterItems.length === filterItems.length ? filterItems : nextFilterItems
|
||||
}
|
||||
|
||||
export const isFilterItemIncomplete = <TFilter extends FilterDefinition, TFilterItem extends FilterItemLike>(
|
||||
filterItem: TFilterItem,
|
||||
filters: TFilter[],
|
||||
) => {
|
||||
const selectedField = filterItem?.fields?.selectedField || ''
|
||||
const selectedFilter = filters.find((filter) => filter.title === selectedField)
|
||||
|
||||
if (!selectedFilter) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (isRangeFilter(selectedFilter)) {
|
||||
return !(filterItem?.fields?.filterValueFrom || filterItem?.fields?.filterValueTo)
|
||||
}
|
||||
|
||||
return !filterItem?.fields?.filterValue
|
||||
}
|
||||
|
||||
export const getIncompleteFilterHint = <TFilter extends FilterDefinition, TFilterItem extends FilterItemLike>(
|
||||
filterItem: TFilterItem,
|
||||
filters: TFilter[],
|
||||
) => {
|
||||
const selectedField = filterItem?.fields?.selectedField || ''
|
||||
const selectedFilter = filters.find((filter) => filter.title === selectedField)
|
||||
const filterLabel = getFilterDisplayLabel(selectedFilter)
|
||||
|
||||
if (!selectedFilter) {
|
||||
return 'Choose a filter field to continue.'
|
||||
}
|
||||
|
||||
if (selectedFilter.number || selectedFilter.date) {
|
||||
return `Enter ${filterLabel} from/to to add another filter.`
|
||||
}
|
||||
|
||||
if (selectedFilter.type === 'enum') {
|
||||
return `Choose ${filterLabel} to add another filter.`
|
||||
}
|
||||
|
||||
return `Enter ${filterLabel} to add another filter.`
|
||||
}
|
||||
|
||||
export const getNextAvailableFilter = <TFilter extends FilterDefinition, TFilterItem extends FilterItemLike>(
|
||||
filterItems: TFilterItem[],
|
||||
filters: TFilter[],
|
||||
) => {
|
||||
const usedFields = new Set(
|
||||
filterItems
|
||||
.map((item) => item?.fields?.selectedField)
|
||||
.filter((selectedField): selectedField is string => Boolean(selectedField)),
|
||||
)
|
||||
|
||||
return filters.find((filter) => !usedFields.has(filter.title)) ?? null
|
||||
}
|
||||
|
||||
export const getAddFilterState = <TFilter extends FilterDefinition, TFilterItem extends FilterItemLike>(
|
||||
filterItems: TFilterItem[],
|
||||
filters: TFilter[],
|
||||
) => {
|
||||
if (!filters.length) {
|
||||
return {
|
||||
canAddFilter: false,
|
||||
buttonLabel: 'No filters available',
|
||||
helperText: 'No filters are available for this view.',
|
||||
}
|
||||
}
|
||||
|
||||
if (filterItems.some((item) => isFilterItemIncomplete(item, filters))) {
|
||||
return {
|
||||
canAddFilter: false,
|
||||
buttonLabel: 'Complete current filter',
|
||||
helperText: 'Finish the current filter before adding another.',
|
||||
}
|
||||
}
|
||||
|
||||
const nextFilter = getNextAvailableFilter(filterItems, filters)
|
||||
|
||||
if (!nextFilter) {
|
||||
return {
|
||||
canAddFilter: false,
|
||||
buttonLabel: 'All filters added',
|
||||
helperText: 'All available filters are already in use.',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canAddFilter: true,
|
||||
buttonLabel: 'Add filter',
|
||||
helperText: `Next filter: ${nextFilter.label ?? nextFilter.title}`,
|
||||
}
|
||||
}
|
||||
|
||||
export const buildNextFilterItem = <TFilter extends FilterDefinition>(
|
||||
filterItems: FilterItemLike[],
|
||||
filters: TFilter[],
|
||||
createId: () => string,
|
||||
) => {
|
||||
if (!filters.length || filterItems.some((item) => isFilterItemIncomplete(item, filters))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextFilter = getNextAvailableFilter(filterItems, filters)
|
||||
|
||||
if (!nextFilter) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: createId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: nextFilter.title,
|
||||
},
|
||||
}
|
||||
}
|
||||
28
frontend/src/helpers/roleLanes.ts
Normal file
28
frontend/src/helpers/roleLanes.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export type RoleLane = 'super_admin' | 'admin' | 'concierge' | 'customer';
|
||||
|
||||
export const ROLE_LANES = {
|
||||
superAdmin: new Set(['Super Administrator']),
|
||||
admin: new Set(['Administrator', 'Platform Owner', 'Operations Director', 'Reservations Lead', 'Finance Controller']),
|
||||
concierge: new Set(['Concierge Coordinator']),
|
||||
customer: new Set(['Customer']),
|
||||
};
|
||||
|
||||
export function getRoleLane(roleName?: string | null, hasGlobalAccess = false): RoleLane {
|
||||
if (hasGlobalAccess || (roleName && ROLE_LANES.superAdmin.has(roleName))) {
|
||||
return 'super_admin';
|
||||
}
|
||||
|
||||
if (roleName && ROLE_LANES.admin.has(roleName)) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
if (roleName && ROLE_LANES.customer.has(roleName)) {
|
||||
return 'customer';
|
||||
}
|
||||
|
||||
return 'concierge';
|
||||
}
|
||||
|
||||
export function getRoleLaneFromUser(user?: any): RoleLane {
|
||||
return getRoleLane(user?.app_role?.name, Boolean(user?.app_role?.globalAccess));
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
import buildMenuAside from '../menuAside'
|
||||
import menuNavBar from '../menuNavBar'
|
||||
import BaseIcon from '../components/BaseIcon'
|
||||
import NavBar from '../components/NavBar'
|
||||
@ -117,7 +117,7 @@ export default function LayoutAuthenticated({
|
||||
<AsideMenu
|
||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||
isAsideLgActive={isAsideLgActive}
|
||||
menu={menuAside}
|
||||
menu={buildMenuAside(currentUser)}
|
||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||
/>
|
||||
{children}
|
||||
|
||||
@ -1,179 +1,205 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import { MenuAsideItem } from './interfaces';
|
||||
import { getRoleLaneFromUser } from './helpers/roleLanes';
|
||||
|
||||
const menuAside: MenuAsideItem[] = [
|
||||
{
|
||||
href: '/command-center',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiShieldHomeOutline' in icon ? icon['mdiShieldHomeOutline' as keyof typeof icon] : icon.mdiViewDashboardOutline,
|
||||
label: 'Command Center',
|
||||
},
|
||||
{
|
||||
label: 'Operations',
|
||||
icon: icon.mdiClipboardTextOutline,
|
||||
permissions: [
|
||||
'READ_BOOKING_REQUESTS',
|
||||
'READ_APPROVAL_STEPS',
|
||||
'READ_RESERVATIONS',
|
||||
'READ_SERVICE_REQUESTS',
|
||||
'READ_INVOICES',
|
||||
'READ_DOCUMENTS',
|
||||
],
|
||||
menu: [
|
||||
{
|
||||
href: '/booking_requests/booking_requests-list',
|
||||
label: 'Booking Requests',
|
||||
icon: icon.mdiClipboardTextOutline,
|
||||
permissions: 'READ_BOOKING_REQUESTS',
|
||||
},
|
||||
{
|
||||
href: '/approval_steps/approval_steps-list',
|
||||
label: 'Approvals',
|
||||
icon: icon.mdiCheckDecagram,
|
||||
permissions: 'READ_APPROVAL_STEPS',
|
||||
},
|
||||
{
|
||||
href: '/reservations/reservations-list',
|
||||
label: 'Reservations',
|
||||
icon: icon.mdiCalendarCheck,
|
||||
permissions: 'READ_RESERVATIONS',
|
||||
},
|
||||
{
|
||||
href: '/service_requests/service_requests-list',
|
||||
label: 'Service Requests',
|
||||
icon: icon.mdiRoomService,
|
||||
permissions: 'READ_SERVICE_REQUESTS',
|
||||
},
|
||||
{
|
||||
href: '/invoices/invoices-list',
|
||||
label: 'Invoices',
|
||||
icon: icon.mdiFileDocument,
|
||||
permissions: 'READ_INVOICES',
|
||||
},
|
||||
{
|
||||
href: '/documents/documents-list',
|
||||
label: 'Documents',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiPaperclip' in icon ? icon['mdiPaperclip' as keyof typeof icon] : icon.mdiFileDocument,
|
||||
permissions: 'READ_DOCUMENTS',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Portfolio',
|
||||
icon: icon.mdiHomeCity,
|
||||
permissions: [
|
||||
'READ_ORGANIZATIONS',
|
||||
'READ_PROPERTIES',
|
||||
'READ_UNITS',
|
||||
'READ_NEGOTIATED_RATES',
|
||||
'READ_UNIT_TYPES',
|
||||
'READ_AMENITIES',
|
||||
],
|
||||
menu: [
|
||||
{
|
||||
href: '/organizations/organizations-list',
|
||||
label: 'Organizations',
|
||||
icon: icon.mdiOfficeBuildingOutline,
|
||||
permissions: 'READ_ORGANIZATIONS',
|
||||
},
|
||||
{
|
||||
href: '/properties/properties-list',
|
||||
label: 'Properties',
|
||||
icon: icon.mdiHomeCity,
|
||||
permissions: 'READ_PROPERTIES',
|
||||
},
|
||||
{
|
||||
href: '/units/units-list',
|
||||
label: 'Units',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiDoor' in icon ? icon['mdiDoor' as keyof typeof icon] : icon.mdiHomeCity,
|
||||
permissions: 'READ_UNITS',
|
||||
},
|
||||
{
|
||||
href: '/negotiated_rates/negotiated_rates-list',
|
||||
label: 'Negotiated Rates',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiTag' in icon ? icon['mdiTag' as keyof typeof icon] : icon.mdiFileDocument,
|
||||
permissions: 'READ_NEGOTIATED_RATES',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Administration',
|
||||
icon: icon.mdiAccountGroup,
|
||||
permissions: [
|
||||
'READ_USERS',
|
||||
'READ_ROLES',
|
||||
'READ_PERMISSIONS',
|
||||
'READ_NOTIFICATIONS',
|
||||
'READ_AUDIT_LOGS',
|
||||
'READ_JOB_RUNS',
|
||||
],
|
||||
menu: [
|
||||
export const buildMenuAside = (currentUser?: any): MenuAsideItem[] => {
|
||||
const roleLane = getRoleLaneFromUser(currentUser);
|
||||
const isSuperAdmin = roleLane === 'super_admin';
|
||||
const isAdmin = roleLane === 'admin';
|
||||
const isConcierge = roleLane === 'concierge';
|
||||
const isCustomer = roleLane === 'customer';
|
||||
|
||||
const operationsLabel = isCustomer ? 'My Stay' : isConcierge ? 'Stay Delivery' : 'Operations';
|
||||
|
||||
const operationsMenu: MenuAsideItem[] = [
|
||||
{
|
||||
href: '/booking_requests/booking_requests-list',
|
||||
label: isCustomer ? 'My Requests' : isAdmin ? 'Incoming Requests' : 'Requests',
|
||||
icon: icon.mdiClipboardTextOutline,
|
||||
permissions: 'READ_BOOKING_REQUESTS',
|
||||
},
|
||||
{
|
||||
href: '/approval_steps/approval_steps-list',
|
||||
label: isCustomer ? 'Approval Status' : 'Approvals',
|
||||
icon: icon.mdiCheckDecagram,
|
||||
permissions: 'READ_APPROVAL_STEPS',
|
||||
},
|
||||
{
|
||||
href: '/reservations/reservations-list',
|
||||
label: isCustomer ? 'My Stays' : isConcierge ? 'Active Stays' : 'Stays',
|
||||
icon: icon.mdiCalendarCheck,
|
||||
permissions: 'READ_RESERVATIONS',
|
||||
},
|
||||
{
|
||||
href: '/service_requests/service_requests-list',
|
||||
label: isCustomer ? 'Support' : 'Service Queue',
|
||||
icon: icon.mdiRoomService,
|
||||
permissions: 'READ_SERVICE_REQUESTS',
|
||||
},
|
||||
{
|
||||
href: '/invoices/invoices-list',
|
||||
label: isCustomer ? 'Billing' : 'Invoices',
|
||||
icon: icon.mdiFileDocument,
|
||||
permissions: 'READ_INVOICES',
|
||||
},
|
||||
{
|
||||
href: '/documents/documents-list',
|
||||
label: isCustomer ? 'My Documents' : isConcierge ? 'Guest Documents' : 'Documents',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiPaperclip' in icon ? icon['mdiPaperclip' as keyof typeof icon] : icon.mdiFileDocument,
|
||||
permissions: 'READ_DOCUMENTS',
|
||||
},
|
||||
];
|
||||
|
||||
const menuAside: MenuAsideItem[] = [
|
||||
{
|
||||
href: '/command-center',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiShieldHomeOutline' in icon ? icon['mdiShieldHomeOutline' as keyof typeof icon] : icon.mdiViewDashboardOutline,
|
||||
label: 'Command Center',
|
||||
},
|
||||
{
|
||||
label: operationsLabel,
|
||||
icon: icon.mdiClipboardTextOutline,
|
||||
permissions: [
|
||||
'READ_BOOKING_REQUESTS',
|
||||
'READ_APPROVAL_STEPS',
|
||||
'READ_RESERVATIONS',
|
||||
'READ_SERVICE_REQUESTS',
|
||||
'READ_INVOICES',
|
||||
'READ_DOCUMENTS',
|
||||
],
|
||||
menu: operationsMenu,
|
||||
},
|
||||
];
|
||||
|
||||
if (!isCustomer) {
|
||||
menuAside.push({
|
||||
label: 'Portfolio',
|
||||
icon: icon.mdiHomeCity,
|
||||
permissions: [
|
||||
'READ_ORGANIZATIONS',
|
||||
'READ_PROPERTIES',
|
||||
'READ_UNITS',
|
||||
'READ_NEGOTIATED_RATES',
|
||||
'READ_UNIT_TYPES',
|
||||
'READ_AMENITIES',
|
||||
],
|
||||
menu: [
|
||||
{
|
||||
href: '/organizations/organizations-list',
|
||||
label: 'Organizations',
|
||||
icon: icon.mdiOfficeBuildingOutline,
|
||||
permissions: 'READ_ORGANIZATIONS',
|
||||
},
|
||||
{
|
||||
href: '/properties/properties-list',
|
||||
label: 'Properties',
|
||||
icon: icon.mdiHomeCity,
|
||||
permissions: 'READ_PROPERTIES',
|
||||
},
|
||||
{
|
||||
href: '/units/units-list',
|
||||
label: 'Units',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiDoor' in icon ? icon['mdiDoor' as keyof typeof icon] : icon.mdiHomeCity,
|
||||
permissions: 'READ_UNITS',
|
||||
},
|
||||
{
|
||||
href: '/negotiated_rates/negotiated_rates-list',
|
||||
label: 'Negotiated Rates',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiTag' in icon ? icon['mdiTag' as keyof typeof icon] : icon.mdiFileDocument,
|
||||
permissions: 'READ_NEGOTIATED_RATES',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (isSuperAdmin || isAdmin) {
|
||||
const adminMenu: MenuAsideItem[] = [
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
icon: icon.mdiAccountGroup,
|
||||
permissions: 'READ_USERS',
|
||||
},
|
||||
{
|
||||
href: '/roles/roles-list',
|
||||
label: 'Roles',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiAccountGroup,
|
||||
permissions: 'READ_ROLES',
|
||||
},
|
||||
{
|
||||
href: '/permissions/permissions-list',
|
||||
label: 'Permissions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiAccountGroup,
|
||||
permissions: 'READ_PERMISSIONS',
|
||||
},
|
||||
{
|
||||
href: '/notifications/notifications-list',
|
||||
label: 'Notifications',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiBell' in icon ? icon['mdiBell' as keyof typeof icon] : icon.mdiFileDocument,
|
||||
permissions: 'READ_NOTIFICATIONS',
|
||||
},
|
||||
{
|
||||
href: '/audit_logs/audit_logs-list',
|
||||
label: 'Audit Logs',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiClipboardPulse' in icon ? icon['mdiClipboardPulse' as keyof typeof icon] : icon.mdiClipboardTextOutline,
|
||||
permissions: 'READ_AUDIT_LOGS',
|
||||
},
|
||||
{
|
||||
href: '/job_runs/job_runs-list',
|
||||
label: 'Job Runs',
|
||||
icon: icon.mdiClockOutline,
|
||||
permissions: 'READ_JOB_RUNS',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'Profile',
|
||||
icon: icon.mdiAccountCircle,
|
||||
withDevider: true,
|
||||
},
|
||||
{
|
||||
href: '/api-docs',
|
||||
target: '_blank',
|
||||
label: 'Swagger API',
|
||||
icon: icon.mdiFileCode,
|
||||
permissions: 'READ_API_DOCS',
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
export default menuAside;
|
||||
if (isSuperAdmin) {
|
||||
adminMenu.push(
|
||||
{
|
||||
href: '/roles/roles-list',
|
||||
label: 'Roles',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiAccountGroup,
|
||||
permissions: 'READ_ROLES',
|
||||
},
|
||||
{
|
||||
href: '/permissions/permissions-list',
|
||||
label: 'Permissions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiAccountGroup,
|
||||
permissions: 'READ_PERMISSIONS',
|
||||
},
|
||||
{
|
||||
href: '/notifications/notifications-list',
|
||||
label: 'Notifications',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiBell' in icon ? icon['mdiBell' as keyof typeof icon] : icon.mdiFileDocument,
|
||||
permissions: 'READ_NOTIFICATIONS',
|
||||
},
|
||||
{
|
||||
href: '/audit_logs/audit_logs-list',
|
||||
label: 'Audit Logs',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiClipboardPulse' in icon ? icon['mdiClipboardPulse' as keyof typeof icon] : icon.mdiClipboardTextOutline,
|
||||
permissions: 'READ_AUDIT_LOGS',
|
||||
},
|
||||
{
|
||||
href: '/job_runs/job_runs-list',
|
||||
label: 'Job Runs',
|
||||
icon: icon.mdiClockOutline,
|
||||
permissions: 'READ_JOB_RUNS',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
menuAside.push({
|
||||
label: 'Administration',
|
||||
icon: icon.mdiAccountGroup,
|
||||
permissions: isSuperAdmin
|
||||
? ['READ_USERS', 'READ_ROLES', 'READ_PERMISSIONS', 'READ_NOTIFICATIONS', 'READ_AUDIT_LOGS', 'READ_JOB_RUNS']
|
||||
: ['READ_USERS'],
|
||||
menu: adminMenu,
|
||||
});
|
||||
}
|
||||
|
||||
menuAside.push(
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'Profile',
|
||||
icon: icon.mdiAccountCircle,
|
||||
withDevider: true,
|
||||
},
|
||||
{
|
||||
href: '/api-docs',
|
||||
target: '_blank',
|
||||
label: 'Swagger API',
|
||||
icon: icon.mdiFileCode,
|
||||
permissions: 'READ_API_DOCS',
|
||||
},
|
||||
);
|
||||
|
||||
return menuAside;
|
||||
};
|
||||
|
||||
export default buildMenuAside;
|
||||
|
||||
@ -17,8 +17,10 @@ import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import {setRefetch, uploadCsv} from '../../stores/booking_requests/booking_requestsSlice';
|
||||
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
import { buildNextFilterItem, filterEntityFieldsForRole, getAddFilterState, pruneHiddenFilterItems } from "../../helpers/entityVisibility";
|
||||
import { humanize } from "../../helpers/humanize";
|
||||
import { getRoleLaneFromUser } from "../../helpers/roleLanes";
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
|
||||
|
||||
@ -30,7 +32,8 @@ const Booking_requestsTablesPage = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const canManageInternalFields = Boolean(currentUser?.app_role?.globalAccess);
|
||||
const roleLane = getRoleLaneFromUser(currentUser);
|
||||
const canManageInternalFields = roleLane === 'super_admin';
|
||||
const createButtonLabel = canManageInternalFields ? 'New request' : 'Request a stay';
|
||||
const filters = React.useMemo(() => {
|
||||
const baseFilters = [
|
||||
@ -62,24 +65,24 @@ const Booking_requestsTablesPage = () => {
|
||||
baseFilters.splice(10, 0, { label: 'Tenant', title: 'tenant' }, { label: 'Requestedby', title: 'requested_by' });
|
||||
}
|
||||
|
||||
return baseFilters.map((filter) => ({ ...filter, label: humanize(filter.title) }));
|
||||
}, [canManageInternalFields]);
|
||||
return filterEntityFieldsForRole('booking_requests', roleLane, baseFilters).map((filter) => ({ ...filter, label: humanize(filter.title) }));
|
||||
}, [canManageInternalFields, roleLane]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BOOKING_REQUESTS');
|
||||
|
||||
React.useEffect(() => {
|
||||
setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters))
|
||||
}, [filters])
|
||||
|
||||
const addFilterState = React.useMemo(() => getAddFilterState(filterItems, filters), [filterItems, filters])
|
||||
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: '',
|
||||
},
|
||||
};
|
||||
newItem.fields.selectedField = filters[0].title;
|
||||
setFilterItems([...filterItems, newItem]);
|
||||
};
|
||||
setFilterItems((currentItems) => {
|
||||
const nextItem = buildNextFilterItem(currentItems, filters, () => uniqueId())
|
||||
|
||||
return nextItem ? [...currentItems, nextItem] : currentItems
|
||||
})
|
||||
}
|
||||
|
||||
const getBooking_requestsCSV = async () => {
|
||||
const response = await axios({url: '/booking_requests?filetype=csv', method: 'GET',responseType: 'blob'});
|
||||
@ -125,16 +128,23 @@ const Booking_requestsTablesPage = () => {
|
||||
<p className='mt-1 text-sm text-slate-400'>Create, filter, export, or switch to table view from one calm action strip.</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-3 xl:items-end'>
|
||||
<div className='flex w-full flex-col gap-3 xl:w-auto xl:items-end'>
|
||||
<BaseButtons
|
||||
type='justify-start xl:justify-end'
|
||||
mb='mb-0'
|
||||
classAddon='mr-2 mb-2 last:mr-0'
|
||||
classAddon='mb-2 mr-0 w-full sm:mr-2 sm:w-auto last:mr-0'
|
||||
className='w-full xl:w-auto'
|
||||
>
|
||||
{hasCreatePermission ? (
|
||||
<BaseButton href={'/booking_requests/booking_requests-new'} color='info' label={createButtonLabel} />
|
||||
) : null}
|
||||
<BaseButton color='whiteDark' outline label='Add filter' onClick={addFilter} />
|
||||
<BaseButton
|
||||
color='whiteDark'
|
||||
outline
|
||||
label={addFilterState.buttonLabel}
|
||||
onClick={addFilter}
|
||||
disabled={!addFilterState.canAddFilter}
|
||||
/>
|
||||
<BaseButton color='whiteDark' outline label='Export CSV' onClick={getBooking_requestsCSV} />
|
||||
{hasCreatePermission ? (
|
||||
<BaseButton color='whiteDark' outline label='Import CSV' onClick={() => setIsModalActive(true)} />
|
||||
@ -142,7 +152,9 @@ const Booking_requestsTablesPage = () => {
|
||||
<BaseButton href={'/booking_requests/booking_requests-table'} color='whiteDark' outline label='Table view' />
|
||||
</BaseButtons>
|
||||
|
||||
<div className='flex min-h-[40px] items-center justify-start xl:justify-end'>
|
||||
<p className='max-w-full text-left text-xs leading-5 text-slate-400 xl:max-w-sm xl:text-right'>{addFilterState.helperText}</p>
|
||||
|
||||
<div className='flex min-h-[40px] w-full items-center justify-start xl:w-auto xl:justify-end'>
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -11,6 +11,8 @@ import CardBoxModal from '../../components/CardBoxModal'
|
||||
import DragDropFilePicker from '../../components/DragDropFilePicker'
|
||||
import TableBooking_requests from '../../components/Booking_requests/TableBooking_requests'
|
||||
import { getPageTitle } from '../../config'
|
||||
import { filterEntityFieldsForRole, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
|
||||
import { getRoleLaneFromUser } from '../../helpers/roleLanes'
|
||||
import { hasPermission } from '../../helpers/userPermissions'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
@ -26,7 +28,8 @@ const Booking_requestsTablesPage = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const canManageInternalFields = Boolean(currentUser?.app_role?.globalAccess)
|
||||
const roleLane = getRoleLaneFromUser(currentUser)
|
||||
const canManageInternalFields = roleLane === 'super_admin'
|
||||
const createButtonLabel = canManageInternalFields ? 'New item' : 'Request a stay'
|
||||
const filters = useMemo(() => {
|
||||
const baseFilters = [
|
||||
@ -58,11 +61,15 @@ const Booking_requestsTablesPage = () => {
|
||||
baseFilters.splice(10, 0, { label: 'Tenant', title: 'tenant' }, { label: 'Requestedby', title: 'requested_by' })
|
||||
}
|
||||
|
||||
return baseFilters
|
||||
}, [canManageInternalFields])
|
||||
return filterEntityFieldsForRole('booking_requests', roleLane, baseFilters)
|
||||
}, [canManageInternalFields, roleLane])
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BOOKING_REQUESTS')
|
||||
|
||||
React.useEffect(() => {
|
||||
setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters))
|
||||
}, [filters])
|
||||
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import { uniqueId } from 'lodash';
|
||||
import React, { ReactElement, useState } from 'react'
|
||||
import React, { ReactElement, useMemo, useState } from 'react'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
@ -9,14 +9,16 @@ import SectionTitleLineWithButton from '../../components/SectionTitleLineWithBut
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableDocuments from '../../components/Documents/TableDocuments'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import BaseButtons from '../../components/BaseButtons'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import {setRefetch, uploadCsv} from '../../stores/documents/documentsSlice';
|
||||
|
||||
|
||||
import { buildNextFilterItem, filterEntityFieldsForRole, getAddFilterState, pruneHiddenFilterItems } from "../../helpers/entityVisibility";
|
||||
import { getRoleLaneFromUser } from "../../helpers/roleLanes";
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
|
||||
@ -25,66 +27,39 @@ const DocumentsTablesPage = () => {
|
||||
const [filterItems, setFilterItems] = useState([]);
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [isModalActive, setIsModalActive] = useState(false);
|
||||
const [showTableView, setShowTableView] = useState(false);
|
||||
|
||||
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const roleLane = getRoleLaneFromUser(currentUser);
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'Filename', title: 'file_name'},{label: 'MIMEtype', title: 'mime_type'},{label: 'Storagekey', title: 'storage_key'},{label: 'PublicURL', title: 'public_url'},{label: 'Notes', title: 'notes'},
|
||||
const filters = useMemo(() => filterEntityFieldsForRole('documents', roleLane, [{label: 'Filename', title: 'file_name'},{label: 'MIMEtype', title: 'mime_type'},{label: 'Storagekey', title: 'storage_key'},{label: 'PublicURL', title: 'public_url'},{label: 'Notes', title: 'notes'},
|
||||
{label: 'Filesize(bytes)', title: 'file_size_bytes', number: 'true'},
|
||||
|
||||
|
||||
|
||||
|
||||
{label: 'Tenant', title: 'tenant'},
|
||||
|
||||
|
||||
|
||||
{label: 'Uploadedby', title: 'uploaded_by'},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{label: 'Bookingrequest', title: 'booking_request'},
|
||||
|
||||
|
||||
|
||||
{label: 'Reservation', title: 'reservation'},
|
||||
|
||||
|
||||
|
||||
{label: 'Servicerequest', title: 'service_request'},
|
||||
|
||||
|
||||
|
||||
{label: 'Invoice', title: 'invoice'},
|
||||
|
||||
|
||||
|
||||
{label: 'Tenant', title: 'tenant'},
|
||||
{label: 'Uploadedby', title: 'uploaded_by'},
|
||||
{label: 'Bookingrequest', title: 'booking_request'},
|
||||
{label: 'Reservation', title: 'reservation'},
|
||||
{label: 'Servicerequest', title: 'service_request'},
|
||||
{label: 'Invoice', title: 'invoice'},
|
||||
{label: 'Documenttype', title: 'document_type', type: 'enum', options: ['invoice_pdf','guest_id','contract','approval_attachment','arrival_instructions','checklist','inspection_report','other']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_DOCUMENTS');
|
||||
]), [roleLane]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters));
|
||||
}, [filters]);
|
||||
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: '',
|
||||
},
|
||||
};
|
||||
newItem.fields.selectedField = filters[0].title;
|
||||
setFilterItems([...filterItems, newItem]);
|
||||
};
|
||||
const addFilterState = useMemo(() => getAddFilterState(filterItems, filters), [filterItems, filters]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_DOCUMENTS');
|
||||
|
||||
const addFilter = () => {
|
||||
setFilterItems((currentItems) => {
|
||||
const nextItem = buildNextFilterItem(currentItems, filters, () => uniqueId());
|
||||
|
||||
return nextItem ? [...currentItems, nextItem] : currentItems;
|
||||
});
|
||||
};
|
||||
|
||||
const getDocumentsCSV = async () => {
|
||||
const response = await axios({url: '/documents?filetype=csv', method: 'GET',responseType: 'blob'});
|
||||
@ -118,30 +93,41 @@ const DocumentsTablesPage = () => {
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Documents" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/documents/documents-new'} color='info' label='New Item'/>}
|
||||
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getDocumentsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
|
||||
<CardBox className='mb-6 border border-white/10 shadow-none'>
|
||||
<div className='flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Working view</p>
|
||||
<h2 className='mt-1 text-lg font-semibold text-white'>Document hub</h2>
|
||||
<p className='mt-1 text-sm text-slate-400'>Collect files, filter the document list, and switch views from a mobile-friendly action strip.</p>
|
||||
</div>
|
||||
|
||||
<div className='flex w-full flex-col gap-3 xl:w-auto xl:items-end'>
|
||||
<BaseButtons
|
||||
type='justify-start xl:justify-end'
|
||||
mb='mb-0'
|
||||
classAddon='mb-2 mr-0 w-full sm:mr-2 sm:w-auto last:mr-0'
|
||||
className='w-full xl:w-auto'
|
||||
>
|
||||
{hasCreatePermission ? <BaseButton href={'/documents/documents-new'} color='info' label='New item' /> : null}
|
||||
<BaseButton
|
||||
color='whiteDark'
|
||||
outline
|
||||
label={addFilterState.buttonLabel}
|
||||
onClick={addFilter}
|
||||
disabled={!addFilterState.canAddFilter}
|
||||
/>
|
||||
<BaseButton color='whiteDark' outline label='Export CSV' onClick={getDocumentsCSV} />
|
||||
{hasCreatePermission ? <BaseButton color='whiteDark' outline label='Import CSV' onClick={() => setIsModalActive(true)} /> : null}
|
||||
<BaseButton href='/documents/documents-table' color='whiteDark' outline label='Table view' />
|
||||
</BaseButtons>
|
||||
|
||||
<p className='max-w-full text-left text-xs leading-5 text-slate-400 xl:max-w-sm xl:text-right'>{addFilterState.helperText}</p>
|
||||
|
||||
<div className='flex min-h-[40px] w-full items-center justify-start xl:w-auto xl:justify-end'>
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="mb-6" hasTable>
|
||||
@ -150,7 +136,7 @@ const DocumentsTablesPage = () => {
|
||||
setFilterItems={setFilterItems}
|
||||
filters={filters}
|
||||
showGrid={false}
|
||||
/>
|
||||
/>
|
||||
</CardBox>
|
||||
|
||||
</SectionMain>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import { uniqueId } from 'lodash';
|
||||
import React, { ReactElement, useState } from 'react'
|
||||
import React, { ReactElement, useMemo, useState } from 'react'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
@ -17,6 +17,8 @@ import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import {setRefetch, uploadCsv} from '../../stores/documents/documentsSlice';
|
||||
|
||||
|
||||
import { filterEntityFieldsForRole, pruneHiddenFilterItems } from "../../helpers/entityVisibility";
|
||||
import { getRoleLaneFromUser } from "../../helpers/roleLanes";
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
|
||||
@ -32,43 +34,23 @@ const DocumentsTablesPage = () => {
|
||||
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const roleLane = getRoleLaneFromUser(currentUser);
|
||||
|
||||
|
||||
const [filters] = useState([{label: 'Filename', title: 'file_name'},{label: 'MIMEtype', title: 'mime_type'},{label: 'Storagekey', title: 'storage_key'},{label: 'PublicURL', title: 'public_url'},{label: 'Notes', title: 'notes'},
|
||||
const filters = useMemo(() => filterEntityFieldsForRole('documents', roleLane, [{label: 'Filename', title: 'file_name'},{label: 'MIMEtype', title: 'mime_type'},{label: 'Storagekey', title: 'storage_key'},{label: 'PublicURL', title: 'public_url'},{label: 'Notes', title: 'notes'},
|
||||
{label: 'Filesize(bytes)', title: 'file_size_bytes', number: 'true'},
|
||||
|
||||
|
||||
|
||||
|
||||
{label: 'Tenant', title: 'tenant'},
|
||||
|
||||
|
||||
|
||||
{label: 'Uploadedby', title: 'uploaded_by'},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{label: 'Bookingrequest', title: 'booking_request'},
|
||||
|
||||
|
||||
|
||||
{label: 'Reservation', title: 'reservation'},
|
||||
|
||||
|
||||
|
||||
{label: 'Servicerequest', title: 'service_request'},
|
||||
|
||||
|
||||
|
||||
{label: 'Invoice', title: 'invoice'},
|
||||
|
||||
|
||||
|
||||
{label: 'Tenant', title: 'tenant'},
|
||||
{label: 'Uploadedby', title: 'uploaded_by'},
|
||||
{label: 'Bookingrequest', title: 'booking_request'},
|
||||
{label: 'Reservation', title: 'reservation'},
|
||||
{label: 'Servicerequest', title: 'service_request'},
|
||||
{label: 'Invoice', title: 'invoice'},
|
||||
{label: 'Documenttype', title: 'document_type', type: 'enum', options: ['invoice_pdf','guest_id','contract','approval_attachment','arrival_instructions','checklist','inspection_report','other']},
|
||||
]);
|
||||
]), [roleLane]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters));
|
||||
}, [filters]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_DOCUMENTS');
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,167 +1,162 @@
|
||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
||||
import { mdiCalendarCheck } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import { uniqueId } from 'lodash';
|
||||
import React, { ReactElement, useState } from 'react'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableReservations from '../../components/Reservations/TableReservations'
|
||||
import { uniqueId } from 'lodash'
|
||||
import React, { ReactElement, useMemo, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import BaseButtons from '../../components/BaseButtons'
|
||||
import axios from "axios";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import {setRefetch, uploadCsv} from '../../stores/reservations/reservationsSlice';
|
||||
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
import { humanize } from "../../helpers/humanize";
|
||||
|
||||
|
||||
import CardBox from '../../components/CardBox'
|
||||
import CardBoxModal from '../../components/CardBoxModal'
|
||||
import DragDropFilePicker from '../../components/DragDropFilePicker'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import TableReservations from '../../components/Reservations/TableReservations'
|
||||
import { getPageTitle } from '../../config'
|
||||
import { buildNextFilterItem, filterEntityFieldsForRole, getAddFilterState, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
|
||||
import { humanize } from '../../helpers/humanize'
|
||||
import { getRoleLaneFromUser } from '../../helpers/roleLanes'
|
||||
import { hasPermission } from '../../helpers/userPermissions'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
||||
import { setRefetch, uploadCsv } from '../../stores/reservations/reservationsSlice'
|
||||
|
||||
const ReservationsTablesPage = () => {
|
||||
const [filterItems, setFilterItems] = useState([]);
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [isModalActive, setIsModalActive] = useState(false);
|
||||
const [filterItems, setFilterItems] = useState([])
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||
const [isModalActive, setIsModalActive] = useState(false)
|
||||
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const dispatch = useAppDispatch();
|
||||
const { currentUser } = useAppSelector((state) => state.auth)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [filters] = useState(([{label: 'Reservationcode', title: 'reservation_code'},{label: 'Currency', title: 'currency'},{label: 'Internalnotes', title: 'internal_notes'},{label: 'Externalnotes', title: 'external_notes'},
|
||||
{label: 'Guestcount', title: 'guest_count', number: 'true'},
|
||||
{label: 'Nightlyrate', title: 'nightly_rate', number: 'true'},{label: 'Monthlyrate', title: 'monthly_rate', number: 'true'},
|
||||
{label: 'Check-inat', title: 'check_in_at', date: 'true'},{label: 'Check-outat', title: 'check_out_at', date: 'true'},{label: 'Actualcheck-inat', title: 'actual_check_in_at', date: 'true'},{label: 'Actualcheck-outat', title: 'actual_check_out_at', date: 'true'},
|
||||
{label: 'Tenant', title: 'tenant'},
|
||||
{label: 'Bookingrequest', title: 'booking_request'},
|
||||
{label: 'Property', title: 'property'},
|
||||
{label: 'Unit', title: 'unit'},
|
||||
{label: 'Unittype', title: 'unit_type'},
|
||||
{label: 'Guests', title: 'guests'},{label: 'Servicerequests', title: 'service_requests'},{label: 'Invoices', title: 'invoices'},{label: 'Documents', title: 'documents'},{label: 'Comments', title: 'comments'},
|
||||
{label: 'Status', title: 'status', type: 'enum', options: ['quoted','confirmed','checked_in','checked_out','canceled','no_show']},
|
||||
]).map((filter) => ({ ...filter, label: humanize(filter.title) })));
|
||||
const roleLane = getRoleLaneFromUser(currentUser)
|
||||
const canManagePlatformFields = roleLane === 'super_admin'
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_RESERVATIONS')
|
||||
const createButtonLabel = roleLane === 'concierge' ? 'Create stay' : 'New stay'
|
||||
const title = roleLane === 'customer' ? 'My Stays' : roleLane === 'concierge' ? 'Active Stays' : 'Stays'
|
||||
const subtitle = roleLane === 'customer'
|
||||
? 'Track confirmed housing, arrivals, departures, and current stay details.'
|
||||
: 'A clean operating board for arrivals, departures, and stay execution.'
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_RESERVATIONS');
|
||||
const filters = useMemo(() => {
|
||||
const baseFilters = [
|
||||
{ label: 'Reservationcode', title: 'reservation_code' },
|
||||
{ label: 'Currency', title: 'currency' },
|
||||
{ label: 'Externalnotes', title: 'external_notes' },
|
||||
{ label: 'Guestcount', title: 'guest_count', number: 'true' },
|
||||
{ label: 'Check-inat', title: 'check_in_at', date: 'true' },
|
||||
{ label: 'Check-outat', title: 'check_out_at', date: 'true' },
|
||||
{ label: 'Actualcheck-inat', title: 'actual_check_in_at', date: 'true' },
|
||||
{ label: 'Actualcheck-outat', title: 'actual_check_out_at', date: 'true' },
|
||||
{ label: 'Bookingrequest', title: 'booking_request' },
|
||||
{ label: 'Property', title: 'property' },
|
||||
{ label: 'Unit', title: 'unit' },
|
||||
{ label: 'Unittype', title: 'unit_type' },
|
||||
{ label: 'Guests', title: 'guests' },
|
||||
{ label: 'Servicerequests', title: 'service_requests' },
|
||||
{ label: 'Documents', title: 'documents' },
|
||||
{ label: 'Status', title: 'status', type: 'enum', options: ['quoted', 'confirmed', 'checked_in', 'checked_out', 'canceled', 'no_show'] },
|
||||
]
|
||||
|
||||
if (canManagePlatformFields) {
|
||||
baseFilters.splice(8, 0, { label: 'Tenant', title: 'tenant' })
|
||||
}
|
||||
|
||||
if (roleLane === 'admin' || roleLane === 'super_admin') {
|
||||
baseFilters.splice(3, 0, { label: 'Nightlyrate', title: 'nightly_rate', number: 'true' }, { label: 'Monthlyrate', title: 'monthly_rate', number: 'true' }, { label: 'Internalnotes', title: 'internal_notes' })
|
||||
baseFilters.push({ label: 'Invoices', title: 'invoices' })
|
||||
}
|
||||
|
||||
return filterEntityFieldsForRole('reservations', roleLane, baseFilters).map((filter) => ({ ...filter, label: humanize(filter.title) }))
|
||||
}, [canManagePlatformFields, roleLane])
|
||||
|
||||
React.useEffect(() => {
|
||||
setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters))
|
||||
}, [filters])
|
||||
|
||||
const addFilterState = useMemo(() => getAddFilterState(filterItems, filters), [filterItems, filters])
|
||||
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: '',
|
||||
},
|
||||
};
|
||||
newItem.fields.selectedField = filters[0].title;
|
||||
setFilterItems([...filterItems, newItem]);
|
||||
};
|
||||
setFilterItems((currentItems) => {
|
||||
const nextItem = buildNextFilterItem(currentItems, filters, () => uniqueId())
|
||||
|
||||
return nextItem ? [...currentItems, nextItem] : currentItems
|
||||
})
|
||||
}
|
||||
|
||||
const getReservationsCSV = async () => {
|
||||
const response = await axios({url: '/reservations?filetype=csv', method: 'GET',responseType: 'blob'});
|
||||
const response = await axios({ url: '/reservations?filetype=csv', method: 'GET', responseType: 'blob' })
|
||||
const type = response.headers['content-type']
|
||||
const blob = new Blob([response.data], { type: type })
|
||||
const blob = new Blob([response.data], { type })
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = 'reservationsCSV.csv'
|
||||
link.click()
|
||||
};
|
||||
}
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
dispatch(setRefetch(true));
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
|
||||
const onModalCancel = () => {
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
if (!csvFile) return
|
||||
await dispatch(uploadCsv(csvFile))
|
||||
dispatch(setRefetch(true))
|
||||
setCsvFile(null)
|
||||
setIsModalActive(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Reservations')}</title>
|
||||
<title>{getPageTitle(title)}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiChartTimelineVariant}
|
||||
title="Reservations"
|
||||
subtitle="A cleaner live calendar for arrivals, departures, and stay execution."
|
||||
main
|
||||
/>
|
||||
<SectionTitleLineWithButton icon={mdiCalendarCheck} title={title} subtitle={subtitle} main />
|
||||
|
||||
<CardBox className='mb-6 border border-white/10 shadow-none'>
|
||||
<div className='flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Working view</p>
|
||||
<h2 className='mt-1 text-lg font-semibold text-white'>Operate the stay calendar</h2>
|
||||
<p className='mt-1 text-sm text-slate-400'>Move between creation, filtering, export, and the full table without leaving the calendar flow.</p>
|
||||
<h2 className='mt-1 text-lg font-semibold text-white'>Stay calendar</h2>
|
||||
<p className='mt-1 text-sm text-slate-400'>Create, filter, export, and review stay execution from a single calm surface.</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-3 xl:items-end'>
|
||||
<div className='flex w-full flex-col gap-3 xl:w-auto xl:items-end'>
|
||||
<BaseButtons
|
||||
type='justify-start xl:justify-end'
|
||||
mb='mb-0'
|
||||
classAddon='mr-2 mb-2 last:mr-0'
|
||||
classAddon='mb-2 mr-0 w-full sm:mr-2 sm:w-auto last:mr-0'
|
||||
className='w-full xl:w-auto'
|
||||
>
|
||||
{hasCreatePermission ? (
|
||||
<BaseButton href={'/reservations/reservations-new'} color='info' label='New reservation' />
|
||||
) : null}
|
||||
<BaseButton color='whiteDark' outline label='Add filter' onClick={addFilter} />
|
||||
{hasCreatePermission ? <BaseButton href='/reservations/reservations-new' color='info' label={createButtonLabel} /> : null}
|
||||
<BaseButton
|
||||
color='whiteDark'
|
||||
outline
|
||||
label={addFilterState.buttonLabel}
|
||||
onClick={addFilter}
|
||||
disabled={!addFilterState.canAddFilter}
|
||||
/>
|
||||
<BaseButton color='whiteDark' outline label='Export CSV' onClick={getReservationsCSV} />
|
||||
{hasCreatePermission ? (
|
||||
<BaseButton color='whiteDark' outline label='Import CSV' onClick={() => setIsModalActive(true)} />
|
||||
) : null}
|
||||
<BaseButton href={'/reservations/reservations-table'} color='whiteDark' outline label='Table view' />
|
||||
{hasCreatePermission ? <BaseButton color='whiteDark' outline label='Import CSV' onClick={() => setIsModalActive(true)} /> : null}
|
||||
<BaseButton href='/reservations/reservations-table' color='whiteDark' outline label='Table view' />
|
||||
</BaseButtons>
|
||||
|
||||
<div className='flex min-h-[40px] items-center justify-start xl:justify-end'>
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
<p className='max-w-full text-left text-xs leading-5 text-slate-400 xl:max-w-sm xl:text-right'>{addFilterState.helperText}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<TableReservations
|
||||
filterItems={filterItems}
|
||||
setFilterItems={setFilterItems}
|
||||
filters={filters}
|
||||
showGrid={false}
|
||||
/>
|
||||
<CardBox className='mb-6' hasTable>
|
||||
<TableReservations filterItems={filterItems} setFilterItems={setFilterItems} filters={filters} />
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
<CardBoxModal
|
||||
title='Upload CSV'
|
||||
buttonColor='info'
|
||||
buttonLabel={'Confirm'}
|
||||
isActive={isModalActive}
|
||||
onConfirm={onModalConfirm}
|
||||
onCancel={onModalCancel}
|
||||
>
|
||||
<DragDropFilePicker
|
||||
file={csvFile}
|
||||
setFile={setCsvFile}
|
||||
formats={'.csv'}
|
||||
/>
|
||||
|
||||
<CardBoxModal title='Upload CSV' buttonColor='info' buttonLabel='Confirm' isActive={isModalActive} onConfirm={onModalConfirm} onCancel={() => setIsModalActive(false)}>
|
||||
<DragDropFilePicker file={csvFile} setFile={setCsvFile} formats={'.csv'} />
|
||||
</CardBoxModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
ReservationsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
permission={'READ_RESERVATIONS'}
|
||||
>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
)
|
||||
return <LayoutAuthenticated permission={'READ_RESERVATIONS'}>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default ReservationsTablesPage
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,186 +1,127 @@
|
||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
||||
import { mdiCalendarCheck } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import { uniqueId } from 'lodash';
|
||||
import React, { ReactElement, useState } from 'react'
|
||||
import { uniqueId } from 'lodash'
|
||||
import Link from 'next/link'
|
||||
import React, { ReactElement, useMemo, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import CardBoxModal from '../../components/CardBoxModal'
|
||||
import DragDropFilePicker from '../../components/DragDropFilePicker'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableReservations from '../../components/Reservations/TableReservations'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import {setRefetch, uploadCsv} from '../../stores/reservations/reservationsSlice';
|
||||
import { getPageTitle } from '../../config'
|
||||
import { filterEntityFieldsForRole, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
|
||||
import { humanize } from '../../helpers/humanize'
|
||||
import { getRoleLaneFromUser } from '../../helpers/roleLanes'
|
||||
import { hasPermission } from '../../helpers/userPermissions'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
||||
import { setRefetch, uploadCsv } from '../../stores/reservations/reservationsSlice'
|
||||
|
||||
const ReservationsTablePage = () => {
|
||||
const [filterItems, setFilterItems] = useState([])
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||
const [isModalActive, setIsModalActive] = useState(false)
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
const { currentUser } = useAppSelector((state) => state.auth)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const roleLane = getRoleLaneFromUser(currentUser)
|
||||
const canManagePlatformFields = roleLane === 'super_admin'
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_RESERVATIONS')
|
||||
|
||||
const filters = useMemo(() => {
|
||||
const baseFilters = [
|
||||
{ label: 'Reservationcode', title: 'reservation_code' },
|
||||
{ label: 'Currency', title: 'currency' },
|
||||
{ label: 'Externalnotes', title: 'external_notes' },
|
||||
{ label: 'Guestcount', title: 'guest_count', number: 'true' },
|
||||
{ label: 'Check-inat', title: 'check_in_at', date: 'true' },
|
||||
{ label: 'Check-outat', title: 'check_out_at', date: 'true' },
|
||||
{ label: 'Actualcheck-inat', title: 'actual_check_in_at', date: 'true' },
|
||||
{ label: 'Actualcheck-outat', title: 'actual_check_out_at', date: 'true' },
|
||||
{ label: 'Bookingrequest', title: 'booking_request' },
|
||||
{ label: 'Property', title: 'property' },
|
||||
{ label: 'Unit', title: 'unit' },
|
||||
{ label: 'Unittype', title: 'unit_type' },
|
||||
{ label: 'Guests', title: 'guests' },
|
||||
{ label: 'Servicerequests', title: 'service_requests' },
|
||||
{ label: 'Documents', title: 'documents' },
|
||||
{ label: 'Status', title: 'status', type: 'enum', options: ['quoted', 'confirmed', 'checked_in', 'checked_out', 'canceled', 'no_show'] },
|
||||
]
|
||||
|
||||
const ReservationsTablesPage = () => {
|
||||
const [filterItems, setFilterItems] = useState([]);
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [isModalActive, setIsModalActive] = useState(false);
|
||||
const [showTableView, setShowTableView] = useState(false);
|
||||
if (canManagePlatformFields) {
|
||||
baseFilters.splice(8, 0, { label: 'Tenant', title: 'tenant' })
|
||||
}
|
||||
|
||||
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
|
||||
return filterEntityFieldsForRole('reservations', roleLane, baseFilters).map((filter) => ({ ...filter, label: humanize(filter.title) }))
|
||||
}, [canManagePlatformFields, roleLane])
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
React.useEffect(() => {
|
||||
setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters))
|
||||
}, [filters])
|
||||
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: { filterValue: '', filterValueFrom: '', filterValueTo: '', selectedField: filters[0].title },
|
||||
}
|
||||
setFilterItems([...filterItems, newItem])
|
||||
}
|
||||
|
||||
const [filters] = useState([{label: 'Reservationcode', title: 'reservation_code'},{label: 'Currency', title: 'currency'},{label: 'Internalnotes', title: 'internal_notes'},{label: 'Externalnotes', title: 'external_notes'},
|
||||
{label: 'Guestcount', title: 'guest_count', number: 'true'},
|
||||
{label: 'Nightlyrate', title: 'nightly_rate', number: 'true'},{label: 'Monthlyrate', title: 'monthly_rate', number: 'true'},
|
||||
{label: 'Check-inat', title: 'check_in_at', date: 'true'},{label: 'Check-outat', title: 'check_out_at', date: 'true'},{label: 'Actualcheck-inat', title: 'actual_check_in_at', date: 'true'},{label: 'Actualcheck-outat', title: 'actual_check_out_at', date: 'true'},
|
||||
|
||||
|
||||
{label: 'Tenant', title: 'tenant'},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{label: 'Bookingrequest', title: 'booking_request'},
|
||||
|
||||
|
||||
|
||||
{label: 'Property', title: 'property'},
|
||||
|
||||
|
||||
|
||||
{label: 'Unit', title: 'unit'},
|
||||
|
||||
|
||||
|
||||
{label: 'Unittype', title: 'unit_type'},
|
||||
|
||||
|
||||
{label: 'Guests', title: 'guests'},{label: 'Servicerequests', title: 'service_requests'},{label: 'Invoices', title: 'invoices'},{label: 'Documents', title: 'documents'},{label: 'Comments', title: 'comments'},
|
||||
{label: 'Status', title: 'status', type: 'enum', options: ['quoted','confirmed','checked_in','checked_out','canceled','no_show']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_RESERVATIONS');
|
||||
|
||||
const getReservationsCSV = async () => {
|
||||
const response = await axios({ url: '/reservations?filetype=csv', method: 'GET', responseType: 'blob' })
|
||||
const type = response.headers['content-type']
|
||||
const blob = new Blob([response.data], { type })
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = 'reservationsCSV.csv'
|
||||
link.click()
|
||||
}
|
||||
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: '',
|
||||
},
|
||||
};
|
||||
newItem.fields.selectedField = filters[0].title;
|
||||
setFilterItems([...filterItems, newItem]);
|
||||
};
|
||||
|
||||
const getReservationsCSV = async () => {
|
||||
const response = await axios({url: '/reservations?filetype=csv', method: 'GET',responseType: 'blob'});
|
||||
const type = response.headers['content-type']
|
||||
const blob = new Blob([response.data], { type: type })
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = 'reservationsCSV.csv'
|
||||
link.click()
|
||||
};
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
dispatch(setRefetch(true));
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
|
||||
const onModalCancel = () => {
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return
|
||||
await dispatch(uploadCsv(csvFile))
|
||||
dispatch(setRefetch(true))
|
||||
setCsvFile(null)
|
||||
setIsModalActive(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Reservations')}</title>
|
||||
<title>{getPageTitle('Stay Table')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Reservations" main>
|
||||
{''}
|
||||
<SectionTitleLineWithButton icon={mdiCalendarCheck} title='Stay Table' main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/reservations/reservations-new'} color='info' label='New Item'/>}
|
||||
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getReservationsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<CardBox className='mb-6 border border-white/10 shadow-none'>
|
||||
{hasCreatePermission && <BaseButton className='mr-3' href='/reservations/reservations-new' color='info' label='New stay' />}
|
||||
<BaseButton className='mr-3' color='info' label='Add filter' onClick={addFilter} />
|
||||
<BaseButton className='mr-3' color='info' label='Export CSV' onClick={getReservationsCSV} />
|
||||
{hasCreatePermission && <BaseButton color='info' label='Import CSV' onClick={() => setIsModalActive(true)} />}
|
||||
<div className='ms-auto inline-flex items-center'>
|
||||
<div id='delete-rows-button'></div>
|
||||
|
||||
<Link href={'/reservations/reservations-list'}>
|
||||
Back to <span className='capitalize'>calendar</span>
|
||||
</Link>
|
||||
|
||||
<Link href='/reservations/reservations-list'>Back to board</Link>
|
||||
</div>
|
||||
</CardBox>
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<TableReservations
|
||||
filterItems={filterItems}
|
||||
setFilterItems={setFilterItems}
|
||||
filters={filters}
|
||||
showGrid={true}
|
||||
/>
|
||||
<CardBox className='mb-6' hasTable>
|
||||
<TableReservations filterItems={filterItems} setFilterItems={setFilterItems} filters={filters} showGrid />
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
<CardBoxModal
|
||||
title='Upload CSV'
|
||||
buttonColor='info'
|
||||
buttonLabel={'Confirm'}
|
||||
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
|
||||
isActive={isModalActive}
|
||||
onConfirm={onModalConfirm}
|
||||
onCancel={onModalCancel}
|
||||
>
|
||||
<DragDropFilePicker
|
||||
file={csvFile}
|
||||
setFile={setCsvFile}
|
||||
formats={'.csv'}
|
||||
/>
|
||||
<CardBoxModal title='Upload CSV' buttonColor='info' buttonLabel='Confirm' isActive={isModalActive} onConfirm={onModalConfirm} onCancel={() => setIsModalActive(false)}>
|
||||
<DragDropFilePicker file={csvFile} setFile={setCsvFile} formats={'.csv'} />
|
||||
</CardBoxModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
ReservationsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
permission={'READ_RESERVATIONS'}
|
||||
|
||||
>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
)
|
||||
ReservationsTablePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated permission={'READ_RESERVATIONS'}>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default ReservationsTablesPage
|
||||
export default ReservationsTablePage
|
||||
|
||||
@ -1,231 +1,203 @@
|
||||
import React, { ReactElement, useEffect } from 'react';
|
||||
import Head from 'next/head';
|
||||
import dayjs from 'dayjs';
|
||||
import { mdiCalendarCheck } from '@mdi/js';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { fetch } from '../../stores/reservations/reservationsSlice';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
import { getPageTitle } from '../../config';
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||
import SectionMain from '../../components/SectionMain';
|
||||
import CardBox from '../../components/CardBox';
|
||||
import BaseButton from '../../components/BaseButton';
|
||||
import BaseButtons from '../../components/BaseButtons';
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import React, { ReactElement, useEffect } from 'react'
|
||||
import Head from 'next/head'
|
||||
import dayjs from 'dayjs'
|
||||
import { mdiCalendarCheck } from '@mdi/js'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import BaseButtons from '../../components/BaseButtons'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../config'
|
||||
import { getRoleLaneFromUser } from '../../helpers/roleLanes'
|
||||
import { hasPermission } from '../../helpers/userPermissions'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import { fetch } from '../../stores/reservations/reservationsSlice'
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
||||
|
||||
type StatProps = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
type RelatedListProps = {
|
||||
title: string;
|
||||
items: any[];
|
||||
getLabel: (item: any) => string;
|
||||
emptyLabel?: string;
|
||||
};
|
||||
label: string
|
||||
value: string | number
|
||||
hint?: string
|
||||
}
|
||||
|
||||
const DetailStat = ({ label, value, hint }: StatProps) => (
|
||||
<CardBox className="rounded-2xl border border-white/10 shadow-none">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</p>
|
||||
<p className="text-2xl font-semibold text-white">{value}</p>
|
||||
{hint && <p className="text-sm text-slate-400">{hint}</p>}
|
||||
<CardBox className='rounded-2xl border border-white/10 shadow-none'>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>{label}</p>
|
||||
<p className='text-2xl font-semibold text-white'>{value}</p>
|
||||
{hint && <p className='text-sm text-slate-400'>{hint}</p>}
|
||||
</div>
|
||||
</CardBox>
|
||||
);
|
||||
)
|
||||
|
||||
const DetailRow = ({ label, value }: { label: string; value?: React.ReactNode }) => (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</p>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-100">{value || 'Not set'}</div>
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 px-4 py-3'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>{label}</p>
|
||||
<div className='mt-2 text-sm leading-6 text-slate-100'>{value || 'Not set'}</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
|
||||
const RelatedList = ({ title, items, getLabel, emptyLabel = 'No items yet' }: RelatedListProps) => (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-white">{title}</p>
|
||||
<span className="rounded-full border border-white/10 px-2.5 py-1 text-xs text-slate-300">
|
||||
{items.length}
|
||||
</span>
|
||||
const RelatedList = ({ title, items, getLabel, emptyLabel = 'No items yet' }: any) => (
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 px-4 py-4'>
|
||||
<div className='mb-3 flex items-center justify-between gap-3'>
|
||||
<p className='text-sm font-semibold text-white'>{title}</p>
|
||||
<span className='rounded-full border border-white/10 px-2.5 py-1 text-xs text-slate-300'>{items.length}</span>
|
||||
</div>
|
||||
|
||||
{items.length ? (
|
||||
<div className="space-y-2">
|
||||
{items.slice(0, 4).map((item: any) => (
|
||||
<div key={item.id} className="rounded-xl border border-white/10 bg-slate-950/40 px-3 py-2 text-sm text-slate-200">
|
||||
<div className='space-y-2'>
|
||||
{items.slice(0, 4).map((item) => (
|
||||
<div key={item.id} className='rounded-xl border border-white/10 bg-slate-950/40 px-3 py-2 text-sm text-slate-200'>
|
||||
{getLabel(item)}
|
||||
</div>
|
||||
))}
|
||||
{items.length > 4 && (
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">+{items.length - 4} more</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">{emptyLabel}</p>
|
||||
<p className='text-sm text-slate-400'>{emptyLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const formatDate = (value?: string) => (value ? dayjs(value).format('MMM D, YYYY') : 'Not set');
|
||||
)
|
||||
|
||||
const formatDate = (value?: string) => (value ? dayjs(value).format('MMM D, YYYY') : 'Not set')
|
||||
const formatDateTime = (value?: string) => (value ? dayjs(value).format('MMM D, YYYY h:mm A') : 'Not set')
|
||||
const formatCurrency = (amount?: string | number, currency?: string) => {
|
||||
if (amount === undefined || amount === null || amount === '') return 'Not set';
|
||||
if (amount === undefined || amount === null || amount === '') return 'Not set'
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Number(amount));
|
||||
}).format(Number(amount))
|
||||
} catch (error) {
|
||||
console.error('Failed to format reservation rate', error);
|
||||
return `${amount} ${currency || ''}`.trim();
|
||||
console.error('Failed to format reservation currency', error)
|
||||
return `${amount} ${currency || ''}`.trim()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const ReservationsView = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { reservations } = useAppSelector((state) => state.reservations);
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const { id } = router.query;
|
||||
const router = useRouter()
|
||||
const dispatch = useAppDispatch()
|
||||
const { reservations } = useAppSelector((state) => state.reservations)
|
||||
const { currentUser } = useAppSelector((state) => state.auth)
|
||||
const { id } = router.query
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetch({ id }));
|
||||
}, [dispatch, id]);
|
||||
dispatch(fetch({ id }))
|
||||
}, [dispatch, id])
|
||||
|
||||
const canEdit = currentUser && hasPermission(currentUser, 'UPDATE_RESERVATIONS');
|
||||
const plannedStay = `${formatDate(reservations?.check_in_at)} → ${formatDate(reservations?.check_out_at)}`;
|
||||
const actualStay =
|
||||
reservations?.actual_check_in_at || reservations?.actual_check_out_at
|
||||
? `${formatDate(reservations?.actual_check_in_at)} → ${formatDate(reservations?.actual_check_out_at)}`
|
||||
: 'Not checked in yet';
|
||||
const roleLane = getRoleLaneFromUser(currentUser)
|
||||
const canManagePlatformFields = roleLane === 'super_admin'
|
||||
const canManageFinancialFields = canManagePlatformFields || roleLane === 'admin'
|
||||
const canEdit = currentUser && hasPermission(currentUser, 'UPDATE_RESERVATIONS')
|
||||
const pageTitle = roleLane === 'customer' ? 'My Stay' : 'Stay Details'
|
||||
const stayWindow = reservations?.check_in_at || reservations?.check_out_at
|
||||
? `${formatDate(reservations?.check_in_at)} → ${formatDate(reservations?.check_out_at)}`
|
||||
: 'Dates not set'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Reservation')}</title>
|
||||
<title>{getPageTitle(pageTitle)}</title>
|
||||
</Head>
|
||||
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiCalendarCheck} title="Reservation" main>
|
||||
<SectionTitleLineWithButton icon={mdiCalendarCheck} title={pageTitle} main>
|
||||
<BaseButtons noWrap>
|
||||
<BaseButton href="/reservations/reservations-list" color="whiteDark" outline label="Back" />
|
||||
{canEdit && (
|
||||
<BaseButton color="info" label="Edit" href={`/reservations/reservations-edit/?id=${id}`} />
|
||||
)}
|
||||
<BaseButton href='/reservations/reservations-list' color='whiteDark' outline label='Back' />
|
||||
{canEdit && <BaseButton color='info' label='Edit' href={`/reservations/reservations-edit/?id=${id}`} />}
|
||||
</BaseButtons>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<DetailStat label="Reservation Code" value={reservations?.reservation_code || 'Pending'} hint="Stay reference" />
|
||||
<DetailStat label="Status" value={reservations?.status || 'Quoted'} hint="Current reservation state" />
|
||||
<DetailStat label="Planned Stay" value={plannedStay} hint="Scheduled check-in and check-out" />
|
||||
<DetailStat
|
||||
label="Guest Count"
|
||||
value={reservations?.guest_count || 0}
|
||||
hint={formatCurrency(reservations?.nightly_rate, reservations?.currency)}
|
||||
/>
|
||||
<div className='mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<DetailStat label='Reservation Code' value={reservations?.reservation_code || 'Pending'} hint='Stay reference' />
|
||||
<DetailStat label='Status' value={reservations?.status || 'quoted'} hint='Current stay stage' />
|
||||
<DetailStat label='Stay Window' value={stayWindow} hint='Scheduled dates' />
|
||||
<DetailStat label='Guests' value={reservations?.guest_count || 0} hint={reservations?.unit?.unit_number || 'Unit pending'} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.9fr)]">
|
||||
<div className="space-y-6">
|
||||
<CardBox className="rounded-2xl border border-white/10 shadow-none">
|
||||
<div className="space-y-4">
|
||||
<div className='grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.9fr)]'>
|
||||
<div className='space-y-6'>
|
||||
<CardBox className='rounded-2xl border border-white/10 shadow-none'>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Overview</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Stay summary</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
The essential operating details for the stay, inventory assignment, and guest timing.
|
||||
</p>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-400'>Overview</p>
|
||||
<h2 className='mt-2 text-xl font-semibold text-white'>Stay snapshot</h2>
|
||||
<p className='mt-1 text-sm text-slate-400'>Housing assignment, dates, and the most important execution details.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<DetailRow label="Property" value={reservations?.property?.name} />
|
||||
<DetailRow label="Unit" value={reservations?.unit?.name || reservations?.unit?.unit_number} />
|
||||
<DetailRow label="Unit Type" value={reservations?.unit_type?.name} />
|
||||
<DetailRow label="Booking Request" value={reservations?.booking_request?.request_code} />
|
||||
<DetailRow label="Tenant" value={reservations?.tenant?.name} />
|
||||
{hasPermission(currentUser, 'READ_ORGANIZATIONS') && (
|
||||
<DetailRow label="Organization" value={reservations?.organization?.name} />
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
{canManagePlatformFields && (
|
||||
<>
|
||||
<DetailRow label='Tenant' value={reservations?.tenant?.name} />
|
||||
<DetailRow label='Organization' value={reservations?.organization?.name} />
|
||||
</>
|
||||
)}
|
||||
<DetailRow label="Early Check-in" value={reservations?.early_check_in ? 'Yes' : 'No'} />
|
||||
<DetailRow label="Late Check-out" value={reservations?.late_check_out ? 'Yes' : 'No'} />
|
||||
<DetailRow label="Monthly Rate" value={formatCurrency(reservations?.monthly_rate, reservations?.currency)} />
|
||||
<DetailRow label="Actual Stay" value={actualStay} />
|
||||
<DetailRow label='Source Request' value={reservations?.booking_request?.request_code} />
|
||||
<DetailRow label='Property' value={reservations?.property?.name} />
|
||||
<DetailRow label='Unit' value={reservations?.unit?.unit_number} />
|
||||
<DetailRow label='Unit Type' value={reservations?.unit_type?.name} />
|
||||
<DetailRow label='Actual Check-in' value={formatDateTime(reservations?.actual_check_in_at)} />
|
||||
<DetailRow label='Actual Check-out' value={formatDateTime(reservations?.actual_check_out_at)} />
|
||||
<DetailRow label='Early Check-in' value={reservations?.early_check_in ? 'Yes' : 'No'} />
|
||||
<DetailRow label='Late Check-out' value={reservations?.late_check_out ? 'Yes' : 'No'} />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="rounded-2xl border border-white/10 shadow-none">
|
||||
<div className="space-y-4">
|
||||
<CardBox className='rounded-2xl border border-white/10 shadow-none'>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Notes</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Internal and guest-facing notes</h2>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-400'>Notes</p>
|
||||
<h2 className='mt-2 text-xl font-semibold text-white'>Stay coordination</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Internal Notes</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-100">
|
||||
{reservations?.internal_notes || 'No internal notes recorded.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">External Notes</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-100">
|
||||
{reservations?.external_notes || 'No guest-facing notes recorded.'}
|
||||
</p>
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 px-4 py-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Guest-facing notes</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-100'>{reservations?.external_notes || 'No guest-facing notes yet.'}</p>
|
||||
</div>
|
||||
{canManageFinancialFields && (
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 px-4 py-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Internal notes</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-100'>{reservations?.internal_notes || 'No internal notes yet.'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<CardBox className="rounded-2xl border border-white/10 shadow-none">
|
||||
<div className="space-y-4">
|
||||
<CardBox className='rounded-2xl border border-white/10 shadow-none'>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Activity</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Connected operations</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">A simple view of guest, service, billing, and document activity.</p>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-400'>Linked records</p>
|
||||
<h2 className='mt-2 text-xl font-semibold text-white'>Operational context</h2>
|
||||
<p className='mt-1 text-sm text-slate-400'>Everything directly attached to this stay.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<RelatedList
|
||||
title="Guests"
|
||||
items={Array.isArray(reservations?.guests) ? reservations.guests : []}
|
||||
getLabel={(item) => item?.full_name || item?.email || item?.id}
|
||||
/>
|
||||
<RelatedList
|
||||
title="Service Requests"
|
||||
items={Array.isArray(reservations?.service_requests) ? reservations.service_requests : []}
|
||||
getLabel={(item) => item?.request_title || item?.status || item?.id}
|
||||
/>
|
||||
<RelatedList
|
||||
title="Invoices"
|
||||
items={Array.isArray(reservations?.invoices) ? reservations.invoices : []}
|
||||
getLabel={(item) => item?.invoice_number || item?.status || item?.id}
|
||||
/>
|
||||
<RelatedList
|
||||
title="Documents"
|
||||
items={Array.isArray(reservations?.documents) ? reservations.documents : []}
|
||||
getLabel={(item) => item?.name || item?.document_name || item?.id}
|
||||
/>
|
||||
<DetailRow label='Nightly Rate' value={canManageFinancialFields ? formatCurrency(reservations?.nightly_rate, reservations?.currency) : 'Restricted'} />
|
||||
<DetailRow label='Monthly Rate' value={canManageFinancialFields ? formatCurrency(reservations?.monthly_rate, reservations?.currency) : 'Restricted'} />
|
||||
<DetailRow label='Currency' value={reservations?.currency} />
|
||||
|
||||
<div className='space-y-3'>
|
||||
<RelatedList title='Guests' items={Array.isArray(reservations?.guests) ? reservations.guests : []} getLabel={(item) => item?.full_name || item?.email || item?.id} />
|
||||
<RelatedList title='Service Requests' items={Array.isArray(reservations?.service_requests) ? reservations.service_requests : []} getLabel={(item) => item?.summary || item?.request_type || item?.id} />
|
||||
<RelatedList title='Documents' items={Array.isArray(reservations?.documents) ? reservations.documents : []} getLabel={(item) => item?.file_name || item?.document_type || item?.id} />
|
||||
<RelatedList title='Invoices' items={Array.isArray(reservations?.invoices) ? reservations.invoices : []} getLabel={(item) => item?.invoice_number || item?.status || item?.id} emptyLabel='No invoices linked yet.' />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
ReservationsView.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated permission={'READ_RESERVATIONS'}>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
return <LayoutAuthenticated permission={'READ_RESERVATIONS'}>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default ReservationsView;
|
||||
export default ReservationsView
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,180 +1,159 @@
|
||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
||||
import { mdiRoomServiceOutline } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import { uniqueId } from 'lodash';
|
||||
import React, { ReactElement, useState } from 'react'
|
||||
import { uniqueId } from 'lodash'
|
||||
import React, { ReactElement, useMemo, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import BaseButtons from '../../components/BaseButtons'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import CardBoxModal from '../../components/CardBoxModal'
|
||||
import DragDropFilePicker from '../../components/DragDropFilePicker'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableService_requests from '../../components/Service_requests/TableService_requests'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import {setRefetch, uploadCsv} from '../../stores/service_requests/service_requestsSlice';
|
||||
import { getPageTitle } from '../../config'
|
||||
import { buildNextFilterItem, filterEntityFieldsForRole, getAddFilterState, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
|
||||
import { humanize } from '../../helpers/humanize'
|
||||
import { getRoleLaneFromUser } from '../../helpers/roleLanes'
|
||||
import { hasPermission } from '../../helpers/userPermissions'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
||||
import { setRefetch, uploadCsv } from '../../stores/service_requests/service_requestsSlice'
|
||||
|
||||
const ServiceRequestsTablesPage = () => {
|
||||
const [filterItems, setFilterItems] = useState([])
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||
const [isModalActive, setIsModalActive] = useState(false)
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
const { currentUser } = useAppSelector((state) => state.auth)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const roleLane = getRoleLaneFromUser(currentUser)
|
||||
const canManagePlatformFields = roleLane === 'super_admin'
|
||||
const canManageOperationalFields = roleLane === 'super_admin' || roleLane === 'admin' || roleLane === 'concierge'
|
||||
const canManageCostFields = roleLane === 'super_admin' || roleLane === 'admin'
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SERVICE_REQUESTS')
|
||||
const title = roleLane === 'customer' ? 'Support Requests' : 'Service Queue'
|
||||
const subtitle = roleLane === 'customer'
|
||||
? 'Track active support requests linked to your stay and any updates from the operations team.'
|
||||
: 'Triage the live service queue, assign owners, and keep active stays supported.'
|
||||
|
||||
const filters = useMemo(() => {
|
||||
const baseFilters = [
|
||||
{ label: 'Summary', title: 'summary' },
|
||||
{ label: 'Details', title: 'details' },
|
||||
{ label: 'Reservation', title: 'reservation' },
|
||||
{ label: 'Dueat', title: 'due_at', date: 'true' },
|
||||
{ label: 'Requesttype', title: 'request_type', type: 'enum', options: ['airport_pickup', 'housekeeping', 'maintenance', 'stocked_fridge', 'chauffeur', 'late_checkout', 'extension', 'custom_service'] },
|
||||
{ label: 'Status', title: 'status', type: 'enum', options: ['new', 'triaged', 'in_progress', 'scheduled', 'waiting_on_guest', 'completed', 'canceled'] },
|
||||
{ label: 'Priority', title: 'priority', type: 'enum', options: ['low', 'normal', 'high', 'urgent'] },
|
||||
{ label: 'Documents', title: 'documents' },
|
||||
{ label: 'Comments', title: 'comments' },
|
||||
]
|
||||
|
||||
const Service_requestsTablesPage = () => {
|
||||
const [filterItems, setFilterItems] = useState([]);
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [isModalActive, setIsModalActive] = useState(false);
|
||||
const [showTableView, setShowTableView] = useState(false);
|
||||
if (canManagePlatformFields) {
|
||||
baseFilters.unshift({ label: 'Tenant', title: 'tenant' })
|
||||
}
|
||||
|
||||
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
|
||||
if (canManageOperationalFields) {
|
||||
baseFilters.splice(3, 0, { label: 'Requestedby', title: 'requested_by' }, { label: 'Assignedto', title: 'assigned_to' }, { label: 'Requestedat', title: 'requested_at', date: 'true' }, { label: 'Completedat', title: 'completed_at', date: 'true' })
|
||||
}
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
if (canManageCostFields) {
|
||||
baseFilters.splice(5, 0, { label: 'Currency', title: 'currency' }, { label: 'Estimatedcost', title: 'estimated_cost', number: 'true' }, { label: 'Actualcost', title: 'actual_cost', number: 'true' })
|
||||
}
|
||||
|
||||
return filterEntityFieldsForRole('service_requests', roleLane, baseFilters).map((filter) => ({ ...filter, label: humanize(filter.title) }))
|
||||
}, [canManageCostFields, canManageOperationalFields, canManagePlatformFields, roleLane])
|
||||
|
||||
const [filters] = useState([{label: 'Summary', title: 'summary'},{label: 'Details', title: 'details'},{label: 'Currency', title: 'currency'},
|
||||
|
||||
{label: 'Estimatedcost', title: 'estimated_cost', number: 'true'},{label: 'Actualcost', title: 'actual_cost', number: 'true'},
|
||||
{label: 'Requestedat', title: 'requested_at', date: 'true'},{label: 'Dueat', title: 'due_at', date: 'true'},{label: 'Completedat', title: 'completed_at', date: 'true'},
|
||||
|
||||
|
||||
{label: 'Tenant', title: 'tenant'},
|
||||
|
||||
|
||||
|
||||
{label: 'Reservation', title: 'reservation'},
|
||||
|
||||
|
||||
|
||||
{label: 'Requestedby', title: 'requested_by'},
|
||||
|
||||
|
||||
|
||||
{label: 'Assignedto', title: 'assigned_to'},
|
||||
|
||||
|
||||
{label: 'Documents', title: 'documents'},{label: 'Comments', title: 'comments'},
|
||||
{label: 'Requesttype', title: 'request_type', type: 'enum', options: ['airport_pickup','housekeeping','maintenance','stocked_fridge','chauffeur','late_checkout','extension','custom_service']},{label: 'Status', title: 'status', type: 'enum', options: ['new','triaged','in_progress','scheduled','waiting_on_guest','completed','canceled']},{label: 'Priority', title: 'priority', type: 'enum', options: ['low','normal','high','urgent']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SERVICE_REQUESTS');
|
||||
|
||||
React.useEffect(() => {
|
||||
setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters))
|
||||
}, [filters])
|
||||
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: '',
|
||||
},
|
||||
};
|
||||
newItem.fields.selectedField = filters[0].title;
|
||||
setFilterItems([...filterItems, newItem]);
|
||||
};
|
||||
const addFilterState = useMemo(() => getAddFilterState(filterItems, filters), [filterItems, filters])
|
||||
|
||||
const getService_requestsCSV = async () => {
|
||||
const response = await axios({url: '/service_requests?filetype=csv', method: 'GET',responseType: 'blob'});
|
||||
const type = response.headers['content-type']
|
||||
const blob = new Blob([response.data], { type: type })
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = 'service_requestsCSV.csv'
|
||||
link.click()
|
||||
};
|
||||
const addFilter = () => {
|
||||
setFilterItems((currentItems) => {
|
||||
const nextItem = buildNextFilterItem(currentItems, filters, () => uniqueId())
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
dispatch(setRefetch(true));
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
return nextItem ? [...currentItems, nextItem] : currentItems
|
||||
})
|
||||
}
|
||||
|
||||
const onModalCancel = () => {
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
const getServiceRequestsCSV = async () => {
|
||||
const response = await axios({ url: '/service_requests?filetype=csv', method: 'GET', responseType: 'blob' })
|
||||
const type = response.headers['content-type']
|
||||
const blob = new Blob([response.data], { type })
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = 'service_requestsCSV.csv'
|
||||
link.click()
|
||||
}
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return
|
||||
await dispatch(uploadCsv(csvFile))
|
||||
dispatch(setRefetch(true))
|
||||
setCsvFile(null)
|
||||
setIsModalActive(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Service_requests')}</title>
|
||||
<title>{getPageTitle(title)}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Service_requests" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/service_requests/service_requests-new'} color='info' label='New Item'/>}
|
||||
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getService_requestsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<Link href={'/service_requests/service_requests-table'}>Switch to Table</Link>
|
||||
<SectionTitleLineWithButton icon={mdiRoomServiceOutline} title={title} subtitle={subtitle} main />
|
||||
|
||||
<CardBox className='mb-6 border border-white/10 shadow-none'>
|
||||
<div className='flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>Working view</p>
|
||||
<h2 className='mt-1 text-lg font-semibold text-white'>Service execution</h2>
|
||||
<p className='mt-1 text-sm text-slate-400'>Capture new requests, filter the queue, and keep support delivery moving.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className='flex w-full flex-col gap-3 xl:w-auto xl:items-end'>
|
||||
<BaseButtons
|
||||
type='justify-start xl:justify-end'
|
||||
mb='mb-0'
|
||||
classAddon='mb-2 mr-0 w-full sm:mr-2 sm:w-auto last:mr-0'
|
||||
className='w-full xl:w-auto'
|
||||
>
|
||||
{hasCreatePermission ? <BaseButton href='/service_requests/service_requests-new' color='info' label={roleLane === 'customer' ? 'Request support' : 'New request'} /> : null}
|
||||
<BaseButton
|
||||
color='whiteDark'
|
||||
outline
|
||||
label={addFilterState.buttonLabel}
|
||||
onClick={addFilter}
|
||||
disabled={!addFilterState.canAddFilter}
|
||||
/>
|
||||
<BaseButton color='whiteDark' outline label='Export CSV' onClick={getServiceRequestsCSV} />
|
||||
{hasCreatePermission ? <BaseButton color='whiteDark' outline label='Import CSV' onClick={() => setIsModalActive(true)} /> : null}
|
||||
<BaseButton href='/service_requests/service_requests-table' color='whiteDark' outline label='Table view' />
|
||||
</BaseButtons>
|
||||
|
||||
<p className='max-w-full text-left text-xs leading-5 text-slate-400 xl:max-w-sm xl:text-right'>{addFilterState.helperText}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='mb-6' hasTable>
|
||||
<TableService_requests filterItems={filterItems} setFilterItems={setFilterItems} filters={filters} />
|
||||
</CardBox>
|
||||
|
||||
<TableService_requests
|
||||
filterItems={filterItems}
|
||||
setFilterItems={setFilterItems}
|
||||
filters={filters}
|
||||
showGrid={false}
|
||||
/>
|
||||
|
||||
</SectionMain>
|
||||
<CardBoxModal
|
||||
title='Upload CSV'
|
||||
buttonColor='info'
|
||||
buttonLabel={'Confirm'}
|
||||
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
|
||||
isActive={isModalActive}
|
||||
onConfirm={onModalConfirm}
|
||||
onCancel={onModalCancel}
|
||||
>
|
||||
<DragDropFilePicker
|
||||
file={csvFile}
|
||||
setFile={setCsvFile}
|
||||
formats={'.csv'}
|
||||
/>
|
||||
|
||||
<CardBoxModal title='Upload CSV' buttonColor='info' buttonLabel='Confirm' isActive={isModalActive} onConfirm={onModalConfirm} onCancel={() => setIsModalActive(false)}>
|
||||
<DragDropFilePicker file={csvFile} setFile={setCsvFile} formats={'.csv'} />
|
||||
</CardBoxModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Service_requestsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
permission={'READ_SERVICE_REQUESTS'}
|
||||
|
||||
>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
)
|
||||
ServiceRequestsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated permission={'READ_SERVICE_REQUESTS'}>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default Service_requestsTablesPage
|
||||
export default ServiceRequestsTablesPage
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,180 +1,120 @@
|
||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
||||
import { mdiRoomServiceOutline } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import { uniqueId } from 'lodash';
|
||||
import React, { ReactElement, useState } from 'react'
|
||||
import { uniqueId } from 'lodash'
|
||||
import Link from 'next/link'
|
||||
import React, { ReactElement, useMemo, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import CardBoxModal from '../../components/CardBoxModal'
|
||||
import DragDropFilePicker from '../../components/DragDropFilePicker'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableService_requests from '../../components/Service_requests/TableService_requests'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
||||
import {setRefetch, uploadCsv} from '../../stores/service_requests/service_requestsSlice';
|
||||
import { getPageTitle } from '../../config'
|
||||
import { filterEntityFieldsForRole, pruneHiddenFilterItems } from '../../helpers/entityVisibility'
|
||||
import { humanize } from '../../helpers/humanize'
|
||||
import { getRoleLaneFromUser } from '../../helpers/roleLanes'
|
||||
import { hasPermission } from '../../helpers/userPermissions'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
||||
import { setRefetch, uploadCsv } from '../../stores/service_requests/service_requestsSlice'
|
||||
|
||||
const ServiceRequestsTablePage = () => {
|
||||
const [filterItems, setFilterItems] = useState([])
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||
const [isModalActive, setIsModalActive] = useState(false)
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
const { currentUser } = useAppSelector((state) => state.auth)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const roleLane = getRoleLaneFromUser(currentUser)
|
||||
const canManagePlatformFields = roleLane === 'super_admin'
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SERVICE_REQUESTS')
|
||||
|
||||
const filters = useMemo(() => {
|
||||
const baseFilters = [
|
||||
{ label: 'Summary', title: 'summary' },
|
||||
{ label: 'Details', title: 'details' },
|
||||
{ label: 'Reservation', title: 'reservation' },
|
||||
{ label: 'Dueat', title: 'due_at', date: 'true' },
|
||||
{ label: 'Requesttype', title: 'request_type', type: 'enum', options: ['airport_pickup', 'housekeeping', 'maintenance', 'stocked_fridge', 'chauffeur', 'late_checkout', 'extension', 'custom_service'] },
|
||||
{ label: 'Status', title: 'status', type: 'enum', options: ['new', 'triaged', 'in_progress', 'scheduled', 'waiting_on_guest', 'completed', 'canceled'] },
|
||||
{ label: 'Priority', title: 'priority', type: 'enum', options: ['low', 'normal', 'high', 'urgent'] },
|
||||
{ label: 'Documents', title: 'documents' },
|
||||
{ label: 'Comments', title: 'comments' },
|
||||
]
|
||||
|
||||
const Service_requestsTablesPage = () => {
|
||||
const [filterItems, setFilterItems] = useState([]);
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [isModalActive, setIsModalActive] = useState(false);
|
||||
const [showTableView, setShowTableView] = useState(false);
|
||||
if (canManagePlatformFields) {
|
||||
baseFilters.unshift({ label: 'Tenant', title: 'tenant' })
|
||||
}
|
||||
|
||||
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
|
||||
return filterEntityFieldsForRole('service_requests', roleLane, baseFilters).map((filter) => ({ ...filter, label: humanize(filter.title) }))
|
||||
}, [canManagePlatformFields, roleLane])
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
React.useEffect(() => {
|
||||
setFilterItems((currentItems) => pruneHiddenFilterItems(currentItems, filters))
|
||||
}, [filters])
|
||||
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: { filterValue: '', filterValueFrom: '', filterValueTo: '', selectedField: filters[0].title },
|
||||
}
|
||||
setFilterItems([...filterItems, newItem])
|
||||
}
|
||||
|
||||
const [filters] = useState([{label: 'Summary', title: 'summary'},{label: 'Details', title: 'details'},{label: 'Currency', title: 'currency'},
|
||||
|
||||
{label: 'Estimatedcost', title: 'estimated_cost', number: 'true'},{label: 'Actualcost', title: 'actual_cost', number: 'true'},
|
||||
{label: 'Requestedat', title: 'requested_at', date: 'true'},{label: 'Dueat', title: 'due_at', date: 'true'},{label: 'Completedat', title: 'completed_at', date: 'true'},
|
||||
|
||||
|
||||
{label: 'Tenant', title: 'tenant'},
|
||||
|
||||
|
||||
|
||||
{label: 'Reservation', title: 'reservation'},
|
||||
|
||||
|
||||
|
||||
{label: 'Requestedby', title: 'requested_by'},
|
||||
|
||||
|
||||
|
||||
{label: 'Assignedto', title: 'assigned_to'},
|
||||
|
||||
|
||||
{label: 'Documents', title: 'documents'},{label: 'Comments', title: 'comments'},
|
||||
{label: 'Requesttype', title: 'request_type', type: 'enum', options: ['airport_pickup','housekeeping','maintenance','stocked_fridge','chauffeur','late_checkout','extension','custom_service']},{label: 'Status', title: 'status', type: 'enum', options: ['new','triaged','in_progress','scheduled','waiting_on_guest','completed','canceled']},{label: 'Priority', title: 'priority', type: 'enum', options: ['low','normal','high','urgent']},
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SERVICE_REQUESTS');
|
||||
|
||||
const getServiceRequestsCSV = async () => {
|
||||
const response = await axios({ url: '/service_requests?filetype=csv', method: 'GET', responseType: 'blob' })
|
||||
const type = response.headers['content-type']
|
||||
const blob = new Blob([response.data], { type })
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = 'service_requestsCSV.csv'
|
||||
link.click()
|
||||
}
|
||||
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: '',
|
||||
},
|
||||
};
|
||||
newItem.fields.selectedField = filters[0].title;
|
||||
setFilterItems([...filterItems, newItem]);
|
||||
};
|
||||
|
||||
const getService_requestsCSV = async () => {
|
||||
const response = await axios({url: '/service_requests?filetype=csv', method: 'GET',responseType: 'blob'});
|
||||
const type = response.headers['content-type']
|
||||
const blob = new Blob([response.data], { type: type })
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = 'service_requestsCSV.csv'
|
||||
link.click()
|
||||
};
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
dispatch(setRefetch(true));
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
|
||||
const onModalCancel = () => {
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return
|
||||
await dispatch(uploadCsv(csvFile))
|
||||
dispatch(setRefetch(true))
|
||||
setCsvFile(null)
|
||||
setIsModalActive(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Service_requests')}</title>
|
||||
<title>{getPageTitle('Service Queue Table')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Service_requests" main>
|
||||
{''}
|
||||
<SectionTitleLineWithButton icon={mdiRoomServiceOutline} title='Service Queue Table' main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/service_requests/service_requests-new'} color='info' label='New Item'/>}
|
||||
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getService_requestsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<BaseButton
|
||||
color='info'
|
||||
label='Upload CSV'
|
||||
onClick={() => setIsModalActive(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<CardBox className='mb-6 border border-white/10 shadow-none'>
|
||||
{hasCreatePermission && <BaseButton className='mr-3' href='/service_requests/service_requests-new' color='info' label={roleLane === 'customer' ? 'Request support' : 'New request'} />}
|
||||
<BaseButton className='mr-3' color='info' label='Add filter' onClick={addFilter} />
|
||||
<BaseButton className='mr-3' color='info' label='Export CSV' onClick={getServiceRequestsCSV} />
|
||||
{hasCreatePermission && <BaseButton color='info' label='Import CSV' onClick={() => setIsModalActive(true)} />}
|
||||
<div className='ms-auto inline-flex items-center'>
|
||||
<div id='delete-rows-button'></div>
|
||||
|
||||
<Link href={'/service_requests/service_requests-list'}>
|
||||
Back to <span className='capitalize'>kanban</span>
|
||||
</Link>
|
||||
|
||||
<Link href='/service_requests/service_requests-list'>Back to board</Link>
|
||||
</div>
|
||||
</CardBox>
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<TableService_requests
|
||||
filterItems={filterItems}
|
||||
setFilterItems={setFilterItems}
|
||||
filters={filters}
|
||||
showGrid={true}
|
||||
/>
|
||||
<CardBox className='mb-6' hasTable>
|
||||
<TableService_requests filterItems={filterItems} setFilterItems={setFilterItems} filters={filters} showGrid />
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
<CardBoxModal
|
||||
title='Upload CSV'
|
||||
buttonColor='info'
|
||||
buttonLabel={'Confirm'}
|
||||
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
|
||||
isActive={isModalActive}
|
||||
onConfirm={onModalConfirm}
|
||||
onCancel={onModalCancel}
|
||||
>
|
||||
<DragDropFilePicker
|
||||
file={csvFile}
|
||||
setFile={setCsvFile}
|
||||
formats={'.csv'}
|
||||
/>
|
||||
<CardBoxModal title='Upload CSV' buttonColor='info' buttonLabel='Confirm' isActive={isModalActive} onConfirm={onModalConfirm} onCancel={() => setIsModalActive(false)}>
|
||||
<DragDropFilePicker file={csvFile} setFile={setCsvFile} formats={'.csv'} />
|
||||
</CardBoxModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Service_requestsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
permission={'READ_SERVICE_REQUESTS'}
|
||||
|
||||
>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
)
|
||||
ServiceRequestsTablePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated permission={'READ_SERVICE_REQUESTS'}>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default Service_requestsTablesPage
|
||||
export default ServiceRequestsTablePage
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,529 +1,152 @@
|
||||
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
|
||||
import { mdiAccountPlusOutline } from '@mdi/js'
|
||||
import Head from 'next/head'
|
||||
import React, { ReactElement } from 'react'
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import BaseButtons from '../../components/BaseButtons'
|
||||
import BaseDivider from '../../components/BaseDivider'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import FormField from '../../components/FormField'
|
||||
import FormImagePicker from '../../components/FormImagePicker'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import { getPageTitle } from '../../config'
|
||||
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
import FormField from '../../components/FormField'
|
||||
import BaseDivider from '../../components/BaseDivider'
|
||||
import BaseButtons from '../../components/BaseButtons'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import FormCheckRadio from '../../components/FormCheckRadio'
|
||||
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
|
||||
import FormFilePicker from '../../components/FormFilePicker'
|
||||
import FormImagePicker from '../../components/FormImagePicker'
|
||||
import { SwitchField } from '../../components/SwitchField'
|
||||
|
||||
import { SelectField } from '../../components/SelectField'
|
||||
import { SelectFieldMany } from "../../components/SelectFieldMany";
|
||||
import {RichTextField} from "../../components/RichTextField";
|
||||
|
||||
import { SelectFieldMany } from '../../components/SelectFieldMany'
|
||||
import { SwitchField } from '../../components/SwitchField'
|
||||
import { getPageTitle } from '../../config'
|
||||
import { getRoleLaneFromUser } from '../../helpers/roleLanes'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
||||
import { create } from '../../stores/users/usersSlice'
|
||||
import { useAppDispatch } from '../../stores/hooks'
|
||||
import { useRouter } from 'next/router'
|
||||
import moment from 'moment';
|
||||
|
||||
const initialValues = {
|
||||
|
||||
|
||||
firstName: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
lastName: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
phoneNumber: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
email: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
disabled: false,
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
avatar: [],
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
app_role: '',
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
custom_permissions: [],
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
organizations: '',
|
||||
|
||||
|
||||
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phoneNumber: '',
|
||||
email: '',
|
||||
disabled: false,
|
||||
avatar: [],
|
||||
app_role: null,
|
||||
custom_permissions: [],
|
||||
organizations: null,
|
||||
}
|
||||
|
||||
|
||||
const UsersNew = () => {
|
||||
const router = useRouter()
|
||||
const dispatch = useAppDispatch()
|
||||
const { currentUser } = useAppSelector((state) => state.auth)
|
||||
|
||||
|
||||
|
||||
const roleLane = getRoleLaneFromUser(currentUser)
|
||||
const canManagePlatformAccess = roleLane === 'super_admin'
|
||||
const pageTitle = canManagePlatformAccess ? 'Invite User' : 'Invite Team Member'
|
||||
const introCopy = canManagePlatformAccess
|
||||
? 'Invite a platform user, choose one of the four business roles, and optionally attach organization-specific access.'
|
||||
: 'Invite a team member into your organization. Role assignment is limited to customer and concierge lanes, and organization access is pinned automatically.'
|
||||
|
||||
const handleSubmit = async (data) => {
|
||||
await dispatch(create(data))
|
||||
const roleQueryParams = {
|
||||
assignableOnly: true,
|
||||
includeHighTrust: canManagePlatformAccess,
|
||||
}
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
const payload = { ...values }
|
||||
|
||||
if (!canManagePlatformAccess) {
|
||||
delete payload.custom_permissions
|
||||
delete payload.organizations
|
||||
}
|
||||
|
||||
await dispatch(create(payload))
|
||||
await router.push('/users/users-list')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('New Item')}</title>
|
||||
<title>{getPageTitle(pageTitle)}</title>
|
||||
</Head>
|
||||
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||
{''}
|
||||
<SectionTitleLineWithButton icon={mdiAccountPlusOutline} title={pageTitle} main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
initialValues
|
||||
|
||||
}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
|
||||
<CardBox className='mx-auto max-w-5xl'>
|
||||
<div className='mb-6 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-600 dark:border-white/10 dark:bg-slate-900/40 dark:text-slate-300'>
|
||||
{introCopy}
|
||||
</div>
|
||||
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||
<Form>
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="First Name"
|
||||
>
|
||||
<Field
|
||||
name="firstName"
|
||||
placeholder="First Name"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="Last Name"
|
||||
>
|
||||
<Field
|
||||
name="lastName"
|
||||
placeholder="Last Name"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="Phone Number"
|
||||
>
|
||||
<Field
|
||||
name="phoneNumber"
|
||||
placeholder="Phone Number"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField
|
||||
label="E-Mail"
|
||||
>
|
||||
<Field
|
||||
name="email"
|
||||
placeholder="E-Mail"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label='Disabled' labelFor='disabled'>
|
||||
<Field
|
||||
name='disabled'
|
||||
id='disabled'
|
||||
component={SwitchField}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField>
|
||||
<Field
|
||||
label='Avatar'
|
||||
color='info'
|
||||
icon={mdiUpload}
|
||||
path={'users/avatar'}
|
||||
name='avatar'
|
||||
id='avatar'
|
||||
schema={{
|
||||
size: undefined,
|
||||
formats: undefined,
|
||||
}}
|
||||
component={FormImagePicker}
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="App Role" labelFor="app_role">
|
||||
<Field name="app_role" id="app_role" component={SelectField} options={[]} itemRef={'roles'}></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label='Custom Permissions' labelFor='custom_permissions'>
|
||||
<Field
|
||||
name='custom_permissions'
|
||||
id='custom_permissions'
|
||||
itemRef={'permissions'}
|
||||
options={[]}
|
||||
component={SelectFieldMany}>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<FormField label="Organizations" labelFor="organizations">
|
||||
<Field name="organizations" id="organizations" component={SelectField} options={[]} itemRef={'organizations'}></Field>
|
||||
</FormField>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='grid gap-5 md:grid-cols-2'>
|
||||
<FormField label='First name'>
|
||||
<Field name='firstName' placeholder='First name' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Last name'>
|
||||
<Field name='lastName' placeholder='Last name' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Email'>
|
||||
<Field name='email' type='email' placeholder='name@company.com' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Phone number'>
|
||||
<Field name='phoneNumber' placeholder='Phone number' />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Business role' labelFor='app_role'>
|
||||
<Field
|
||||
name='app_role'
|
||||
id='app_role'
|
||||
component={SelectField}
|
||||
options={[]}
|
||||
itemRef={'roles'}
|
||||
queryParams={roleQueryParams}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{canManagePlatformAccess ? (
|
||||
<FormField label='Organization' labelFor='organizations'>
|
||||
<Field name='organizations' id='organizations' component={SelectField} options={[]} itemRef={'organizations'} />
|
||||
</FormField>
|
||||
) : (
|
||||
<div className='rounded-2xl border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500 dark:border-white/10 dark:text-slate-400'>
|
||||
Organization access will be assigned automatically from your current admin context.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField label='Avatar' labelFor='avatar'>
|
||||
<Field name='avatar' id='avatar' component={FormImagePicker} />
|
||||
</FormField>
|
||||
|
||||
<FormField label='Disable account' labelFor='disabled'>
|
||||
<Field name='disabled' id='disabled' component={SwitchField} />
|
||||
</FormField>
|
||||
|
||||
{canManagePlatformAccess && (
|
||||
<div className='md:col-span-2'>
|
||||
<FormField label='Custom permissions' labelFor='custom_permissions'>
|
||||
<Field
|
||||
name='custom_permissions'
|
||||
id='custom_permissions'
|
||||
itemRef={'permissions'}
|
||||
options={[]}
|
||||
component={SelectFieldMany}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton type="submit" color="info" label="Submit" />
|
||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/users/users-list')}/>
|
||||
<BaseButton type='submit' color='info' label='Send invite' />
|
||||
<BaseButton type='reset' color='info' outline label='Reset' />
|
||||
<BaseButton type='button' color='danger' outline label='Cancel' onClick={() => router.push('/users/users-list')} />
|
||||
</BaseButtons>
|
||||
</Form>
|
||||
</Formik>
|
||||
@ -534,15 +157,7 @@ const UsersNew = () => {
|
||||
}
|
||||
|
||||
UsersNew.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
permission={'CREATE_USERS'}
|
||||
|
||||
>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
)
|
||||
return <LayoutAuthenticated permission={'CREATE_USERS'}>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default UsersNew
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user