Autosave: 20260403-062902
This commit is contained in:
parent
67559d5582
commit
d6ec6bb83a
0
.perm_test_apache
Normal file
0
.perm_test_apache
Normal file
0
.perm_test_exec
Normal file
0
.perm_test_exec
Normal file
@ -1,9 +1,3 @@
|
||||
const config = require('../../config');
|
||||
const providers = config.providers;
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const moment = require('moment');
|
||||
|
||||
module.exports = function(sequelize, DataTypes) {
|
||||
const booking_requests = sequelize.define(
|
||||
'booking_requests',
|
||||
@ -14,111 +8,59 @@ module.exports = function(sequelize, DataTypes) {
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
request_code: {
|
||||
request_code: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
status: {
|
||||
status: {
|
||||
type: DataTypes.ENUM,
|
||||
|
||||
|
||||
|
||||
values: [
|
||||
|
||||
"draft",
|
||||
|
||||
|
||||
"submitted",
|
||||
|
||||
|
||||
"in_review",
|
||||
|
||||
|
||||
"changes_requested",
|
||||
|
||||
|
||||
"approved",
|
||||
|
||||
|
||||
"rejected",
|
||||
|
||||
|
||||
"expired",
|
||||
|
||||
|
||||
"converted_to_reservation",
|
||||
|
||||
|
||||
"canceled"
|
||||
|
||||
'draft',
|
||||
'submitted',
|
||||
'in_review',
|
||||
'changes_requested',
|
||||
'approved',
|
||||
'rejected',
|
||||
'expired',
|
||||
'converted_to_reservation',
|
||||
'canceled',
|
||||
],
|
||||
|
||||
},
|
||||
|
||||
check_in_at: {
|
||||
check_in_at: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
check_out_at: {
|
||||
check_out_at: {
|
||||
type: DataTypes.DATE,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
preferred_bedrooms: {
|
||||
preferred_bedrooms: {
|
||||
type: DataTypes.INTEGER,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
guest_count: {
|
||||
guest_count: {
|
||||
type: DataTypes.INTEGER,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
purpose_of_stay: {
|
||||
purpose_of_stay: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
special_requirements: {
|
||||
special_requirements: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
budget_code: {
|
||||
budget_code: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
max_budget_amount: {
|
||||
max_budget_amount: {
|
||||
type: DataTypes.DECIMAL,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
currency: {
|
||||
currency: {
|
||||
type: DataTypes.TEXT,
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
importHash: {
|
||||
@ -135,157 +77,110 @@ currency: {
|
||||
);
|
||||
|
||||
booking_requests.associate = (db) => {
|
||||
|
||||
db.booking_requests.belongsToMany(db.booking_request_travelers, {
|
||||
db.booking_requests.hasMany(db.booking_request_travelers, {
|
||||
as: 'travelers',
|
||||
foreignKey: {
|
||||
name: 'booking_requests_travelersId',
|
||||
name: 'booking_requestId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'booking_requestsTravelersBooking_request_travelers',
|
||||
});
|
||||
|
||||
db.booking_requests.belongsToMany(db.booking_request_travelers, {
|
||||
db.booking_requests.hasMany(db.booking_request_travelers, {
|
||||
as: 'travelers_filter',
|
||||
foreignKey: {
|
||||
name: 'booking_requests_travelersId',
|
||||
name: 'booking_requestId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'booking_requestsTravelersBooking_request_travelers',
|
||||
});
|
||||
|
||||
db.booking_requests.belongsToMany(db.approval_steps, {
|
||||
db.booking_requests.hasMany(db.approval_steps, {
|
||||
as: 'approval_steps',
|
||||
foreignKey: {
|
||||
name: 'booking_requests_approval_stepsId',
|
||||
name: 'booking_requestId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'booking_requestsApproval_stepsApproval_steps',
|
||||
});
|
||||
|
||||
db.booking_requests.belongsToMany(db.approval_steps, {
|
||||
db.booking_requests.hasMany(db.approval_steps, {
|
||||
as: 'approval_steps_filter',
|
||||
foreignKey: {
|
||||
name: 'booking_requests_approval_stepsId',
|
||||
name: 'booking_requestId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'booking_requestsApproval_stepsApproval_steps',
|
||||
});
|
||||
|
||||
db.booking_requests.belongsToMany(db.documents, {
|
||||
db.booking_requests.hasMany(db.documents, {
|
||||
as: 'documents',
|
||||
foreignKey: {
|
||||
name: 'booking_requests_documentsId',
|
||||
name: 'booking_requestId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'booking_requestsDocumentsDocuments',
|
||||
});
|
||||
|
||||
db.booking_requests.belongsToMany(db.documents, {
|
||||
db.booking_requests.hasMany(db.documents, {
|
||||
as: 'documents_filter',
|
||||
foreignKey: {
|
||||
name: 'booking_requests_documentsId',
|
||||
name: 'booking_requestId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'booking_requestsDocumentsDocuments',
|
||||
});
|
||||
|
||||
db.booking_requests.belongsToMany(db.activity_comments, {
|
||||
db.booking_requests.hasMany(db.activity_comments, {
|
||||
as: 'comments',
|
||||
foreignKey: {
|
||||
name: 'booking_requests_commentsId',
|
||||
name: 'booking_requestId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'booking_requestsCommentsActivity_comments',
|
||||
});
|
||||
|
||||
db.booking_requests.belongsToMany(db.activity_comments, {
|
||||
db.booking_requests.hasMany(db.activity_comments, {
|
||||
as: 'comments_filter',
|
||||
foreignKey: {
|
||||
name: 'booking_requests_commentsId',
|
||||
name: 'booking_requestId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'booking_requestsCommentsActivity_comments',
|
||||
});
|
||||
|
||||
|
||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
db.booking_requests.hasMany(db.booking_request_travelers, {
|
||||
as: 'booking_request_travelers_booking_request',
|
||||
foreignKey: {
|
||||
name: 'booking_requestId',
|
||||
name: 'booking_requestId',
|
||||
},
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
db.booking_requests.hasMany(db.approval_steps, {
|
||||
as: 'approval_steps_booking_request',
|
||||
foreignKey: {
|
||||
name: 'booking_requestId',
|
||||
name: 'booking_requestId',
|
||||
},
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
db.booking_requests.hasMany(db.reservations, {
|
||||
as: 'reservations_booking_request',
|
||||
foreignKey: {
|
||||
name: 'booking_requestId',
|
||||
name: 'booking_requestId',
|
||||
},
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
db.booking_requests.hasMany(db.documents, {
|
||||
as: 'documents_booking_request',
|
||||
foreignKey: {
|
||||
name: 'booking_requestId',
|
||||
name: 'booking_requestId',
|
||||
},
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
db.booking_requests.hasMany(db.activity_comments, {
|
||||
as: 'activity_comments_booking_request',
|
||||
foreignKey: {
|
||||
name: 'booking_requestId',
|
||||
name: 'booking_requestId',
|
||||
},
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//end loop
|
||||
|
||||
|
||||
|
||||
db.booking_requests.belongsTo(db.tenants, {
|
||||
as: 'tenant',
|
||||
foreignKey: {
|
||||
@ -326,9 +221,6 @@ currency: {
|
||||
constraints: false,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
db.booking_requests.belongsTo(db.users, {
|
||||
as: 'createdBy',
|
||||
});
|
||||
@ -338,9 +230,5 @@ currency: {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
return booking_requests;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -1,8 +1,3 @@
|
||||
const config = require('../../config');
|
||||
const providers = config.providers;
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const moment = require('moment');
|
||||
|
||||
module.exports = function(sequelize, DataTypes) {
|
||||
const properties = sequelize.define(
|
||||
@ -88,58 +83,52 @@ is_active: {
|
||||
|
||||
properties.associate = (db) => {
|
||||
|
||||
db.properties.belongsToMany(db.unit_types, {
|
||||
db.properties.hasMany(db.unit_types, {
|
||||
as: 'unit_types',
|
||||
foreignKey: {
|
||||
name: 'properties_unit_typesId',
|
||||
name: 'propertyId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'propertiesUnit_typesUnit_types',
|
||||
});
|
||||
|
||||
db.properties.belongsToMany(db.unit_types, {
|
||||
db.properties.hasMany(db.unit_types, {
|
||||
as: 'unit_types_filter',
|
||||
foreignKey: {
|
||||
name: 'properties_unit_typesId',
|
||||
name: 'propertyId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'propertiesUnit_typesUnit_types',
|
||||
});
|
||||
|
||||
db.properties.belongsToMany(db.units, {
|
||||
db.properties.hasMany(db.units, {
|
||||
as: 'units',
|
||||
foreignKey: {
|
||||
name: 'properties_unitsId',
|
||||
name: 'propertyId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'propertiesUnitsUnits',
|
||||
});
|
||||
|
||||
db.properties.belongsToMany(db.units, {
|
||||
db.properties.hasMany(db.units, {
|
||||
as: 'units_filter',
|
||||
foreignKey: {
|
||||
name: 'properties_unitsId',
|
||||
name: 'propertyId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'propertiesUnitsUnits',
|
||||
});
|
||||
|
||||
db.properties.belongsToMany(db.amenities, {
|
||||
db.properties.hasMany(db.amenities, {
|
||||
as: 'amenities',
|
||||
foreignKey: {
|
||||
name: 'properties_amenitiesId',
|
||||
name: 'propertyId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'propertiesAmenitiesAmenities',
|
||||
});
|
||||
|
||||
db.properties.belongsToMany(db.amenities, {
|
||||
db.properties.hasMany(db.amenities, {
|
||||
as: 'amenities_filter',
|
||||
foreignKey: {
|
||||
name: 'properties_amenitiesId',
|
||||
name: 'propertyId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'propertiesAmenitiesAmenities',
|
||||
});
|
||||
|
||||
|
||||
|
||||
@ -1,8 +1,3 @@
|
||||
const config = require('../../config');
|
||||
const providers = config.providers;
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const moment = require('moment');
|
||||
|
||||
module.exports = function(sequelize, DataTypes) {
|
||||
const unit_types = sequelize.define(
|
||||
@ -99,22 +94,20 @@ minimum_stay_nights: {
|
||||
|
||||
unit_types.associate = (db) => {
|
||||
|
||||
db.unit_types.belongsToMany(db.units, {
|
||||
db.unit_types.hasMany(db.units, {
|
||||
as: 'units',
|
||||
foreignKey: {
|
||||
name: 'unit_types_unitsId',
|
||||
name: 'unit_typeId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'unit_typesUnitsUnits',
|
||||
});
|
||||
|
||||
db.unit_types.belongsToMany(db.units, {
|
||||
db.unit_types.hasMany(db.units, {
|
||||
as: 'units_filter',
|
||||
foreignKey: {
|
||||
name: 'unit_types_unitsId',
|
||||
name: 'unit_typeId',
|
||||
},
|
||||
constraints: false,
|
||||
through: 'unit_typesUnitsUnits',
|
||||
});
|
||||
|
||||
|
||||
|
||||
@ -81,6 +81,8 @@ const checklist_itemsRoutes = require('./routes/checklist_items');
|
||||
|
||||
const job_runsRoutes = require('./routes/job_runs');
|
||||
|
||||
const corporateStayPortalRoutes = require('./routes/corporate_stay_portal');
|
||||
|
||||
|
||||
const getBaseUrl = (url) => {
|
||||
if (!url) return '';
|
||||
@ -197,6 +199,8 @@ app.use('/api/checklist_items', passport.authenticate('jwt', {session: false}),
|
||||
|
||||
app.use('/api/job_runs', passport.authenticate('jwt', {session: false}), job_runsRoutes);
|
||||
|
||||
app.use('/api/corporate-stay-portal', passport.authenticate('jwt', {session: false}), corporateStayPortalRoutes);
|
||||
|
||||
app.use(
|
||||
'/api/openai',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
|
||||
437
backend/src/routes/corporate_stay_portal.js
Normal file
437
backend/src/routes/corporate_stay_portal.js
Normal file
@ -0,0 +1,437 @@
|
||||
const express = require('express');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
const db = require('../db/models');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const bookingPermissions = ['READ_BOOKING_REQUESTS'];
|
||||
const approvalPermissions = ['READ_APPROVAL_STEPS'];
|
||||
const reservationPermissions = ['READ_RESERVATIONS'];
|
||||
const servicePermissions = ['READ_SERVICE_REQUESTS'];
|
||||
const financePermissions = ['READ_INVOICES'];
|
||||
const inventoryPermissions = ['READ_PROPERTIES', 'READ_UNITS'];
|
||||
const accountPermissions = ['READ_ORGANIZATIONS'];
|
||||
|
||||
function getPermissionSet(currentUser) {
|
||||
return new Set([
|
||||
...((currentUser?.custom_permissions || []).map((permission) => permission.name)),
|
||||
...((currentUser?.app_role_permissions || []).map((permission) => permission.name)),
|
||||
]);
|
||||
}
|
||||
|
||||
function hasAnyPermission(currentUser, permissions) {
|
||||
if (currentUser?.app_role?.globalAccess) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const permissionSet = getPermissionSet(currentUser);
|
||||
return permissions.some((permission) => permissionSet.has(permission));
|
||||
}
|
||||
|
||||
function scopedWhere(currentUser, key, extraWhere = {}) {
|
||||
if (currentUser?.app_role?.globalAccess || !currentUser?.organizationId) {
|
||||
return extraWhere;
|
||||
}
|
||||
|
||||
return {
|
||||
...extraWhere,
|
||||
[key]: currentUser.organizationId,
|
||||
};
|
||||
}
|
||||
|
||||
function groupRowsToCounts(rows, field) {
|
||||
return rows.reduce((accumulator, row) => {
|
||||
accumulator[row[field]] = Number(row.count || 0);
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function formatUserDisplay(user) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
|
||||
return fullName || user.email || 'Assigned staff';
|
||||
}
|
||||
|
||||
async function getGroupedCounts(model, statusField, where) {
|
||||
const rows = await model.findAll({
|
||||
attributes: [
|
||||
statusField,
|
||||
[db.sequelize.fn('COUNT', db.sequelize.col('id')), 'count'],
|
||||
],
|
||||
where,
|
||||
group: [statusField],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
return groupRowsToCounts(rows, statusField);
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/overview',
|
||||
wrapAsync(async (req, res) => {
|
||||
const { currentUser } = req;
|
||||
const now = new Date();
|
||||
const nextSevenDays = new Date(now);
|
||||
nextSevenDays.setDate(nextSevenDays.getDate() + 7);
|
||||
|
||||
const access = {
|
||||
accounts: hasAnyPermission(currentUser, accountPermissions),
|
||||
bookings: hasAnyPermission(currentUser, bookingPermissions),
|
||||
approvals: hasAnyPermission(currentUser, approvalPermissions),
|
||||
reservations: hasAnyPermission(currentUser, reservationPermissions),
|
||||
serviceRequests: hasAnyPermission(currentUser, servicePermissions),
|
||||
invoices: hasAnyPermission(currentUser, financePermissions),
|
||||
inventory: hasAnyPermission(currentUser, inventoryPermissions),
|
||||
};
|
||||
|
||||
const response = {
|
||||
generatedAt: now.toISOString(),
|
||||
access,
|
||||
organizations: {
|
||||
total: 0,
|
||||
},
|
||||
bookingRequests: {
|
||||
statusCounts: {},
|
||||
pendingReview: 0,
|
||||
approvedReady: 0,
|
||||
recent: [],
|
||||
},
|
||||
approvals: {
|
||||
pending: 0,
|
||||
},
|
||||
reservations: {
|
||||
statusCounts: {},
|
||||
upcomingArrivals: 0,
|
||||
upcomingDepartures: 0,
|
||||
inHouse: 0,
|
||||
recent: [],
|
||||
},
|
||||
serviceRequests: {
|
||||
statusCounts: {},
|
||||
open: 0,
|
||||
urgent: 0,
|
||||
recent: [],
|
||||
},
|
||||
invoices: {
|
||||
statusCounts: {},
|
||||
openBalance: 0,
|
||||
recent: [],
|
||||
},
|
||||
inventory: {
|
||||
activeProperties: 0,
|
||||
unitStatusCounts: {},
|
||||
},
|
||||
};
|
||||
|
||||
if (access.accounts) {
|
||||
response.organizations.total = await db.organizations.count({
|
||||
where: currentUser?.app_role?.globalAccess
|
||||
? {}
|
||||
: { id: currentUser.organizationId },
|
||||
});
|
||||
}
|
||||
|
||||
if (access.bookings) {
|
||||
const bookingWhere = scopedWhere(currentUser, 'organizationId');
|
||||
response.bookingRequests.statusCounts = await getGroupedCounts(
|
||||
db.booking_requests,
|
||||
'status',
|
||||
bookingWhere,
|
||||
);
|
||||
response.bookingRequests.pendingReview = [
|
||||
'submitted',
|
||||
'in_review',
|
||||
'changes_requested',
|
||||
].reduce(
|
||||
(sum, status) => sum + (response.bookingRequests.statusCounts[status] || 0),
|
||||
0,
|
||||
);
|
||||
response.bookingRequests.approvedReady = (
|
||||
response.bookingRequests.statusCounts.approved || 0
|
||||
);
|
||||
|
||||
const recentBookingRequests = await db.booking_requests.findAll({
|
||||
where: bookingWhere,
|
||||
attributes: [
|
||||
'id',
|
||||
'request_code',
|
||||
'status',
|
||||
'check_in_at',
|
||||
'check_out_at',
|
||||
'guest_count',
|
||||
'preferred_bedrooms',
|
||||
'updatedAt',
|
||||
],
|
||||
include: [
|
||||
{
|
||||
model: db.organizations,
|
||||
as: 'organization',
|
||||
attributes: ['id', 'name'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: db.properties,
|
||||
as: 'preferred_property',
|
||||
attributes: ['id', 'name', 'city'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: db.unit_types,
|
||||
as: 'preferred_unit_type',
|
||||
attributes: ['id', 'name'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: db.users,
|
||||
as: 'requested_by',
|
||||
attributes: ['id', 'firstName', 'lastName', 'email'],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [['updatedAt', 'DESC']],
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
response.bookingRequests.recent = recentBookingRequests.map((item) => ({
|
||||
id: item.id,
|
||||
request_code: item.request_code,
|
||||
status: item.status,
|
||||
check_in_at: item.check_in_at,
|
||||
check_out_at: item.check_out_at,
|
||||
guest_count: item.guest_count,
|
||||
preferred_bedrooms: item.preferred_bedrooms,
|
||||
updatedAt: item.updatedAt,
|
||||
organizationName: item.organization?.name || 'Unassigned account',
|
||||
requestedBy: formatUserDisplay(item.requested_by),
|
||||
propertyName: item.preferred_property?.name || 'Open inventory',
|
||||
unitTypeName: item.preferred_unit_type?.name || 'Any unit type',
|
||||
}));
|
||||
}
|
||||
|
||||
if (access.approvals) {
|
||||
response.approvals.pending = await db.approval_steps.count({
|
||||
where: scopedWhere(currentUser, 'organizationsId', { decision: 'pending' }),
|
||||
});
|
||||
}
|
||||
|
||||
if (access.reservations) {
|
||||
const reservationWhere = scopedWhere(currentUser, 'organizationId');
|
||||
response.reservations.statusCounts = await getGroupedCounts(
|
||||
db.reservations,
|
||||
'status',
|
||||
reservationWhere,
|
||||
);
|
||||
response.reservations.upcomingArrivals = await db.reservations.count({
|
||||
where: scopedWhere(currentUser, 'organizationId', {
|
||||
status: { [Op.in]: ['confirmed', 'checked_in'] },
|
||||
check_in_at: { [Op.between]: [now, nextSevenDays] },
|
||||
}),
|
||||
});
|
||||
response.reservations.upcomingDepartures = await db.reservations.count({
|
||||
where: scopedWhere(currentUser, 'organizationId', {
|
||||
status: { [Op.in]: ['confirmed', 'checked_in'] },
|
||||
check_out_at: { [Op.between]: [now, nextSevenDays] },
|
||||
}),
|
||||
});
|
||||
response.reservations.inHouse = await db.reservations.count({
|
||||
where: scopedWhere(currentUser, 'organizationId', {
|
||||
status: 'checked_in',
|
||||
}),
|
||||
});
|
||||
|
||||
const recentReservations = await db.reservations.findAll({
|
||||
where: reservationWhere,
|
||||
attributes: [
|
||||
'id',
|
||||
'reservation_code',
|
||||
'status',
|
||||
'check_in_at',
|
||||
'check_out_at',
|
||||
'guest_count',
|
||||
'currency',
|
||||
'nightly_rate',
|
||||
'updatedAt',
|
||||
],
|
||||
include: [
|
||||
{
|
||||
model: db.organizations,
|
||||
as: 'organization',
|
||||
attributes: ['id', 'name'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: db.properties,
|
||||
as: 'property',
|
||||
attributes: ['id', 'name', 'city'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: db.units,
|
||||
as: 'unit',
|
||||
attributes: ['id', 'unit_number', 'status'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: db.booking_requests,
|
||||
as: 'booking_request',
|
||||
attributes: ['id', 'request_code'],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [['check_in_at', 'ASC']],
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
response.reservations.recent = recentReservations.map((item) => ({
|
||||
id: item.id,
|
||||
reservation_code: item.reservation_code,
|
||||
status: item.status,
|
||||
check_in_at: item.check_in_at,
|
||||
check_out_at: item.check_out_at,
|
||||
guest_count: item.guest_count,
|
||||
updatedAt: item.updatedAt,
|
||||
organizationName: item.organization?.name || 'Unassigned account',
|
||||
propertyName: item.property?.name || 'Property pending',
|
||||
unitNumber: item.unit?.unit_number || 'TBD',
|
||||
sourceRequestCode: item.booking_request?.request_code || null,
|
||||
}));
|
||||
}
|
||||
|
||||
if (access.serviceRequests) {
|
||||
const serviceWhere = scopedWhere(currentUser, 'organizationsId');
|
||||
response.serviceRequests.statusCounts = await getGroupedCounts(
|
||||
db.service_requests,
|
||||
'status',
|
||||
serviceWhere,
|
||||
);
|
||||
response.serviceRequests.open = Object.entries(response.serviceRequests.statusCounts)
|
||||
.filter(([status]) => !['completed', 'canceled'].includes(status))
|
||||
.reduce((sum, [, count]) => sum + Number(count || 0), 0);
|
||||
response.serviceRequests.urgent = await db.service_requests.count({
|
||||
where: scopedWhere(currentUser, 'organizationsId', {
|
||||
priority: 'urgent',
|
||||
status: { [Op.notIn]: ['completed', 'canceled'] },
|
||||
}),
|
||||
});
|
||||
|
||||
const recentServiceRequests = await db.service_requests.findAll({
|
||||
where: serviceWhere,
|
||||
attributes: [
|
||||
'id',
|
||||
'request_type',
|
||||
'status',
|
||||
'priority',
|
||||
'summary',
|
||||
'due_at',
|
||||
'updatedAt',
|
||||
],
|
||||
include: [
|
||||
{
|
||||
model: db.reservations,
|
||||
as: 'reservation',
|
||||
attributes: ['id', 'reservation_code'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: db.users,
|
||||
as: 'assigned_to',
|
||||
attributes: ['id', 'firstName', 'lastName', 'email'],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [['updatedAt', 'DESC']],
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
response.serviceRequests.recent = recentServiceRequests.map((item) => ({
|
||||
id: item.id,
|
||||
request_type: item.request_type,
|
||||
status: item.status,
|
||||
priority: item.priority,
|
||||
summary: item.summary,
|
||||
due_at: item.due_at,
|
||||
updatedAt: item.updatedAt,
|
||||
reservationCode: item.reservation?.reservation_code || null,
|
||||
assignedTo: formatUserDisplay(item.assigned_to),
|
||||
}));
|
||||
}
|
||||
|
||||
if (access.invoices) {
|
||||
const invoiceWhere = scopedWhere(currentUser, 'organizationId');
|
||||
response.invoices.statusCounts = await getGroupedCounts(
|
||||
db.invoices,
|
||||
'status',
|
||||
invoiceWhere,
|
||||
);
|
||||
const openBalance = await db.invoices.sum('balance_due', {
|
||||
where: scopedWhere(currentUser, 'organizationId', {
|
||||
status: { [Op.in]: ['issued', 'overdue', 'partially_paid'] },
|
||||
}),
|
||||
});
|
||||
response.invoices.openBalance = Number(openBalance || 0);
|
||||
|
||||
const recentInvoices = await db.invoices.findAll({
|
||||
where: invoiceWhere,
|
||||
attributes: [
|
||||
'id',
|
||||
'invoice_number',
|
||||
'status',
|
||||
'total_amount',
|
||||
'balance_due',
|
||||
'currency',
|
||||
'due_at',
|
||||
'updatedAt',
|
||||
],
|
||||
include: [
|
||||
{
|
||||
model: db.organizations,
|
||||
as: 'organization',
|
||||
attributes: ['id', 'name'],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: db.reservations,
|
||||
as: 'reservation',
|
||||
attributes: ['id', 'reservation_code'],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [['updatedAt', 'DESC']],
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
response.invoices.recent = recentInvoices.map((item) => ({
|
||||
id: item.id,
|
||||
invoice_number: item.invoice_number,
|
||||
status: item.status,
|
||||
total_amount: Number(item.total_amount || 0),
|
||||
balance_due: Number(item.balance_due || 0),
|
||||
currency: item.currency || 'USD',
|
||||
due_at: item.due_at,
|
||||
updatedAt: item.updatedAt,
|
||||
organizationName: item.organization?.name || 'Unassigned account',
|
||||
reservationCode: item.reservation?.reservation_code || null,
|
||||
}));
|
||||
}
|
||||
|
||||
if (access.inventory) {
|
||||
response.inventory.activeProperties = await db.properties.count({
|
||||
where: scopedWhere(currentUser, 'organizationsId', { is_active: true }),
|
||||
});
|
||||
response.inventory.unitStatusCounts = await getGroupedCounts(
|
||||
db.units,
|
||||
'status',
|
||||
scopedWhere(currentUser, 'organizationsId'),
|
||||
);
|
||||
}
|
||||
|
||||
res.status(200).send(response);
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@ -3,8 +3,22 @@
|
||||
*/
|
||||
|
||||
const output = process.env.NODE_ENV === 'production' ? 'export' : 'standalone';
|
||||
const backendProxyTarget = process.env.BACKEND_INTERNAL_URL || 'http://127.0.0.1:3000';
|
||||
|
||||
const nextConfig = {
|
||||
trailingSlash: true,
|
||||
async rewrites() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${backendProxyTarget}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
distDir: 'build',
|
||||
output,
|
||||
basePath: "",
|
||||
|
||||
@ -15,66 +15,58 @@ type Props = {
|
||||
|
||||
const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
|
||||
const [isLinkActive, setIsLinkActive] = useState(false)
|
||||
const [isDropdownActive, setIsDropdownActive] = useState(false)
|
||||
const [isDropdownActive, setIsDropdownActive] = useState(!isDropdownList && !!item.menu)
|
||||
|
||||
const asideMenuItemStyle = useAppSelector((state) => state.style.asideMenuItemStyle)
|
||||
const asideMenuDropdownStyle = useAppSelector((state) => state.style.asideMenuDropdownStyle)
|
||||
const asideMenuItemActiveStyle = useAppSelector((state) => state.style.asideMenuItemActiveStyle)
|
||||
const borders = useAppSelector((state) => state.style.borders);
|
||||
const activeLinkColor = useAppSelector(
|
||||
(state) => state.style.activeLinkColor,
|
||||
);
|
||||
const borders = useAppSelector((state) => state.style.borders)
|
||||
const activeLinkColor = useAppSelector((state) => state.style.activeLinkColor)
|
||||
const activeClassAddon = !item.color && isLinkActive ? asideMenuItemActiveStyle : ''
|
||||
|
||||
const { asPath, isReady } = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (item.href && isReady) {
|
||||
const linkPathName = new URL(item.href, location.href).pathname + '/';
|
||||
const linkPathName = new URL(item.href, location.href).pathname + '/'
|
||||
const activePathname = new URL(asPath, location.href).pathname
|
||||
|
||||
const activeView = activePathname.split('/')[1];
|
||||
const linkPathNameView = linkPathName.split('/')[1];
|
||||
const activeView = activePathname.split('/')[1]
|
||||
const linkPathNameView = linkPathName.split('/')[1]
|
||||
|
||||
setIsLinkActive(linkPathNameView === activeView);
|
||||
setIsLinkActive(linkPathNameView === activeView)
|
||||
}
|
||||
}, [item.href, isReady, asPath])
|
||||
|
||||
const asideMenuItemInnerContents = (
|
||||
<>
|
||||
{item.icon && (
|
||||
<BaseIcon path={item.icon} className={`flex-none mx-3 ${activeClassAddon}`} size="18" />
|
||||
<BaseIcon path={item.icon} className={`mt-0.5 flex-none ${activeClassAddon}`} size="18" />
|
||||
)}
|
||||
<span
|
||||
className={`grow text-ellipsis line-clamp-1 ${
|
||||
item.menu ? '' : 'pr-12'
|
||||
} ${activeClassAddon}`}
|
||||
className={`grow break-words text-sm leading-5 ${item.menu ? '' : 'pr-3'} ${activeClassAddon}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.menu && (
|
||||
<BaseIcon
|
||||
path={isDropdownActive ? mdiMinus : mdiPlus}
|
||||
className={`flex-none ${activeClassAddon}`}
|
||||
w="w-12"
|
||||
className={`mt-0.5 flex-none ${activeClassAddon}`}
|
||||
size="18"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const componentClass = [
|
||||
'flex cursor-pointer py-1.5 ',
|
||||
isDropdownList ? 'px-6 text-sm' : '',
|
||||
item.color
|
||||
? getButtonColor(item.color, false, true)
|
||||
: `${asideMenuItemStyle}`,
|
||||
isLinkActive
|
||||
? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800`
|
||||
: '',
|
||||
].join(' ');
|
||||
'flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3 transition-colors',
|
||||
isDropdownList ? 'ml-2 text-sm' : '',
|
||||
item.color ? getButtonColor(item.color, false, true) : `${asideMenuItemStyle}`,
|
||||
isLinkActive ? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800` : '',
|
||||
].join(' ')
|
||||
|
||||
return (
|
||||
<li className={'px-3 py-1.5'}>
|
||||
<li className={'px-2 py-1'}>
|
||||
{item.withDevider && <hr className={`${borders} mb-3`} />}
|
||||
{item.href && (
|
||||
<Link href={item.href} target={item.target} className={componentClass}>
|
||||
@ -89,8 +81,8 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
|
||||
{item.menu && (
|
||||
<AsideMenuList
|
||||
menu={item.menu}
|
||||
className={`${asideMenuDropdownStyle} ${
|
||||
isDropdownActive ? 'block dark:bg-slate-800/50' : 'hidden'
|
||||
className={`${asideMenuDropdownStyle} mt-2 rounded-xl border border-white/5 p-1 ${
|
||||
isDropdownActive ? 'block dark:bg-slate-800/40' : 'hidden'
|
||||
}`}
|
||||
isDropdownList
|
||||
/>
|
||||
|
||||
@ -1,88 +1,122 @@
|
||||
import React from 'react'
|
||||
import { mdiLogout, mdiClose } from '@mdi/js'
|
||||
import BaseIcon from './BaseIcon'
|
||||
import AsideMenuList from './AsideMenuList'
|
||||
import { MenuAsideItem } from '../interfaces'
|
||||
import { useAppSelector } from '../stores/hooks'
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useAppDispatch } from '../stores/hooks';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { mdiClose, mdiMinus, mdiPlus } from '@mdi/js';
|
||||
import BaseIcon from './BaseIcon';
|
||||
import AsideMenuList from './AsideMenuList';
|
||||
import { MenuAsideItem } from '../interfaces';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
|
||||
const ASIDE_WIDTH_STORAGE_KEY = 'aside-width';
|
||||
const DEFAULT_ASIDE_WIDTH = 320;
|
||||
const MIN_ASIDE_WIDTH = 288;
|
||||
const MAX_ASIDE_WIDTH = 400;
|
||||
const ASIDE_WIDTH_STEP = 24;
|
||||
|
||||
type Props = {
|
||||
menu: MenuAsideItem[]
|
||||
className?: string
|
||||
onAsideLgCloseClick: () => void
|
||||
}
|
||||
menu: MenuAsideItem[];
|
||||
className?: string;
|
||||
onAsideLgCloseClick: () => void;
|
||||
};
|
||||
|
||||
export default function AsideMenuLayer({ menu, className = '', ...props }: Props) {
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const asideStyle = useAppSelector((state) => state.style.asideStyle)
|
||||
const asideBrandStyle = useAppSelector((state) => state.style.asideBrandStyle)
|
||||
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||
const asideStyle = useAppSelector((state) => state.style.asideStyle);
|
||||
const asideBrandStyle = useAppSelector((state) => state.style.asideBrandStyle);
|
||||
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle);
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [asideWidth, setAsideWidth] = useState(DEFAULT_ASIDE_WIDTH);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const storedValue = Number(window.localStorage.getItem(ASIDE_WIDTH_STORAGE_KEY));
|
||||
if (!Number.isNaN(storedValue) && storedValue >= MIN_ASIDE_WIDTH && storedValue <= MAX_ASIDE_WIDTH) {
|
||||
setAsideWidth(storedValue);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
document.documentElement.style.setProperty('--aside-width', `${asideWidth}px`);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(ASIDE_WIDTH_STORAGE_KEY, String(asideWidth));
|
||||
}
|
||||
}, [asideWidth]);
|
||||
|
||||
const handleAsideLgCloseClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
props.onAsideLgCloseClick()
|
||||
}
|
||||
e.preventDefault();
|
||||
props.onAsideLgCloseClick();
|
||||
};
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const organizationsId = currentUser?.organizations?.id;
|
||||
const [organizations, setOrganizations] = React.useState(null);
|
||||
const resizeAside = (direction: 'narrower' | 'wider') => {
|
||||
setAsideWidth((currentWidth) => {
|
||||
const nextWidth =
|
||||
direction === 'wider' ? currentWidth + ASIDE_WIDTH_STEP : currentWidth - ASIDE_WIDTH_STEP;
|
||||
|
||||
const fetchOrganizations = createAsyncThunk('/org-for-auth', async () => {
|
||||
try {
|
||||
const response = await axios.get('/org-for-auth');
|
||||
setOrganizations(response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error(error.response);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(fetchOrganizations());
|
||||
}, [dispatch]);
|
||||
|
||||
let organizationName = organizations?.find(item => item.id === organizationsId)?.name;
|
||||
if(organizationName?.length > 25){
|
||||
organizationName = organizationName?.substring(0, 25) + '...';
|
||||
}
|
||||
return Math.min(MAX_ASIDE_WIDTH, Math.max(MIN_ASIDE_WIDTH, nextWidth));
|
||||
});
|
||||
};
|
||||
|
||||
const organizationName =
|
||||
currentUser?.organizations?.name || currentUser?.organization?.name || 'Corporate workspace';
|
||||
|
||||
return (
|
||||
<aside
|
||||
id='asideMenu'
|
||||
className={`${className} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
|
||||
id="asideMenu"
|
||||
className={`${className} fixed top-0 z-40 flex h-screen w-56 overflow-hidden transition-position lg:py-3 lg:pl-3 xl:w-[var(--aside-width)]`}
|
||||
>
|
||||
<div
|
||||
className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`}
|
||||
className={`flex flex-1 flex-col overflow-hidden border border-white/10 shadow-2xl dark:bg-dark-900 ${asideStyle} ${corners}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
|
||||
>
|
||||
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
||||
|
||||
<b className="font-black">Gracey Corporate Stay Portal</b>
|
||||
|
||||
|
||||
{organizationName && <p>{organizationName}</p>}
|
||||
|
||||
<div className={`flex min-h-20 items-start justify-between gap-3 px-4 py-4 ${asideBrandStyle}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-cyan-200/80">
|
||||
Gracey
|
||||
</div>
|
||||
<div className="mt-1 break-words text-base font-semibold leading-6 text-white">
|
||||
Corporate Stay Portal
|
||||
</div>
|
||||
<p className="mt-1 break-words text-sm leading-5 text-slate-300">{organizationName}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="hidden items-center gap-1 xl:flex">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-slate-200 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => resizeAside('narrower')}
|
||||
disabled={asideWidth <= MIN_ASIDE_WIDTH}
|
||||
aria-label="Make sidebar narrower"
|
||||
title="Make sidebar narrower"
|
||||
>
|
||||
<BaseIcon path={mdiMinus} size="16" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-slate-200 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => resizeAside('wider')}
|
||||
disabled={asideWidth >= MAX_ASIDE_WIDTH}
|
||||
aria-label="Make sidebar wider"
|
||||
title="Make sidebar wider"
|
||||
>
|
||||
<BaseIcon path={mdiPlus} size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="hidden p-2 text-slate-300 transition hover:text-white lg:inline-block xl:hidden"
|
||||
onClick={handleAsideLgCloseClick}
|
||||
type="button"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<BaseIcon path={mdiClose} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="hidden lg:inline-block xl:hidden p-3"
|
||||
onClick={handleAsideLgCloseClick}
|
||||
>
|
||||
<BaseIcon path={mdiClose} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex-1 overflow-y-auto overflow-x-hidden ${
|
||||
className={`flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 ${
|
||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
||||
}`}
|
||||
>
|
||||
@ -90,5 +124,5 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,175 +1,244 @@
|
||||
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
Views,
|
||||
momentLocalizer,
|
||||
SlotInfo,
|
||||
EventProps,
|
||||
Calendar,
|
||||
EventProps,
|
||||
SlotInfo,
|
||||
Views,
|
||||
momentLocalizer,
|
||||
} from 'react-big-calendar';
|
||||
import moment from 'moment';
|
||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||||
import ListActionsPopover from './ListActionsPopover';
|
||||
import Link from 'next/link';
|
||||
|
||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||||
import BaseButton from './BaseButton';
|
||||
import ListActionsPopover from './ListActionsPopover';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
import CardBoxComponentEmpty from './CardBoxComponentEmpty';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import { hasPermission } from '../helpers/userPermissions';
|
||||
|
||||
|
||||
const localizer = momentLocalizer(moment);
|
||||
|
||||
type TEvent = {
|
||||
id: string;
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
id: string;
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
events: any[];
|
||||
handleDeleteAction: (id: string) => void;
|
||||
handleCreateEventAction: (slotInfo: SlotInfo) => void;
|
||||
onDateRangeChange: (range: { start: string; end: string }) => void;
|
||||
entityName: string;
|
||||
showField: string;
|
||||
pathEdit?: string;
|
||||
pathView?: string;
|
||||
'start-data-key': string;
|
||||
'end-data-key': string;
|
||||
events: any[];
|
||||
handleDeleteAction: (id: string) => void;
|
||||
handleCreateEventAction: (slotInfo: SlotInfo) => void;
|
||||
onDateRangeChange: (range: { start: string; end: string }) => void;
|
||||
entityName: string;
|
||||
showField: string;
|
||||
pathEdit?: string;
|
||||
pathView?: string;
|
||||
isLoading?: boolean;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
'start-data-key': string;
|
||||
'end-data-key': string;
|
||||
};
|
||||
|
||||
const formatEventWindow = (event: TEvent) => {
|
||||
const sameDay = moment(event.start).isSame(event.end, 'day');
|
||||
if (sameDay) {
|
||||
return `${moment(event.start).format('MMM D')} · ${moment(event.start).format('h:mm A')} - ${moment(event.end).format('h:mm A')}`;
|
||||
}
|
||||
|
||||
return `${moment(event.start).format('MMM D')} - ${moment(event.end).format('MMM D')}`;
|
||||
};
|
||||
|
||||
const CalendarToolbar = ({ label, onNavigate }: any) => {
|
||||
return (
|
||||
<div className='mb-4 flex flex-col gap-3 border-b border-gray-200 pb-4 md:flex-row md:items-center md:justify-between dark:border-dark-700'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500'>
|
||||
Schedule
|
||||
</p>
|
||||
<p className='mt-1 text-lg font-semibold text-gray-900 dark:text-white'>{label}</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<BaseButton small outline color='info' label='Prev' onClick={() => onNavigate('PREV')} />
|
||||
<BaseButton small outline color='info' label='Today' onClick={() => onNavigate('TODAY')} />
|
||||
<BaseButton small outline color='info' label='Next' onClick={() => onNavigate('NEXT')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BigCalendar = ({
|
||||
events,
|
||||
handleDeleteAction,
|
||||
handleCreateEventAction,
|
||||
onDateRangeChange,
|
||||
entityName,
|
||||
showField,
|
||||
pathEdit,
|
||||
pathView,
|
||||
'start-data-key': startDataKey,
|
||||
'end-data-key': endDataKey,
|
||||
}: Props) => {
|
||||
const [myEvents, setMyEvents] = useState<TEvent[]>([]);
|
||||
const prevRange = useRef<{ start: string; end: string } | null>(null);
|
||||
events,
|
||||
handleDeleteAction,
|
||||
handleCreateEventAction,
|
||||
onDateRangeChange,
|
||||
entityName,
|
||||
showField,
|
||||
pathEdit,
|
||||
pathView,
|
||||
isLoading = false,
|
||||
emptyTitle = 'Nothing is scheduled in this window',
|
||||
emptyDescription = 'Adjust the date range or create a new item to start filling this calendar.',
|
||||
'start-data-key': startDataKey,
|
||||
'end-data-key': endDataKey,
|
||||
}: Props) => {
|
||||
const [myEvents, setMyEvents] = useState<TEvent[]>([]);
|
||||
const prevRange = useRef<{ start: string; end: string } | null>(null);
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission =
|
||||
currentUser && hasPermission(currentUser, `UPDATE_${entityName.toUpperCase()}`);
|
||||
const hasCreatePermission =
|
||||
currentUser && hasPermission(currentUser, `CREATE_${entityName.toUpperCase()}`);
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission =
|
||||
currentUser &&
|
||||
hasPermission(currentUser, `UPDATE_${entityName.toUpperCase()}`);
|
||||
const hasCreatePermission =
|
||||
currentUser &&
|
||||
hasPermission(currentUser, `CREATE_${entityName.toUpperCase()}`);
|
||||
const { defaultDate, scrollToTime } = useMemo(
|
||||
() => ({
|
||||
defaultDate: new Date(),
|
||||
scrollToTime: new Date(1970, 1, 1, 8),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Array.isArray(events) || !events.length) {
|
||||
setMyEvents([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const { defaultDate, scrollToTime } = useMemo(
|
||||
() => ({
|
||||
defaultDate: new Date(),
|
||||
scrollToTime: new Date(1970, 1, 1, 6),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const formattedEvents = events.map((event) => ({
|
||||
...event,
|
||||
start: new Date(event[startDataKey]),
|
||||
end: new Date(event[endDataKey]),
|
||||
title: event[showField],
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!events || !Array.isArray(events) || !events?.length) return;
|
||||
setMyEvents(formattedEvents);
|
||||
}, [endDataKey, events, showField, startDataKey]);
|
||||
|
||||
const formattedEvents = events.map((event) => ({
|
||||
...event,
|
||||
start: new Date(event[startDataKey]),
|
||||
end: new Date(event[endDataKey]),
|
||||
title: event[showField],
|
||||
}));
|
||||
const onRangeChange = (range: Date[] | { start: Date; end: Date }) => {
|
||||
const newRange = { start: '', end: '' };
|
||||
const format = 'YYYY-MM-DDTHH:mm';
|
||||
|
||||
setMyEvents(formattedEvents);
|
||||
}, [endDataKey, events, startDataKey, showField]);
|
||||
if (Array.isArray(range)) {
|
||||
newRange.start = moment(range[0]).format(format);
|
||||
newRange.end = moment(range[range.length - 1]).format(format);
|
||||
} else {
|
||||
newRange.start = moment(range.start).format(format);
|
||||
newRange.end = moment(range.end).format(format);
|
||||
}
|
||||
|
||||
const onRangeChange = (
|
||||
range: Date[] | { start: Date; end: Date },
|
||||
) => {
|
||||
const newRange = { start: '', end: '' };
|
||||
const format = 'YYYY-MM-DDTHH:mm';
|
||||
if (newRange.start === newRange.end) {
|
||||
newRange.end = moment(newRange.end).add(1, 'days').format(format);
|
||||
}
|
||||
|
||||
if (Array.isArray(range)) {
|
||||
newRange.start = moment(range[0]).format(format);
|
||||
newRange.end = moment(range[range.length - 1]).format(format);
|
||||
} else {
|
||||
newRange.start = moment(range.start).format(format);
|
||||
newRange.end = moment(range.end).format(format);
|
||||
}
|
||||
if (
|
||||
prevRange.current &&
|
||||
prevRange.current.start <= newRange.start &&
|
||||
prevRange.current.end >= newRange.end
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newRange.start === newRange.end) {
|
||||
newRange.end = moment(newRange.end).add(1, 'days').format(format);
|
||||
}
|
||||
prevRange.current = { start: newRange.start, end: newRange.end };
|
||||
onDateRangeChange(newRange);
|
||||
};
|
||||
|
||||
// check if the range fits in the previous range
|
||||
if (
|
||||
prevRange.current &&
|
||||
prevRange.current.start <= newRange.start &&
|
||||
prevRange.current.end >= newRange.end
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<div className='rounded-2xl border border-gray-200/80 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-800/80'>
|
||||
{isLoading ? (
|
||||
<LoadingSpinner
|
||||
compact
|
||||
label='Loading calendar view'
|
||||
detail='Syncing the current date range and live reservations.'
|
||||
/>
|
||||
) : null}
|
||||
|
||||
prevRange.current = { start: newRange.start, end: newRange.end };
|
||||
onDateRangeChange(newRange);
|
||||
};
|
||||
{!isLoading && myEvents.length === 0 ? (
|
||||
<CardBoxComponentEmpty
|
||||
compact
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
return (
|
||||
<div className='h-[600px] p-4'>
|
||||
<Calendar
|
||||
defaultDate={defaultDate}
|
||||
defaultView={Views.MONTH}
|
||||
events={myEvents}
|
||||
localizer={localizer}
|
||||
selectable={hasCreatePermission}
|
||||
onSelectSlot={handleCreateEventAction}
|
||||
onRangeChange={onRangeChange}
|
||||
scrollToTime={scrollToTime}
|
||||
components={{
|
||||
event: (props) => (
|
||||
<MyCustomEvent
|
||||
{...props}
|
||||
onDelete={handleDeleteAction}
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
pathEdit ={pathEdit}
|
||||
pathView={pathView}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
<div className={isLoading ? 'pointer-events-none opacity-40' : ''}>
|
||||
<Calendar
|
||||
className='min-h-[680px]'
|
||||
defaultDate={defaultDate}
|
||||
defaultView={Views.MONTH}
|
||||
events={myEvents}
|
||||
localizer={localizer}
|
||||
selectable={hasCreatePermission}
|
||||
onSelectSlot={handleCreateEventAction}
|
||||
onRangeChange={onRangeChange}
|
||||
scrollToTime={scrollToTime}
|
||||
dayPropGetter={(date) => ({
|
||||
style: {
|
||||
backgroundColor: [0, 6].includes(date.getDay()) ? 'rgba(148, 163, 184, 0.06)' : 'transparent',
|
||||
},
|
||||
})}
|
||||
eventPropGetter={() => ({
|
||||
style: {
|
||||
backgroundColor: '#eff6ff',
|
||||
border: '1px solid #bfdbfe',
|
||||
borderRadius: '14px',
|
||||
color: '#1e3a8a',
|
||||
padding: '2px 4px',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
})}
|
||||
components={{
|
||||
toolbar: (toolbarProps: any) => <CalendarToolbar {...toolbarProps} />,
|
||||
event: (props) => (
|
||||
<MyCustomEvent
|
||||
{...props}
|
||||
onDelete={handleDeleteAction}
|
||||
hasUpdatePermission={!!hasUpdatePermission}
|
||||
pathEdit={pathEdit}
|
||||
pathView={pathView}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MyCustomEvent = (
|
||||
props: {
|
||||
onDelete: (id: string) => void;
|
||||
hasUpdatePermission: boolean;
|
||||
pathEdit?: string;
|
||||
pathView?: string;
|
||||
} & EventProps<TEvent>,
|
||||
props: {
|
||||
onDelete: (id: string) => void;
|
||||
hasUpdatePermission: boolean;
|
||||
pathEdit?: string;
|
||||
pathView?: string;
|
||||
} & EventProps<TEvent>,
|
||||
) => {
|
||||
const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } = props;
|
||||
const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } = props;
|
||||
|
||||
return (
|
||||
<div className={'flex items-center justify-between relative'}>
|
||||
<Link
|
||||
href={`${pathView}${event.id}`}
|
||||
className={'text-ellipsis overflow-hidden grow'}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
<ListActionsPopover
|
||||
className={'w-2 h-2 text-white'}
|
||||
iconClassName={'text-white w-5'}
|
||||
itemId={event.id}
|
||||
onDelete={onDelete}
|
||||
pathEdit={`${pathEdit}${event.id}`}
|
||||
pathView={`${pathView}${event.id}`}
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className='flex items-start justify-between gap-2 overflow-hidden rounded-xl'>
|
||||
<div className='min-w-0'>
|
||||
<Link
|
||||
href={`${pathView}${event.id}`}
|
||||
className='block truncate text-xs font-semibold text-blue-900'
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
<p className='truncate text-[11px] text-blue-700/80'>{formatEventWindow(event)}</p>
|
||||
</div>
|
||||
<ListActionsPopover
|
||||
className='h-5 w-5 text-blue-700'
|
||||
iconClassName='w-4 text-blue-700'
|
||||
itemId={event.id}
|
||||
onDelete={onDelete}
|
||||
pathEdit={`${pathEdit}${event.id}`}
|
||||
pathView={`${pathView}${event.id}`}
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BigCalendar;
|
||||
|
||||
@ -22,7 +22,23 @@ import KanbanBoard from '../KanbanBoard/KanbanBoard';
|
||||
import axios from 'axios';
|
||||
|
||||
|
||||
const perPage = 10
|
||||
const perPage = 10
|
||||
|
||||
const compactColumnVisibilityModel = {
|
||||
tenant: false,
|
||||
organization: false,
|
||||
preferred_unit_type: false,
|
||||
preferred_bedrooms: false,
|
||||
purpose_of_stay: false,
|
||||
special_requirements: false,
|
||||
budget_code: false,
|
||||
currency: false,
|
||||
travelers: false,
|
||||
approval_steps: false,
|
||||
documents: false,
|
||||
comments: false,
|
||||
}
|
||||
|
||||
|
||||
const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, showGrid }) => {
|
||||
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
||||
@ -97,25 +113,15 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
|
||||
|
||||
setKanbanColumns([
|
||||
|
||||
{ id: "draft", label: "draft" },
|
||||
|
||||
{ id: "submitted", label: "submitted" },
|
||||
|
||||
{ id: "in_review", label: "in_review" },
|
||||
|
||||
{ id: "changes_requested", label: "changes_requested" },
|
||||
|
||||
{ id: "approved", label: "approved" },
|
||||
|
||||
{ id: "rejected", label: "rejected" },
|
||||
|
||||
{ id: "expired", label: "expired" },
|
||||
|
||||
{ id: "converted_to_reservation", label: "converted_to_reservation" },
|
||||
|
||||
{ id: "canceled", label: "canceled" },
|
||||
|
||||
{ id: "draft", label: _.startCase("draft") },
|
||||
{ id: "submitted", label: _.startCase("submitted") },
|
||||
{ id: "in_review", label: _.startCase("in_review") },
|
||||
{ id: "changes_requested", label: _.startCase("changes_requested") },
|
||||
{ id: "approved", label: _.startCase("approved") },
|
||||
{ id: "rejected", label: _.startCase("rejected") },
|
||||
{ id: "expired", label: _.startCase("expired") },
|
||||
{ id: "converted_to_reservation", label: _.startCase("converted_to_reservation") },
|
||||
{ id: "canceled", label: _.startCase("canceled") },
|
||||
]);
|
||||
|
||||
|
||||
@ -136,6 +142,53 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
}
|
||||
};
|
||||
|
||||
const bookingKanbanOverview = useMemo(() => {
|
||||
const totals = booking_requests.reduce(
|
||||
(accumulator, item) => {
|
||||
const guestCount = Number(item?.guest_count || 0);
|
||||
const status = item?.status || 'draft';
|
||||
|
||||
accumulator.total += 1;
|
||||
accumulator.guests += Number.isNaN(guestCount) ? 0 : guestCount;
|
||||
accumulator[status] = (accumulator[status] || 0) + 1;
|
||||
|
||||
return accumulator;
|
||||
},
|
||||
{
|
||||
total: 0,
|
||||
guests: 0,
|
||||
submitted: 0,
|
||||
in_review: 0,
|
||||
changes_requested: 0,
|
||||
approved: 0,
|
||||
converted_to_reservation: 0,
|
||||
},
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Visible requests',
|
||||
value: totals.total,
|
||||
hint: 'Currently in view',
|
||||
},
|
||||
{
|
||||
label: 'Needs review',
|
||||
value: totals.submitted + totals.in_review + totals.changes_requested,
|
||||
hint: 'Submitted or in progress',
|
||||
},
|
||||
{
|
||||
label: 'Approved',
|
||||
value: totals.approved,
|
||||
hint: 'Ready to place',
|
||||
},
|
||||
{
|
||||
label: 'Guests',
|
||||
value: totals.guests,
|
||||
hint: 'Across visible requests',
|
||||
},
|
||||
];
|
||||
}, [booking_requests]);
|
||||
|
||||
const generateFilterRequests = useMemo(() => {
|
||||
let request = '&';
|
||||
filterItems.forEach((item) => {
|
||||
@ -243,16 +296,16 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
};
|
||||
|
||||
const controlClasses =
|
||||
'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' +
|
||||
` ${bgColor} ${focusRing} ${corners} ` +
|
||||
'dark:bg-slate-800 border';
|
||||
'w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-400 ' +
|
||||
' ' + bgColor + ' ' + focusRing + ' ' + corners + ' ' +
|
||||
'dark:bg-slate-800/80 my-1';
|
||||
|
||||
|
||||
const dataGrid = (
|
||||
<div className='relative overflow-x-auto'>
|
||||
<DataGrid
|
||||
autoHeight
|
||||
rowHeight={64}
|
||||
rowHeight={56}
|
||||
sx={dataGridStyles}
|
||||
className={'datagrid--table'}
|
||||
getRowClassName={() => `datagrid--row`}
|
||||
@ -264,6 +317,9 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
pageSize: 10,
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
columnVisibilityModel: compactColumnVisibilityModel,
|
||||
},
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
onProcessRowUpdateError={(params) => {
|
||||
@ -303,7 +359,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
return (
|
||||
<>
|
||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
||||
<CardBox>
|
||||
<CardBox className='mb-6 border border-white/10 shadow-none'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
checkboxes: ['lorem'],
|
||||
@ -316,9 +372,9 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
<>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
return (
|
||||
<div key={filterItem.id} className="flex mb-4">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Filter</div>
|
||||
<div key={filterItem.id} className="mb-3 grid gap-3 rounded-2xl border border-white/10 bg-white/5 p-4 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_auto]">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Filter</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='selectedField'
|
||||
@ -340,8 +396,8 @@ const TableSampleBooking_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="text-gray-500 font-bold">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Value
|
||||
</div>
|
||||
<Field
|
||||
@ -365,9 +421,9 @@ const TableSampleBooking_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=" text-gray-500 font-bold">From</div>
|
||||
<div className="grid min-w-0 gap-3 md:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">From</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueFrom'
|
||||
@ -378,7 +434,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className=" text-gray-500 font-bold">To</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -394,9 +450,9 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
filter.title ===
|
||||
filterItem?.fields?.selectedField
|
||||
)?.date ? (
|
||||
<div className='flex flex-row w-full mr-3'>
|
||||
<div className='grid min-w-0 gap-3 md:grid-cols-2'>
|
||||
<div className='flex flex-col w-full mr-3'>
|
||||
<div className=' text-gray-500 font-bold'>
|
||||
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>
|
||||
From
|
||||
</div>
|
||||
<Field
|
||||
@ -410,7 +466,7 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className=' text-gray-500 font-bold'>To</div>
|
||||
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -423,8 +479,8 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Contains</div>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Contains</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValue'
|
||||
@ -436,11 +492,12 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className=" text-gray-500 font-bold">Action</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Action</div>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
className="my-1"
|
||||
type='reset'
|
||||
color='danger'
|
||||
color='whiteDark'
|
||||
outline
|
||||
label='Delete'
|
||||
onClick={() => {
|
||||
deleteFilter(filterItem.id)
|
||||
@ -450,16 +507,16 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<BaseButton
|
||||
className="my-2 mr-3"
|
||||
className="my-1 mr-0"
|
||||
type='submit' color='info'
|
||||
label='Apply'
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
type='reset' color='info' outline
|
||||
className="my-1"
|
||||
type='reset' color='whiteDark' outline
|
||||
label='Cancel'
|
||||
onClick={handleReset}
|
||||
/>
|
||||
@ -483,15 +540,32 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
|
||||
|
||||
|
||||
{!showGrid && kanbanColumns && (
|
||||
<KanbanBoard
|
||||
columnFieldName={'status'}
|
||||
showFieldName={'request_code'}
|
||||
entityName={'booking_requests'}
|
||||
filtersQuery={kanbanFilters}
|
||||
deleteThunk={deleteItem}
|
||||
updateThunk={update}
|
||||
columns={kanbanColumns}
|
||||
/>
|
||||
<>
|
||||
<div className='mb-6 grid gap-3 md:grid-cols-4'>
|
||||
{bookingKanbanOverview.map((item) => (
|
||||
<CardBox key={item.label} className='rounded-2xl border border-gray-200/80 shadow-none dark:border-dark-700'>
|
||||
<div className='p-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500'>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className='mt-2 text-2xl font-semibold text-gray-900 dark:text-white'>
|
||||
{item.value}
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>{item.hint}</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
))}
|
||||
</div>
|
||||
<KanbanBoard
|
||||
columnFieldName={'status'}
|
||||
showFieldName={'request_code'}
|
||||
entityName={'booking_requests'}
|
||||
filtersQuery={kanbanFilters}
|
||||
deleteThunk={deleteItem}
|
||||
updateThunk={update}
|
||||
columns={kanbanColumns}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
@ -1,9 +1,27 @@
|
||||
import React from 'react'
|
||||
|
||||
const CardBoxComponentEmpty = () => {
|
||||
type Props = {
|
||||
title?: string
|
||||
description?: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
const CardBoxComponentEmpty = ({
|
||||
title = 'Nothing to review yet',
|
||||
description = 'When records appear here, this view will turn into a live operating workspace.',
|
||||
compact = false,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="text-center py-24 text-gray-500 dark:text-slate-400">
|
||||
<p>Nothing's here…</p>
|
||||
<div
|
||||
className={`flex items-center justify-center px-4 text-center ${compact ? 'py-10' : 'py-24'}`}
|
||||
>
|
||||
<div className="max-w-md">
|
||||
<div className="mx-auto inline-flex rounded-full border border-dashed border-gray-300 bg-gray-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-gray-500 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-400">
|
||||
Empty state
|
||||
</div>
|
||||
<p className="mt-4 text-base font-semibold text-gray-900 dark:text-white">{title}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-gray-500 dark:text-slate-400">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import KanbanColumn from './KanbanColumn';
|
||||
import { AsyncThunk } from '@reduxjs/toolkit';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import KanbanColumn from './KanbanColumn';
|
||||
|
||||
type Props = {
|
||||
columns: Array<{id: string, label: string}>;
|
||||
columns: Array<{ id: string; label: string }>;
|
||||
filtersQuery: string;
|
||||
entityName: string;
|
||||
columnFieldName: string;
|
||||
@ -24,27 +24,24 @@ const KanbanBoard = ({
|
||||
updateThunk,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'pb-2 flex-grow min-h-[400px] flex-1 grid grid-rows-1 auto-cols-min grid-flow-col gap-x-3 overflow-y-hidden overflow-x-auto'
|
||||
}
|
||||
>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
{columns.map((column) => (
|
||||
<div key={column.id}>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className='rounded-2xl border border-gray-200/80 bg-white/70 p-2 shadow-sm dark:border-dark-700 dark:bg-dark-800/70'>
|
||||
<div className='flex min-h-[420px] flex-1 gap-4 overflow-x-auto overflow-y-hidden pb-2'>
|
||||
{columns.map((column) => (
|
||||
<KanbanColumn
|
||||
entityName={entityName}
|
||||
columnFieldName={columnFieldName}
|
||||
showFieldName={showFieldName}
|
||||
column={column}
|
||||
filtersQuery={filtersQuery}
|
||||
deleteThunk={deleteThunk}
|
||||
updateThunk={updateThunk}
|
||||
key={column.id}
|
||||
entityName={entityName}
|
||||
columnFieldName={columnFieldName}
|
||||
showFieldName={showFieldName}
|
||||
column={column}
|
||||
filtersQuery={filtersQuery}
|
||||
deleteThunk={deleteThunk}
|
||||
updateThunk={updateThunk}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</DndProvider>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DndProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,64 +1,133 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import moment from 'moment';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import { DragSourceMonitor, useDrag } from 'react-dnd';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
|
||||
type Props = {
|
||||
item: any;
|
||||
column: { id: string; label: string };
|
||||
entityName: string;
|
||||
showFieldName: string;
|
||||
setItemIdToDelete: (id: string) => void;
|
||||
item: any;
|
||||
column: { id: string; label: string };
|
||||
entityName: string;
|
||||
showFieldName: string;
|
||||
setItemIdToDelete: (id: string) => void;
|
||||
};
|
||||
|
||||
const humanize = (value?: string | null) => {
|
||||
if (!value) return '';
|
||||
return value
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
};
|
||||
|
||||
const formatDateRange = (start?: string, end?: string) => {
|
||||
if (!start && !end) return '';
|
||||
if (start && end) {
|
||||
return `${moment(start).format('MMM D')} – ${moment(end).format('MMM D')}`;
|
||||
}
|
||||
|
||||
return moment(start || end).format('MMM D');
|
||||
};
|
||||
|
||||
const KanbanCard = ({
|
||||
item,
|
||||
entityName,
|
||||
showFieldName,
|
||||
setItemIdToDelete,
|
||||
column,
|
||||
}: Props) => {
|
||||
const [{ isDragging }, drag] = useDrag(
|
||||
() => ({
|
||||
type: 'box',
|
||||
item: { item, column },
|
||||
collect: (monitor: DragSourceMonitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
}),
|
||||
[item],
|
||||
);
|
||||
item,
|
||||
entityName,
|
||||
showFieldName,
|
||||
setItemIdToDelete,
|
||||
column,
|
||||
}: Props) => {
|
||||
const [{ isDragging }, drag] = useDrag(
|
||||
() => ({
|
||||
type: 'box',
|
||||
item: { item, column },
|
||||
collect: (monitor: DragSourceMonitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
}),
|
||||
[item, column],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drag}
|
||||
className={
|
||||
`bg-midnightBlueTheme-cardColor dark:bg-dark-800 rounded-md space-y-2 p-4 relative ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`
|
||||
}
|
||||
>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<Link
|
||||
href={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
||||
className={'text-base font-semibold'}
|
||||
>
|
||||
{item[showFieldName] ?? 'No data'}
|
||||
</Link>
|
||||
</div>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<p>{moment(item.createdAt).format('MMM DD hh:mm a')}</p>
|
||||
<ListActionsPopover
|
||||
itemId={item.id}
|
||||
pathEdit={`/${entityName}/${entityName}-edit/?id=${item.id}`}
|
||||
pathView={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
||||
onDelete={(id) => setItemIdToDelete(id)}
|
||||
hasUpdatePermission={true}
|
||||
className={'w-2 h-2 text-white'}
|
||||
iconClassName={'w-5'}
|
||||
/>
|
||||
</div>
|
||||
const title = item?.[showFieldName] ?? 'Untitled';
|
||||
const dateRange = formatDateRange(item?.check_in_at || item?.start_at, item?.check_out_at || item?.end_at);
|
||||
const locationLabel =
|
||||
item?.preferred_property?.name ||
|
||||
item?.property?.name ||
|
||||
item?.unit?.unit_number ||
|
||||
item?.unit_type?.name ||
|
||||
'';
|
||||
const supportingLabel =
|
||||
item?.requested_by?.email ||
|
||||
item?.requested_by?.firstName ||
|
||||
item?.organization?.name ||
|
||||
item?.organizations?.name ||
|
||||
'';
|
||||
const updatedAt = item?.updatedAt || item?.createdAt;
|
||||
const stats = [
|
||||
item?.guest_count
|
||||
? { label: 'Guests', value: String(item.guest_count) }
|
||||
: null,
|
||||
item?.priority
|
||||
? { label: 'Priority', value: humanize(item.priority) }
|
||||
: null,
|
||||
dateRange
|
||||
? { label: entityName === 'booking_requests' ? 'Stay' : 'Dates', value: dateRange }
|
||||
: null,
|
||||
locationLabel ? { label: 'Location', value: locationLabel } : null,
|
||||
].filter(Boolean) as Array<{ label: string; value: string }>;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drag}
|
||||
className={`rounded-2xl border border-gray-200/80 bg-white p-4 shadow-sm transition ${
|
||||
isDragging ? 'cursor-grabbing opacity-70' : 'cursor-grab hover:-translate-y-0.5'
|
||||
} dark:border-dark-700 dark:bg-dark-900`}
|
||||
>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<Link
|
||||
href={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
||||
className='block truncate text-sm font-semibold text-gray-900 dark:text-white'
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
{supportingLabel && (
|
||||
<p className='mt-1 truncate text-xs text-gray-500 dark:text-gray-400'>
|
||||
{supportingLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
<ListActionsPopover
|
||||
itemId={item.id}
|
||||
pathEdit={`/${entityName}/${entityName}-edit/?id=${item.id}`}
|
||||
pathView={`/${entityName}/${entityName}-view/?id=${item.id}`}
|
||||
onDelete={(id) => setItemIdToDelete(id)}
|
||||
hasUpdatePermission={true}
|
||||
className='h-5 w-5 text-gray-400 dark:text-gray-500'
|
||||
iconClassName='w-5 text-gray-400 dark:text-gray-500'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stats.length > 0 && (
|
||||
<div className='mt-3 grid gap-2'>
|
||||
{stats.slice(0, 3).map((stat) => (
|
||||
<div
|
||||
key={`${item.id}-${stat.label}`}
|
||||
className='flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2 text-xs dark:bg-dark-800/80'
|
||||
>
|
||||
<span className='text-gray-500 dark:text-gray-400'>{stat.label}</span>
|
||||
<span className='ml-3 truncate font-medium text-gray-700 dark:text-gray-200'>
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-4 flex items-center justify-between text-[11px] uppercase tracking-wide text-gray-400 dark:text-gray-500'>
|
||||
<span>{humanize(column.label)}</span>
|
||||
<span>{updatedAt ? moment(updatedAt).format('MMM D') : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KanbanCard;
|
||||
|
||||
@ -1,209 +1,204 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import Axios from 'axios';
|
||||
import CardBox from '../CardBox';
|
||||
import CardBoxModal from '../CardBoxModal';
|
||||
import { AsyncThunk } from '@reduxjs/toolkit';
|
||||
import { useDrop } from 'react-dnd';
|
||||
import CardBox from '../CardBox';
|
||||
import CardBoxModal from '../CardBoxModal';
|
||||
import LoadingSpinner from '../LoadingSpinner';
|
||||
import CardBoxComponentEmpty from '../CardBoxComponentEmpty';
|
||||
import KanbanCard from './KanbanCard';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
|
||||
type Props = {
|
||||
column: { id: string; label: string };
|
||||
entityName: string;
|
||||
columnFieldName: string;
|
||||
showFieldName: string;
|
||||
filtersQuery: any;
|
||||
deleteThunk: AsyncThunk<any, any, any>;
|
||||
updateThunk: AsyncThunk<any, any, any>;
|
||||
column: { id: string; label: string };
|
||||
entityName: string;
|
||||
columnFieldName: string;
|
||||
showFieldName: string;
|
||||
filtersQuery: string;
|
||||
deleteThunk: AsyncThunk<any, any, any>;
|
||||
updateThunk: AsyncThunk<any, any, any>;
|
||||
};
|
||||
|
||||
type DropResult = {
|
||||
sourceColumn: { id: string; label: string };
|
||||
item: any;
|
||||
sourceColumn: { id: string; label: string };
|
||||
item: any;
|
||||
};
|
||||
|
||||
const perPage = 10;
|
||||
|
||||
const KanbanColumn = ({
|
||||
column,
|
||||
entityName,
|
||||
columnFieldName,
|
||||
showFieldName,
|
||||
filtersQuery,
|
||||
deleteThunk,
|
||||
updateThunk,
|
||||
}: Props) => {
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [itemIdToDelete, setItemIdToDelete] = useState('');
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const listInnerRef = useRef<HTMLDivElement | null>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
column,
|
||||
entityName,
|
||||
columnFieldName,
|
||||
showFieldName,
|
||||
filtersQuery,
|
||||
deleteThunk,
|
||||
updateThunk,
|
||||
}: Props) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const listInnerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [{ dropResult }, drop] = useDrop<
|
||||
{
|
||||
item: any;
|
||||
column: {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
},
|
||||
unknown,
|
||||
{
|
||||
dropResult: DropResult;
|
||||
}
|
||||
>(
|
||||
() => ({
|
||||
accept: 'box',
|
||||
drop: ({
|
||||
item,
|
||||
column: sourceColumn,
|
||||
}: {
|
||||
item: any;
|
||||
column: { id: string; label: string };
|
||||
}) => {
|
||||
if (sourceColumn.id === column.id) return;
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [itemIdToDelete, setItemIdToDelete] = useState('');
|
||||
|
||||
dispatch(
|
||||
updateThunk({
|
||||
id: item.id,
|
||||
data: {
|
||||
[columnFieldName]: column.id,
|
||||
},
|
||||
}),
|
||||
).then((res) => {
|
||||
setData((prevState) => (prevState ? [...prevState, item] : [item]));
|
||||
setCount((prevState) => prevState + 1);
|
||||
});
|
||||
const [{ dropResult }, drop] = useDrop(
|
||||
() => ({
|
||||
accept: 'box',
|
||||
drop: ({ item, column: sourceColumn }: { item: any; column: { id: string; label: string } }) => {
|
||||
if (sourceColumn.id === column.id) return;
|
||||
|
||||
return { sourceColumn, item };
|
||||
dispatch(
|
||||
updateThunk({
|
||||
id: item.id,
|
||||
data: {
|
||||
[columnFieldName]: column.id,
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
dropResult: monitor.getDropResult(),
|
||||
}),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}),
|
||||
).then(() => {
|
||||
const movedItem = {
|
||||
...item,
|
||||
[columnFieldName]: column.id,
|
||||
};
|
||||
|
||||
const loadData = useCallback(
|
||||
(page: number, filters = '') => {
|
||||
const query = `?page=${page}&limit=${perPage}&field=createdAt&sort=desc&${columnFieldName}=${column.id}&${filters}`;
|
||||
setLoading(true);
|
||||
Axios.get(`${entityName}${query}`)
|
||||
.then((res) => {
|
||||
setData((prevState) =>
|
||||
page === 0 ? res.data.rows : [...prevState, ...res.data.rows],
|
||||
);
|
||||
setCount(res.data.count);
|
||||
setCurrentPage(page);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[currentUser, column],
|
||||
);
|
||||
setData((prevState) => [movedItem, ...prevState.filter((record) => record.id !== item.id)]);
|
||||
setCount((prevState) => prevState + 1);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
loadData(0, filtersQuery);
|
||||
}, [currentUser, loadData, filtersQuery]);
|
||||
return { sourceColumn, item };
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
dropResult: monitor.getDropResult() as DropResult | null,
|
||||
}),
|
||||
}),
|
||||
[column.id, columnFieldName, dispatch, updateThunk],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadData(0, filtersQuery);
|
||||
}, [loadData, filtersQuery]);
|
||||
const loadData = useCallback(
|
||||
async (page: number, filters = '') => {
|
||||
const query = `?page=${page}&limit=${perPage}&field=createdAt&sort=desc&${columnFieldName}=${column.id}&${filters}`;
|
||||
setLoading(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (dropResult?.sourceColumn && dropResult.sourceColumn.id === column.id) {
|
||||
setData((prevState) =>
|
||||
prevState.filter((item) => item.id !== dropResult.item.id),
|
||||
);
|
||||
setCount((prevState) => prevState - 1);
|
||||
try {
|
||||
const res = await Axios.get(`/${entityName}${query}`);
|
||||
setData((prevState) => (page === 0 ? res.data.rows : [...prevState, ...res.data.rows]));
|
||||
setCount(res.data.count);
|
||||
setCurrentPage(page);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load ${entityName} kanban column ${column.id}`, error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[column.id, columnFieldName, entityName],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
loadData(0, filtersQuery);
|
||||
}, [currentUser, loadData, filtersQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dropResult?.sourceColumn || dropResult.sourceColumn.id !== column.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setData((prevState) => prevState.filter((item) => item.id !== dropResult.item.id));
|
||||
setCount((prevState) => Math.max(prevState - 1, 0));
|
||||
}, [column.id, dropResult]);
|
||||
|
||||
const onScroll = () => {
|
||||
if (!listInnerRef.current || loading) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = listInnerRef.current;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 12 && data.length < count) {
|
||||
loadData(currentPage + 1, filtersQuery);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteConfirm = () => {
|
||||
if (!itemIdToDelete) return;
|
||||
|
||||
dispatch(deleteThunk(itemIdToDelete))
|
||||
.then((res) => {
|
||||
if (res.meta.requestStatus === 'fulfilled') {
|
||||
setItemIdToDelete('');
|
||||
loadData(0, filtersQuery);
|
||||
}
|
||||
}, [dropResult]);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to delete ${entityName} item ${itemIdToDelete}`, error);
|
||||
})
|
||||
.finally(() => {
|
||||
setItemIdToDelete('');
|
||||
});
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (listInnerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = listInnerRef.current;
|
||||
if (Math.floor(scrollTop + clientHeight) === scrollHeight) {
|
||||
if (data.length < count && !loading) {
|
||||
loadData(currentPage + 1, filtersQuery);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<CardBox hasComponentLayout className='w-[280px] overflow-hidden rounded-2xl border border-gray-200/80 bg-gray-50/80 shadow-none dark:border-dark-700 dark:bg-dark-900/70'>
|
||||
<div className='flex items-center justify-between border-b border-gray-200/80 px-4 py-4 dark:border-dark-700'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500'>
|
||||
Stage
|
||||
</p>
|
||||
<p className='mt-1 text-sm font-semibold text-gray-900 dark:text-white'>{column.label}</p>
|
||||
</div>
|
||||
<div className='rounded-full bg-white px-3 py-1 text-xs font-semibold text-gray-600 shadow-sm dark:bg-dark-800 dark:text-gray-300'>
|
||||
{count}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={(node) => {
|
||||
drop(node);
|
||||
listInnerRef.current = node;
|
||||
}}
|
||||
className='max-h-[560px] flex-1 space-y-3 overflow-y-auto p-3'
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{data.map((item) => (
|
||||
<KanbanCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
column={column}
|
||||
showFieldName={showFieldName}
|
||||
entityName={entityName}
|
||||
setItemIdToDelete={setItemIdToDelete}
|
||||
/>
|
||||
))}
|
||||
|
||||
const onDeleteConfirm = () => {
|
||||
if (!itemIdToDelete) return;
|
||||
{!loading && !data.length && (
|
||||
<CardBoxComponentEmpty
|
||||
compact
|
||||
title={`No ${entityName.replace(/_/g, ' ')} in ${column.label}`}
|
||||
description='When records move into this stage, they will appear here automatically.'
|
||||
/>
|
||||
)}
|
||||
|
||||
dispatch(deleteThunk(itemIdToDelete))
|
||||
.then((res) => {
|
||||
if (res.meta.requestStatus === 'fulfilled') {
|
||||
setItemIdToDelete('');
|
||||
loadData(0, filtersQuery);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setItemIdToDelete('');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardBox
|
||||
hasComponentLayout
|
||||
className={
|
||||
'w-72 rounded-md h-fit max-h-full overflow-hidden flex flex-col'
|
||||
}
|
||||
>
|
||||
<div className={'flex items-center justify-between p-3'}>
|
||||
<p className={'uppercase'}>{column.label}</p>
|
||||
<p>{count}</p>
|
||||
</div>
|
||||
<div
|
||||
ref={(node) => {
|
||||
drop(node);
|
||||
listInnerRef.current = node;
|
||||
}}
|
||||
className={'p-3 space-y-3 flex-1 overflow-y-auto max-h-[400px]'}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{data?.map((item) => (
|
||||
<div key={item.id}>
|
||||
<KanbanCard
|
||||
item={item}
|
||||
column={column}
|
||||
showFieldName={showFieldName}
|
||||
entityName={entityName}
|
||||
setItemIdToDelete={setItemIdToDelete}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!data?.length && (
|
||||
<p className={'text-center py-8 bg-midnightBlueTheme-cardColor dark:bg-dark-800'}>No data</p>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
<CardBoxModal
|
||||
title='Please confirm'
|
||||
buttonColor='info'
|
||||
buttonLabel={loading ? 'Deleting...' : 'Confirm'}
|
||||
isActive={!!itemIdToDelete}
|
||||
onConfirm={onDeleteConfirm}
|
||||
onCancel={() => setItemIdToDelete('')}
|
||||
>
|
||||
<p>Are you sure you want to delete this item?</p>
|
||||
</CardBoxModal>
|
||||
</>
|
||||
);
|
||||
{loading && (
|
||||
<LoadingSpinner
|
||||
compact
|
||||
label={`Loading ${column.label.toLowerCase()} stage`}
|
||||
detail='Pulling the latest items for this lane.'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
<CardBoxModal
|
||||
title='Please confirm'
|
||||
buttonColor='info'
|
||||
buttonLabel={loading ? 'Deleting...' : 'Confirm'}
|
||||
isActive={!!itemIdToDelete}
|
||||
onConfirm={onDeleteConfirm}
|
||||
onCancel={() => setItemIdToDelete('')}
|
||||
>
|
||||
<p>Are you sure you want to delete this item?</p>
|
||||
</CardBoxModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default KanbanColumn;
|
||||
|
||||
@ -1,15 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
const LoadingSpinner = () => {
|
||||
type Props = {
|
||||
label?: string;
|
||||
detail?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const LoadingSpinner = ({
|
||||
label = 'Loading your workspace',
|
||||
detail = 'Refreshing the latest operational data.',
|
||||
compact = false,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className='flex items-center justify-center h-40'>
|
||||
<div className='relative w-12'>
|
||||
<div
|
||||
className='w-12 h-12 rounded-full absolute border-4 border-solid border-gray-200 dark:border-slate-800'
|
||||
></div>
|
||||
<div
|
||||
className="w-12 h-12 rounded-full animate-spin absolute border-4 border-solid border-midnightBlueTheme-iconsColor dark:border-blue-500 border-t-transparent"
|
||||
></div>
|
||||
<div
|
||||
className={`flex items-center justify-center ${compact ? 'min-h-[140px]' : 'min-h-[220px]'} px-4 py-6`}
|
||||
>
|
||||
<div className="flex max-w-md flex-col items-center text-center">
|
||||
<div className="relative h-14 w-14">
|
||||
<div className="absolute inset-0 rounded-full border-4 border-solid border-gray-200 dark:border-slate-800" />
|
||||
<div className="absolute inset-0 animate-spin rounded-full border-4 border-solid border-midnightBlueTheme-iconsColor border-t-transparent dark:border-blue-500" />
|
||||
</div>
|
||||
<p className="mt-5 text-sm font-semibold text-gray-900 dark:text-white">{label}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-gray-500 dark:text-slate-400">{detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import React from 'react';
|
||||
import ImageField from '../ImageField';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import { Pagination } from '../Pagination';
|
||||
import {saveFile} from "../../helpers/fileSaver";
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
import Link from 'next/link';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
import CardBox from '../CardBox';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import { Pagination } from '../Pagination';
|
||||
import LoadingSpinner from '../LoadingSpinner';
|
||||
import CardBoxComponentEmpty from '../CardBoxComponentEmpty';
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
|
||||
type Props = {
|
||||
properties: any[];
|
||||
@ -28,241 +25,110 @@ const CardProperties = ({
|
||||
numPages,
|
||||
onPageChange,
|
||||
}: Props) => {
|
||||
const asideScrollbarsStyle = useAppSelector(
|
||||
(state) => state.style.asideScrollbarsStyle,
|
||||
);
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PROPERTIES')
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PROPERTIES');
|
||||
const cardRadius = corners !== 'rounded-full' ? corners : 'rounded-3xl';
|
||||
|
||||
return (
|
||||
<div className={'p-4'}>
|
||||
{loading && <LoadingSpinner />}
|
||||
<ul
|
||||
role='list'
|
||||
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
|
||||
>
|
||||
{!loading && properties.map((item, index) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
|
||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
||||
}`}
|
||||
>
|
||||
|
||||
<div className={`flex items-center ${bgColor} p-6 md:p-0 md:block gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
|
||||
|
||||
<Link
|
||||
href={`/properties/properties-view/?id=${item.id}`}
|
||||
className={'cursor-pointer'}
|
||||
>
|
||||
<ImageField
|
||||
name={'Avatar'}
|
||||
image={item.images}
|
||||
className='w-12 h-12 md:w-full md:h-44 rounded-lg md:rounded-b-none overflow-hidden ring-1 ring-gray-900/10'
|
||||
imageClassName='h-full w-full flex-none rounded-lg md:rounded-b-none bg-white object-cover'
|
||||
/>
|
||||
<p className={'px-6 py-2 font-semibold'}>{item.name}</p>
|
||||
</Link>
|
||||
|
||||
|
||||
<div className='ml-auto md:absolute md:top-0 md:right-0 '>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/properties/properties-edit/?id=${item.id}`}
|
||||
pathView={`/properties/properties-view/?id=${item.id}`}
|
||||
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<dl className='divide-y divide-gray-600 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Tenant</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.tenantsOneListFormatter(item.tenant) }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Propertyname</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.name }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Propertycode</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.code }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Address</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.address }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>City</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.city }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Country</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.country }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Timezone</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.timezone }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Description</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.description }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Images</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium'>
|
||||
<ImageField
|
||||
name={'Avatar'}
|
||||
image={item.images}
|
||||
className='mx-auto w-8 h-8'
|
||||
/>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Isactive</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.booleanFormatter(item.is_active) }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Unittypes</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.unit_typesManyListFormatter(item.unit_types).join(', ')}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Units</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.unitsManyListFormatter(item.units).join(', ')}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Amenities</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.amenitiesManyListFormatter(item.amenities).join(', ')}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
{!loading && properties.length === 0 && (
|
||||
<div className='col-span-full flex items-center justify-center h-40'>
|
||||
<p className=''>No data to display</p>
|
||||
</div>
|
||||
<>
|
||||
<div className='space-y-4 p-4'>
|
||||
{loading && (
|
||||
<LoadingSpinner
|
||||
label='Loading properties'
|
||||
detail='Preparing the current portfolio snapshot and live inventory.'
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
properties.map((item) => {
|
||||
const location = [item.city, item.country].filter(Boolean).join(', ') || item.address || 'Location not set';
|
||||
const unitsCount = Array.isArray(item.units) ? item.units.length : 0;
|
||||
const unitTypesCount = Array.isArray(item.unit_types) ? item.unit_types.length : 0;
|
||||
const amenitiesCount = Array.isArray(item.amenities) ? item.amenities.length : 0;
|
||||
|
||||
return (
|
||||
<CardBox key={item.id} className='shadow-none'>
|
||||
<div
|
||||
className={`border border-gray-200 dark:border-dark-700 ${cardRadius} ${bgColor} p-5`}
|
||||
>
|
||||
<div className='flex flex-col gap-4 md:flex-row md:items-start'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<Link
|
||||
href={`/properties/properties-view/?id=${item.id}`}
|
||||
className='text-lg font-semibold leading-6 text-gray-900 dark:text-white'
|
||||
>
|
||||
{item.name || item.code || 'Untitled property'}
|
||||
</Link>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${
|
||||
item.is_active
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-slate-700 dark:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{item.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<p className='mt-1 text-sm text-gray-500 dark:text-dark-600'>{location}</p>
|
||||
{item.description && (
|
||||
<p className='mt-3 line-clamp-2 text-sm text-gray-600 dark:text-slate-300'>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='self-start'>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/properties/properties-edit/?id=${item.id}`}
|
||||
pathView={`/properties/properties-view/?id=${item.id}`}
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
<div>
|
||||
<p className='text-xs uppercase tracking-wide text-gray-500'>Code</p>
|
||||
<p className='mt-1 text-sm font-medium'>{item.code || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs uppercase tracking-wide text-gray-500'>Timezone</p>
|
||||
<p className='mt-1 text-sm font-medium'>{item.timezone || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs uppercase tracking-wide text-gray-500'>Units</p>
|
||||
<p className='mt-1 text-sm font-medium'>{unitsCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs uppercase tracking-wide text-gray-500'>Unit types / amenities</p>
|
||||
<p className='mt-1 text-sm font-medium'>
|
||||
{unitTypesCount} / {amenitiesCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
);
|
||||
})}
|
||||
|
||||
{!loading && properties.length === 0 && (
|
||||
<CardBoxComponentEmpty
|
||||
title='No properties match this view'
|
||||
description='Try clearing filters or add a property to bring the portfolio into view.'
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
<div className={'flex items-center justify-center my-6'}>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
setCurrentPage={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='my-6 flex items-center justify-center'>
|
||||
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardProperties;
|
||||
export default CardProperties
|
||||
|
||||
@ -21,7 +21,17 @@ import {dataGridStyles} from "../../styles";
|
||||
import CardProperties from './CardProperties';
|
||||
|
||||
|
||||
const perPage = 10
|
||||
const perPage = 10
|
||||
|
||||
const compactColumnVisibilityModel = {
|
||||
address: false,
|
||||
description: false,
|
||||
images: false,
|
||||
unit_types: false,
|
||||
units: false,
|
||||
amenities: false,
|
||||
}
|
||||
|
||||
|
||||
const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid }) => {
|
||||
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
||||
@ -204,16 +214,16 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
||||
};
|
||||
|
||||
const controlClasses =
|
||||
'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' +
|
||||
` ${bgColor} ${focusRing} ${corners} ` +
|
||||
'dark:bg-slate-800 border';
|
||||
'w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-400 ' +
|
||||
' ' + bgColor + ' ' + focusRing + ' ' + corners + ' ' +
|
||||
'dark:bg-slate-800/80 my-1';
|
||||
|
||||
|
||||
const dataGrid = (
|
||||
<div className='relative overflow-x-auto'>
|
||||
<DataGrid
|
||||
autoHeight
|
||||
rowHeight={64}
|
||||
rowHeight={56}
|
||||
sx={dataGridStyles}
|
||||
className={'datagrid--table'}
|
||||
getRowClassName={() => `datagrid--row`}
|
||||
@ -225,6 +235,9 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
||||
pageSize: 10,
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
columnVisibilityModel: compactColumnVisibilityModel,
|
||||
},
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
onProcessRowUpdateError={(params) => {
|
||||
@ -264,7 +277,7 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
||||
return (
|
||||
<>
|
||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
||||
<CardBox>
|
||||
<CardBox className='mb-6 border border-white/10 shadow-none'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
checkboxes: ['lorem'],
|
||||
@ -277,9 +290,9 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
||||
<>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
return (
|
||||
<div key={filterItem.id} className="flex mb-4">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Filter</div>
|
||||
<div key={filterItem.id} className="mb-3 grid gap-3 rounded-2xl border border-white/10 bg-white/5 p-4 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_auto]">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Filter</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='selectedField'
|
||||
@ -301,8 +314,8 @@ const TableSampleProperties = ({ 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="text-gray-500 font-bold">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Value
|
||||
</div>
|
||||
<Field
|
||||
@ -326,9 +339,9 @@ const TableSampleProperties = ({ 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=" text-gray-500 font-bold">From</div>
|
||||
<div className="grid min-w-0 gap-3 md:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">From</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueFrom'
|
||||
@ -339,7 +352,7 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className=" text-gray-500 font-bold">To</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -355,9 +368,9 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
||||
filter.title ===
|
||||
filterItem?.fields?.selectedField
|
||||
)?.date ? (
|
||||
<div className='flex flex-row w-full mr-3'>
|
||||
<div className='grid min-w-0 gap-3 md:grid-cols-2'>
|
||||
<div className='flex flex-col w-full mr-3'>
|
||||
<div className=' text-gray-500 font-bold'>
|
||||
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>
|
||||
From
|
||||
</div>
|
||||
<Field
|
||||
@ -371,7 +384,7 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className=' text-gray-500 font-bold'>To</div>
|
||||
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -384,8 +397,8 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Contains</div>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Contains</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValue'
|
||||
@ -397,11 +410,12 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className=" text-gray-500 font-bold">Action</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Action</div>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
className="my-1"
|
||||
type='reset'
|
||||
color='danger'
|
||||
color='whiteDark'
|
||||
outline
|
||||
label='Delete'
|
||||
onClick={() => {
|
||||
deleteFilter(filterItem.id)
|
||||
@ -411,16 +425,16 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<BaseButton
|
||||
className="my-2 mr-3"
|
||||
className="my-1 mr-0"
|
||||
type='submit' color='info'
|
||||
label='Apply'
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
type='reset' color='info' outline
|
||||
className="my-1"
|
||||
type='reset' color='whiteDark' outline
|
||||
label='Cancel'
|
||||
onClick={handleReset}
|
||||
/>
|
||||
|
||||
@ -22,7 +22,28 @@ import BigCalendar from "../BigCalendar";
|
||||
import { SlotInfo } from 'react-big-calendar';
|
||||
|
||||
|
||||
const perPage = 100
|
||||
const perPage = 25
|
||||
|
||||
const compactColumnVisibilityModel = {
|
||||
tenant: false,
|
||||
organization: false,
|
||||
booking_request: false,
|
||||
unit_type: false,
|
||||
actual_check_in_at: false,
|
||||
actual_check_out_at: false,
|
||||
early_check_in: false,
|
||||
late_check_out: false,
|
||||
monthly_rate: false,
|
||||
currency: false,
|
||||
internal_notes: false,
|
||||
external_notes: false,
|
||||
guests: false,
|
||||
service_requests: false,
|
||||
invoices: false,
|
||||
documents: false,
|
||||
comments: false,
|
||||
}
|
||||
|
||||
|
||||
const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGrid }) => {
|
||||
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
||||
@ -110,6 +131,63 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
}
|
||||
};
|
||||
|
||||
const reservationCalendarOverview = useMemo(() => {
|
||||
const today = new Date();
|
||||
const nextSevenDays = new Date(today);
|
||||
nextSevenDays.setDate(nextSevenDays.getDate() + 7);
|
||||
|
||||
const totals = reservations.reduce(
|
||||
(accumulator, item) => {
|
||||
const checkIn = item?.check_in_at ? new Date(item.check_in_at) : null;
|
||||
const checkOut = item?.check_out_at ? new Date(item.check_out_at) : null;
|
||||
const status = item?.status || 'quoted';
|
||||
|
||||
accumulator.total += 1;
|
||||
accumulator[status] = (accumulator[status] || 0) + 1;
|
||||
|
||||
if (checkIn && checkIn >= today && checkIn <= nextSevenDays) {
|
||||
accumulator.upcomingArrivals += 1;
|
||||
}
|
||||
|
||||
if (checkOut && checkOut >= today && checkOut <= nextSevenDays) {
|
||||
accumulator.upcomingDepartures += 1;
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
},
|
||||
{
|
||||
total: 0,
|
||||
confirmed: 0,
|
||||
checked_in: 0,
|
||||
upcomingArrivals: 0,
|
||||
upcomingDepartures: 0,
|
||||
},
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Visible stays',
|
||||
value: totals.total,
|
||||
hint: 'Loaded in this calendar range',
|
||||
},
|
||||
{
|
||||
label: 'Confirmed',
|
||||
value: totals.confirmed,
|
||||
hint: 'Booked and upcoming',
|
||||
},
|
||||
{
|
||||
label: 'In house',
|
||||
value: totals.checked_in,
|
||||
hint: 'Currently checked in',
|
||||
},
|
||||
{
|
||||
label: 'Departures soon',
|
||||
value: totals.upcomingDepartures,
|
||||
hint: 'Next 7 days',
|
||||
},
|
||||
];
|
||||
}, [reservations]);
|
||||
|
||||
const generateFilterRequests = useMemo(() => {
|
||||
let request = '&';
|
||||
filterItems.forEach((item) => {
|
||||
@ -211,16 +289,16 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
};
|
||||
|
||||
const controlClasses =
|
||||
'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' +
|
||||
` ${bgColor} ${focusRing} ${corners} ` +
|
||||
'dark:bg-slate-800 border';
|
||||
'w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-400 ' +
|
||||
' ' + bgColor + ' ' + focusRing + ' ' + corners + ' ' +
|
||||
'dark:bg-slate-800/80 my-1';
|
||||
|
||||
|
||||
const dataGrid = (
|
||||
<div className='relative overflow-x-auto'>
|
||||
<DataGrid
|
||||
autoHeight
|
||||
rowHeight={64}
|
||||
rowHeight={56}
|
||||
sx={dataGridStyles}
|
||||
className={'datagrid--table'}
|
||||
getRowClassName={() => `datagrid--row`}
|
||||
@ -229,9 +307,12 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
initialState={{
|
||||
pagination: {
|
||||
paginationModel: {
|
||||
pageSize: 10,
|
||||
pageSize: 25,
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
columnVisibilityModel: compactColumnVisibilityModel,
|
||||
},
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
onProcessRowUpdateError={(params) => {
|
||||
@ -258,7 +339,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
: setSortModel([{ field: '', sort: 'desc' }]);
|
||||
}}
|
||||
rowCount={count}
|
||||
pageSizeOptions={[10]}
|
||||
pageSizeOptions={[25]}
|
||||
paginationMode={'server'}
|
||||
loading={loading}
|
||||
onPaginationModelChange={(params) => {
|
||||
@ -271,7 +352,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
return (
|
||||
<>
|
||||
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
|
||||
<CardBox>
|
||||
<CardBox className='mb-6 border border-white/10 shadow-none'>
|
||||
<Formik
|
||||
initialValues={{
|
||||
checkboxes: ['lorem'],
|
||||
@ -284,9 +365,9 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
<>
|
||||
{filterItems && filterItems.map((filterItem) => {
|
||||
return (
|
||||
<div key={filterItem.id} className="flex mb-4">
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Filter</div>
|
||||
<div key={filterItem.id} className="mb-3 grid gap-3 rounded-2xl border border-white/10 bg-white/5 p-4 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)_auto]">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Filter</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='selectedField'
|
||||
@ -308,8 +389,8 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
{filters.find((filter) =>
|
||||
filter.title === filterItem?.fields?.selectedField
|
||||
)?.type === 'enum' ? (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className="text-gray-500 font-bold">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Value
|
||||
</div>
|
||||
<Field
|
||||
@ -333,9 +414,9 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
) : 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=" text-gray-500 font-bold">From</div>
|
||||
<div className="grid min-w-0 gap-3 md:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">From</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueFrom'
|
||||
@ -346,7 +427,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className=" text-gray-500 font-bold">To</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -362,9 +443,9 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
filter.title ===
|
||||
filterItem?.fields?.selectedField
|
||||
)?.date ? (
|
||||
<div className='flex flex-row w-full mr-3'>
|
||||
<div className='grid min-w-0 gap-3 md:grid-cols-2'>
|
||||
<div className='flex flex-col w-full mr-3'>
|
||||
<div className=' text-gray-500 font-bold'>
|
||||
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>
|
||||
From
|
||||
</div>
|
||||
<Field
|
||||
@ -378,7 +459,7 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col w-full'>
|
||||
<div className=' text-gray-500 font-bold'>To</div>
|
||||
<div className='text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400'>To</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValueTo'
|
||||
@ -391,8 +472,8 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col w-full mr-3">
|
||||
<div className=" text-gray-500 font-bold">Contains</div>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Contains</div>
|
||||
<Field
|
||||
className={controlClasses}
|
||||
name='filterValue'
|
||||
@ -404,11 +485,12 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className=" text-gray-500 font-bold">Action</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Action</div>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
className="my-1"
|
||||
type='reset'
|
||||
color='danger'
|
||||
color='whiteDark'
|
||||
outline
|
||||
label='Delete'
|
||||
onClick={() => {
|
||||
deleteFilter(filterItem.id)
|
||||
@ -418,16 +500,16 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<BaseButton
|
||||
className="my-2 mr-3"
|
||||
className="my-1 mr-0"
|
||||
type='submit' color='info'
|
||||
label='Apply'
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<BaseButton
|
||||
className="my-2"
|
||||
type='reset' color='info' outline
|
||||
className="my-1"
|
||||
type='reset' color='whiteDark' outline
|
||||
label='Cancel'
|
||||
onClick={handleReset}
|
||||
/>
|
||||
@ -450,6 +532,22 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
|
||||
|
||||
{!showGrid && (
|
||||
<>
|
||||
<div className='mb-6 grid gap-3 md:grid-cols-4'>
|
||||
{reservationCalendarOverview.map((item) => (
|
||||
<CardBox key={item.label} className='rounded-2xl border border-gray-200/80 shadow-none dark:border-dark-700'>
|
||||
<div className='p-4'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500'>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className='mt-2 text-2xl font-semibold text-gray-900 dark:text-white'>
|
||||
{item.value}
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-gray-500 dark:text-gray-400'>{item.hint}</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
))}
|
||||
</div>
|
||||
<BigCalendar
|
||||
events={reservations}
|
||||
showField={'reservation_code'}
|
||||
@ -462,8 +560,12 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
|
||||
onDateRangeChange={(range) => {
|
||||
loadData(0,`&calendarStart=${range.start}&calendarEnd=${range.end}`);
|
||||
}}
|
||||
isLoading={loading}
|
||||
emptyTitle='No reservations in this range'
|
||||
emptyDescription='Adjust the calendar window or add a reservation to start filling the schedule.'
|
||||
entityName={'reservations'}
|
||||
/>
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
@ -6,5 +6,5 @@ type Props = {
|
||||
}
|
||||
|
||||
export default function SectionMain({ children }: Props) {
|
||||
return <section className={`p-6 ${containerMaxW}`}>{children}</section>
|
||||
return <section className={`px-5 py-6 md:px-8 md:py-8 ${containerMaxW}`}>{children}</section>
|
||||
}
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { mdiCog } from '@mdi/js'
|
||||
import React, { Children, ReactNode } from 'react'
|
||||
import BaseButton from './BaseButton'
|
||||
import BaseIcon from './BaseIcon'
|
||||
import IconRounded from './IconRounded'
|
||||
import { humanize } from '../helpers/humanize';
|
||||
@ -9,21 +7,35 @@ type Props = {
|
||||
icon: string
|
||||
title: string
|
||||
main?: boolean
|
||||
subtitle?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export default function SectionTitleLineWithButton({ icon, title, main = false, children }: Props) {
|
||||
const hasChildren = !!Children.count(children)
|
||||
export default function SectionTitleLineWithButton({
|
||||
icon,
|
||||
title,
|
||||
main = false,
|
||||
subtitle,
|
||||
children,
|
||||
}: Props) {
|
||||
const childArray = Children.toArray(children).filter((child) => !(typeof child === 'string' && child.trim() === ''))
|
||||
const hasChildren = childArray.length > 0
|
||||
|
||||
return (
|
||||
<section className={`${main ? '' : 'pt-6'} mb-6 flex items-center justify-between`}>
|
||||
<div className="flex items-center justify-start">
|
||||
{icon && main && <IconRounded icon={icon} color="light" className="mr-3" bg />}
|
||||
{icon && !main && <BaseIcon path={icon} className="mr-2" size="20" />}
|
||||
<h1 className={`leading-tight ${main ? 'text-3xl' : 'text-2xl'}`}>{humanize(title)}</h1>
|
||||
<section
|
||||
className={`${main ? '' : 'pt-6'} mb-6 flex flex-col gap-4 md:flex-row md:items-start md:justify-between`}
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-start gap-3">
|
||||
{icon && main && <IconRounded icon={icon} color="light" className="mt-1" bg />}
|
||||
{icon && !main && <BaseIcon path={icon} className="mt-1" size="20" />}
|
||||
<div className="min-w-0">
|
||||
<h1 className={`leading-tight ${main ? 'text-3xl' : 'text-2xl'}`}>{humanize(title)}</h1>
|
||||
{subtitle ? (
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-gray-500 dark:text-slate-400">{subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
{!hasChildren && <BaseButton icon={mdiCog} color="whiteDark" />}
|
||||
{hasChildren ? <div className="flex flex-wrap items-center gap-3">{childArray}</div> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -462,6 +462,7 @@ const TableSampleUnit_availability_blocks = ({ filterItems, setFilterItems, filt
|
||||
onDateRangeChange={(range) => {
|
||||
loadData(0,`&calendarStart=${range.start}&calendarEnd=${range.end}`);
|
||||
}}
|
||||
isLoading={loading}
|
||||
entityName={'unit_availability_blocks'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,15 +1,12 @@
|
||||
import React from 'react';
|
||||
import ImageField from '../ImageField';
|
||||
import Link from 'next/link';
|
||||
import CardBox from '../CardBox';
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
import { Pagination } from '../Pagination';
|
||||
import LoadingSpinner from '../LoadingSpinner';
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import { Pagination } from '../Pagination';
|
||||
import {saveFile} from "../../helpers/fileSaver";
|
||||
import LoadingSpinner from "../LoadingSpinner";
|
||||
import Link from 'next/link';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
|
||||
type Props = {
|
||||
units: any[];
|
||||
@ -20,176 +17,121 @@ type Props = {
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
const CardUnits = ({
|
||||
units,
|
||||
loading,
|
||||
onDelete,
|
||||
currentPage,
|
||||
numPages,
|
||||
onPageChange,
|
||||
}: Props) => {
|
||||
const asideScrollbarsStyle = useAppSelector(
|
||||
(state) => state.style.asideScrollbarsStyle,
|
||||
);
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
||||
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_UNITS')
|
||||
const statusTone = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300';
|
||||
case 'occupied':
|
||||
case 'reserved':
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-300';
|
||||
case 'maintenance':
|
||||
case 'out_of_service':
|
||||
return 'bg-rose-100 text-rose-700 dark:bg-rose-500/10 dark:text-rose-300';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-slate-700 dark:text-slate-200';
|
||||
}
|
||||
};
|
||||
|
||||
const CardUnits = ({ units, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
|
||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_UNITS');
|
||||
const cardRadius = corners !== 'rounded-full' ? corners : 'rounded-3xl';
|
||||
|
||||
return (
|
||||
<div className={'p-4'}>
|
||||
{loading && <LoadingSpinner />}
|
||||
<ul
|
||||
role='list'
|
||||
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
|
||||
>
|
||||
{!loading && units.map((item, index) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
|
||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
||||
}`}
|
||||
>
|
||||
<>
|
||||
<div className='p-4 space-y-4'>
|
||||
{loading && <LoadingSpinner />}
|
||||
|
||||
<div className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
|
||||
{!loading &&
|
||||
units.map((item) => {
|
||||
const property = dataFormatter.propertiesOneListFormatter(item.property) || 'Unassigned property';
|
||||
const unitType = dataFormatter.unit_typesOneListFormatter(item.unit_type) || 'No unit type';
|
||||
const availabilityCount = Array.isArray(item.availability_blocks)
|
||||
? item.availability_blocks.length
|
||||
: 0;
|
||||
|
||||
<Link href={`/units/units-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
|
||||
{item.unit_number}
|
||||
</Link>
|
||||
return (
|
||||
<CardBox key={item.id} className='shadow-none'>
|
||||
<div
|
||||
className={`border border-gray-200 dark:border-dark-700 ${cardRadius} ${bgColor} p-5`}
|
||||
>
|
||||
<div className='flex flex-col gap-4 md:flex-row md:items-start'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<Link
|
||||
href={`/units/units-view/?id=${item.id}`}
|
||||
className='text-lg font-semibold leading-6 text-gray-900 dark:text-white'
|
||||
>
|
||||
{item.unit_number || 'Untitled unit'}
|
||||
</Link>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold capitalize ${statusTone(
|
||||
item.status,
|
||||
)}`}
|
||||
>
|
||||
{item.status || 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<p className='mt-1 text-sm text-gray-500 dark:text-dark-600'>
|
||||
{property} • {unitType}
|
||||
</p>
|
||||
{item.notes && (
|
||||
<p className='mt-3 line-clamp-2 text-sm text-gray-600 dark:text-slate-300'>
|
||||
{item.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='self-start'>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/units/units-edit/?id=${item.id}`}
|
||||
pathView={`/units/units-view/?id=${item.id}`}
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='ml-auto '>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/units/units-edit/?id=${item.id}`}
|
||||
pathView={`/units/units-view/?id=${item.id}`}
|
||||
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<dl className='divide-y divide-gray-600 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Property</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.propertiesOneListFormatter(item.property) }
|
||||
</div>
|
||||
</dd>
|
||||
<div className='mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
<div>
|
||||
<p className='text-xs uppercase tracking-wide text-gray-500'>Floor</p>
|
||||
<p className='mt-1 text-sm font-medium'>{item.floor || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs uppercase tracking-wide text-gray-500'>Max occupancy</p>
|
||||
<p className='mt-1 text-sm font-medium'>
|
||||
{item.max_occupancy_override ?? 'Default'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs uppercase tracking-wide text-gray-500'>Availability blocks</p>
|
||||
<p className='mt-1 text-sm font-medium'>{availabilityCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs uppercase tracking-wide text-gray-500'>Type</p>
|
||||
<p className='mt-1 text-sm font-medium'>{unitType}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Unittype</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.unit_typesOneListFormatter(item.unit_type) }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Unitnumber</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.unit_number }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Floor</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.floor }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Status</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.status }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Maxoccupancyoverride</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.max_occupancy_override }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Notes</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ item.notes }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Availabilityblocks</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.unit_availability_blocksManyListFormatter(item.availability_blocks).join(', ')}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
{!loading && units.length === 0 && (
|
||||
<div className='col-span-full flex items-center justify-center h-40'>
|
||||
<p className=''>No data to display</p>
|
||||
<div className='flex h-40 items-center justify-center'>
|
||||
<p>No data to display</p>
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
<div className={'flex items-center justify-center my-6'}>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
setCurrentPage={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='my-6 flex items-center justify-center'>
|
||||
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardUnits;
|
||||
export default CardUnits
|
||||
|
||||
@ -16,10 +16,16 @@ import {loadColumns} from "./configureUnitsCols";
|
||||
import _ from 'lodash';
|
||||
import dataFormatter from '../../helpers/dataFormatter'
|
||||
import {dataGridStyles} from "../../styles";
|
||||
import CardUnits from './CardUnits';
|
||||
|
||||
const perPage = 10
|
||||
|
||||
const compactColumnVisibilityModel = {
|
||||
max_occupancy_override: false,
|
||||
notes: false,
|
||||
availability_blocks: false,
|
||||
}
|
||||
|
||||
const perPage = 10
|
||||
|
||||
const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) => {
|
||||
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
||||
@ -211,7 +217,7 @@ const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) =>
|
||||
<div className='relative overflow-x-auto'>
|
||||
<DataGrid
|
||||
autoHeight
|
||||
rowHeight={64}
|
||||
rowHeight={56}
|
||||
sx={dataGridStyles}
|
||||
className={'datagrid--table'}
|
||||
getRowClassName={() => `datagrid--row`}
|
||||
@ -223,6 +229,9 @@ const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) =>
|
||||
pageSize: 10,
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
columnVisibilityModel: compactColumnVisibilityModel,
|
||||
},
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
onProcessRowUpdateError={(params) => {
|
||||
@ -440,10 +449,18 @@ const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) =>
|
||||
</CardBoxModal>
|
||||
|
||||
|
||||
{dataGrid}
|
||||
|
||||
|
||||
{!showGrid && (
|
||||
<CardUnits
|
||||
units={units ?? []}
|
||||
loading={loading}
|
||||
onDelete={handleDeleteModalAction}
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showGrid && dataGrid}
|
||||
|
||||
{selectedRows.length > 0 &&
|
||||
createPortal(
|
||||
|
||||
@ -1,9 +1,19 @@
|
||||
:root {
|
||||
--aside-width: 320px;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply pt-14 xl:pl-60 h-full;
|
||||
@apply pt-14 h-full;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
body {
|
||||
padding-left: var(--aside-width);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
@ -86,7 +85,7 @@ export default function LayoutAuthenticated({
|
||||
}, [router.events, dispatch])
|
||||
|
||||
|
||||
const layoutAsidePadding = 'xl:pl-60'
|
||||
const layoutAsidePadding = 'xl:pl-[var(--aside-width)]'
|
||||
|
||||
return (
|
||||
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
||||
|
||||
@ -1,259 +1,184 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import { MenuAsideItem } from './interfaces'
|
||||
import { MenuAsideItem } from './interfaces';
|
||||
|
||||
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',
|
||||
},
|
||||
{
|
||||
href: '/dashboard',
|
||||
icon: icon.mdiViewDashboardOutline,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
||||
permissions: 'READ_USERS'
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/roles/roles-list',
|
||||
label: 'Roles',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_ROLES'
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/permissions/permissions-list',
|
||||
label: 'Permissions',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
||||
permissions: 'READ_PERMISSIONS'
|
||||
},
|
||||
{
|
||||
href: '/organizations/organizations-list',
|
||||
label: 'Organizations',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_ORGANIZATIONS'
|
||||
},
|
||||
{
|
||||
href: '/tenants/tenants-list',
|
||||
label: 'Tenants',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiDomain' in icon ? icon['mdiDomain' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_TENANTS'
|
||||
},
|
||||
{
|
||||
href: '/role_assignments/role_assignments-list',
|
||||
label: 'Role assignments',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAccountKey' in icon ? icon['mdiAccountKey' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_ROLE_ASSIGNMENTS'
|
||||
},
|
||||
{
|
||||
href: '/organization_memberships/organization_memberships-list',
|
||||
label: 'Organization memberships',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAccountGroup' in icon ? icon['mdiAccountGroup' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_ORGANIZATION_MEMBERSHIPS'
|
||||
},
|
||||
{
|
||||
href: '/properties/properties-list',
|
||||
label: 'Properties',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiHomeCity' in icon ? icon['mdiHomeCity' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_PROPERTIES'
|
||||
},
|
||||
{
|
||||
href: '/amenities/amenities-list',
|
||||
label: 'Amenities',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiSilverwareForkKnife' in icon ? icon['mdiSilverwareForkKnife' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_AMENITIES'
|
||||
},
|
||||
{
|
||||
href: '/unit_types/unit_types-list',
|
||||
label: 'Unit types',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiHomeModern' in icon ? icon['mdiHomeModern' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_UNIT_TYPES'
|
||||
},
|
||||
{
|
||||
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.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_UNITS'
|
||||
},
|
||||
{
|
||||
href: '/unit_availability_blocks/unit_availability_blocks-list',
|
||||
label: 'Unit availability blocks',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCalendarRemove' in icon ? icon['mdiCalendarRemove' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_UNIT_AVAILABILITY_BLOCKS'
|
||||
},
|
||||
{
|
||||
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.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_NEGOTIATED_RATES'
|
||||
},
|
||||
{
|
||||
href: '/booking_requests/booking_requests-list',
|
||||
label: 'Booking requests',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiClipboardTextOutline' in icon ? icon['mdiClipboardTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_BOOKING_REQUESTS'
|
||||
},
|
||||
{
|
||||
href: '/booking_request_travelers/booking_request_travelers-list',
|
||||
label: 'Booking request travelers',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_BOOKING_REQUEST_TRAVELERS'
|
||||
},
|
||||
{
|
||||
href: '/approval_steps/approval_steps-list',
|
||||
label: 'Approval steps',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCheckDecagram' in icon ? icon['mdiCheckDecagram' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_APPROVAL_STEPS'
|
||||
},
|
||||
{
|
||||
href: '/reservations/reservations-list',
|
||||
label: 'Reservations',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCalendarCheck' in icon ? icon['mdiCalendarCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_RESERVATIONS'
|
||||
},
|
||||
{
|
||||
href: '/reservation_guests/reservation_guests-list',
|
||||
label: 'Reservation guests',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiAccount' in icon ? icon['mdiAccount' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_RESERVATION_GUESTS'
|
||||
},
|
||||
{
|
||||
href: '/service_requests/service_requests-list',
|
||||
label: 'Service requests',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiRoomService' in icon ? icon['mdiRoomService' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_SERVICE_REQUESTS'
|
||||
},
|
||||
{
|
||||
href: '/invoices/invoices-list',
|
||||
label: 'Invoices',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiFileDocument' in icon ? icon['mdiFileDocument' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_INVOICES'
|
||||
},
|
||||
{
|
||||
href: '/invoice_line_items/invoice_line_items-list',
|
||||
label: 'Invoice line items',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiFormatListBulleted' in icon ? icon['mdiFormatListBulleted' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_INVOICE_LINE_ITEMS'
|
||||
},
|
||||
{
|
||||
href: '/payments/payments-list',
|
||||
label: 'Payments',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCreditCardCheck' in icon ? icon['mdiCreditCardCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_PAYMENTS'
|
||||
},
|
||||
{
|
||||
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.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_DOCUMENTS'
|
||||
},
|
||||
{
|
||||
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.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_AUDIT_LOGS'
|
||||
},
|
||||
{
|
||||
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.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_NOTIFICATIONS'
|
||||
},
|
||||
{
|
||||
href: '/activity_comments/activity_comments-list',
|
||||
label: 'Activity comments',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCommentTextOutline' in icon ? icon['mdiCommentTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_ACTIVITY_COMMENTS'
|
||||
},
|
||||
{
|
||||
href: '/checklists/checklists-list',
|
||||
label: 'Checklists',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiChecklist' in icon ? icon['mdiChecklist' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_CHECKLISTS'
|
||||
},
|
||||
{
|
||||
href: '/checklist_items/checklist_items-list',
|
||||
label: 'Checklist items',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiCheckboxMarkedOutline' in icon ? icon['mdiCheckboxMarkedOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_CHECKLIST_ITEMS'
|
||||
},
|
||||
{
|
||||
href: '/job_runs/job_runs-list',
|
||||
label: 'Job runs',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiClockOutline' in icon ? icon['mdiClockOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
||||
permissions: 'READ_JOB_RUNS'
|
||||
label: 'Administration',
|
||||
icon: icon.mdiAccountGroup,
|
||||
permissions: [
|
||||
'READ_USERS',
|
||||
'READ_ROLES',
|
||||
'READ_PERMISSIONS',
|
||||
'READ_NOTIFICATIONS',
|
||||
'READ_AUDIT_LOGS',
|
||||
'READ_JOB_RUNS',
|
||||
],
|
||||
menu: [
|
||||
{
|
||||
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'
|
||||
permissions: 'READ_API_DOCS',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
export default menuAside
|
||||
export default menuAside;
|
||||
|
||||
@ -638,19 +638,20 @@ const EditBooking_requestsPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Edit booking_requests')}</title>
|
||||
<title>{getPageTitle('Edit Booking Request')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit booking_requests'} main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit Booking Request'} main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<CardBox className='mx-auto max-w-6xl'>
|
||||
<Formik
|
||||
enableReinitialize
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<div className='grid gap-5 md:grid-cols-2'>
|
||||
|
||||
|
||||
|
||||
@ -1987,9 +1988,11 @@ const EditBooking_requestsPage = () => {
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
<BaseButton type="submit" color="info" label="Submit" />
|
||||
<BaseButton type="submit" color="info" label="Save" />
|
||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/booking_requests/booking_requests-list')}/>
|
||||
</BaseButtons>
|
||||
|
||||
@ -9,8 +9,8 @@ import SectionTitleLineWithButton from '../../components/SectionTitleLineWithBut
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableBooking_requests from '../../components/Booking_requests/TableBooking_requests'
|
||||
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";
|
||||
@ -18,6 +18,7 @@ import {setRefetch, uploadCsv} from '../../stores/booking_requests/booking_reque
|
||||
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
import { humanize } from "../../helpers/humanize";
|
||||
|
||||
|
||||
|
||||
@ -25,134 +26,117 @@ const Booking_requestsTablesPage = () => {
|
||||
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 [filters] = useState([{label: 'Requestcode', title: 'request_code'},{label: 'Purposeofstay', title: 'purpose_of_stay'},{label: 'Specialrequirements', title: 'special_requirements'},{label: 'Budgetcode', title: 'budget_code'},{label: 'Currency', title: 'currency'},
|
||||
const [filters] = useState(([{label: 'Requestcode', title: 'request_code'},{label: 'Purposeofstay', title: 'purpose_of_stay'},{label: 'Specialrequirements', title: 'special_requirements'},{label: 'Budgetcode', title: 'budget_code'},{label: 'Currency', title: 'currency'},
|
||||
{label: 'Preferredbedrooms', title: 'preferred_bedrooms', number: 'true'},{label: 'Guestcount', title: 'guest_count', number: 'true'},
|
||||
{label: 'Maxbudgetamount', title: 'max_budget_amount', number: 'true'},
|
||||
{label: 'Check-inat', title: 'check_in_at', date: 'true'},{label: 'Check-outat', title: 'check_out_at', date: 'true'},
|
||||
|
||||
|
||||
{label: 'Tenant', title: 'tenant'},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{label: 'Requestedby', title: 'requested_by'},
|
||||
|
||||
|
||||
|
||||
{label: 'Preferredproperty', title: 'preferred_property'},
|
||||
|
||||
|
||||
|
||||
{label: 'Preferredunittype', title: 'preferred_unit_type'},
|
||||
|
||||
|
||||
{label: 'Travelers', title: 'travelers'},{label: 'Approvalsteps', title: 'approval_steps'},{label: 'Documents', title: 'documents'},{label: 'Comments', title: 'comments'},
|
||||
{label: 'Status', title: 'status', type: 'enum', options: ['draft','submitted','in_review','changes_requested','approved','rejected','expired','converted_to_reservation','canceled']},
|
||||
]);
|
||||
]).map((filter) => ({ ...filter, label: humanize(filter.title) })));
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BOOKING_REQUESTS');
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BOOKING_REQUESTS');
|
||||
|
||||
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: '',
|
||||
},
|
||||
};
|
||||
newItem.fields.selectedField = filters[0].title;
|
||||
setFilterItems([...filterItems, newItem]);
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: '',
|
||||
},
|
||||
};
|
||||
newItem.fields.selectedField = filters[0].title;
|
||||
setFilterItems([...filterItems, newItem]);
|
||||
};
|
||||
|
||||
const getBooking_requestsCSV = async () => {
|
||||
const response = await axios({url: '/booking_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 = 'booking_requestsCSV.csv'
|
||||
link.click()
|
||||
};
|
||||
const getBooking_requestsCSV = async () => {
|
||||
const response = await axios({url: '/booking_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 = 'booking_requestsCSV.csv'
|
||||
link.click()
|
||||
};
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
dispatch(setRefetch(true));
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
dispatch(setRefetch(true));
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
|
||||
const onModalCancel = () => {
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
const onModalCancel = () => {
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Booking_requests')}</title>
|
||||
<title>{getPageTitle('Booking Requests')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Booking_requests" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiChartTimelineVariant}
|
||||
title="Booking Requests"
|
||||
subtitle="A lighter intake board for incoming stay demand, approvals, and next actions."
|
||||
main
|
||||
/>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/booking_requests/booking_requests-new'} color='info' label='New Item'/>}
|
||||
|
||||
<BaseButton
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Filter'
|
||||
onClick={addFilter}
|
||||
/>
|
||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getBooking_requestsCSV} />
|
||||
|
||||
{hasCreatePermission && (
|
||||
<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={'/booking_requests/booking_requests-table'}>Switch to Table</Link>
|
||||
<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'>Keep demand moving</h2>
|
||||
<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'>
|
||||
<BaseButtons
|
||||
type='justify-start xl:justify-end'
|
||||
mb='mb-0'
|
||||
classAddon='mr-2 mb-2 last:mr-0'
|
||||
>
|
||||
{hasCreatePermission ? (
|
||||
<BaseButton href={'/booking_requests/booking_requests-new'} color='info' label='New request' />
|
||||
) : null}
|
||||
<BaseButton color='whiteDark' outline label='Add filter' onClick={addFilter} />
|
||||
<BaseButton color='whiteDark' outline label='Export CSV' onClick={getBooking_requestsCSV} />
|
||||
{hasCreatePermission ? (
|
||||
<BaseButton color='whiteDark' outline label='Import CSV' onClick={() => setIsModalActive(true)} />
|
||||
) : null}
|
||||
<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'>
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<TableBooking_requests
|
||||
filterItems={filterItems}
|
||||
setFilterItems={setFilterItems}
|
||||
filters={filters}
|
||||
showGrid={false}
|
||||
/>
|
||||
|
||||
<TableBooking_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}
|
||||
@ -170,9 +154,7 @@ const Booking_requestsTablesPage = () => {
|
||||
Booking_requestsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
permission={'READ_BOOKING_REQUESTS'}
|
||||
|
||||
>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
|
||||
@ -367,13 +367,13 @@ const Booking_requestsNew = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('New Item')}</title>
|
||||
<title>{getPageTitle('New Booking Request')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Booking Request" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<CardBox className='mx-auto max-w-6xl'>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
@ -383,6 +383,7 @@ const Booking_requestsNew = () => {
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<div className='grid gap-5 md:grid-cols-2'>
|
||||
|
||||
|
||||
|
||||
@ -1073,9 +1074,11 @@ const Booking_requestsNew = () => {
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
<BaseButton type="submit" color="info" label="Submit" />
|
||||
<BaseButton type="submit" color="info" label="Save" />
|
||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/booking_requests/booking_requests-list')}/>
|
||||
</BaseButtons>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
890
frontend/src/pages/command-center.tsx
Normal file
890
frontend/src/pages/command-center.tsx
Normal file
@ -0,0 +1,890 @@
|
||||
import {
|
||||
mdiAccountTie,
|
||||
mdiArrowTopRight,
|
||||
mdiCalendarCheck,
|
||||
mdiCheckDecagram,
|
||||
mdiClipboardTextOutline,
|
||||
mdiClockOutline,
|
||||
mdiFileDocument,
|
||||
mdiHomeCity,
|
||||
mdiRefresh,
|
||||
mdiRoomService,
|
||||
mdiViewDashboardOutline,
|
||||
} from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import { hasPermission } from '../helpers/userPermissions';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
|
||||
type AccessMap = {
|
||||
accounts: boolean;
|
||||
bookings: boolean;
|
||||
approvals: boolean;
|
||||
reservations: boolean;
|
||||
serviceRequests: boolean;
|
||||
invoices: boolean;
|
||||
inventory: boolean;
|
||||
};
|
||||
|
||||
type BookingRequestItem = {
|
||||
id: string;
|
||||
request_code: string | null;
|
||||
status: string;
|
||||
check_in_at: string | null;
|
||||
check_out_at: string | null;
|
||||
guest_count: number | null;
|
||||
preferred_bedrooms: number | null;
|
||||
updatedAt: string;
|
||||
organizationName: string;
|
||||
requestedBy: string | null;
|
||||
propertyName: string;
|
||||
unitTypeName: string;
|
||||
};
|
||||
|
||||
type ReservationItem = {
|
||||
id: string;
|
||||
reservation_code: string | null;
|
||||
status: string;
|
||||
check_in_at: string | null;
|
||||
check_out_at: string | null;
|
||||
guest_count: number | null;
|
||||
updatedAt: string;
|
||||
organizationName: string;
|
||||
propertyName: string;
|
||||
unitNumber: string;
|
||||
sourceRequestCode: string | null;
|
||||
};
|
||||
|
||||
type ServiceRequestItem = {
|
||||
id: string;
|
||||
request_type: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
summary: string | null;
|
||||
due_at: string | null;
|
||||
updatedAt: string;
|
||||
reservationCode: string | null;
|
||||
assignedTo: string | null;
|
||||
};
|
||||
|
||||
type InvoiceItem = {
|
||||
id: string;
|
||||
invoice_number: string | null;
|
||||
status: string;
|
||||
total_amount: number;
|
||||
balance_due: number;
|
||||
currency: string;
|
||||
due_at: string | null;
|
||||
updatedAt: string;
|
||||
organizationName: string;
|
||||
reservationCode: string | null;
|
||||
};
|
||||
|
||||
type OverviewResponse = {
|
||||
generatedAt: string;
|
||||
access: AccessMap;
|
||||
organizations: {
|
||||
total: number;
|
||||
};
|
||||
bookingRequests: {
|
||||
statusCounts: Record<string, number>;
|
||||
pendingReview: number;
|
||||
approvedReady: number;
|
||||
recent: BookingRequestItem[];
|
||||
};
|
||||
approvals: {
|
||||
pending: number;
|
||||
};
|
||||
reservations: {
|
||||
statusCounts: Record<string, number>;
|
||||
upcomingArrivals: number;
|
||||
upcomingDepartures: number;
|
||||
inHouse: number;
|
||||
recent: ReservationItem[];
|
||||
};
|
||||
serviceRequests: {
|
||||
statusCounts: Record<string, number>;
|
||||
open: number;
|
||||
urgent: number;
|
||||
recent: ServiceRequestItem[];
|
||||
};
|
||||
invoices: {
|
||||
statusCounts: Record<string, number>;
|
||||
openBalance: number;
|
||||
recent: InvoiceItem[];
|
||||
};
|
||||
inventory: {
|
||||
activeProperties: number;
|
||||
unitStatusCounts: Record<string, number>;
|
||||
};
|
||||
};
|
||||
|
||||
type MetricCard = {
|
||||
title: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
const emptyOverview: OverviewResponse = {
|
||||
generatedAt: '',
|
||||
access: {
|
||||
accounts: false,
|
||||
bookings: false,
|
||||
approvals: false,
|
||||
reservations: false,
|
||||
serviceRequests: false,
|
||||
invoices: false,
|
||||
inventory: false,
|
||||
},
|
||||
organizations: {
|
||||
total: 0,
|
||||
},
|
||||
bookingRequests: {
|
||||
statusCounts: {},
|
||||
pendingReview: 0,
|
||||
approvedReady: 0,
|
||||
recent: [],
|
||||
},
|
||||
approvals: {
|
||||
pending: 0,
|
||||
},
|
||||
reservations: {
|
||||
statusCounts: {},
|
||||
upcomingArrivals: 0,
|
||||
upcomingDepartures: 0,
|
||||
inHouse: 0,
|
||||
recent: [],
|
||||
},
|
||||
serviceRequests: {
|
||||
statusCounts: {},
|
||||
open: 0,
|
||||
urgent: 0,
|
||||
recent: [],
|
||||
},
|
||||
invoices: {
|
||||
statusCounts: {},
|
||||
openBalance: 0,
|
||||
recent: [],
|
||||
},
|
||||
inventory: {
|
||||
activeProperties: 0,
|
||||
unitStatusCounts: {},
|
||||
},
|
||||
};
|
||||
|
||||
const statusTone = {
|
||||
draft: 'bg-slate-100 text-slate-700',
|
||||
submitted: 'bg-amber-100 text-amber-800',
|
||||
in_review: 'bg-blue-100 text-blue-800',
|
||||
changes_requested: 'bg-orange-100 text-orange-800',
|
||||
approved: 'bg-emerald-100 text-emerald-800',
|
||||
converted_to_reservation: 'bg-emerald-100 text-emerald-800',
|
||||
quoted: 'bg-sky-100 text-sky-800',
|
||||
confirmed: 'bg-emerald-100 text-emerald-800',
|
||||
checked_in: 'bg-indigo-100 text-indigo-800',
|
||||
checked_out: 'bg-slate-100 text-slate-700',
|
||||
rejected: 'bg-rose-100 text-rose-800',
|
||||
canceled: 'bg-slate-100 text-slate-700',
|
||||
new: 'bg-amber-100 text-amber-800',
|
||||
triaged: 'bg-blue-100 text-blue-800',
|
||||
in_progress: 'bg-blue-100 text-blue-800',
|
||||
scheduled: 'bg-indigo-100 text-indigo-800',
|
||||
waiting_on_guest: 'bg-fuchsia-100 text-fuchsia-800',
|
||||
completed: 'bg-emerald-100 text-emerald-800',
|
||||
issued: 'bg-sky-100 text-sky-800',
|
||||
overdue: 'bg-rose-100 text-rose-800',
|
||||
partially_paid: 'bg-orange-100 text-orange-800',
|
||||
paid: 'bg-emerald-100 text-emerald-800',
|
||||
maintenance: 'bg-rose-100 text-rose-800',
|
||||
reserved: 'bg-amber-100 text-amber-800',
|
||||
occupied: 'bg-indigo-100 text-indigo-800',
|
||||
available: 'bg-emerald-100 text-emerald-800',
|
||||
cleaning_hold: 'bg-cyan-100 text-cyan-800',
|
||||
urgent: 'bg-rose-100 text-rose-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
normal: 'bg-slate-100 text-slate-700',
|
||||
low: 'bg-slate-100 text-slate-700',
|
||||
} as const;
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return 'TBD';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return 'No timestamp';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
const formatMoney = (amount: number, currency = 'USD') => {
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount || 0);
|
||||
} catch (_error) {
|
||||
return `$${amount || 0}`;
|
||||
}
|
||||
};
|
||||
|
||||
const humanizeToken = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return 'Unspecified';
|
||||
}
|
||||
|
||||
return value
|
||||
.split('_')
|
||||
.map((token) => token.charAt(0).toUpperCase() + token.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const getBadgeClassName = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return 'bg-slate-100 text-slate-700';
|
||||
}
|
||||
|
||||
return statusTone[value as keyof typeof statusTone] || 'bg-slate-100 text-slate-700';
|
||||
};
|
||||
|
||||
type ActionItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
visible: boolean;
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
type FocusItem = {
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type ActivitySection = {
|
||||
title: string;
|
||||
href: string;
|
||||
emptyState: string;
|
||||
items: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
meta: string;
|
||||
badge: string | null;
|
||||
extra?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const MetricSummaryCard = ({ title, value, detail, icon }: MetricCard) => (
|
||||
<CardBox className="h-full">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">{title}</p>
|
||||
<p className="mt-3 text-3xl font-semibold text-white">{value}</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{detail}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-3 text-slate-200">
|
||||
<BaseIcon path={icon} size={22} />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
);
|
||||
|
||||
const FocusListItem = ({ label, value, detail }: FocusItem) => (
|
||||
<div className="flex items-start justify-between gap-4 rounded-xl border border-white/10 bg-white/5 px-4 py-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{label}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">{detail}</div>
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-white">{value}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ActivityCard = ({ title, href, emptyState, items }: ActivitySection) => (
|
||||
<CardBox className="h-full">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
<p className="text-sm text-slate-400">Latest operational items.</p>
|
||||
</div>
|
||||
<Link href={href} className="text-sm font-medium text-sky-300 hover:text-sky-200">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-white/10 px-4 py-5 text-sm text-slate-400">
|
||||
{emptyState}
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-white/10 bg-white/5 px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{item.title}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">{item.subtitle}</div>
|
||||
</div>
|
||||
<span className={`rounded-full px-2.5 py-1 text-xs font-medium ${getBadgeClassName(item.badge)}`}>
|
||||
{humanizeToken(item.badge)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-slate-400">
|
||||
<span>{item.meta}</span>
|
||||
{item.extra ? <span>{item.extra}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
);
|
||||
|
||||
const CommandCenterPage = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [overview, setOverview] = useState<OverviewResponse>(emptyOverview);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const organizationName =
|
||||
currentUser?.organizations?.name || currentUser?.organization?.name || 'your corporate workspace';
|
||||
const firstName = currentUser?.firstName || currentUser?.email || 'team';
|
||||
const roleName = currentUser?.app_role?.name || 'Team member';
|
||||
const isSuperAdmin = Boolean(currentUser?.app_role?.globalAccess || roleName === 'Super Administrator');
|
||||
const isAdmin = !isSuperAdmin && roleName === 'Administrator';
|
||||
const dashboardLens = isSuperAdmin
|
||||
? 'Network oversight'
|
||||
: isAdmin
|
||||
? 'Approval and portfolio operations'
|
||||
: 'Concierge delivery';
|
||||
const dashboardHeadline = isSuperAdmin
|
||||
? 'See every handoff across accounts, inventory, service, and revenue.'
|
||||
: isAdmin
|
||||
? 'Approve faster, place stays cleanly, and keep billing exposure visible.'
|
||||
: 'Move travelers from request to arrival without losing operational detail.';
|
||||
const dashboardDescription = isSuperAdmin
|
||||
? `You share the same command center as every operating role, but your lens spans organizations, portfolio health, and revenue risk across ${organizationName}.`
|
||||
: isAdmin
|
||||
? `You work from the same live records as coordinators and leadership, with a sharper focus on approvals, reservations, and financial follow-through for ${organizationName}.`
|
||||
: `You operate from the same live workspace as administrators and leadership, focused on intake, guest movement, and service execution for ${organizationName}.`;
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
const response = await axios.get('/corporate-stay-portal/overview');
|
||||
setOverview(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load command center overview:', error);
|
||||
setErrorMessage('The command center could not load live operational data. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadOverview();
|
||||
}, [loadOverview]);
|
||||
|
||||
const canCreateBookingRequest = Boolean(currentUser && hasPermission(currentUser, 'CREATE_BOOKING_REQUESTS'));
|
||||
const canReadApprovals = Boolean(currentUser && hasPermission(currentUser, 'READ_APPROVAL_STEPS'));
|
||||
const canReadReservations = Boolean(currentUser && hasPermission(currentUser, 'READ_RESERVATIONS'));
|
||||
const canReadServiceRequests = Boolean(currentUser && hasPermission(currentUser, 'READ_SERVICE_REQUESTS'));
|
||||
const canReadInvoices = Boolean(currentUser && hasPermission(currentUser, 'READ_INVOICES'));
|
||||
const canReadBookings = Boolean(currentUser && hasPermission(currentUser, 'READ_BOOKING_REQUESTS'));
|
||||
|
||||
const metricCards: MetricCard[] = [
|
||||
{
|
||||
title: 'Pending review',
|
||||
value: `${overview.bookingRequests.pendingReview}`,
|
||||
detail: `${overview.approvals.pending} approvals are waiting on sign-off.`,
|
||||
icon: mdiClipboardTextOutline,
|
||||
},
|
||||
{
|
||||
title: 'Upcoming arrivals',
|
||||
value: `${overview.reservations.upcomingArrivals}`,
|
||||
detail: `${overview.reservations.inHouse} travelers are currently in house.`,
|
||||
icon: mdiCalendarCheck,
|
||||
},
|
||||
{
|
||||
title: 'Open service queue',
|
||||
value: `${overview.serviceRequests.open}`,
|
||||
detail: `${overview.serviceRequests.urgent} urgent requests need attention.`,
|
||||
icon: mdiRoomService,
|
||||
},
|
||||
{
|
||||
title: 'Open balance',
|
||||
value: formatMoney(overview.invoices.openBalance, overview.invoices.recent[0]?.currency || 'USD'),
|
||||
detail: `${overview.invoices.statusCounts.overdue || 0} invoices are overdue.`,
|
||||
icon: mdiFileDocument,
|
||||
},
|
||||
];
|
||||
|
||||
const quickActions: ActionItem[] = [
|
||||
{
|
||||
title: 'Create booking request',
|
||||
description: 'Start a new corporate stay request.',
|
||||
href: '/booking_requests/booking_requests-new',
|
||||
icon: mdiArrowTopRight,
|
||||
visible: canCreateBookingRequest,
|
||||
priority: isSuperAdmin ? 5 : isAdmin ? 4 : 1,
|
||||
},
|
||||
{
|
||||
title: 'Review approvals',
|
||||
description: 'Process pending client sign-off.',
|
||||
href: '/approval_steps/approval_steps-list',
|
||||
icon: mdiCheckDecagram,
|
||||
visible: canReadApprovals,
|
||||
priority: isSuperAdmin ? 3 : isAdmin ? 1 : 4,
|
||||
},
|
||||
{
|
||||
title: 'Open reservations',
|
||||
description: 'Confirm stays and monitor arrivals.',
|
||||
href: '/reservations/reservations-list',
|
||||
icon: mdiCalendarCheck,
|
||||
visible: canReadReservations,
|
||||
priority: isSuperAdmin ? 4 : isAdmin ? 2 : 2,
|
||||
},
|
||||
{
|
||||
title: 'Service queue',
|
||||
description: 'Stay on top of open guest and property requests.',
|
||||
href: '/service_requests/service_requests-list',
|
||||
icon: mdiRoomService,
|
||||
visible: canReadServiceRequests,
|
||||
priority: isSuperAdmin ? 6 : isAdmin ? 5 : 3,
|
||||
},
|
||||
{
|
||||
title: 'Finance view',
|
||||
description: 'Track invoice exposure and overdue items.',
|
||||
href: '/invoices/invoices-list',
|
||||
icon: mdiFileDocument,
|
||||
visible: canReadInvoices,
|
||||
priority: isSuperAdmin ? 1 : isAdmin ? 3 : 6,
|
||||
},
|
||||
{
|
||||
title: 'Portfolio view',
|
||||
description: 'Monitor active properties and operating capacity.',
|
||||
href: '/properties/properties-list',
|
||||
icon: mdiHomeCity,
|
||||
visible: overview.access.inventory,
|
||||
priority: isSuperAdmin ? 2 : isAdmin ? 6 : 5,
|
||||
},
|
||||
]
|
||||
.filter((item) => item.visible)
|
||||
.sort((left, right) => (left.priority || 99) - (right.priority || 99));
|
||||
|
||||
const laneTitle = isSuperAdmin
|
||||
? 'Leadership lane'
|
||||
: isAdmin
|
||||
? 'Approval and portfolio lane'
|
||||
: 'Concierge execution lane';
|
||||
|
||||
const laneDescription = isSuperAdmin
|
||||
? 'The same shared records, viewed through account health, supply, and revenue exposure.'
|
||||
: isAdmin
|
||||
? 'The same shared records, centered on approvals, placement quality, and billing follow-through.'
|
||||
: 'The same shared records, centered on intake speed, arrivals, and service coordination.';
|
||||
|
||||
const focusItems: FocusItem[] = isSuperAdmin
|
||||
? [
|
||||
{
|
||||
label: 'Organizations in scope',
|
||||
value: `${overview.organizations.total}`,
|
||||
detail: 'Corporate accounts currently visible in your network view.',
|
||||
},
|
||||
{
|
||||
label: 'Active properties',
|
||||
value: `${overview.inventory.activeProperties}`,
|
||||
detail: 'Supply currently available for corporate allocation.',
|
||||
},
|
||||
{
|
||||
label: 'Open balance',
|
||||
value: formatMoney(overview.invoices.openBalance, overview.invoices.recent[0]?.currency || 'USD'),
|
||||
detail: 'Outstanding invoice exposure across the current operating scope.',
|
||||
},
|
||||
{
|
||||
label: 'Urgent service items',
|
||||
value: `${overview.serviceRequests.urgent}`,
|
||||
detail: 'High-risk stays or property issues that may affect service quality.',
|
||||
},
|
||||
]
|
||||
: isAdmin
|
||||
? [
|
||||
{
|
||||
label: 'Approvals waiting',
|
||||
value: `${overview.approvals.pending}`,
|
||||
detail: 'Requests that still need client or internal sign-off.',
|
||||
},
|
||||
{
|
||||
label: 'Approved and ready',
|
||||
value: `${overview.bookingRequests.approvedReady}`,
|
||||
detail: 'Demand ready to quote or convert into reservations.',
|
||||
},
|
||||
{
|
||||
label: 'Arrivals ahead',
|
||||
value: `${overview.reservations.upcomingArrivals}`,
|
||||
detail: 'Upcoming arrivals that may need final placement review.',
|
||||
},
|
||||
{
|
||||
label: 'Open balance',
|
||||
value: formatMoney(overview.invoices.openBalance, overview.invoices.recent[0]?.currency || 'USD'),
|
||||
detail: 'Outstanding billing that may need follow-up before closeout.',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: 'New demand',
|
||||
value: `${overview.bookingRequests.pendingReview}`,
|
||||
detail: 'Requests currently entering review or awaiting next action.',
|
||||
},
|
||||
{
|
||||
label: 'Guests in house',
|
||||
value: `${overview.reservations.inHouse}`,
|
||||
detail: 'Travelers currently active across your operating scope.',
|
||||
},
|
||||
{
|
||||
label: 'Service queue',
|
||||
value: `${overview.serviceRequests.open}`,
|
||||
detail: 'Open guest or property requests needing execution.',
|
||||
},
|
||||
{
|
||||
label: 'Departures ahead',
|
||||
value: `${overview.reservations.upcomingDepartures}`,
|
||||
detail: 'Upcoming departures that may require extensions or billing checks.',
|
||||
},
|
||||
];
|
||||
|
||||
const connectedWorkflow = [
|
||||
{
|
||||
title: 'Demand intake',
|
||||
owner: 'Concierge Coordinator',
|
||||
value: `${overview.bookingRequests.pendingReview}`,
|
||||
detail: 'Booking requests enter here, with traveler needs, dates, and preferred placement captured once for everyone.',
|
||||
href: '/booking_requests/booking_requests-list',
|
||||
visible: canReadBookings || canCreateBookingRequest,
|
||||
},
|
||||
{
|
||||
title: 'Approval and placement',
|
||||
owner: 'Administrator',
|
||||
value: `${overview.bookingRequests.approvedReady}`,
|
||||
detail: 'Approved demand becomes placement-ready work, so administrators can quote, confirm, and convert stays without rekeying data.',
|
||||
href: '/approval_steps/approval_steps-list',
|
||||
visible: canReadApprovals || canReadReservations,
|
||||
},
|
||||
{
|
||||
title: 'Stay execution',
|
||||
owner: 'Concierge Coordinator',
|
||||
value: `${overview.serviceRequests.open}`,
|
||||
detail: 'Confirmed reservations carry forward into arrivals, in-house service, and operational follow-through.',
|
||||
href: '/service_requests/service_requests-list',
|
||||
visible: canReadReservations || canReadServiceRequests,
|
||||
},
|
||||
{
|
||||
title: 'Oversight and finance',
|
||||
owner: 'Super Administrator',
|
||||
value: formatMoney(overview.invoices.openBalance, overview.invoices.recent[0]?.currency || 'USD'),
|
||||
detail: 'Leadership sees the same records aggregated across accounts, portfolio capacity, and invoice exposure.',
|
||||
href: '/invoices/invoices-list',
|
||||
visible: canReadInvoices || overview.access.inventory || overview.access.accounts,
|
||||
},
|
||||
].filter((step) => step.visible);
|
||||
|
||||
const activitySections: ActivitySection[] = [
|
||||
{
|
||||
title: 'Booking requests',
|
||||
href: '/booking_requests/booking_requests-list',
|
||||
emptyState: 'No recent booking requests in scope.',
|
||||
items: canReadBookings
|
||||
? overview.bookingRequests.recent.slice(0, 3).map((item) => ({
|
||||
id: item.id,
|
||||
title: item.request_code || 'Booking request',
|
||||
subtitle: `${item.organizationName} · ${item.propertyName || item.unitTypeName || 'Pending property'}`,
|
||||
meta: `${formatDate(item.check_in_at)} → ${formatDate(item.check_out_at)}`,
|
||||
extra: `${item.guest_count || 0} guests`,
|
||||
badge: item.status,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
{
|
||||
title: 'Reservations',
|
||||
href: '/reservations/reservations-list',
|
||||
emptyState: 'No recent reservations in scope.',
|
||||
items: canReadReservations
|
||||
? overview.reservations.recent.slice(0, 3).map((item) => ({
|
||||
id: item.id,
|
||||
title: item.reservation_code || 'Reservation',
|
||||
subtitle: `${item.organizationName} · ${item.propertyName || item.unitNumber || 'Pending unit'}`,
|
||||
meta: `${formatDate(item.check_in_at)} → ${formatDate(item.check_out_at)}`,
|
||||
extra: item.sourceRequestCode ? `From ${item.sourceRequestCode}` : undefined,
|
||||
badge: item.status,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
{
|
||||
title: 'Service requests',
|
||||
href: '/service_requests/service_requests-list',
|
||||
emptyState: 'No recent service requests in scope.',
|
||||
items: canReadServiceRequests
|
||||
? overview.serviceRequests.recent.slice(0, 3).map((item) => ({
|
||||
id: item.id,
|
||||
title: humanizeToken(item.request_type),
|
||||
subtitle: item.summary || item.assignedTo || 'Awaiting assignment',
|
||||
meta: item.due_at ? `Due ${formatDate(item.due_at)}` : `Updated ${formatDateTime(item.updatedAt)}`,
|
||||
extra: item.reservationCode ? `Reservation ${item.reservationCode}` : undefined,
|
||||
badge: item.priority === 'urgent' ? item.priority : item.status,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
{
|
||||
title: 'Invoices',
|
||||
href: '/invoices/invoices-list',
|
||||
emptyState: 'No recent invoices in scope.',
|
||||
items: canReadInvoices
|
||||
? overview.invoices.recent.slice(0, 3).map((item) => ({
|
||||
id: item.id,
|
||||
title: item.invoice_number || 'Invoice draft',
|
||||
subtitle: item.organizationName,
|
||||
meta: `Balance ${formatMoney(item.balance_due, item.currency)}`,
|
||||
extra: item.due_at ? `Due ${formatDate(item.due_at)}` : undefined,
|
||||
badge: item.status,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
].filter((section) => section.items.length > 0);
|
||||
|
||||
const hasModuleAccess = Object.values(overview.access).some(Boolean);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Command Center')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiViewDashboardOutline}
|
||||
title="Command Center"
|
||||
subtitle="One shared operating dashboard with a different lens for each role."
|
||||
main
|
||||
>
|
||||
<BaseButton color="whiteDark" icon={mdiRefresh} label="Refresh" onClick={loadOverview} />
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
{errorMessage ? (
|
||||
<CardBox className="mb-6 border border-rose-200 bg-rose-50">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold uppercase tracking-[0.18em] text-rose-700">
|
||||
Operational feed unavailable
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-rose-900">{errorMessage}</p>
|
||||
</div>
|
||||
<BaseButton color="danger" label="Retry" onClick={loadOverview} />
|
||||
</div>
|
||||
</CardBox>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<CardBox className="mb-6">
|
||||
<div className="flex items-center gap-3 text-sm text-slate-300">
|
||||
<BaseIcon path={mdiClockOutline} className="animate-pulse text-slate-400" />
|
||||
Building the latest corporate operations snapshot...
|
||||
</div>
|
||||
</CardBox>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !hasModuleAccess ? (
|
||||
<CardBox>
|
||||
<div className="max-w-2xl">
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-slate-400">Limited role access</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">
|
||||
Your account is authenticated, but it does not currently expose operational modules.
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-400">
|
||||
Ask a super admin to grant booking, reservation, concierge, or finance permissions. Once those are
|
||||
enabled, this workspace will populate automatically with live data.
|
||||
</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
) : null}
|
||||
|
||||
{!isLoading && hasModuleAccess ? (
|
||||
<>
|
||||
<section className="mb-8 overflow-hidden rounded-2xl border border-white/10 bg-slate-950 shadow-lg">
|
||||
<div className="grid gap-6 px-6 py-7 lg:grid-cols-[1.35fr_0.85fr] lg:px-8">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.24em] text-slate-300">
|
||||
<BaseIcon path={mdiAccountTie} className="text-slate-200" />
|
||||
Shared dashboard · {dashboardLens}
|
||||
</div>
|
||||
<div className="mt-4 inline-flex items-center rounded-full border border-cyan-300/20 bg-cyan-400/10 px-3 py-1 text-xs font-medium text-cyan-100">
|
||||
Role: {roleName}
|
||||
</div>
|
||||
<h2 className="mt-4 max-w-3xl text-3xl font-semibold leading-tight text-white md:text-4xl">
|
||||
{dashboardHeadline}
|
||||
</h2>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-7 text-slate-300 md:text-base">
|
||||
Welcome back, {firstName}. {dashboardDescription}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{quickActions.map((action, index) => (
|
||||
<BaseButton
|
||||
key={action.title}
|
||||
href={action.href}
|
||||
color={index === 0 ? 'info' : 'whiteDark'}
|
||||
icon={action.icon}
|
||||
label={action.title}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Corporate accounts</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-white">{overview.organizations.total}</div>
|
||||
<div className="mt-2 text-sm text-slate-400">Organizations in the current operating scope.</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Live properties</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-white">{overview.inventory.activeProperties}</div>
|
||||
<div className="mt-2 text-sm text-slate-400">Properties open for negotiated corporate stays.</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Last refresh</div>
|
||||
<div className="mt-2 text-xl font-semibold text-white">{formatDateTime(overview.generatedAt)}</div>
|
||||
<div className="mt-2 text-sm text-slate-400">Live snapshot across operations and billing.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{metricCards.map((card) => (
|
||||
<MetricSummaryCard key={card.title} {...card} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<CardBox>
|
||||
<div className="mb-5 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Core workflows</h3>
|
||||
<p className="text-sm text-slate-400">Use the main operational flows without opening the full menu.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{quickActions.length ? (
|
||||
quickActions.map((action) => (
|
||||
<Link
|
||||
key={action.title}
|
||||
href={action.href}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-4 py-4 transition hover:bg-white/10"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-2 text-slate-200">
|
||||
<BaseIcon path={action.icon} size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{action.title}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">{action.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 px-4 py-5 text-sm text-slate-400">
|
||||
No primary workflows are available for your current role.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
<div className="mb-5 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{laneTitle}</h3>
|
||||
<p className="text-sm text-slate-400">{laneDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{focusItems.map((item) => (
|
||||
<FocusListItem key={item.label} {...item} />
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
<CardBox className="mb-8">
|
||||
<div className="mb-5 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Interconnected operating model</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
All three account types land in the same command center, but each role acts on a different part of the same live workflow.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{connectedWorkflow.map((step) => (
|
||||
<Link
|
||||
key={step.title}
|
||||
href={step.href}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-4 py-4 transition hover:bg-white/10"
|
||||
>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{step.owner}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{step.title}</div>
|
||||
<div className="mt-3 text-2xl font-semibold text-white">{step.value}</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-400">{step.detail}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2 2xl:grid-cols-4">
|
||||
{activitySections.map((section) => (
|
||||
<ActivityCard key={section.title} {...section} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CommandCenterPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default CommandCenterPage;
|
||||
@ -1,166 +1,177 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
mdiArrowTopRight,
|
||||
mdiCalendarCheck,
|
||||
mdiCheckDecagram,
|
||||
mdiFileDocument,
|
||||
mdiHomeCity,
|
||||
mdiRoomService,
|
||||
mdiShieldLockOutline,
|
||||
} from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
|
||||
const platformHighlights = [
|
||||
{
|
||||
title: 'Corporate accounts',
|
||||
description: 'Manage negotiated rate plans, account history, billing rules, and repeat-booker relationships.',
|
||||
icon: mdiShieldLockOutline,
|
||||
},
|
||||
{
|
||||
title: 'Reservation control',
|
||||
description: 'Move a stay from request to approval, inventory assignment, confirmation, arrival, and post-stay handling.',
|
||||
icon: mdiCalendarCheck,
|
||||
},
|
||||
{
|
||||
title: 'Concierge operations',
|
||||
description: 'Track airport pickup, housekeeping, maintenance, extension, and custom guest service requests.',
|
||||
icon: mdiRoomService,
|
||||
},
|
||||
{
|
||||
title: 'Finance visibility',
|
||||
description: 'Issue invoices, watch overdue exposure, and keep executive housing accounts commercially disciplined.',
|
||||
icon: mdiFileDocument,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('image');
|
||||
const [contentPosition, setContentPosition] = useState('right');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
|
||||
const title = 'Gracey Corporate Stay Portal'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
const workflowSteps = [
|
||||
'Corporate booker submits a stay request',
|
||||
'Approver reviews against policy and budget',
|
||||
'Reservations team quotes and allocates inventory',
|
||||
'Traveler receives confirmed stay details',
|
||||
'Concierge and finance teams manage service and billing',
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Gracey Corporate Stay Portal')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your Gracey Corporate Stay Portal app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center '>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
<main className="min-h-screen bg-[#f4f6f8] text-slate-950">
|
||||
<section className="border-b border-slate-200 bg-[#0b1220] text-white">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-12 px-6 py-6 lg:px-10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-slate-400">Gracey Corporate Stay Portal</div>
|
||||
<div className="mt-2 text-sm text-slate-300">Executive software for serviced apartments, embassy housing, and long-stay corporate operations.</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<BaseButton href="/login" color="whiteDark" label="Login" />
|
||||
<BaseButton href="/dashboard" color="info" label="Admin interface" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
<div className="grid gap-10 lg:grid-cols-[1.2fr_0.8fr] lg:items-end">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.24em] text-slate-300">
|
||||
<BaseIcon path={mdiHomeCity} className="text-slate-100" />
|
||||
Luxury corporate stay operations
|
||||
</div>
|
||||
<h1 className="mt-6 max-w-4xl text-4xl font-semibold leading-tight text-white md:text-6xl">
|
||||
The control layer for premium furnished inventory, approvals, guest logistics, and invoice billing.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-slate-300">
|
||||
Gracey helps serviced-apartment operators win repeat institutional accounts with faster quoting,
|
||||
cleaner approval workflows, better arrival readiness, and a more executive operating posture.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<BaseButton href="/command-center" color="info" label="Open command center" icon={mdiArrowTopRight} />
|
||||
<BaseButton href="/booking_requests/booking_requests-list" color="whiteDark" label="Review booking workflow" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
<CardBox className="border border-white/10 bg-white/5 text-white shadow-[0_30px_80px_-40px_rgba(15,23,42,0.9)]">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Use cases</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">Corporate teams, NGOs, embassies</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-300">Built for medium-stay housing that needs negotiated rates, approvals, and invoice-grade accountability.</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Operational posture</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">Cold, executive, credible</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-300">No consumer fluff. Just the controls needed to run luxury serviced inventory for professional clients.</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<section className="mx-auto max-w-7xl px-6 py-16 lg:px-10">
|
||||
<div className="grid gap-6 lg:grid-cols-4">
|
||||
{platformHighlights.map((item) => (
|
||||
<CardBox key={item.title} className="border border-slate-200 bg-white shadow-[0_20px_60px_-40px_rgba(15,23,42,0.45)]">
|
||||
<div className="rounded-2xl bg-slate-950 p-3 text-white w-fit">
|
||||
<BaseIcon path={item.icon} size={20} />
|
||||
</div>
|
||||
<h2 className="mt-5 text-2xl font-semibold text-slate-950">{item.title}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600">{item.description}</p>
|
||||
</CardBox>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-7xl px-6 pb-16 lg:px-10">
|
||||
<div className="grid gap-8 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<CardBox className="border border-slate-200 bg-white shadow-[0_20px_60px_-40px_rgba(15,23,42,0.45)]">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-slate-500">First delivered workflow</div>
|
||||
<h2 className="mt-3 text-3xl font-semibold text-slate-950">Corporate stay booking lifecycle</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-600">
|
||||
The initial slice connects the public brand front door to a live authenticated command center, then into the existing request, approval, reservation, concierge, and finance records.
|
||||
</p>
|
||||
<BaseDivider />
|
||||
<div className="space-y-4">
|
||||
{workflowSteps.map((step, index) => (
|
||||
<div key={step} className="flex gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-950 text-sm font-semibold text-white">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="text-sm leading-7 text-slate-700">{step}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border border-slate-200 bg-[#0f172a] text-white shadow-[0_24px_70px_-38px_rgba(15,23,42,0.8)]">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-slate-400">Immediate access</div>
|
||||
<h2 className="mt-3 text-3xl font-semibold text-white">Choose your entry point</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">
|
||||
Enter the authenticated workspace to manage live operations, or go straight to the broader admin environment.
|
||||
</p>
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Operations</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">Command center</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-300">Live view across bookings, reservations, service pressure, and invoice exposure.</div>
|
||||
<div className="mt-5">
|
||||
<BaseButton href="/command-center" color="info" label="Open workspace" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-slate-400">Administration</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">Admin dashboard</div>
|
||||
<div className="mt-2 text-sm leading-6 text-slate-300">Access entity management, permissions, and full system configuration.</div>
|
||||
<div className="mt-5">
|
||||
<BaseButton href="/dashboard" color="whiteDark" label="Open admin" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
@ -470,19 +470,20 @@ const EditPropertiesPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Edit properties')}</title>
|
||||
<title>{getPageTitle('Edit Property')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit properties'} main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit Property'} main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<CardBox className='mx-auto max-w-6xl'>
|
||||
<Formik
|
||||
enableReinitialize
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<div className='grid gap-5 md:grid-cols-2'>
|
||||
|
||||
|
||||
|
||||
@ -1320,9 +1321,11 @@ const EditPropertiesPage = () => {
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
<BaseButton type="submit" color="info" label="Submit" />
|
||||
<BaseButton type="submit" color="info" label="Save" />
|
||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/properties/properties-list')}/>
|
||||
</BaseButtons>
|
||||
|
||||
@ -9,8 +9,8 @@ import SectionTitleLineWithButton from '../../components/SectionTitleLineWithBut
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableProperties from '../../components/Properties/TableProperties'
|
||||
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";
|
||||
@ -18,6 +18,7 @@ import {setRefetch, uploadCsv} from '../../stores/properties/propertiesSlice';
|
||||
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
import { humanize } from "../../helpers/humanize";
|
||||
|
||||
|
||||
|
||||
@ -25,67 +26,53 @@ const PropertiesTablesPage = () => {
|
||||
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 [filters] = useState([{label: 'Propertyname', title: 'name'},{label: 'Propertycode', title: 'code'},{label: 'Address', title: 'address'},{label: 'City', title: 'city'},{label: 'Country', title: 'country'},{label: 'Timezone', title: 'timezone'},{label: 'Description', title: 'description'},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const [filters] = useState(([{label: 'Propertyname', title: 'name'},{label: 'Propertycode', title: 'code'},{label: 'Address', title: 'address'},{label: 'City', title: 'city'},{label: 'Country', title: 'country'},{label: 'Timezone', title: 'timezone'},{label: 'Description', title: 'description'},
|
||||
{label: 'Tenant', title: 'tenant'},
|
||||
{label: 'Unittypes', title: 'unit_types'},{label: 'Units', title: 'units'},{label: 'Amenities', title: 'amenities'},
|
||||
]).map((filter) => ({ ...filter, label: humanize(filter.title) })));
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_PROPERTIES');
|
||||
|
||||
{label: 'Unittypes', title: 'unit_types'},{label: 'Units', title: 'units'},{label: 'Amenities', title: 'amenities'},
|
||||
|
||||
]);
|
||||
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_PROPERTIES');
|
||||
|
||||
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: '',
|
||||
},
|
||||
};
|
||||
newItem.fields.selectedField = filters[0].title;
|
||||
setFilterItems([...filterItems, newItem]);
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: '',
|
||||
},
|
||||
};
|
||||
newItem.fields.selectedField = filters[0].title;
|
||||
setFilterItems([...filterItems, newItem]);
|
||||
};
|
||||
|
||||
const getPropertiesCSV = async () => {
|
||||
const response = await axios({url: '/properties?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 = 'propertiesCSV.csv'
|
||||
link.click()
|
||||
};
|
||||
const getPropertiesCSV = async () => {
|
||||
const response = await axios({url: '/properties?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 = 'propertiesCSV.csv'
|
||||
link.click()
|
||||
};
|
||||
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
dispatch(setRefetch(true));
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
const onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
dispatch(setRefetch(true));
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
|
||||
const onModalCancel = () => {
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
const onModalCancel = () => {
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -93,37 +80,43 @@ const PropertiesTablesPage = () => {
|
||||
<title>{getPageTitle('Properties')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Properties" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiChartTimelineVariant}
|
||||
title="Properties"
|
||||
subtitle="A lighter portfolio view for locations, availability, and inventory context."
|
||||
main
|
||||
/>
|
||||
|
||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/properties/properties-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={getPropertiesCSV} />
|
||||
|
||||
{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={'/properties/properties-table'}>Switch to Table</Link>
|
||||
<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'>Curate the portfolio</h2>
|
||||
<p className='mt-1 text-sm text-slate-400'>Add properties, refine filters, export data, or switch into a detailed table when needed.</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-3 xl:items-end'>
|
||||
<BaseButtons
|
||||
type='justify-start xl:justify-end'
|
||||
mb='mb-0'
|
||||
classAddon='mr-2 mb-2 last:mr-0'
|
||||
>
|
||||
{hasCreatePermission ? (
|
||||
<BaseButton href={'/properties/properties-new'} color='info' label='New property' />
|
||||
) : null}
|
||||
<BaseButton color='whiteDark' outline label='Add filter' onClick={addFilter} />
|
||||
<BaseButton color='whiteDark' outline label='Export CSV' onClick={getPropertiesCSV} />
|
||||
{hasCreatePermission ? (
|
||||
<BaseButton color='whiteDark' outline label='Import CSV' onClick={() => setIsModalActive(true)} />
|
||||
) : null}
|
||||
<BaseButton href={'/properties/properties-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>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="mb-6" hasTable>
|
||||
@ -134,13 +127,11 @@ const PropertiesTablesPage = () => {
|
||||
showGrid={false}
|
||||
/>
|
||||
</CardBox>
|
||||
|
||||
</SectionMain>
|
||||
<CardBoxModal
|
||||
title='Upload CSV'
|
||||
buttonColor='info'
|
||||
buttonLabel={'Confirm'}
|
||||
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
|
||||
isActive={isModalActive}
|
||||
onConfirm={onModalConfirm}
|
||||
onCancel={onModalCancel}
|
||||
@ -158,9 +149,7 @@ const PropertiesTablesPage = () => {
|
||||
PropertiesTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
permission={'READ_PROPERTIES'}
|
||||
|
||||
>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
|
||||
@ -270,13 +270,13 @@ const PropertiesNew = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('New Item')}</title>
|
||||
<title>{getPageTitle('New Property')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Property" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<CardBox className='mx-auto max-w-6xl'>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
@ -286,6 +286,7 @@ const PropertiesNew = () => {
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<div className='grid gap-5 md:grid-cols-2'>
|
||||
|
||||
|
||||
|
||||
@ -770,9 +771,11 @@ const PropertiesNew = () => {
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
<BaseButton type="submit" color="info" label="Submit" />
|
||||
<BaseButton type="submit" color="info" label="Save" />
|
||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/properties/properties-list')}/>
|
||||
</BaseButtons>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -778,19 +778,20 @@ const EditReservationsPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Edit reservations')}</title>
|
||||
<title>{getPageTitle('Edit Reservation')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit reservations'} main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit Reservation'} main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<CardBox className='mx-auto max-w-6xl'>
|
||||
<Formik
|
||||
enableReinitialize
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<div className='grid gap-5 md:grid-cols-2'>
|
||||
|
||||
|
||||
|
||||
@ -2443,9 +2444,11 @@ const EditReservationsPage = () => {
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
<BaseButton type="submit" color="info" label="Submit" />
|
||||
<BaseButton type="submit" color="info" label="Save" />
|
||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/reservations/reservations-list')}/>
|
||||
</BaseButtons>
|
||||
|
||||
@ -9,8 +9,8 @@ import SectionTitleLineWithButton from '../../components/SectionTitleLineWithBut
|
||||
import { getPageTitle } from '../../config'
|
||||
import TableReservations from '../../components/Reservations/TableReservations'
|
||||
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";
|
||||
@ -18,6 +18,7 @@ import {setRefetch, uploadCsv} from '../../stores/reservations/reservationsSlice
|
||||
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
import { humanize } from "../../helpers/humanize";
|
||||
|
||||
|
||||
|
||||
@ -25,85 +26,61 @@ const ReservationsTablesPage = () => {
|
||||
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 [filters] = useState([{label: 'Reservationcode', title: 'reservation_code'},{label: 'Currency', title: 'currency'},{label: 'Internalnotes', title: 'internal_notes'},{label: 'Externalnotes', title: 'external_notes'},
|
||||
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 hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_RESERVATIONS');
|
||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_RESERVATIONS');
|
||||
|
||||
|
||||
const addFilter = () => {
|
||||
const newItem = {
|
||||
id: uniqueId(),
|
||||
fields: {
|
||||
filterValue: '',
|
||||
filterValueFrom: '',
|
||||
filterValueTo: '',
|
||||
selectedField: '',
|
||||
},
|
||||
};
|
||||
newItem.fields.selectedField = filters[0].title;
|
||||
setFilterItems([...filterItems, newItem]);
|
||||
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 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 onModalConfirm = async () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
dispatch(setRefetch(true));
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
|
||||
const onModalCancel = () => {
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
const onModalCancel = () => {
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -111,37 +88,43 @@ const ReservationsTablesPage = () => {
|
||||
<title>{getPageTitle('Reservations')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Reservations" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiChartTimelineVariant}
|
||||
title="Reservations"
|
||||
subtitle="A cleaner live calendar for arrivals, departures, and stay execution."
|
||||
main
|
||||
/>
|
||||
|
||||
{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'>
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<Link href={'/reservations/reservations-table'}>Switch to Table</Link>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-3 xl:items-end'>
|
||||
<BaseButtons
|
||||
type='justify-start xl:justify-end'
|
||||
mb='mb-0'
|
||||
classAddon='mr-2 mb-2 last:mr-0'
|
||||
>
|
||||
{hasCreatePermission ? (
|
||||
<BaseButton href={'/reservations/reservations-new'} color='info' label='New reservation' />
|
||||
) : null}
|
||||
<BaseButton color='whiteDark' outline label='Add filter' onClick={addFilter} />
|
||||
<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' />
|
||||
</BaseButtons>
|
||||
|
||||
<div className='flex min-h-[40px] items-center justify-start xl:justify-end'>
|
||||
<div id='delete-rows-button'></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="mb-6" hasTable>
|
||||
@ -152,13 +135,11 @@ const ReservationsTablesPage = () => {
|
||||
showGrid={false}
|
||||
/>
|
||||
</CardBox>
|
||||
|
||||
</SectionMain>
|
||||
<CardBoxModal
|
||||
title='Upload CSV'
|
||||
buttonColor='info'
|
||||
buttonLabel={'Confirm'}
|
||||
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
|
||||
isActive={isModalActive}
|
||||
onConfirm={onModalConfirm}
|
||||
onCancel={onModalCancel}
|
||||
@ -176,9 +157,7 @@ const ReservationsTablesPage = () => {
|
||||
ReservationsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<LayoutAuthenticated
|
||||
|
||||
permission={'READ_RESERVATIONS'}
|
||||
|
||||
>
|
||||
{page}
|
||||
</LayoutAuthenticated>
|
||||
|
||||
@ -450,13 +450,13 @@ const ReservationsNew = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('New Item')}</title>
|
||||
<title>{getPageTitle('New Reservation')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Reservation" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<CardBox className='mx-auto max-w-6xl'>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
@ -473,6 +473,7 @@ const ReservationsNew = () => {
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<div className='grid gap-5 md:grid-cols-2'>
|
||||
|
||||
|
||||
|
||||
@ -1328,9 +1329,11 @@ const ReservationsNew = () => {
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
<BaseButton type="submit" color="info" label="Submit" />
|
||||
<BaseButton type="submit" color="info" label="Save" />
|
||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/reservations/reservations-list')}/>
|
||||
</BaseButtons>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,7 @@
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import { useAppDispatch } from '../stores/hooks';
|
||||
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
|
||||
@ -330,19 +330,20 @@ const EditUnitsPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Edit units')}</title>
|
||||
<title>{getPageTitle('Edit Unit')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit units'} main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit Unit'} main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<CardBox className='mx-auto max-w-6xl'>
|
||||
<Formik
|
||||
enableReinitialize
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<div className='grid gap-5 md:grid-cols-2'>
|
||||
|
||||
|
||||
|
||||
@ -941,9 +942,11 @@ const EditUnitsPage = () => {
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
<BaseButton type="submit" color="info" label="Submit" />
|
||||
<BaseButton type="submit" color="info" label="Save" />
|
||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/units/units-list')}/>
|
||||
</BaseButtons>
|
||||
|
||||
@ -191,13 +191,13 @@ const UnitsNew = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('New Item')}</title>
|
||||
<title>{getPageTitle('New Unit')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
|
||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Unit" main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<CardBox className='mx-auto max-w-6xl'>
|
||||
<Formik
|
||||
initialValues={
|
||||
|
||||
@ -207,6 +207,7 @@ const UnitsNew = () => {
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<div className='grid gap-5 md:grid-cols-2'>
|
||||
|
||||
|
||||
|
||||
@ -514,9 +515,11 @@ const UnitsNew = () => {
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
<BaseButton type="submit" color="info" label="Submit" />
|
||||
<BaseButton type="submit" color="info" label="Save" />
|
||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/units/units-list')}/>
|
||||
</BaseButtons>
|
||||
|
||||
@ -53,31 +53,31 @@ export const white: StyleObject = {
|
||||
|
||||
|
||||
export const midnightBlueTheme: StyleObject = {
|
||||
aside: 'bg-midnightBlueTheme-800 text-midnightBlueTheme-text dark:text-white lg:rounded-lg',
|
||||
aside: 'bg-slate-950/95 text-slate-100 dark:text-white lg:rounded-2xl',
|
||||
asideScrollbars: 'aside-scrollbars-blue',
|
||||
asideBrand: 'text-blue-500 bg-white',
|
||||
asideBrand: 'border-b border-white/10 bg-slate-950/95 text-white',
|
||||
asideMenuItem:
|
||||
'text-midnightBlueTheme-text hover:text-white dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800 dark:text-white',
|
||||
asideMenuItemActive: 'font-bold text-white dark:text-white',
|
||||
activeLinkColor: 'bg-midnightBlueTheme-buttonColor rounded-lg',
|
||||
asideMenuDropdown: 'bg-blue-700/50',
|
||||
'text-slate-100/90 hover:bg-white/8 hover:text-white dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800',
|
||||
asideMenuItemActive: 'font-semibold text-white dark:text-white',
|
||||
activeLinkColor: 'bg-cyan-400/12 rounded-xl border border-cyan-300/15 shadow-none',
|
||||
asideMenuDropdown: 'bg-white/4',
|
||||
navBarItemLabel: 'text-primaryText',
|
||||
iconsColor: 'text-midnightBlueTheme-iconsColor dark:text-blue-500',
|
||||
iconsColor: 'text-cyan-300 dark:text-blue-500',
|
||||
navBarItemLabelHover: 'hover:text-stone-400',
|
||||
navBarItemLabelActiveColor: 'text-midnightBlueTheme-800',
|
||||
overlay: 'bg-midnightBlueTheme-mainBG',
|
||||
bgLayoutColor: 'bg-midnightBlueTheme-mainBG',
|
||||
cardsColor: 'bg-midnightBlueTheme-cardColor',
|
||||
focusRingColor:
|
||||
'focus:ring focus:ring-midnightBlueTheme-800 focus:border-midnightBlueTheme-800 focus:outline-none border border-gray-600 dark:focus:ring-blue-600 dark:focus:border-blue-600',
|
||||
corners: 'rounded-lg',
|
||||
cardsStyle: 'bg-midnightBlueTheme-outsideCardColor border border-midnightBlueTheme-outsideCardColor shadow-xl',
|
||||
'focus:ring focus:ring-midnightBlueTheme-800 focus:border-midnightBlueTheme-800 focus:outline-none border border-gray-600 dark:focus:ring-blue-600 dark:focus:border-blue-600',
|
||||
corners: 'rounded-xl',
|
||||
cardsStyle: 'bg-midnightBlueTheme-outsideCardColor border border-white/10 shadow-lg',
|
||||
linkColor: 'text-midnightBlueTheme-buttonColor',
|
||||
websiteHeder: 'border-b border-white border-opacity-10 shadow-md',
|
||||
borders: 'border-white border-opacity-10',
|
||||
websiteHeder: 'border-b border-white border-opacity-10',
|
||||
borders: 'border-white/10',
|
||||
shadow: 'shadow-md',
|
||||
websiteSectionStyle: ' bg-midnightBlueTheme-webSiteComponentBg text-white',
|
||||
textSecondary: 'text-gray-300',
|
||||
textSecondary: 'text-slate-400',
|
||||
};
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user