From d8c8294cc5f6b662da0668da18f37f1363281a8d Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 4 Apr 2026 15:53:16 +0000 Subject: [PATCH] Autosave: 20260404-155315 --- backend/src/db/api/booking_requests.js | 4 +- backend/src/db/api/reservations.js | 15 +- .../src/components/CurrentWorkspaceChip.tsx | 27 +- .../src/components/MyOrgTenantSummary.tsx | 24 +- frontend/src/components/PublicSiteFooter.tsx | 44 +++ frontend/src/components/PublicSiteHeader.tsx | 41 +++ frontend/src/components/SelectFieldMany.tsx | 135 ++++++--- frontend/src/menuNavBar.ts | 17 +- frontend/src/pages/index.tsx | 282 +++++++++++------- frontend/src/pages/privacy-policy.tsx | 14 +- frontend/src/pages/tenants/tenants-edit.tsx | 2 + frontend/src/pages/tenants/tenants-new.tsx | 19 +- frontend/src/pages/terms-of-use.tsx | 14 +- 13 files changed, 460 insertions(+), 178 deletions(-) create mode 100644 frontend/src/components/PublicSiteFooter.tsx create mode 100644 frontend/src/components/PublicSiteHeader.tsx diff --git a/backend/src/db/api/booking_requests.js b/backend/src/db/api/booking_requests.js index efde39c..b3886cf 100644 --- a/backend/src/db/api/booking_requests.js +++ b/backend/src/db/api/booking_requests.js @@ -570,7 +570,7 @@ module.exports = class Booking_requestsDBApi { if (userOrganizations) { if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; + where.organizationId = options.currentUser.organizationsId; } } @@ -1036,7 +1036,7 @@ module.exports = class Booking_requestsDBApi { if (globalAccess) { - delete where.organizationsId; + delete where.organizationId; } diff --git a/backend/src/db/api/reservations.js b/backend/src/db/api/reservations.js index c60a506..5939744 100644 --- a/backend/src/db/api/reservations.js +++ b/backend/src/db/api/reservations.js @@ -607,15 +607,18 @@ module.exports = class ReservationsDBApi { const user = (options && options.currentUser) || null; - const userOrganizations = (user && user.organizations?.id) || null; + const userOrganizationId = + user?.organization?.id || + user?.organizations?.id || + user?.organizationId || + user?.organizationsId || + null; const isCustomer = isCustomerUser(user); - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } + if (!globalAccess && userOrganizationId) { + where.organizationId = userOrganizationId; } @@ -1196,7 +1199,7 @@ module.exports = class ReservationsDBApi { if (globalAccess) { - delete where.organizationsId; + delete where.organizationId; } diff --git a/frontend/src/components/CurrentWorkspaceChip.tsx b/frontend/src/components/CurrentWorkspaceChip.tsx index 8f524ba..62164d3 100644 --- a/frontend/src/components/CurrentWorkspaceChip.tsx +++ b/frontend/src/components/CurrentWorkspaceChip.tsx @@ -49,11 +49,13 @@ const CurrentWorkspaceChip = () => { currentUser?.organizationsId, ], ) + const canViewOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS') + const canViewTenants = hasPermission(currentUser, 'READ_TENANTS') useEffect(() => { let isMounted = true - if (!organizationId) { + if (!organizationId || !canViewTenants) { setLinkedTenantSummary(emptyOrganizationTenantSummary) setIsLoadingTenants(false) return () => { @@ -89,7 +91,7 @@ const CurrentWorkspaceChip = () => { return () => { isMounted = false } - }, [organizationId]) + }, [canViewTenants, organizationId]) useEffect(() => { setIsPopoverActive(false) @@ -139,11 +141,18 @@ const CurrentWorkspaceChip = () => { const appRoleName = currentUser?.app_role?.name || 'User' const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : [] const linkedTenantCount = linkedTenantSummary.count || 0 - const tenantLabel = isLoadingTenants - ? 'Loading tenants…' - : `${linkedTenantCount} tenant${linkedTenantCount === 1 ? '' : 's'}` - const canViewOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS') - const canViewTenants = hasPermission(currentUser, 'READ_TENANTS') + const tenantLabel = !organizationId + ? 'No workspace linked' + : !canViewTenants + ? 'Tenant access restricted' + : isLoadingTenants + ? 'Loading tenants…' + : `${linkedTenantCount} tenant${linkedTenantCount === 1 ? '' : 's'}` + const tenantEmptyStateMessage = !organizationId + ? 'No organization is attached to this account yet.' + : !canViewTenants + ? 'Your current role can open the workspace, but it does not include tenant-read access.' + : 'No tenant link surfaced yet for this workspace.' const organizationHref = canViewOrganizations && organizationId ? getOrganizationViewHref(organizationId) : '/profile' const hasOrganizationMetadata = Boolean(organizationSlug || organizationDomain) @@ -340,7 +349,7 @@ const CurrentWorkspaceChip = () => { )) ) : (
- No tenant link surfaced yet for this workspace. + {tenantEmptyStateMessage}
)} @@ -464,7 +473,7 @@ const CurrentWorkspaceChip = () => { ) : (
- No tenant link surfaced yet for this workspace. + {tenantEmptyStateMessage}
)} diff --git a/frontend/src/components/MyOrgTenantSummary.tsx b/frontend/src/components/MyOrgTenantSummary.tsx index 6b5a9db..5bb1676 100644 --- a/frontend/src/components/MyOrgTenantSummary.tsx +++ b/frontend/src/components/MyOrgTenantSummary.tsx @@ -41,11 +41,13 @@ const MyOrgTenantSummary = ({ className = '' }: MyOrgTenantSummaryProps) => { currentUser?.organization?.name || currentUser?.organizationName || 'No organization assigned yet' + const canViewOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS') + const canViewTenants = hasPermission(currentUser, 'READ_TENANTS') useEffect(() => { let isMounted = true - if (!organizationId) { + if (!organizationId || !canViewTenants) { setLinkedTenantSummary(emptyOrganizationTenantSummary) setIsLoadingTenants(false) return () => { @@ -81,7 +83,7 @@ const MyOrgTenantSummary = ({ className = '' }: MyOrgTenantSummaryProps) => { return () => { isMounted = false } - }, [organizationId]) + }, [canViewTenants, organizationId]) if (!currentUser) { return null @@ -89,8 +91,18 @@ const MyOrgTenantSummary = ({ className = '' }: MyOrgTenantSummaryProps) => { const appRoleName = currentUser?.app_role?.name || 'No role surfaced yet' const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : [] - const canViewOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS') - const canViewTenants = hasPermission(currentUser, 'READ_TENANTS') + const tenantSummaryValue = !organizationId + ? 'No workspace linked' + : !canViewTenants + ? 'Restricted' + : isLoadingTenants + ? 'Loading…' + : String(linkedTenantSummary.count || 0) + const tenantEmptyStateMessage = !organizationId + ? 'No organization is attached to this account yet.' + : !canViewTenants + ? 'Your current role does not include tenant-read access for this organization context.' + : 'No tenant link surfaced yet for your organization.' return ( @@ -116,7 +128,7 @@ const MyOrgTenantSummary = ({ className = '' }: MyOrgTenantSummaryProps) => { { label: 'Email', value: currentUser?.email }, { label: 'Linked tenants', - value: isLoadingTenants ? 'Loading…' : String(linkedTenantSummary.count || 0), + value: tenantSummaryValue, }, ]} actions={ @@ -179,7 +191,7 @@ const MyOrgTenantSummary = ({ className = '' }: MyOrgTenantSummaryProps) => { ) : (
- No tenant link surfaced yet for your organization. + {tenantEmptyStateMessage}
)} diff --git a/frontend/src/components/PublicSiteFooter.tsx b/frontend/src/components/PublicSiteFooter.tsx new file mode 100644 index 0000000..2886251 --- /dev/null +++ b/frontend/src/components/PublicSiteFooter.tsx @@ -0,0 +1,44 @@ +import Link from 'next/link'; +import React from 'react'; + +import BaseButton from './BaseButton'; +import { webPagesNavBar } from '../menuNavBar'; + +type Props = { + title?: string; + subtitle?: string; +}; + +export default function PublicSiteFooter({ + title = 'Gracey Corporate Stay Portal', + subtitle = 'A cleaner public front door for bookings, guest operations, and billing.', +}: Props) { + const year = new Date().getFullYear(); + + return ( +
+
+
+
{title}
+

{subtitle}

+
© {year} {title}. All rights reserved.
+
+ +
+ + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/PublicSiteHeader.tsx b/frontend/src/components/PublicSiteHeader.tsx new file mode 100644 index 0000000..bce60a2 --- /dev/null +++ b/frontend/src/components/PublicSiteHeader.tsx @@ -0,0 +1,41 @@ +import Link from 'next/link'; +import React from 'react'; + +import BaseButton from './BaseButton'; +import { webPagesNavBar } from '../menuNavBar'; + +type Props = { + title?: string; + subtitle?: string; +}; + +export default function PublicSiteHeader({ + title = 'Gracey Corporate Stay Portal', + subtitle = 'Corporate stay operations', +}: Props) { + return ( +
+
+ +
{subtitle}
+
{title}
+ + +
+ + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/SelectFieldMany.tsx b/frontend/src/components/SelectFieldMany.tsx index 34268ff..ab3c26f 100644 --- a/frontend/src/components/SelectFieldMany.tsx +++ b/frontend/src/components/SelectFieldMany.tsx @@ -1,31 +1,95 @@ -import React, {useEffect, useId, useState} from 'react'; +import React, { useEffect, useId, useState } from 'react'; import { AsyncPaginate } from 'react-select-async-paginate'; import axios from 'axios'; +const getOptionLabel = (item: any, showField?: string) => { + if (!item) { + return ''; + } + + if (showField && item[showField]) { + return item[showField]; + } + + return ( + item.label || + item.name || + item.firstName || + item.email || + item.action || + item.title || + item.request_code || + item.reservation_code || + item.file_name || + item.summary || + item.reference || + item.id || + '' + ); +}; + +const toSelectOption = (item: any, showField?: string) => { + if (!item) { + return null; + } + + const value = item.value || item.id; + + if (!value) { + return null; + } + + return { + value, + label: getOptionLabel(item, showField), + }; +}; + export const SelectFieldMany = ({ options, field, form, itemRef, showField }) => { const [value, setValue] = useState([]); const PAGE_SIZE = 100; useEffect(() => { - if (field.value?.[0] && typeof field.value[0] !== 'string') { - form.setFieldValue( - field.name, - field.value.map((el) => el.id), - ); - } else if (!field.value || field.value.length === 0) { + if (!Array.isArray(field?.value) || field.value.length === 0) { setValue([]); + return; } - }, [field.name, field.value, form]); - useEffect(() => { - if (options) { - setValue(options.map((el) => ({ value: el.id, label: el[showField] }))); - form.setFieldValue( - field.name, - options.map((el) => ({ value: el.id, label: el[showField] })), - ); + const nextValue = field.value + .map((item) => { + if (typeof item === 'string') { + const matchingOption = Array.isArray(options) + ? options.find((option) => option?.id === item || option?.value === item) + : null; + + return { + value: item, + label: getOptionLabel(matchingOption, showField) || item, + }; + } + + return toSelectOption(item, showField); + }) + .filter(Boolean); + + setValue(nextValue); + + const normalizedIds = field.value + .map((item) => { + if (typeof item === 'string') { + return item; + } + + return item?.id || item?.value || null; + }) + .filter(Boolean); + + const shouldNormalizeFieldValue = field.value.some((item) => typeof item !== 'string'); + + if (shouldNormalizeFieldValue) { + form.setFieldValue(field.name, normalizedIds); } - }, [options]); + }, [field?.name, field?.value, form, options, showField]); const mapResponseToValuesAndLabels = (data) => ({ value: data.id, @@ -33,10 +97,12 @@ export const SelectFieldMany = ({ options, field, form, itemRef, showField }) => }); const handleChange = (data: any) => { - setValue(data) + const nextValue = Array.isArray(data) ? data : []; + + setValue(nextValue); form.setFieldValue( - field.name, - data.map(el => (el?.value || null)), + field.name, + nextValue.map((item) => item?.value).filter(Boolean), ); }; @@ -46,22 +112,23 @@ export const SelectFieldMany = ({ options, field, form, itemRef, showField }) => return { options: data.map(mapResponseToValuesAndLabels), hasMore: data.length === PAGE_SIZE, - } + }; } + return ( - 'px-1 py-2', - }} - classNamePrefix='react-select' - instanceId={useId()} - value={value} - isMulti - debounceTimeout={1000} - loadOptions={callApi} - onChange={handleChange} - defaultOptions - isClearable - /> + 'px-1 py-2', + }} + classNamePrefix='react-select' + instanceId={useId()} + value={value} + isMulti + debounceTimeout={1000} + loadOptions={callApi} + onChange={handleChange} + defaultOptions + isClearable + /> ); }; diff --git a/frontend/src/menuNavBar.ts b/frontend/src/menuNavBar.ts index a5dd956..aa67d54 100644 --- a/frontend/src/menuNavBar.ts +++ b/frontend/src/menuNavBar.ts @@ -46,8 +46,19 @@ const menuNavBar: MenuNavBarItem[] = [ }, ] -export const webPagesNavBar = [ - -]; +export const webPagesNavBar: MenuNavBarItem[] = [ + { + label: 'Home', + href: '/', + }, + { + label: 'Privacy', + href: '/privacy-policy', + }, + { + label: 'Terms', + href: '/terms-of-use', + }, +] export default menuNavBar diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 09b8ebf..90e2305 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -5,47 +5,64 @@ import { mdiFileDocument, mdiHomeCity, mdiRoomService, - mdiShieldLockOutline, } from '@mdi/js'; import Head from 'next/head'; import React, { ReactElement } from 'react'; import BaseButton from '../components/BaseButton'; -import BaseDivider from '../components/BaseDivider'; import BaseIcon from '../components/BaseIcon'; import CardBox from '../components/CardBox'; +import PublicSiteFooter from '../components/PublicSiteFooter'; +import PublicSiteHeader from '../components/PublicSiteHeader'; import LayoutGuest from '../layouts/Guest'; import { getPageTitle } from '../config'; -const platformHighlights = [ +const productAreas = [ { - title: 'Corporate accounts', - description: 'Manage negotiated rate plans, account history, billing rules, and repeat-booker relationships.', - icon: mdiShieldLockOutline, - }, - { - title: 'Reservation control', - description: 'Move a stay from request to approval, inventory assignment, confirmation, arrival, and post-stay handling.', + title: 'Booking intake', + description: 'Receive requests with the right tenant, dates, approvals, and context before service delivery begins.', icon: mdiCalendarCheck, }, { - title: 'Concierge operations', - description: 'Track airport pickup, housekeeping, maintenance, extension, and custom guest service requests.', + title: 'Stay operations', + description: 'Coordinate readiness, guest service, and internal updates from one controlled operating view.', icon: mdiRoomService, }, { - title: 'Finance visibility', - description: 'Issue invoices, watch overdue exposure, and keep executive housing accounts commercially disciplined.', + title: 'Billing follow-through', + description: 'Keep invoicing and account visibility tied to the same reservation record from start to finish.', icon: mdiFileDocument, }, ]; -const workflowSteps = [ - 'Corporate booker submits a stay request', - 'Approver reviews against policy and budget', - 'Reservations team quotes and allocates inventory', - 'Traveler receives confirmed stay details', - 'Concierge and finance teams manage service and billing', +const servicePillars = [ + 'Tenant-aware workspace context for multi-organization teams', + 'Reserved, premium visual tone without turning the page into a template', + 'A direct path from public website to the real operating workspace', +]; + +const workflowMoments = [ + { + step: '01', + title: 'Receive the brief', + description: 'Capture the request with account context, approvals, dates, and service notes intact.', + }, + { + step: '02', + title: 'Deliver the stay', + description: 'Move into reservations, readiness, support, and day-to-day guest coordination with clarity.', + }, + { + step: '03', + title: 'Close with confidence', + description: 'Finish with billing and account follow-through attached to the same operational source of truth.', + }, +]; + +const highlights = [ + { label: 'Private operator view', value: 'Multi-tenant workspace' }, + { label: 'Service posture', value: 'Calm, controlled, premium' }, + { label: 'Execution model', value: 'Request to reservation to billing' }, ]; export default function HomePage() { @@ -53,120 +70,167 @@ export default function HomePage() { <> {getPageTitle('Gracey Corporate Stay Portal')} + -
-
-
-
-
-
Gracey Corporate Stay Portal
-
Executive software for serviced apartments, embassy housing, and long-stay corporate operations.
+ +
+ + +
+
+
+
+
+ + Gracey private operations suite
-
+ +

+ A more elegant operating layer for premium corporate stays. +

+ +

+ Gracey gives booking, service, and billing teams one composed system for high-touch stays—quiet in tone, + precise in execution, and built for the work behind the guest experience. +

+ +
+ - +
+ +
+ {highlights.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))}
-
-
-
- - Luxury corporate stay operations + +
+
+
Operator brief
+

What the platform quietly keeps under control

-

- The control layer for premium furnished inventory, approvals, guest logistics, and invoice billing. -

-

- Gracey helps serviced-apartment operators win repeat institutional accounts with faster quoting, - cleaner approval workflows, better arrival readiness, and a more executive operating posture. -

-
- - +
+ Refined workflow
- -
-
-
Use cases
-
Corporate teams, NGOs, embassies
-
Built for medium-stay housing that needs negotiated rates, approvals, and invoice-grade accountability.
-
-
-
Operational posture
-
Cold, executive, credible
-
No consumer fluff. Just the controls needed to run luxury serviced inventory for professional clients.
+
+ {workflowMoments.map((item) => ( +
+
{item.step}
+

{item.title}

+

{item.description}

+ ))} +
+ +
+
+ +

+ The public experience stays polished and restrained. The real depth appears only where it belongs: + inside the authenticated workspace. +

- -
+
+
-
-
- {platformHighlights.map((item) => ( - -
- +
+
+
+
Signature coverage
+

+ Designed for teams that want operational polish, not marketing noise. +

+
+

+ Every section is meant to feel intentional: fewer blocks, better restraint, and a stronger sense that the + product supports a premium service model behind the scenes. +

+
+ +
+ {productAreas.map((item) => ( +
+
+
-

{item.title}

-

{item.description}

- +

{item.title}

+

{item.description}

+
))}
-
-
- -
First delivered workflow
-

Corporate stay booking lifecycle

-

- The initial slice connects the public brand front door to a live authenticated command center, then into the existing request, approval, reservation, concierge, and finance records. +

+
+
+
Service standard
+

+ Luxury online should feel composed, selective, and credible. +

+

+ So the page leads with restraint: rich tone, quiet confidence, and a product story grounded in actual + operations rather than inflated feature theater.

- -
- {workflowSteps.map((step, index) => ( -
-
- {index + 1} -
-
{step}
-
- ))} -
- +
- -
Immediate access
-

Choose your entry point

-

- Enter the authenticated workspace to manage live operations, or go straight to the broader admin environment. -

-
-
-
Operations
-
Command center
-
Live view across bookings, reservations, service pressure, and invoice exposure.
-
- -
+
+ {servicePillars.map((item) => ( +
+ +

{item}

-
-
Administration
-
Role-based workspace
-
Land in one shared command center, then branch into the modules your role actually owns.
-
- -
-
-
- + ))} +
+ +
+
+
+
+
Private access
+

+ Enter the workspace when the conversation turns into execution. +

+

+ Review booking flow activity, open the operating workspace, and continue from live data instead of a + brochure-style homepage. +

+
+ +
+
For internal teams
+

+ Reservations, service operations, and billing remain connected—without losing the elevated feel the + brand should project externally. +

+
+
+ +
+ + +
+
+
+ +
); diff --git a/frontend/src/pages/privacy-policy.tsx b/frontend/src/pages/privacy-policy.tsx index c52b07a..4b5434c 100644 --- a/frontend/src/pages/privacy-policy.tsx +++ b/frontend/src/pages/privacy-policy.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; import LayoutGuest from '../layouts/Guest'; +import PublicSiteFooter from '../components/PublicSiteFooter'; +import PublicSiteHeader from '../components/PublicSiteHeader'; import { getPageTitle } from '../config'; export default function PrivacyPolicy() { @@ -261,14 +263,16 @@ export default function PrivacyPolicy() { }; return ( -
+
{getPageTitle('Privacy Policy')} -
-
-
+ + +
+
+

Privacy Policy

@@ -283,6 +287,8 @@ export default function PrivacyPolicy() {
+ +
); } diff --git a/frontend/src/pages/tenants/tenants-edit.tsx b/frontend/src/pages/tenants/tenants-edit.tsx index 58b0c63..8467a02 100644 --- a/frontend/src/pages/tenants/tenants-edit.tsx +++ b/frontend/src/pages/tenants/tenants-edit.tsx @@ -390,6 +390,7 @@ const EditTenantsPage = () => { itemRef={'organizations'} options={initialValues.organizations} component={SelectFieldMany} + showField={'name'} > @@ -400,6 +401,7 @@ const EditTenantsPage = () => { itemRef={'properties'} options={initialValues.properties} component={SelectFieldMany} + showField={'name'} > diff --git a/frontend/src/pages/tenants/tenants-new.tsx b/frontend/src/pages/tenants/tenants-new.tsx index c8ed71c..178c652 100644 --- a/frontend/src/pages/tenants/tenants-new.tsx +++ b/frontend/src/pages/tenants/tenants-new.tsx @@ -225,6 +225,7 @@ const TenantsNew = () => { itemRef={'organizations'} options={initialValues.organizations} component={SelectFieldMany} + showField={'name'} > @@ -233,8 +234,24 @@ const TenantsNew = () => { name="properties" id="properties" itemRef={'properties'} - options={[]} + options={initialValues.properties} component={SelectFieldMany} + showField={'name'} + > + + + + diff --git a/frontend/src/pages/terms-of-use.tsx b/frontend/src/pages/terms-of-use.tsx index c36d08f..eeb9195 100644 --- a/frontend/src/pages/terms-of-use.tsx +++ b/frontend/src/pages/terms-of-use.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; import LayoutGuest from '../layouts/Guest'; +import PublicSiteFooter from '../components/PublicSiteFooter'; +import PublicSiteHeader from '../components/PublicSiteHeader'; import { getPageTitle } from '../config'; export default function PrivacyPolicy() { @@ -173,14 +175,16 @@ export default function PrivacyPolicy() { }; return ( -
+
{getPageTitle('Terms of Use')} -
-
-
+ + +
+
+

Terms of Use

@@ -197,6 +201,8 @@ export default function PrivacyPolicy() {
+ +
); }