diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/db/models/booking_requests.js b/backend/src/db/models/booking_requests.js index 6fa79e3..69034be 100644 --- a/backend/src/db/models/booking_requests.js +++ b/backend/src/db/models/booking_requests.js @@ -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; }; - - diff --git a/backend/src/db/models/properties.js b/backend/src/db/models/properties.js index 25b0587..88bf090 100644 --- a/backend/src/db/models/properties.js +++ b/backend/src/db/models/properties.js @@ -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', }); diff --git a/backend/src/db/models/unit_types.js b/backend/src/db/models/unit_types.js index 6aa4b23..d94ee28 100644 --- a/backend/src/db/models/unit_types.js +++ b/backend/src/db/models/unit_types.js @@ -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', }); diff --git a/backend/src/index.js b/backend/src/index.js index aab8a61..bdeb726 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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 }), diff --git a/backend/src/routes/corporate_stay_portal.js b/backend/src/routes/corporate_stay_portal.js new file mode 100644 index 0000000..7e51cb7 --- /dev/null +++ b/backend/src/routes/corporate_stay_portal.js @@ -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; diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 89767ec..00d0a57 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -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: "", diff --git a/frontend/src/components/AsideMenuItem.tsx b/frontend/src/components/AsideMenuItem.tsx index dbb09b2..8acc175 100644 --- a/frontend/src/components/AsideMenuItem.tsx +++ b/frontend/src/components/AsideMenuItem.tsx @@ -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 && ( - + )} {item.label} {item.menu && ( )} ) 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 ( -
  • +
  • {item.withDevider &&
    } {item.href && ( @@ -89,8 +81,8 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => { {item.menu && ( diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index a5d4b1e..68c4fe4 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -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 ( - ) + ); } diff --git a/frontend/src/components/BigCalendar.tsx b/frontend/src/components/BigCalendar.tsx index a457be6..3aa52c0 100644 --- a/frontend/src/components/BigCalendar.tsx +++ b/frontend/src/components/BigCalendar.tsx @@ -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 ( +
    +
    +

    + Schedule +

    +

    {label}

    +
    +
    + onNavigate('PREV')} /> + onNavigate('TODAY')} /> + onNavigate('NEXT')} /> +
    +
    + ); }; const BigCalendar = ({ - events, - handleDeleteAction, - handleCreateEventAction, - onDateRangeChange, - entityName, - showField, - pathEdit, - pathView, - 'start-data-key': startDataKey, - 'end-data-key': endDataKey, - }: Props) => { - const [myEvents, setMyEvents] = useState([]); - 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([]); + 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, 6), - }), - [], - ); + const { defaultDate, scrollToTime } = useMemo( + () => ({ + defaultDate: new Date(), + scrollToTime: new Date(1970, 1, 1, 8), + }), + [], + ); - useEffect(() => { - if (!events || !Array.isArray(events) || !events?.length) return; + useEffect(() => { + if (!Array.isArray(events) || !events.length) { + setMyEvents([]); + return; + } - const formattedEvents = events.map((event) => ({ - ...event, - start: new Date(event[startDataKey]), - end: new Date(event[endDataKey]), - title: event[showField], - })); + const formattedEvents = events.map((event) => ({ + ...event, + start: new Date(event[startDataKey]), + end: new Date(event[endDataKey]), + title: event[showField], + })); - setMyEvents(formattedEvents); - }, [endDataKey, events, startDataKey, showField]); + setMyEvents(formattedEvents); + }, [endDataKey, events, showField, startDataKey]); - const onRangeChange = ( - range: Date[] | { start: Date; end: Date }, - ) => { - const newRange = { start: '', end: '' }; - const format = 'YYYY-MM-DDTHH:mm'; + const onRangeChange = (range: Date[] | { start: Date; end: Date }) => { + const newRange = { start: '', end: '' }; + const format = 'YYYY-MM-DDTHH:mm'; - 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 (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 (newRange.start === newRange.end) { - newRange.end = moment(newRange.end).add(1, 'days').format(format); - } + if (newRange.start === newRange.end) { + newRange.end = moment(newRange.end).add(1, 'days').format(format); + } - // check if the range fits in the previous range - if ( - prevRange.current && - prevRange.current.start <= newRange.start && - prevRange.current.end >= newRange.end - ) { - return; - } + if ( + prevRange.current && + prevRange.current.start <= newRange.start && + prevRange.current.end >= newRange.end + ) { + return; + } - prevRange.current = { start: newRange.start, end: newRange.end }; - onDateRangeChange(newRange); - }; + prevRange.current = { start: newRange.start, end: newRange.end }; + onDateRangeChange(newRange); + }; - return ( -
    - ( - - ), - }} - /> -
    - ); + return ( +
    + {isLoading ? ( + + ) : null} + + {!isLoading && myEvents.length === 0 ? ( + + ) : null} + +
    + ({ + 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) => , + event: (props) => ( + + ), + }} + /> +
    +
    + ); }; const MyCustomEvent = ( - props: { - onDelete: (id: string) => void; - hasUpdatePermission: boolean; - pathEdit?: string; - pathView?: string; - } & EventProps, + props: { + onDelete: (id: string) => void; + hasUpdatePermission: boolean; + pathEdit?: string; + pathView?: string; + } & EventProps, ) => { - const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } = props; + const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } = props; - return ( -
    - - {title} - - -
    - ); + return ( +
    +
    + + {title} + +

    {formatEventWindow(event)}

    +
    + +
    + ); }; export default BigCalendar; diff --git a/frontend/src/components/Booking_requests/TableBooking_requests.tsx b/frontend/src/components/Booking_requests/TableBooking_requests.tsx index a90d5a8..24dbcea 100644 --- a/frontend/src/components/Booking_requests/TableBooking_requests.tsx +++ b/frontend/src/components/Booking_requests/TableBooking_requests.tsx @@ -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 = (
    `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 ? - + {filterItems && filterItems.map((filterItem) => { return ( -
    -
    -
    Filter
    +
    +
    +
    Filter
    filter.title === filterItem?.fields?.selectedField )?.type === 'enum' ? ( -
    -
    +
    +
    Value
    filter.title === filterItem?.fields?.selectedField )?.number ? ( -
    -
    -
    From
    +
    +
    +
    From
    -
    To
    +
    To
    +
    -
    +
    From
    -
    To
    +
    To
    ) : ( -
    -
    Contains
    +
    +
    Contains
    )}
    -
    Action
    +
    Action
    { deleteFilter(filterItem.id) @@ -450,16 +507,16 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho
    ) })} -
    +
    @@ -483,15 +540,32 @@ const TableSampleBooking_requests = ({ filterItems, setFilterItems, filters, sho {!showGrid && kanbanColumns && ( - + <> +
    + {bookingKanbanOverview.map((item) => ( + +
    +

    + {item.label} +

    +

    + {item.value} +

    +

    {item.hint}

    +
    +
    + ))} +
    + + )} diff --git a/frontend/src/components/CardBoxComponentEmpty.tsx b/frontend/src/components/CardBoxComponentEmpty.tsx index c71413e..ad30d3a 100644 --- a/frontend/src/components/CardBoxComponentEmpty.tsx +++ b/frontend/src/components/CardBoxComponentEmpty.tsx @@ -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 ( -
    -

    Nothing's hereโ€ฆ

    +
    +
    +
    + Empty state +
    +

    {title}

    +

    {description}

    +
    ) } diff --git a/frontend/src/components/KanbanBoard/KanbanBoard.tsx b/frontend/src/components/KanbanBoard/KanbanBoard.tsx index 84954b8..b3b9e59 100644 --- a/frontend/src/components/KanbanBoard/KanbanBoard.tsx +++ b/frontend/src/components/KanbanBoard/KanbanBoard.tsx @@ -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 ( -
    - - {columns.map((column) => ( -
    + +
    +
    + {columns.map((column) => ( -
    - ))} - -
    + ))} +
    +
    + ); }; diff --git a/frontend/src/components/KanbanBoard/KanbanCard.tsx b/frontend/src/components/KanbanBoard/KanbanCard.tsx index 639e479..43109e8 100644 --- a/frontend/src/components/KanbanBoard/KanbanCard.tsx +++ b/frontend/src/components/KanbanBoard/KanbanCard.tsx @@ -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 ( -
    -
    - - {item[showFieldName] ?? 'No data'} - -
    -
    -

    {moment(item.createdAt).format('MMM DD hh:mm a')}

    - setItemIdToDelete(id)} - hasUpdatePermission={true} - className={'w-2 h-2 text-white'} - iconClassName={'w-5'} - /> -
    + 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 ( +
    +
    +
    + + {title} + + {supportingLabel && ( +

    + {supportingLabel} +

    + )}
    - ); + 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' + /> +
    + + {stats.length > 0 && ( +
    + {stats.slice(0, 3).map((stat) => ( +
    + {stat.label} + + {stat.value} + +
    + ))} +
    + )} + +
    + {humanize(column.label)} + {updatedAt ? moment(updatedAt).format('MMM D') : 'โ€”'} +
    +
    + ); }; export default KanbanCard; diff --git a/frontend/src/components/KanbanBoard/KanbanColumn.tsx b/frontend/src/components/KanbanBoard/KanbanColumn.tsx index db0097d..865c1b3 100644 --- a/frontend/src/components/KanbanBoard/KanbanColumn.tsx +++ b/frontend/src/components/KanbanBoard/KanbanColumn.tsx @@ -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; - updateThunk: AsyncThunk; + column: { id: string; label: string }; + entityName: string; + columnFieldName: string; + showFieldName: string; + filtersQuery: string; + deleteThunk: AsyncThunk; + updateThunk: AsyncThunk; }; 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(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(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([]); + 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 ( + <> + +
    +
    +

    + Stage +

    +

    {column.label}

    +
    +
    + {count} +
    +
    +
    { + drop(node); + listInnerRef.current = node; + }} + className='max-h-[560px] flex-1 space-y-3 overflow-y-auto p-3' + onScroll={onScroll} + > + {data.map((item) => ( + + ))} - const onDeleteConfirm = () => { - if (!itemIdToDelete) return; + {!loading && !data.length && ( + + )} - dispatch(deleteThunk(itemIdToDelete)) - .then((res) => { - if (res.meta.requestStatus === 'fulfilled') { - setItemIdToDelete(''); - loadData(0, filtersQuery); - } - }) - .catch((err) => { - console.error(err); - }) - .finally(() => { - setItemIdToDelete(''); - }); - }; - - return ( - <> - -
    -

    {column.label}

    -

    {count}

    -
    -
    { - drop(node); - listInnerRef.current = node; - }} - className={'p-3 space-y-3 flex-1 overflow-y-auto max-h-[400px]'} - onScroll={onScroll} - > - {data?.map((item) => ( -
    - -
    - ))} - {!data?.length && ( -

    No data

    - )} -
    -
    - setItemIdToDelete('')} - > -

    Are you sure you want to delete this item?

    -
    - - ); + {loading && ( + + )} +
    +
    + setItemIdToDelete('')} + > +

    Are you sure you want to delete this item?

    +
    + + ); }; export default KanbanColumn; diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx index 899d4fa..20757b8 100644 --- a/frontend/src/components/LoadingSpinner.tsx +++ b/frontend/src/components/LoadingSpinner.tsx @@ -1,18 +1,30 @@ 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 ( -
    -
    -
    -
    +
    +
    +
    +
    +
    +
    +

    {label}

    +

    {detail}

    ); }; -export default LoadingSpinner; \ No newline at end of file +export default LoadingSpinner; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index eb155e3..fb0fca2 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -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' diff --git a/frontend/src/components/Properties/CardProperties.tsx b/frontend/src/components/Properties/CardProperties.tsx index dee48a1..bb5d4f5 100644 --- a/frontend/src/components/Properties/CardProperties.tsx +++ b/frontend/src/components/Properties/CardProperties.tsx @@ -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 ( -
    - {loading && } -
      - {!loading && properties.map((item, index) => ( -
    • - -
      - - - -

      {item.name}

      - - - -
      - -
      -
      -
      - - -
      -
      Tenant
      -
      -
      - { dataFormatter.tenantsOneListFormatter(item.tenant) } -
      -
      -
      - - - - -
      -
      Propertyname
      -
      -
      - { item.name } -
      -
      -
      - - - - -
      -
      Propertycode
      -
      -
      - { item.code } -
      -
      -
      - - - - -
      -
      Address
      -
      -
      - { item.address } -
      -
      -
      - - - - -
      -
      City
      -
      -
      - { item.city } -
      -
      -
      - - - - -
      -
      Country
      -
      -
      - { item.country } -
      -
      -
      - - - - -
      -
      Timezone
      -
      -
      - { item.timezone } -
      -
      -
      - - - - -
      -
      Description
      -
      -
      - { item.description } -
      -
      -
      - - - - -
      -
      Images
      -
      -
      - -
      -
      -
      - - - - -
      -
      Isactive
      -
      -
      - { dataFormatter.booleanFormatter(item.is_active) } -
      -
      -
      - - - - -
      -
      Unittypes
      -
      -
      - { dataFormatter.unit_typesManyListFormatter(item.unit_types).join(', ')} -
      -
      -
      - - - - -
      -
      Units
      -
      -
      - { dataFormatter.unitsManyListFormatter(item.units).join(', ')} -
      -
      -
      - - - - -
      -
      Amenities
      -
      -
      - { dataFormatter.amenitiesManyListFormatter(item.amenities).join(', ')} -
      -
      -
      - - - -
      -
    • - ))} - {!loading && properties.length === 0 && ( -
      -

      No data to display

      -
      + <> +
      + {loading && ( + + )} + + {!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 ( + +
      +
      +
      +
      + + {item.name || item.code || 'Untitled property'} + + + {item.is_active ? 'Active' : 'Inactive'} + +
      +

      {location}

      + {item.description && ( +

      + {item.description} +

      + )} +
      + +
      + +
      +
      + +
      +
      +

      Code

      +

      {item.code || 'โ€”'}

      +
      +
      +

      Timezone

      +

      {item.timezone || 'โ€”'}

      +
      +
      +

      Units

      +

      {unitsCount}

      +
      +
      +

      Unit types / amenities

      +

      + {unitTypesCount} / {amenitiesCount} +

      +
      +
      +
      +
      + ); + })} + + {!loading && properties.length === 0 && ( + )} -
    -
    -
    -
    + +
    + +
    + ); }; -export default CardProperties; +export default CardProperties diff --git a/frontend/src/components/Properties/TableProperties.tsx b/frontend/src/components/Properties/TableProperties.tsx index 5b10d4a..0424799 100644 --- a/frontend/src/components/Properties/TableProperties.tsx +++ b/frontend/src/components/Properties/TableProperties.tsx @@ -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 = (
    `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 ? - + {filterItems && filterItems.map((filterItem) => { return ( -
    -
    -
    Filter
    +
    +
    +
    Filter
    filter.title === filterItem?.fields?.selectedField )?.type === 'enum' ? ( -
    -
    +
    +
    Value
    filter.title === filterItem?.fields?.selectedField )?.number ? ( -
    -
    -
    From
    +
    +
    +
    From
    -
    To
    +
    To
    +
    -
    +
    From
    -
    To
    +
    To
    ) : ( -
    -
    Contains
    +
    +
    Contains
    )}
    -
    Action
    +
    Action
    { deleteFilter(filterItem.id) @@ -411,16 +425,16 @@ const TableSampleProperties = ({ filterItems, setFilterItems, filters, showGrid
    ) })} -
    +
    diff --git a/frontend/src/components/Reservations/TableReservations.tsx b/frontend/src/components/Reservations/TableReservations.tsx index 5033f46..9bd77d5 100644 --- a/frontend/src/components/Reservations/TableReservations.tsx +++ b/frontend/src/components/Reservations/TableReservations.tsx @@ -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 = (
    `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 ? - + {filterItems && filterItems.map((filterItem) => { return ( -
    -
    -
    Filter
    +
    +
    +
    Filter
    filter.title === filterItem?.fields?.selectedField )?.type === 'enum' ? ( -
    -
    +
    +
    Value
    filter.title === filterItem?.fields?.selectedField )?.number ? ( -
    -
    -
    From
    +
    +
    +
    From
    -
    To
    +
    To
    +
    -
    +
    From
    -
    To
    +
    To
    ) : ( -
    -
    Contains
    +
    +
    Contains
    )}
    -
    Action
    +
    Action
    { deleteFilter(filterItem.id) @@ -418,16 +500,16 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri
    ) })} -
    +
    @@ -450,6 +532,22 @@ const TableSampleReservations = ({ filterItems, setFilterItems, filters, showGri {!showGrid && ( + <> +
    + {reservationCalendarOverview.map((item) => ( + +
    +

    + {item.label} +

    +

    + {item.value} +

    +

    {item.hint}

    +
    +
    + ))} +
    { 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'} - /> + /> + )} diff --git a/frontend/src/components/SectionMain.tsx b/frontend/src/components/SectionMain.tsx index 2b18097..8db2558 100644 --- a/frontend/src/components/SectionMain.tsx +++ b/frontend/src/components/SectionMain.tsx @@ -6,5 +6,5 @@ type Props = { } export default function SectionMain({ children }: Props) { - return
    {children}
    + return
    {children}
    } diff --git a/frontend/src/components/SectionTitleLineWithButton.tsx b/frontend/src/components/SectionTitleLineWithButton.tsx index 3d3c5ef..2b6dfdb 100644 --- a/frontend/src/components/SectionTitleLineWithButton.tsx +++ b/frontend/src/components/SectionTitleLineWithButton.tsx @@ -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 ( -
    -
    - {icon && main && } - {icon && !main && } -

    {humanize(title)}

    +
    +
    + {icon && main && } + {icon && !main && } +
    +

    {humanize(title)}

    + {subtitle ? ( +

    {subtitle}

    + ) : null} +
    - {children} - {!hasChildren && } + {hasChildren ?
    {childArray}
    : null}
    ) } diff --git a/frontend/src/components/Unit_availability_blocks/TableUnit_availability_blocks.tsx b/frontend/src/components/Unit_availability_blocks/TableUnit_availability_blocks.tsx index 55ade79..f5b8d57 100644 --- a/frontend/src/components/Unit_availability_blocks/TableUnit_availability_blocks.tsx +++ b/frontend/src/components/Unit_availability_blocks/TableUnit_availability_blocks.tsx @@ -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'} /> )} diff --git a/frontend/src/components/Units/CardUnits.tsx b/frontend/src/components/Units/CardUnits.tsx index 35c2c49..5e080b6 100644 --- a/frontend/src/components/Units/CardUnits.tsx +++ b/frontend/src/components/Units/CardUnits.tsx @@ -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 ( -
    - {loading && } -
      - {!loading && units.map((item, index) => ( -
    • - -
      - - - {item.unit_number} - - + <> +
      + {loading && } -
      - -
      -
      -
      - - -
      -
      Property
      -
      -
      - { dataFormatter.propertiesOneListFormatter(item.property) } -
      -
      + {!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; + + return ( + +
      +
      +
      +
      + + {item.unit_number || 'Untitled unit'} + + + {item.status || 'unknown'} + +
      +

      + {property} โ€ข {unitType} +

      + {item.notes && ( +

      + {item.notes} +

      + )} +
      + +
      + +
      +
      + +
      +
      +

      Floor

      +

      {item.floor || 'โ€”'}

      +
      +
      +

      Max occupancy

      +

      + {item.max_occupancy_override ?? 'Default'} +

      +
      +
      +

      Availability blocks

      +

      {availabilityCount}

      +
      +
      +

      Type

      +

      {unitType}

      +
      +
      - +
      + ); + })} - - -
      -
      Unittype
      -
      -
      - { dataFormatter.unit_typesOneListFormatter(item.unit_type) } -
      -
      -
      - - - - -
      -
      Unitnumber
      -
      -
      - { item.unit_number } -
      -
      -
      - - - - -
      -
      Floor
      -
      -
      - { item.floor } -
      -
      -
      - - - - -
      -
      Status
      -
      -
      - { item.status } -
      -
      -
      - - - - -
      -
      Maxoccupancyoverride
      -
      -
      - { item.max_occupancy_override } -
      -
      -
      - - - - -
      -
      Notes
      -
      -
      - { item.notes } -
      -
      -
      - - - - -
      -
      Availabilityblocks
      -
      -
      - { dataFormatter.unit_availability_blocksManyListFormatter(item.availability_blocks).join(', ')} -
      -
      -
      - - - -
      -
    • - ))} {!loading && units.length === 0 && ( -
      -

      No data to display

      +
      +

      No data to display

      )} -
    -
    -
    -
    + +
    + +
    + ); }; -export default CardUnits; +export default CardUnits diff --git a/frontend/src/components/Units/TableUnits.tsx b/frontend/src/components/Units/TableUnits.tsx index 2a7e924..3297fd8 100644 --- a/frontend/src/components/Units/TableUnits.tsx +++ b/frontend/src/components/Units/TableUnits.tsx @@ -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 }) =>
    `datagrid--row`} @@ -223,6 +229,9 @@ const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) => pageSize: 10, }, }, + columns: { + columnVisibilityModel: compactColumnVisibilityModel, + }, }} disableRowSelectionOnClick onProcessRowUpdateError={(params) => { @@ -439,11 +448,19 @@ const TableSampleUnits = ({ filterItems, setFilterItems, filters, showGrid }) =>

    Are you sure you want to delete this item?

    - - {dataGrid} - - + {!showGrid && ( + + )} + + {showGrid && dataGrid} {selectedRows.length > 0 && createPortal( diff --git a/frontend/src/css/_app.css b/frontend/src/css/_app.css index affc655..1519d7c 100644 --- a/frontend/src/css/_app.css +++ b/frontend/src/css/_app.css @@ -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 { diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..c234639 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -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 (
    diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 1b70828..d58df1b 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -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; diff --git a/frontend/src/pages/booking_requests/booking_requests-edit.tsx b/frontend/src/pages/booking_requests/booking_requests-edit.tsx index ab65a2a..c723ba3 100644 --- a/frontend/src/pages/booking_requests/booking_requests-edit.tsx +++ b/frontend/src/pages/booking_requests/booking_requests-edit.tsx @@ -638,19 +638,20 @@ const EditBooking_requestsPage = () => { return ( <> - {getPageTitle('Edit booking_requests')} + {getPageTitle('Edit Booking Request')} - + {''} - + handleSubmit(values)} >
    +
    @@ -1987,9 +1988,11 @@ const EditBooking_requestsPage = () => { +
    + - + router.push('/booking_requests/booking_requests-list')}/> diff --git a/frontend/src/pages/booking_requests/booking_requests-list.tsx b/frontend/src/pages/booking_requests/booking_requests-list.tsx index 122022a..e249c3c 100644 --- a/frontend/src/pages/booking_requests/booking_requests-list.tsx +++ b/frontend/src/pages/booking_requests/booking_requests-list.tsx @@ -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(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']}, - ]); - - const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BOOKING_REQUESTS'); - + ]).map((filter) => ({ ...filter, label: humanize(filter.title) }))); - const addFilter = () => { - const newItem = { - id: uniqueId(), - fields: { - filterValue: '', - filterValueFrom: '', - filterValueTo: '', - selectedField: '', - }, - }; - newItem.fields.selectedField = filters[0].title; - setFilterItems([...filterItems, newItem]); - }; + const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_BOOKING_REQUESTS'); - 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 addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; - const onModalConfirm = async () => { - if (!csvFile) return; - await dispatch(uploadCsv(csvFile)); - dispatch(setRefetch(true)); - setCsvFile(null); - setIsModalActive(false); - }; + 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 onModalCancel = () => { - 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); + }; return ( <> - {getPageTitle('Booking_requests')} + {getPageTitle('Booking Requests')} - - {''} - - - - {hasCreatePermission && } - - - - - {hasCreatePermission && ( - setIsModalActive(true)} - /> - )} - -
    -
    -
    - -
    - Switch to Table + + + +
    +
    +

    Working view

    +

    Keep demand moving

    +

    Create, filter, export, or switch to table view from one calm action strip.

    - + +
    + + {hasCreatePermission ? ( + + ) : null} + + + {hasCreatePermission ? ( + setIsModalActive(true)} /> + ) : null} + + + +
    +
    +
    +
    +
    - - - + + { Booking_requestsTablesPage.getLayout = function getLayout(page: ReactElement) { return ( {page} diff --git a/frontend/src/pages/booking_requests/booking_requests-new.tsx b/frontend/src/pages/booking_requests/booking_requests-new.tsx index fe87062..6283eb1 100644 --- a/frontend/src/pages/booking_requests/booking_requests-new.tsx +++ b/frontend/src/pages/booking_requests/booking_requests-new.tsx @@ -367,13 +367,13 @@ const Booking_requestsNew = () => { return ( <> - {getPageTitle('New Item')} + {getPageTitle('New Booking Request')} - + {''} - + { onSubmit={(values) => handleSubmit(values)} > +
    @@ -1073,9 +1074,11 @@ const Booking_requestsNew = () => { +
    + - + router.push('/booking_requests/booking_requests-list')}/> diff --git a/frontend/src/pages/booking_requests/booking_requests-view.tsx b/frontend/src/pages/booking_requests/booking_requests-view.tsx index 55d254f..206430a 100644 --- a/frontend/src/pages/booking_requests/booking_requests-view.tsx +++ b/frontend/src/pages/booking_requests/booking_requests-view.tsx @@ -1,2321 +1,240 @@ import React, { ReactElement, useEffect } from 'react'; -import Head from 'next/head' -import DatePicker from "react-datepicker"; -import "react-datepicker/dist/react-datepicker.css"; -import dayjs from "dayjs"; -import {useAppDispatch, useAppSelector} from "../../stores/hooks"; -import {useRouter} from "next/router"; -import { fetch } from '../../stores/booking_requests/booking_requestsSlice' -import {saveFile} from "../../helpers/fileSaver"; -import dataFormatter from '../../helpers/dataFormatter'; -import ImageField from "../../components/ImageField"; -import LayoutAuthenticated from "../../layouts/Authenticated"; -import {getPageTitle} from "../../config"; -import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton"; -import SectionMain from "../../components/SectionMain"; -import CardBox from "../../components/CardBox"; -import BaseButton from "../../components/BaseButton"; -import BaseDivider from "../../components/BaseDivider"; -import {mdiChartTimelineVariant} from "@mdi/js"; -import {SwitchField} from "../../components/SwitchField"; -import FormField from "../../components/FormField"; - -import {hasPermission} from "../../helpers/userPermissions"; - - -const Booking_requestsView = () => { - const router = useRouter() - const dispatch = useAppDispatch() - const { booking_requests } = useAppSelector((state) => state.booking_requests) - - const { currentUser } = useAppSelector((state) => state.auth); - - - const { id } = router.query; - - function removeLastCharacter(str) { - console.log(str,`str`) - return str.slice(0, -1); - } - - useEffect(() => { - dispatch(fetch({ id })); - }, [dispatch, id]); - - - return ( - <> - - {getPageTitle('View booking_requests')} - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -

    Tenant

    - - - - - - - - - - -

    {booking_requests?.tenant?.name ?? 'No data'}

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {hasPermission(currentUser, 'READ_ORGANIZATIONS') && -
    -

    Organization

    - - - - - - - - -

    {booking_requests?.organization?.name ?? 'No data'}

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -

    Requestedby

    - - -

    {booking_requests?.requested_by?.firstName ?? 'No data'}

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - - - - - - - - - - -
    -

    Requestcode

    -

    {booking_requests?.request_code}

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -

    Status

    -

    {booking_requests?.status ?? 'No data'}

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - {booking_requests.check_in_at ? :

    No Check-inat

    } -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {booking_requests.check_out_at ? :

    No Check-outat

    } -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -

    Preferredproperty

    - - - - - - - - - - - - - - - - -

    {booking_requests?.preferred_property?.name ?? 'No data'}

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -

    Preferredunittype

    - - - - - - - - - - - - - - - - - - - - -

    {booking_requests?.preferred_unit_type?.name ?? 'No data'}

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - -
    -

    Preferredbedrooms

    -

    {booking_requests?.preferred_bedrooms || 'No data'}

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -

    Guestcount

    -

    {booking_requests?.guest_count || 'No data'}

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - -