From 7da7c174e8b04a5a7e69561d481b763ca9d06ccf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 05:31:43 +0000 Subject: [PATCH] feat(dashboard): role-aware hotel BI home and report gallery Replace the generic KPI home and dropdown reports tool with permission-based dashboards (manager flash, revenue pace, accounting close, front office, housekeeping) and a card gallery for flash/demand/ledger reports, reusing existing report APIs with richer charts, exceptions, and period controls. Co-authored-by: telivity-otaip --- apps/dashboard/src/components/bi/BiCharts.tsx | 232 +++++ apps/dashboard/src/components/bi/BiHero.tsx | 57 + .../dashboard/src/components/bi/BiKpiCard.tsx | 112 ++ apps/dashboard/src/components/bi/BiPanel.tsx | 59 ++ .../src/components/bi/ExceptionAlerts.tsx | 156 +++ .../src/components/bi/PeriodChips.test.ts | 24 + .../src/components/bi/PeriodChips.tsx | 81 ++ .../src/components/bi/ReportGallery.tsx | 91 ++ .../dashboard/src/components/bi/Sparkline.tsx | 46 + .../dashboard/src/components/bi/chartTheme.ts | 37 + apps/dashboard/src/index.css | 21 + .../src/lib/dashboard-persona.test.ts | 54 + apps/dashboard/src/lib/dashboard-persona.ts | 72 ++ apps/dashboard/src/locales/en.json | 133 ++- apps/dashboard/src/pages/Dashboard.tsx | 965 ++++++++++++----- apps/dashboard/src/pages/Reports.tsx | 985 +++++++++++------- apps/dashboard/tailwind.config.ts | 5 + 17 files changed, 2508 insertions(+), 622 deletions(-) create mode 100644 apps/dashboard/src/components/bi/BiCharts.tsx create mode 100644 apps/dashboard/src/components/bi/BiHero.tsx create mode 100644 apps/dashboard/src/components/bi/BiKpiCard.tsx create mode 100644 apps/dashboard/src/components/bi/BiPanel.tsx create mode 100644 apps/dashboard/src/components/bi/ExceptionAlerts.tsx create mode 100644 apps/dashboard/src/components/bi/PeriodChips.test.ts create mode 100644 apps/dashboard/src/components/bi/PeriodChips.tsx create mode 100644 apps/dashboard/src/components/bi/ReportGallery.tsx create mode 100644 apps/dashboard/src/components/bi/Sparkline.tsx create mode 100644 apps/dashboard/src/components/bi/chartTheme.ts create mode 100644 apps/dashboard/src/lib/dashboard-persona.test.ts create mode 100644 apps/dashboard/src/lib/dashboard-persona.ts diff --git a/apps/dashboard/src/components/bi/BiCharts.tsx b/apps/dashboard/src/components/bi/BiCharts.tsx new file mode 100644 index 00000000..85cf48f2 --- /dev/null +++ b/apps/dashboard/src/components/bi/BiCharts.tsx @@ -0,0 +1,232 @@ +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + Line, + LineChart, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { BI, BI_SERIES, ROOM_STATUS_COLORS, chartTooltipStyle } from './chartTheme'; + +export function RoomStatusDonut({ + data, + emptyLabel, +}: { + data: { name: string; status: string; value: number }[]; + emptyLabel: string; +}) { + if (!data.length) { + return ( +
+ {emptyLabel} +
+ ); + } + return ( + + + + {data.map((entry, i) => ( + + ))} + + + + + + ); +} + +export function OccupancyTrendChart({ + data, + emptyLabel, + valueLabel, +}: { + data: { date: string; occupancyPct: number }[]; + emptyLabel: string; + valueLabel: string; +}) { + if (!data.length) { + return

{emptyLabel}

; + } + return ( + + + + + + [`${v.toFixed(1)}%`, valueLabel]} + /> + + + + ); +} + +export function RevenueMixBars({ + data, + currencyFmt, + emptyLabel, +}: { + data: { name: string; amount: number }[]; + currencyFmt: (n: number) => string; + emptyLabel: string; +}) { + if (!data.length) { + return

{emptyLabel}

; + } + return ( + + + + + + [currencyFmt(v), '']} + /> + + {data.map((_, i) => ( + + ))} + + + + ); +} + +export function PaymentMethodBars({ + data, + currencyFmt, + emptyLabel, +}: { + data: { method: string; amount: number }[]; + currencyFmt: (n: number) => string; + emptyLabel: string; +}) { + if (!data.length) { + return

{emptyLabel}

; + } + return ( + + + + + + [currencyFmt(v), '']} + /> + + + + ); +} + +export function PortfolioRevenueBars({ + data, + currencyFmt, + revenueLabel, +}: { + data: { name: string; revenue: number }[]; + currencyFmt: (n: number) => string; + revenueLabel: string; +}) { + if (!data.length) return null; + return ( + + + + + + [currencyFmt(v), revenueLabel]} + /> + + + + ); +} + +export function PaceDualLineChart({ + data, + emptyLabel, + roomsLabel, + bookingsLabel, +}: { + data: { date: string; roomsOnBooks: number; newBookings: number }[]; + emptyLabel: string; + roomsLabel: string; + bookingsLabel: string; +}) { + if (!data.length) { + return

{emptyLabel}

; + } + return ( + + + + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/bi/BiHero.tsx b/apps/dashboard/src/components/bi/BiHero.tsx new file mode 100644 index 00000000..5fe7da93 --- /dev/null +++ b/apps/dashboard/src/components/bi/BiHero.tsx @@ -0,0 +1,57 @@ +import type { LucideIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import PeriodChips, { type BiPeriod } from './PeriodChips'; + +interface BiHeroProps { + eyebrow: string; + title: string; + subtitle: string; + dateLabel: string; + icon: LucideIcon; + period?: BiPeriod; + onPeriodChange?: (p: BiPeriod) => void; + periodLabels?: Record; + actions?: ReactNode; +} + +export default function BiHero({ + eyebrow, + title, + subtitle, + dateLabel, + icon: Icon, + period, + onPeriodChange, + periodLabels, + actions, +}: BiHeroProps) { + return ( +
+
+
+
+
+
+ + {eyebrow} +
+

{title}

+

{subtitle}

+

{dateLabel}

+
+
+ {period && onPeriodChange && periodLabels && ( + + )} + {actions} +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/bi/BiKpiCard.tsx b/apps/dashboard/src/components/bi/BiKpiCard.tsx new file mode 100644 index 00000000..9180813e --- /dev/null +++ b/apps/dashboard/src/components/bi/BiKpiCard.tsx @@ -0,0 +1,112 @@ +import type { LucideIcon } from 'lucide-react'; +import Sparkline from './Sparkline'; +import { BI } from './chartTheme'; + +interface BiKpiCardProps { + title: string; + value: string | number; + subtitle?: string; + icon: LucideIcon; + trend?: { value: number; label: string }; + sparkline?: number[]; + sparkColor?: string; + numericValue?: number; + threshold?: { warnBelow?: number; goodAbove?: number }; + onClick?: () => void; +} + +function thresholdStatus( + numericValue: number | undefined, + threshold?: { warnBelow?: number; goodAbove?: number }, +): 'ok' | 'warn' | 'neutral' { + if (numericValue == null || !threshold) return 'neutral'; + if (threshold.warnBelow != null && numericValue < threshold.warnBelow) return 'warn'; + if (threshold.goodAbove != null && numericValue >= threshold.goodAbove) return 'ok'; + if (threshold.warnBelow == null && threshold.goodAbove == null) return 'neutral'; + return 'ok'; +} + +export default function BiKpiCard({ + title, + value, + subtitle, + icon: Icon, + trend, + sparkline, + sparkColor, + numericValue, + threshold, + onClick, +}: BiKpiCardProps) { + const status = thresholdStatus(numericValue, threshold); + const valueClass = + status === 'warn' + ? 'text-telivity-orange' + : status === 'ok' + ? 'text-telivity-dark-teal' + : 'text-telivity-navy'; + const iconWrap = + status === 'warn' + ? 'bg-telivity-orange/10' + : status === 'ok' + ? 'bg-telivity-dark-teal/10' + : 'bg-telivity-teal/10'; + const iconColor = + status === 'warn' + ? 'text-telivity-orange' + : status === 'ok' + ? 'text-telivity-dark-teal' + : 'text-telivity-teal'; + + const body = ( + <> +
+
+
+

+ {title} +

+

+ {value} +

+ {subtitle &&

{subtitle}

} + {trend && ( +

= 0 ? 'text-telivity-dark-teal' : 'text-telivity-orange' + }`} + > + {trend.value >= 0 ? '▲' : '▼'} {Math.abs(trend.value).toFixed(1)}% {trend.label} +

+ )} +
+
+ +
+
+ {sparkline && sparkline.length > 1 && ( +
+ +
+ )} + + ); + + if (onClick) { + return ( + + ); + } + + return ( +
+ {body} +
+ ); +} diff --git a/apps/dashboard/src/components/bi/BiPanel.tsx b/apps/dashboard/src/components/bi/BiPanel.tsx new file mode 100644 index 00000000..68d7dcb3 --- /dev/null +++ b/apps/dashboard/src/components/bi/BiPanel.tsx @@ -0,0 +1,59 @@ +import type { LucideIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +interface BiPanelProps { + title: string; + subtitle?: string; + icon?: LucideIcon; + action?: ReactNode; + children: ReactNode; + className?: string; + /** Soft atmospheric wash behind content */ + tone?: 'default' | 'navy' | 'teal'; +} + +export default function BiPanel({ + title, + subtitle, + icon: Icon, + action, + children, + className = '', + tone = 'default', +}: BiPanelProps) { + const toneClass = + tone === 'navy' + ? 'bg-gradient-to-br from-telivity-navy to-[#2c3150] text-white' + : tone === 'teal' + ? 'bg-gradient-to-br from-telivity-teal/10 via-white to-white' + : 'bg-white'; + + const titleClass = tone === 'navy' ? 'text-white' : 'text-telivity-navy'; + const subClass = tone === 'navy' ? 'text-white/70' : 'text-telivity-mid-grey'; + + return ( +
+
+
+ {Icon && ( +
+ +
+ )} +
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {action} +
+
{children}
+
+ ); +} diff --git a/apps/dashboard/src/components/bi/ExceptionAlerts.tsx b/apps/dashboard/src/components/bi/ExceptionAlerts.tsx new file mode 100644 index 00000000..fd946666 --- /dev/null +++ b/apps/dashboard/src/components/bi/ExceptionAlerts.tsx @@ -0,0 +1,156 @@ +import type { LucideIcon } from 'lucide-react'; +import { + AlertTriangle, + CheckCircle2, + ClipboardList, + FileWarning, + Wallet, +} from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +export interface ExceptionItem { + id: string; + severity: 'critical' | 'warn' | 'ok' | 'info'; + title: string; + detail?: string; + href?: string; +} + +const SEVERITY: Record< + ExceptionItem['severity'], + { wrap: string; icon: LucideIcon; iconClass: string } +> = { + critical: { + wrap: 'border-telivity-orange/30 bg-telivity-orange/5', + icon: AlertTriangle, + iconClass: 'text-telivity-orange', + }, + warn: { + wrap: 'border-telivity-yellow/40 bg-telivity-yellow/10', + icon: FileWarning, + iconClass: 'text-telivity-yellow', + }, + ok: { + wrap: 'border-telivity-dark-teal/20 bg-telivity-dark-teal/5', + icon: CheckCircle2, + iconClass: 'text-telivity-dark-teal', + }, + info: { + wrap: 'border-telivity-deep-blue/20 bg-telivity-deep-blue/5', + icon: ClipboardList, + iconClass: 'text-telivity-deep-blue', + }, +}; + +interface ExceptionAlertsProps { + items: ExceptionItem[]; + emptyLabel: string; +} + +export function buildFinanceExceptions(input: { + outstandingBalance?: number; + openFolios?: number; + lastAuditStatus?: string | null; + auditErrors?: number; + ooo?: number; + pendingDecisions?: number; + currencyFmt: (n: number) => string; + labels: { + openBalances: string; + openBalancesDetail: string; + auditOk: string; + auditFail: string; + auditFailDetail: string; + ooo: string; + oooDetail: string; + pendingAgents: string; + pendingAgentsDetail: string; + }; +}): ExceptionItem[] { + const items: ExceptionItem[] = []; + if ((input.outstandingBalance ?? 0) > 0) { + items.push({ + id: 'balances', + severity: 'warn', + title: input.labels.openBalances, + detail: input.labels.openBalancesDetail + .replace('{{amount}}', input.currencyFmt(input.outstandingBalance!)) + .replace('{{count}}', String(input.openFolios ?? 0)), + href: '/folios', + }); + } + if (input.lastAuditStatus) { + const failed = input.lastAuditStatus !== 'completed' && input.lastAuditStatus !== 'success'; + items.push({ + id: 'audit', + severity: failed || (input.auditErrors ?? 0) > 0 ? 'critical' : 'ok', + title: failed || (input.auditErrors ?? 0) > 0 ? input.labels.auditFail : input.labels.auditOk, + detail: + failed || (input.auditErrors ?? 0) > 0 + ? input.labels.auditFailDetail.replace('{{count}}', String(input.auditErrors ?? 0)) + : undefined, + href: '/night-audit', + }); + } + if ((input.ooo ?? 0) > 0) { + items.push({ + id: 'ooo', + severity: 'info', + title: input.labels.ooo, + detail: input.labels.oooDetail.replace('{{count}}', String(input.ooo)), + href: '/rooms', + }); + } + if ((input.pendingDecisions ?? 0) > 0) { + items.push({ + id: 'agents', + severity: 'info', + title: input.labels.pendingAgents, + detail: input.labels.pendingAgentsDetail.replace('{{count}}', String(input.pendingDecisions)), + href: '/revenue', + }); + } + return items; +} + +export default function ExceptionAlerts({ items, emptyLabel }: ExceptionAlertsProps) { + const navigate = useNavigate(); + + if (!items.length) { + return ( +
+ + {emptyLabel} +
+ ); + } + + return ( +
+ {items.map((item) => { + const cfg = SEVERITY[item.severity]; + const Icon = item.id === 'balances' ? Wallet : cfg.icon; + return ( + + ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/bi/PeriodChips.test.ts b/apps/dashboard/src/components/bi/PeriodChips.test.ts new file mode 100644 index 00000000..2b6c1854 --- /dev/null +++ b/apps/dashboard/src/components/bi/PeriodChips.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { periodRange } from './PeriodChips'; + +describe('periodRange', () => { + const now = new Date('2026-08-16T12:00:00Z'); + + it('resolves today vs yesterday compare', () => { + const r = periodRange('today', now); + expect(r.date).toBe('2026-08-16'); + expect(r.compareDate).toBe('2026-08-15'); + }); + + it('resolves 7d window', () => { + const r = periodRange('7d', now); + expect(r.startDate).toBe('2026-08-10'); + expect(r.endDate).toBe('2026-08-16'); + }); + + it('resolves mtd from month start', () => { + const r = periodRange('mtd', now); + expect(r.startDate).toBe('2026-08-01'); + expect(r.endDate).toBe('2026-08-16'); + }); +}); diff --git a/apps/dashboard/src/components/bi/PeriodChips.tsx b/apps/dashboard/src/components/bi/PeriodChips.tsx new file mode 100644 index 00000000..dc4d25b3 --- /dev/null +++ b/apps/dashboard/src/components/bi/PeriodChips.tsx @@ -0,0 +1,81 @@ +import { format, startOfMonth, subDays } from 'date-fns'; + +export type BiPeriod = 'today' | 'yesterday' | '7d' | '30d' | 'mtd'; + +const PERIODS: BiPeriod[] = ['today', 'yesterday', '7d', '30d', 'mtd']; + +interface PeriodChipsProps { + value: BiPeriod; + onChange: (period: BiPeriod) => void; + labels: Record; +} + +export function periodRange(period: BiPeriod, now = new Date()): { + date: string; + startDate: string; + endDate: string; + compareDate: string; +} { + const today = format(now, 'yyyy-MM-dd'); + const yesterday = format(subDays(now, 1), 'yyyy-MM-dd'); + + switch (period) { + case 'yesterday': + return { + date: yesterday, + startDate: yesterday, + endDate: yesterday, + compareDate: format(subDays(now, 2), 'yyyy-MM-dd'), + }; + case '7d': + return { + date: today, + startDate: format(subDays(now, 6), 'yyyy-MM-dd'), + endDate: today, + compareDate: yesterday, + }; + case '30d': + return { + date: today, + startDate: format(subDays(now, 29), 'yyyy-MM-dd'), + endDate: today, + compareDate: yesterday, + }; + case 'mtd': + return { + date: today, + startDate: format(startOfMonth(now), 'yyyy-MM-dd'), + endDate: today, + compareDate: yesterday, + }; + case 'today': + default: + return { + date: today, + startDate: today, + endDate: today, + compareDate: yesterday, + }; + } +} + +export default function PeriodChips({ value, onChange, labels }: PeriodChipsProps) { + return ( +
+ {PERIODS.map((p) => ( + + ))} +
+ ); +} diff --git a/apps/dashboard/src/components/bi/ReportGallery.tsx b/apps/dashboard/src/components/bi/ReportGallery.tsx new file mode 100644 index 00000000..3c9b107d --- /dev/null +++ b/apps/dashboard/src/components/bi/ReportGallery.tsx @@ -0,0 +1,91 @@ +import type { LucideIcon } from 'lucide-react'; +import { Star } from 'lucide-react'; + +export interface ReportGalleryItem { + id: string; + title: string; + description: string; + icon: LucideIcon; + category: string; + favorite?: boolean; + portfolioOk?: boolean; +} + +interface ReportGalleryProps { + items: ReportGalleryItem[]; + activeId: string | null; + onSelect: (id: string) => void; + onToggleFavorite: (id: string) => void; + favoriteLabel: string; + unfavoriteLabel: string; +} + +export default function ReportGallery({ + items, + activeId, + onSelect, + onToggleFavorite, + favoriteLabel, + unfavoriteLabel, +}: ReportGalleryProps) { + return ( +
+ {items.map((item) => { + const Icon = item.icon; + const active = activeId === item.id; + return ( +
+ + +
+ ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/bi/Sparkline.tsx b/apps/dashboard/src/components/bi/Sparkline.tsx new file mode 100644 index 00000000..8337c561 --- /dev/null +++ b/apps/dashboard/src/components/bi/Sparkline.tsx @@ -0,0 +1,46 @@ +import { Area, AreaChart, ResponsiveContainer } from 'recharts'; +import { BI } from './chartTheme'; + +interface SparklineProps { + data: number[]; + color?: string; + height?: number; + className?: string; +} + +/** Compact trend ribbon for KPI cards. */ +export default function Sparkline({ + data, + color = BI.teal, + height = 36, + className, +}: SparklineProps) { + if (!data.length) return null; + const chartData = data.map((v, i) => ({ i, v })); + const id = `spark-${color.replace('#', '')}`; + + return ( +
+ + + + + + + + + + + +
+ ); +} diff --git a/apps/dashboard/src/components/bi/chartTheme.ts b/apps/dashboard/src/components/bi/chartTheme.ts new file mode 100644 index 00000000..751b0798 --- /dev/null +++ b/apps/dashboard/src/components/bi/chartTheme.ts @@ -0,0 +1,37 @@ +/** Shared Recharts styling for BI surfaces — Telivity tokens. */ +export const BI = { + teal: '#06bdb4', + darkTeal: '#00a692', + lightTeal: '#2cd1b9', + orange: '#f2641b', + yellow: '#eec517', + deepBlue: '#016491', + purple: '#5838c0', + navy: '#23273d', + slate: '#444863', + midGrey: '#bbbbc4', + grid: '#e8e8ef', + tooltipBg: '#ffffff', + tooltipBorder: '#e8e8ef', +} as const; + +export const BI_SERIES = [BI.teal, BI.deepBlue, BI.orange, BI.purple, BI.yellow, BI.darkTeal]; + +export const ROOM_STATUS_COLORS: Record = { + occupied: BI.teal, + vacant_clean: BI.darkTeal, + vacant_dirty: BI.orange, + out_of_order: BI.yellow, + out_of_service: BI.midGrey, + clean: BI.darkTeal, + inspected: BI.deepBlue, + guest_ready: BI.lightTeal, +}; + +export const chartTooltipStyle = { + backgroundColor: BI.tooltipBg, + border: `1px solid ${BI.tooltipBorder}`, + borderRadius: 10, + fontSize: 12, + boxShadow: '0 8px 24px rgba(35, 39, 61, 0.08)', +}; diff --git a/apps/dashboard/src/index.css b/apps/dashboard/src/index.css index b5c61c95..89718a05 100644 --- a/apps/dashboard/src/index.css +++ b/apps/dashboard/src/index.css @@ -1,3 +1,24 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@layer utilities { + .bi-enter { + animation: bi-fade-up 0.45s ease-out both; + } + + .bi-hero { + animation: bi-fade-up 0.35s ease-out both; + } +} + +@keyframes bi-fade-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/apps/dashboard/src/lib/dashboard-persona.test.ts b/apps/dashboard/src/lib/dashboard-persona.test.ts new file mode 100644 index 00000000..f6ec6b15 --- /dev/null +++ b/apps/dashboard/src/lib/dashboard-persona.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { resolveDashboardPersona } from './dashboard-persona'; + +describe('resolveDashboardPersona', () => { + it('returns manager when auth is disabled', () => { + expect(resolveDashboardPersona(['housekeeping.read'], false)).toBe('manager'); + }); + + it('returns ops when no permissions', () => { + expect(resolveDashboardPersona([], true)).toBe('ops'); + }); + + it('maps front desk without reports to front_office', () => { + expect( + resolveDashboardPersona(['dashboard.view', 'frontdesk.access', 'reservations.read'], true), + ).toBe('front_office'); + }); + + it('maps housekeeping without reports to housekeeping', () => { + expect( + resolveDashboardPersona(['dashboard.view', 'housekeeping.read', 'rooms.read'], true), + ).toBe('housekeeping'); + }); + + it('maps revenue manager to revenue', () => { + expect( + resolveDashboardPersona(['dashboard.view', 'reports.view', 'revenue.manage', 'channels.manage'], true), + ).toBe('revenue'); + }); + + it('maps accounting to accounting', () => { + expect( + resolveDashboardPersona( + ['dashboard.view', 'reports.view', 'accounting.view', 'nightaudit.run'], + true, + ), + ).toBe('accounting'); + }); + + it('maps GM/admin (broad) to manager', () => { + expect( + resolveDashboardPersona( + ['reports.view', 'revenue.manage', 'accounting.view', 'frontdesk.access'], + true, + ), + ).toBe('manager'); + }); + + it('maps readonly reports.view to manager', () => { + expect(resolveDashboardPersona(['dashboard.view', 'reports.view', 'reservations.read'], true)).toBe( + 'manager', + ); + }); +}); diff --git a/apps/dashboard/src/lib/dashboard-persona.ts b/apps/dashboard/src/lib/dashboard-persona.ts new file mode 100644 index 00000000..bae821df --- /dev/null +++ b/apps/dashboard/src/lib/dashboard-persona.ts @@ -0,0 +1,72 @@ +/** + * Resolve which home dashboard layout to show from effective property permissions. + * Prefer permission sets over Keycloak realm role names so custom roles still map cleanly. + */ +export type DashboardPersona = + | 'manager' + | 'revenue' + | 'accounting' + | 'front_office' + | 'housekeeping' + | 'ops'; + +export function resolveDashboardPersona( + permissions: string[], + authEnabled: boolean, +): DashboardPersona { + // Demo / auth-off: show the full manager flash report. + if (!authEnabled) return 'manager'; + if (!permissions.length) return 'ops'; + + const has = (key: string) => permissions.includes(key); + + // GM / admin: broad finance + revenue + ops. + if (has('reports.view') && has('revenue.manage') && has('accounting.view')) { + return 'manager'; + } + if (has('revenue.manage') && has('reports.view')) return 'revenue'; + if ((has('accounting.view') || has('nightaudit.run')) && has('reports.view')) { + return 'accounting'; + } + if (has('reports.view')) return 'manager'; + + if (has('housekeeping.read') && !has('frontdesk.access')) return 'housekeeping'; + if (has('frontdesk.access')) return 'front_office'; + if (has('housekeeping.read')) return 'housekeeping'; + + return 'ops'; +} + +export function personaHeadlineKey(persona: DashboardPersona): string { + switch (persona) { + case 'manager': + return 'dashboard.persona.manager'; + case 'revenue': + return 'dashboard.persona.revenue'; + case 'accounting': + return 'dashboard.persona.accounting'; + case 'front_office': + return 'dashboard.persona.frontOffice'; + case 'housekeeping': + return 'dashboard.persona.housekeeping'; + default: + return 'dashboard.persona.ops'; + } +} + +export function personaSubtitleKey(persona: DashboardPersona): string { + switch (persona) { + case 'manager': + return 'dashboard.persona.managerHint'; + case 'revenue': + return 'dashboard.persona.revenueHint'; + case 'accounting': + return 'dashboard.persona.accountingHint'; + case 'front_office': + return 'dashboard.persona.frontOfficeHint'; + case 'housekeeping': + return 'dashboard.persona.housekeepingHint'; + default: + return 'dashboard.persona.opsHint'; + } +} diff --git a/apps/dashboard/src/locales/en.json b/apps/dashboard/src/locales/en.json index 2172d12a..a8662e32 100644 --- a/apps/dashboard/src/locales/en.json +++ b/apps/dashboard/src/locales/en.json @@ -349,14 +349,85 @@ "dashboard": { "activeAgents": "{{count}} active agents", "adr": "ADR", + "aging": { + "1_30": "1–30 days", + "31_60": "31–60 days", + "61_90": "61–90 days", + "90_plus": "90+ days", + "current": "Current" + }, "arrivals": "Arrivals", "availableRooms": "Available Rooms", "averageDailyRate": "Average Daily Rate", + "biEyebrow": "Hotel intelligence", "departures": "Departures", + "exceptions": { + "allClear": "No exceptions — house looks clean", + "auditFail": "Night audit needs attention", + "auditFailDetail": "{{count}} error(s) on last close", + "auditOk": "Night audit completed", + "ooo": "Rooms out of order", + "oooDetail": "{{count}} room(s) unavailable for sale", + "openBalances": "Open folio balances", + "openBalancesDetail": "{{amount}} across {{count}} open folios", + "pendingAgents": "Revenue decisions waiting", + "pendingAgentsDetail": "{{count}} agent decision(s) pending review" + }, + "hk": { + "completed": "Completed", + "inProgress": "In progress", + "noUrgent": "No urgent rooms right now", + "pending": "Pending tasks", + "room": "Room {{number}}", + "urgent": "Urgent rooms", + "urgentRooms": "Urgent rooms" + }, "inHouse": "In-House", + "noRoomData": "No room data available", "occupancy": "Occupancy", "occupiedOfRooms": "{{occupied}} of {{total}} rooms", + "panels": { + "arAging": "A/R aging", + "arAgingHint": "Outstanding city-ledger balances by age", + "bookingPace": "Booking pace", + "last14Days": "Last 14 days", + "last30Days": "Last 30 days", + "lastAudit": "Last night audit", + "ledgerHealth": "Ledger health", + "noAudit": "No audit recorded yet", + "noPayments": "No payment activity for this date", + "noRevenueMix": "No revenue posted for this date", + "occupancyTrend": "Occupancy trend", + "openAccounting": "Open accounting", + "openFolioBalance": "Open folio balance", + "openFolios": "{{count}} open folios", + "openTrialBalance": "Open trial balance", + "paymentMix": "Payments by method", + "pickupStay": "Stay date {{date}}", + "revenueMix": "Revenue mix" + }, "pendingDecisions": "{{count}} pending decisions", + "period": { + "mtd": "MTD", + "sevenDay": "7D", + "thirtyDay": "30D", + "today": "Today", + "yesterday": "Yesterday" + }, + "persona": { + "accounting": "Night close & ledgers", + "accountingHint": "Trial balance, open folios, and A/R aging — everything you need before and after close.", + "frontOffice": "Front office command", + "frontOfficeHint": "Arrivals, departures, in-house guests, and room readiness at a glance.", + "housekeeping": "Housekeeping board", + "housekeepingHint": "Task load, urgent rooms, and room status so the floor stays guest-ready.", + "manager": "Manager's flash report", + "managerHint": "Occupancy, ADR, RevPAR, revenue mix, and exceptions — the pulse of the house.", + "ops": "Operations overview", + "opsHint": "Today's activity and room status for your property.", + "revenue": "Demand & pace desk", + "revenueHint": "Pickup, booking pace, and occupancy trend so you can price and restrict with confidence." + }, "portfolio": { "acrossAllProperties": "Across all properties", "adr": "Portfolio ADR", @@ -368,10 +439,18 @@ "revenue": "Revenue", "revenueByProperty": "Revenue by Property", "revpar": "Portfolio RevPAR", + "subtitle": "Cross-property scorecard with drill-down into each house.", "title": "Portfolio Dashboard", "totalRevenueToday": "Total Revenue Today", "weightedAverage": "Weighted average" }, + "quick": { + "accounting": "Accounting", + "frontDesk": "Front desk", + "housekeeping": "Housekeeping", + "reports": "Reports", + "revenue": "Revenue" + }, "recentActivityLive": "Recent Activity (Live)", "revenueBreakdown": "Room + F&B + Other", "revenueIntelligence": "Revenue Intelligence", @@ -390,7 +469,9 @@ "vacant_dirty": "Vacant Dirty" }, "selectProperty": "Select a property to view the dashboard", + "todaysActivity": "Today's activity", "viewRevenueManagement": "View Revenue Management", + "vsPriorDay": "vs prior day", "waitingForEvents": "Waiting for real-time events…" }, "folios": { @@ -1321,28 +1402,49 @@ }, "reports": { "addFavorite": "Add favorite", + "added": "Added", "arrivals": "Arrivals", "available": "Available", + "avgRoomsOnBooks": "Avg Rooms on Books", + "baselineRoomNights": "Baseline Room Nights", + "biEyebrow": "Business intelligence", + "biSubtitle": "Flash, demand, and ledger packs — pick a report and run it for any date.", + "bookingPace": "Booking Pace", "byProperty": "By Property", + "catAccounting": "Accounting", + "catDemand": "Demand", + "catFlash": "Flash", + "catRooms": "Rooms", + "currentRoomNights": "Current Room Nights", + "dailyPickup": "Daily Pickup", "dailyRevenue": "Daily Revenue", "date": "Date", + "descBookingPace": "Rooms on books and new bookings over a date range.", + "descDailyRevenue": "Room vs other revenue with payment-method mix.", + "descFinancialSummary": "ADR, RevPAR, occupancy, revenue mix, and folio health.", + "descOccupancy": "Occupied, available, OOO, and occupancy rate.", + "descOccupancyTrend": "Daily occupancy curve across a selected range.", + "descPickup": "Baseline vs current room nights and net pickup for a stay date.", + "descTrialBalance": "Deposit, guest, and A/R ledger opening through closing.", + "exportCsv": "Export CSV", "financialSummary": "Financial Summary", "from": "From", + "loading": "Refreshing…", + "lost": "Lost", + "netPickup": "Net Pickup", + "newBookings": "New Bookings", + "noPace": "No booking pace data available", + "noPickup": "No pickup activity in this period", "noTrend": "No trend data available", "occupancy": "Occupancy", "occupancyTrend": "Occupancy Trend", - "pickup": "Pickup", - "stayDate": "Stay Date", - "baselineRoomNights": "Baseline Room Nights", - "currentRoomNights": "Current Room Nights", - "netPickup": "Net Pickup", - "dailyPickup": "Daily Pickup", - "added": "Added", - "lost": "Lost", - "noPickup": "No pickup activity in this period", "occupied": "Occupied", + "ooo": "OOO", "otherRevenue": "Other Revenue", - "portfolioNotice": "Daily revenue and occupancy trend are per-property reports.", + "pickReport": "Choose a report to begin", + "pickReportHint": "Star your favorites for one-click access. Deep links from Accounting still work.", + "pickup": "Pickup", + "portfolioNotice": "This report runs on a single property. Financial summary and occupancy support portfolio mode.", "portfolioTitle": "Portfolio Reports", "property": "Property", "removeFavorite": "Remove favorite", @@ -1351,10 +1453,13 @@ "revenueBreakdown": "Revenue Breakdown", "revenueByPaymentMethod": "Revenue by Payment Method", "roomRevenue": "Room Revenue", + "roomsOnBooks": "Rooms on Books", "selectProperty": "Select a property", "selectSingleProperty": "Select a single property to run this report.", + "stayDate": "Stay Date", "title": "Reports", "to": "To", + "totalNewBookings": "Total New Bookings", "totalRevenue": "Total Revenue", "trialBalance": "Trial Balance", "trialBalanceAr": "A/R ledger", @@ -1366,13 +1471,7 @@ "trialBalanceNetActivity": "Net activity", "trialBalanceOpening": "Opening", "trialBalanceTransfersIn": "Transfers in", - "trialBalanceTransfersOut": "Transfers out", - "avgRoomsOnBooks": "Avg Rooms on Books", - "bookingPace": "Booking Pace", - "newBookings": "New Bookings", - "noPace": "No booking pace data available", - "roomsOnBooks": "Rooms on Books", - "totalNewBookings": "Total New Bookings" + "trialBalanceTransfersOut": "Transfers out" }, "reservations": { "actions": "Actions", diff --git a/apps/dashboard/src/pages/Dashboard.tsx b/apps/dashboard/src/pages/Dashboard.tsx index 6f01abf2..a3c71f55 100644 --- a/apps/dashboard/src/pages/Dashboard.tsx +++ b/apps/dashboard/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { @@ -13,28 +13,42 @@ import { DoorOpen, Brain, Building2, + Wallet, + ShieldCheck, + Sparkles, + CalendarClock, + ArrowRight, + ClipboardCheck, + BarChart3, } from 'lucide-react'; -import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'; -import { format } from 'date-fns'; +import { addDays, format, subDays } from 'date-fns'; +import { useTranslation } from 'react-i18next'; import { api } from '../lib/api'; import { formatOccupancyPercent } from '../lib/api-helpers'; import { getDateLocale } from '../lib/date-locale'; import { useProperty } from '../context/PropertyContext'; +import { useAuth } from '../context/AuthContext'; import { getSocket } from '../lib/socket'; -import KpiCard from '../components/ui/KpiCard'; import { formatMoney } from '../lib/money'; -import { useTranslation } from 'react-i18next'; - -const ROOM_STATUS_COLORS: Record = { - occupied: '#06bdb4', - vacant_clean: '#00a692', - vacant_dirty: '#f2641b', - out_of_order: '#eec517', - out_of_service: '#bbbbc4', - clean: '#00a692', - inspected: '#016491', - guest_ready: '#2cd1b9', -}; +import { + personaHeadlineKey, + personaSubtitleKey, + resolveDashboardPersona, + type DashboardPersona, +} from '../lib/dashboard-persona'; +import BiHero from '../components/bi/BiHero'; +import BiKpiCard from '../components/bi/BiKpiCard'; +import BiPanel from '../components/bi/BiPanel'; +import ExceptionAlerts, { buildFinanceExceptions } from '../components/bi/ExceptionAlerts'; +import { periodRange, type BiPeriod } from '../components/bi/PeriodChips'; +import { + OccupancyTrendChart, + PaymentMethodBars, + PortfolioRevenueBars, + PaceDualLineChart, + RevenueMixBars, + RoomStatusDonut, +} from '../components/bi/BiCharts'; interface ActivityEvent { id: string; @@ -43,42 +57,149 @@ interface ActivityEvent { data?: Record; } +function pctDelta(current: number | null | undefined, prior: number | null | undefined): number | undefined { + if (current == null || prior == null || prior === 0) return undefined; + return ((Number(current) - Number(prior)) / Math.abs(Number(prior))) * 100; +} + +function listLen(data: unknown): number { + const list = (data as { data?: unknown })?.data ?? data; + return Array.isArray(list) ? list.length : 0; +} + export default function Dashboard() { const { t, i18n } = useTranslation(); const { propertyId, setPropertyId, isPortfolioMode, properties, currencyCode } = useProperty(); + const { permissions, authEnabled, hasPermission } = useAuth(); const navigate = useNavigate(); const now = new Date(); const dateLocale = getDateLocale(i18n.resolvedLanguage); - const today = format(now, 'yyyy-MM-dd'); const formattedToday = format(now, 'PPPP', { locale: dateLocale }); + const [period, setPeriod] = useState('today'); + const range = useMemo(() => periodRange(period, now), [period, now]); + + const persona: DashboardPersona = useMemo( + () => resolveDashboardPersona(permissions, authEnabled), + [permissions, authEnabled], + ); - const activeProperty = isPortfolioMode - ? null - : properties.find((p) => p.id === propertyId); + const canReports = !authEnabled || hasPermission('reports.view'); + const canHk = !authEnabled || hasPermission('housekeeping.read'); + const canRevenue = !authEnabled || hasPermission('revenue.manage'); + const canAccounting = !authEnabled || hasPermission('accounting.view'); + + const activeProperty = isPortfolioMode ? null : properties.find((p) => p.id === propertyId); const thr = activeProperty?.settings?.kpiThresholds ?? {}; + const money = (n: number) => formatMoney(n, currencyCode); + + const periodLabels: Record = { + today: t('dashboard.period.today'), + yesterday: t('dashboard.period.yesterday'), + '7d': t('dashboard.period.sevenDay'), + '30d': t('dashboard.period.thirtyDay'), + mtd: t('dashboard.period.mtd'), + }; + // ---- Portfolio queries ---- const { data: portfolioFinancial } = useQuery({ - queryKey: ['reports', 'portfolio', 'financial-summary', today], - queryFn: () => api.get('/v1/reports/portfolio/financial-summary', { params: { date: today } }).then((r) => r.data), - enabled: isPortfolioMode, + queryKey: ['reports', 'portfolio', 'financial-summary', range.date], + queryFn: () => + api.get('/v1/reports/portfolio/financial-summary', { params: { date: range.date } }).then((r) => r.data), + enabled: isPortfolioMode && canReports, }); const { data: portfolioOccupancy } = useQuery({ - queryKey: ['reports', 'portfolio', 'occupancy', today], - queryFn: () => api.get('/v1/reports/portfolio/occupancy', { params: { date: today } }).then((r) => r.data), - enabled: isPortfolioMode, + queryKey: ['reports', 'portfolio', 'occupancy', range.date], + queryFn: () => + api.get('/v1/reports/portfolio/occupancy', { params: { date: range.date } }).then((r) => r.data), + enabled: isPortfolioMode && canReports, }); + // ---- Property finance / ops queries ---- const { data: financial } = useQuery({ - queryKey: ['reports', 'financial-summary', propertyId, today], - queryFn: () => api.get('/v1/reports/financial-summary', { params: { propertyId, date: today } }).then((r) => r.data), - enabled: !!propertyId && !isPortfolioMode, + queryKey: ['reports', 'financial-summary', propertyId, range.date], + queryFn: () => + api + .get('/v1/reports/financial-summary', { params: { propertyId, date: range.date } }) + .then((r) => r.data), + enabled: !!propertyId && !isPortfolioMode && canReports, + }); + + const { data: financialCompare } = useQuery({ + queryKey: ['reports', 'financial-summary', propertyId, range.compareDate], + queryFn: () => + api + .get('/v1/reports/financial-summary', { + params: { propertyId, date: range.compareDate }, + }) + .then((r) => r.data), + enabled: !!propertyId && !isPortfolioMode && canReports && period === 'today', }); const { data: occupancy } = useQuery({ - queryKey: ['reports', 'occupancy', propertyId, today], - queryFn: () => api.get('/v1/reports/occupancy', { params: { propertyId, date: today } }).then((r) => r.data), - enabled: !!propertyId && !isPortfolioMode, + queryKey: ['reports', 'occupancy', propertyId, range.date], + queryFn: () => + api.get('/v1/reports/occupancy', { params: { propertyId, date: range.date } }).then((r) => r.data), + enabled: !!propertyId && !isPortfolioMode && canReports, + }); + + const { data: dailyRevenue } = useQuery({ + queryKey: ['reports', 'daily-revenue', propertyId, range.date], + queryFn: () => + api + .get('/v1/reports/daily-revenue', { params: { propertyId, date: range.date } }) + .then((r) => r.data), + enabled: !!propertyId && !isPortfolioMode && canReports && (persona === 'manager' || persona === 'accounting'), + }); + + const trendStart = range.startDate; + const trendEnd = range.endDate; + + const { data: occupancyTrend } = useQuery({ + queryKey: ['reports', 'occupancy-trend', propertyId, trendStart, trendEnd], + queryFn: () => + api + .get('/v1/reports/occupancy-trend', { + params: { propertyId, startDate: trendStart, endDate: trendEnd }, + }) + .then((r) => r.data), + enabled: !!propertyId && !isPortfolioMode && canReports, + }); + + const paceStart = + period === 'today' || period === 'yesterday' + ? format(subDays(now, 13), 'yyyy-MM-dd') + : range.startDate; + const { data: bookingPace } = useQuery({ + queryKey: ['reports', 'booking-pace', propertyId, paceStart, trendEnd], + queryFn: () => + api + .get('/v1/reports/booking-pace', { + params: { propertyId, startDate: paceStart, endDate: trendEnd }, + }) + .then((r) => r.data), + enabled: + !!propertyId && + !isPortfolioMode && + canReports && + (persona === 'revenue' || persona === 'manager'), + }); + + const stayDate = format(addDays(now, 14), 'yyyy-MM-dd'); + const { data: pickup } = useQuery({ + queryKey: ['reports', 'pickup', propertyId, stayDate], + queryFn: () => + api + .get('/v1/reports/pickup', { + params: { + propertyId, + stayDate, + from: format(subDays(now, 7), 'yyyy-MM-dd'), + to: format(now, 'yyyy-MM-dd'), + }, + }) + .then((r) => r.data), + enabled: !!propertyId && !isPortfolioMode && canReports && persona === 'revenue', }); const { data: roomSummary } = useQuery({ @@ -87,41 +208,75 @@ export default function Dashboard() { enabled: !!propertyId && !isPortfolioMode, }); + const today = format(now, 'yyyy-MM-dd'); + const { data: arrivals } = useQuery({ queryKey: ['reservations', 'arrivals', propertyId, today], - queryFn: () => api.get('/v1/reservations', { params: { propertyId, status: 'confirmed', arrivalDateFrom: today, arrivalDateTo: today } }).then((r) => r.data), + queryFn: () => + api + .get('/v1/reservations', { + params: { propertyId, status: 'confirmed', arrivalDateFrom: today, arrivalDateTo: today }, + }) + .then((r) => r.data), enabled: !!propertyId && !isPortfolioMode, }); const { data: departures } = useQuery({ queryKey: ['reservations', 'departures', propertyId, today], - queryFn: () => api.get('/v1/reservations', { params: { propertyId, status: 'checked_in', departureDateFrom: today, departureDateTo: today } }).then((r) => r.data), + queryFn: () => + api + .get('/v1/reservations', { + params: { + propertyId, + status: 'checked_in', + departureDateFrom: today, + departureDateTo: today, + }, + }) + .then((r) => r.data), enabled: !!propertyId && !isPortfolioMode, }); const { data: inHouse } = useQuery({ queryKey: ['reservations', 'in-house', propertyId], - queryFn: () => api.get('/v1/reservations', { params: { propertyId, status: 'checked_in' } }).then((r) => r.data), + queryFn: () => + api.get('/v1/reservations', { params: { propertyId, status: 'checked_in' } }).then((r) => r.data), enabled: !!propertyId && !isPortfolioMode, }); const { data: agentStatuses } = useQuery({ queryKey: ['agents', propertyId], queryFn: () => api.get(`/v1/agents/${propertyId}`).then((r) => r.data?.data ?? r.data ?? []), - enabled: !!propertyId && !isPortfolioMode, + enabled: !!propertyId && !isPortfolioMode && canRevenue, }); - const [activities, setActivities] = useState([]); + const { data: hkDash } = useQuery({ + queryKey: ['housekeeping', 'dashboard', propertyId, today], + queryFn: () => + api + .get('/v1/housekeeping/dashboard', { params: { propertyId, serviceDate: today } }) + .then((r) => r.data), + enabled: !!propertyId && !isPortfolioMode && canHk && (persona === 'housekeeping' || persona === 'manager'), + }); + + const { data: arAging } = useQuery({ + queryKey: ['ar-aging', 'property', propertyId], + queryFn: () => api.get('/v1/ar/aging', { params: { propertyId } }).then((r) => r.data), + enabled: !!propertyId && !isPortfolioMode && canAccounting && persona === 'accounting', + }); + const [activities, setActivities] = useState([]); const handleEvent = useCallback((payload: ActivityEvent) => { - setActivities((prev) => [payload, ...prev].slice(0, 10)); + setActivities((prev) => [payload, ...prev].slice(0, 12)); }, []); useEffect(() => { if (isPortfolioMode) return; const socket = getSocket(); socket.on('pmsEvent', handleEvent); - return () => { socket.off('pmsEvent', handleEvent); }; + return () => { + socket.off('pmsEvent', handleEvent); + }; }, [handleEvent, isPortfolioMode]); if (!propertyId) { @@ -132,278 +287,620 @@ export default function Dashboard() { ); } - // Portfolio mode dashboard + // ========== PORTFOLIO ========== if (isPortfolioMode) { const fin = portfolioFinancial?.data ?? portfolioFinancial ?? {}; const occ = portfolioOccupancy?.data ?? portfolioOccupancy ?? {}; const kpis = fin.kpis ?? {}; const byProperty = fin.byProperty ?? []; const propertyNameMap = new Map(properties.map((p) => [p.id, p.name])); - const chartData = byProperty.map((row: { propertyId: string; totalRevenue: number }) => ({ name: propertyNameMap.get(row.propertyId) ?? row.propertyId.slice(0, 8), - revenue: row.totalRevenue, + revenue: Number(row.totalRevenue), })); return (
-
- -

{t('dashboard.portfolio.title')}

- - {t('dashboard.portfolio.propertyCount', { count: fin.propertyCount ?? properties.length })} · {formattedToday} - -
+ -
- + - - -
- {chartData.length > 0 && ( -
-

{t('dashboard.portfolio.revenueByProperty')}

- - - - - - [formatMoney(v, currencyCode), t('dashboard.portfolio.revenue')]} /> - - - -
- )} - -
-

{t('dashboard.portfolio.propertyBreakdown')}

-
- - - - - - - - - - - - {byProperty.map((row: { propertyId: string; occupancyRate: number; adr: number; revpar: number; totalRevenue: number }) => ( - - - - - - +
+ + + + +
+
{t('dashboard.portfolio.property')}{t('dashboard.occupancy')}{t('dashboard.adr')}{t('dashboard.revpar')}{t('dashboard.portfolio.revenue')}
- - {formatOccupancyPercent(row.occupancyRate)}{formatMoney(row.adr, currencyCode)}{formatMoney(row.revpar, currencyCode)}{formatMoney(row.totalRevenue, currencyCode)}
+ + + + + - ))} - -
{t('dashboard.portfolio.property')}{t('dashboard.occupancy')}{t('dashboard.portfolio.revenue')}
-
+ + + {byProperty.map( + (row: { + propertyId: string; + occupancyRate: number; + totalRevenue: number; + }) => ( + + + + + {formatOccupancyPercent(row.occupancyRate)} + {money(row.totalRevenue)} + + ), + )} + + +
+
); } + // ========== PROPERTY DATA ========== const occ = occupancy?.data ?? occupancy ?? {}; const fin = financial?.data ?? financial ?? {}; const kpis = fin.kpis ?? {}; - const arrList = arrivals?.data ?? arrivals ?? []; - const depList = departures?.data ?? departures ?? []; - const ihList = inHouse?.data ?? inHouse ?? []; + const compareKpis = (financialCompare?.data ?? financialCompare ?? {}).kpis ?? {}; + const outstanding = fin.outstandingBalances ?? {}; + const audit = fin.auditStatus ?? {}; + const revenueByType = fin.revenueByType ?? {}; + const daily = dailyRevenue?.data ?? dailyRevenue ?? {}; + const payments = daily.payments ?? fin.paymentsByMethod ?? {}; + + const arrCount = listLen(arrivals); + const depCount = listLen(departures); + const ihCount = listLen(inHouse); const roomData = roomSummary?.data ?? roomSummary ?? []; const chartData = Array.isArray(roomData) ? roomData.map((r: { status: string; count: number }) => ({ - name: t(`dashboard.roomStatuses.${r.status}`, { defaultValue: r.status.replace(/_/g, ' ') }), + name: t(`dashboard.roomStatuses.${r.status}`, { + defaultValue: r.status.replace(/_/g, ' '), + }), status: r.status, value: Number(r.count), - color: ROOM_STATUS_COLORS[r.status] ?? '#bbbbc4', })) : []; - const totalRooms = chartData.reduce((sum: number, d: { value: number }) => sum + d.value, 0); - const occupiedCount = chartData.find((d: { status: string }) => d.status === 'occupied')?.value ?? 0; + const occupiedCount = + chartData.find((d: { status: string }) => d.status === 'occupied')?.value ?? 0; + const oooCount = + chartData.find((d: { status: string }) => d.status === 'out_of_order')?.value ?? + occ.outOfOrder ?? + 0; + + const trendDaily = ((occupancyTrend?.data ?? occupancyTrend ?? {}).daily ?? []) as { + date: string; + occupancyRate: number; + }[]; + const occSpark = trendDaily.slice(-14).map((d) => Number(d.occupancyRate) * 100); + const trendChart = trendDaily.map((d) => ({ + date: d.date.slice(5), + occupancyPct: Number(d.occupancyRate) * 100, + })); + + const revMix = Object.entries(revenueByType as Record).map(([k, v]) => ({ + name: k.replace(/_/g, ' '), + amount: Number(v), + })); + const payMix = Object.entries(payments as Record) + .filter(([k]) => k !== 'total') + .map(([k, v]) => ({ method: k.replace(/_/g, ' '), amount: Number(v) })); + + const pendingDecisions = Array.isArray(agentStatuses) + ? agentStatuses.reduce( + (s: number, a: { pendingDecisions?: number }) => s + (a.pendingDecisions ?? 0), + 0, + ) + : 0; + + const exceptions = + canReports + ? buildFinanceExceptions({ + outstandingBalance: Number(outstanding.totalBalanceDue ?? 0), + openFolios: Number(outstanding.totalFoliosOpen ?? 0), + lastAuditStatus: audit.lastAuditStatus ?? null, + auditErrors: Number(audit.errorsInLastAudit ?? 0), + ooo: Number(oooCount), + pendingDecisions, + currencyFmt: money, + labels: { + openBalances: t('dashboard.exceptions.openBalances'), + openBalancesDetail: t('dashboard.exceptions.openBalancesDetail'), + auditOk: t('dashboard.exceptions.auditOk'), + auditFail: t('dashboard.exceptions.auditFail'), + auditFailDetail: t('dashboard.exceptions.auditFailDetail'), + ooo: t('dashboard.exceptions.ooo'), + oooDetail: t('dashboard.exceptions.oooDetail'), + pendingAgents: t('dashboard.exceptions.pendingAgents'), + pendingAgentsDetail: t('dashboard.exceptions.pendingAgentsDetail'), + }, + }) + : []; + + const occTrendPct = pctDelta(kpis.occupancyRate ?? occ.occupancyRate, compareKpis.occupancyRate); + const adrTrendPct = pctDelta(kpis.adr, compareKpis.adr); + const revparTrendPct = pctDelta(kpis.revpar, compareKpis.revpar); + const revTrendPct = pctDelta(kpis.totalRevenue, compareKpis.totalRevenue); + + const vsLabel = t('dashboard.vsPriorDay'); + + const hk = hkDash?.data ?? hkDash ?? {}; + const hkTasks = hk.taskSummary ?? {}; + const hkUrgent = (hk.urgentRooms ?? []) as { roomNumber?: string; reason?: string }[]; + + const pace = bookingPace?.data ?? bookingPace ?? {}; + const paceDaily = (pace.daily ?? []) as { + date: string; + roomsOnBooks: number; + newBookings: number; + }[]; + const pickupData = pickup?.data ?? pickup ?? {}; + + const aging = arAging?.data ?? arAging ?? {}; + const agingBuckets = (aging.buckets ?? {}) as Record; + + const quickLinks = [ + canReports && { + label: t('dashboard.quick.reports'), + href: '/reports', + icon: BarChart3, + }, + (persona === 'front_office' || persona === 'manager' || persona === 'ops') && { + label: t('dashboard.quick.frontDesk'), + href: '/front-desk', + icon: LogIn, + }, + canHk && { + label: t('dashboard.quick.housekeeping'), + href: '/housekeeping', + icon: Sparkles, + }, + canRevenue && { + label: t('dashboard.quick.revenue'), + href: '/revenue', + icon: Brain, + }, + canAccounting && { + label: t('dashboard.quick.accounting'), + href: '/accounting', + icon: Wallet, + }, + ].filter(Boolean) as { label: string; href: string; icon: typeof BarChart3 }[]; return (
-
- -

{t('nav.dashboard')}

- {formattedToday} -
+ + {quickLinks.slice(0, 3).map((link) => ( + + ))} +
+ } + /> -
- - - - -
+ {canReports && exceptions.length > 0 && ( +
+ +
+ )} -
-
-

Today's Activity

-
-
-
- -
-
-

{Array.isArray(arrList) ? arrList.length : 0}

-

{t('dashboard.arrivals')}

-
-
-
-
- -
-
-

{Array.isArray(ihList) ? ihList.length : 0}

-

{t('dashboard.inHouse')}

-
-
-
-
- -
+ {/* KPI strip — finance personas */} + {canReports && ( +
+ navigate('/reports?report=occupancy')} + /> + navigate('/reports?report=financial-summary')} + /> + navigate('/reports?report=financial-summary')} + /> + navigate('/reports?report=daily-revenue')} + /> +
+ )} + + {/* Front office / ops activity KPIs when no reports */} + {!canReports && ( +
+ navigate('/front-desk')} /> + + navigate('/front-desk')} /> + navigate('/rooms')} + /> +
+ )} + + {/* Manager / accounting finance depth */} + {(persona === 'manager' || persona === 'accounting') && canReports && ( +
+ + + + + + + +
-

{Array.isArray(depList) ? depList.length : 0}

-

{t('dashboard.departures')}

-
-
-
-
- +

+ {t('dashboard.panels.openFolioBalance')} +

+

+ {money(Number(outstanding.totalBalanceDue ?? 0))} +

+

+ {t('dashboard.panels.openFolios', { + count: Number(outstanding.totalFoliosOpen ?? 0), + })} +

-
-

{totalRooms - occupiedCount}

-

{t('dashboard.availableRooms')}

+
+

+ {t('dashboard.panels.lastAudit')} +

+

+ {audit.lastAuditDate + ? `${audit.lastAuditDate} · ${audit.lastAuditStatus ?? '—'}` + : t('dashboard.panels.noAudit')} +

+ {persona === 'accounting' && ( + + )}
-
+
+ )} -
-

{t('dashboard.roomStatus')}

- {chartData.length > 0 ? ( - - - - {chartData.map((entry: { name: string; color: string }, i: number) => ( - - ))} - - - - - - ) : ( -
- No room data available + {/* Revenue persona */} + {(persona === 'revenue' || persona === 'manager') && canReports && ( +
+ + + + + ({ ...d, date: d.date.slice(5) }))} + emptyLabel={t('reports.noPace')} + roomsLabel={t('reports.roomsOnBooks')} + bookingsLabel={t('reports.newBookings')} + /> + +
+ )} + + {persona === 'revenue' && ( +
+ + + navigate('/reports?report=pickup')} + /> +
+ )} + + {/* Accounting aging */} + {persona === 'accounting' && ( +
+ +
+ {( + [ + ['current', 'dashboard.aging.current'], + ['days31to60', 'dashboard.aging.31_60'], + ['days61to90', 'dashboard.aging.61_90'], + ['days90plus', 'dashboard.aging.90_plus'], + ] as const + ).map(([key, labelKey]) => ( +
+

+ {t(labelKey)} +

+

+ {money(Number(agingBuckets[key] ?? 0))} +

+
+ ))}
- )} + +
+
+ )} + + {/* Housekeeping persona home */} + {persona === 'housekeeping' && ( +
+
+ navigate('/housekeeping')} + /> + + + +
+ + {hkUrgent.length ? ( +
    + {hkUrgent.slice(0, 6).map((r, i) => ( +
  • + + {t('dashboard.hk.room', { number: r.roomNumber ?? '—' })} + + {r.reason ?? ''} +
  • + ))} +
+ ) : ( +

{t('dashboard.hk.noUrgent')}

+ )} + +
+ )} + + {/* Ops + room board — all personas */} +
+ +
+ {[ + { icon: LogIn, color: 'text-telivity-teal', bg: 'bg-telivity-teal/10', value: arrCount, label: t('dashboard.arrivals') }, + { icon: Users, color: 'text-telivity-deep-blue', bg: 'bg-telivity-deep-blue/10', value: ihCount, label: t('dashboard.inHouse') }, + { icon: LogOut, color: 'text-telivity-orange', bg: 'bg-telivity-orange/10', value: depCount, label: t('dashboard.departures') }, + { + icon: DoorOpen, + color: 'text-telivity-dark-teal', + bg: 'bg-telivity-dark-teal/10', + value: Math.max(totalRooms - occupiedCount, 0), + label: t('dashboard.availableRooms'), + }, + ].map((row) => ( +
+
+ +
+
+

{row.value}

+

{row.label}

+
+
+ ))} +
+
+ + + +
- {Array.isArray(agentStatuses) && agentStatuses.length > 0 && ( -
0 && ( + )} -
-

{t('dashboard.recentActivityLive')}

+ {activities.length > 0 ? (
{activities.map((a, i) => ( -
-
+
+
{a.event} {format(new Date(a.timestamp), 'pp', { locale: dateLocale })} @@ -414,7 +911,7 @@ export default function Dashboard() { ) : (

{t('dashboard.waitingForEvents')}

)} -
+
); } diff --git a/apps/dashboard/src/pages/Reports.tsx b/apps/dashboard/src/pages/Reports.tsx index f2d30642..a6127a84 100644 --- a/apps/dashboard/src/pages/Reports.tsx +++ b/apps/dashboard/src/pages/Reports.tsx @@ -1,42 +1,118 @@ import { useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { BarChart3, Percent, DollarSign, TrendingUp, Building2, Star } from 'lucide-react'; -import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'; +import { + BarChart3, + Percent, + DollarSign, + TrendingUp, + Building2, + BookOpen, + CalendarRange, + LineChart as LineChartIcon, + Scale, + Download, +} from 'lucide-react'; import { format, subDays } from 'date-fns'; import { api } from '../lib/api'; import { formatOccupancyPercent } from '../lib/api-helpers'; import { useProperty } from '../context/PropertyContext'; -import KpiCard from '../components/ui/KpiCard'; +import BiKpiCard from '../components/bi/BiKpiCard'; +import BiPanel from '../components/bi/BiPanel'; +import BiHero from '../components/bi/BiHero'; +import ReportGallery, { type ReportGalleryItem } from '../components/bi/ReportGallery'; +import { + OccupancyTrendChart, + PaymentMethodBars, + PaceDualLineChart, + RevenueMixBars, +} from '../components/bi/BiCharts'; import { formatMoney } from '../lib/money'; import { useTranslation } from 'react-i18next'; -type ReportType = 'financial-summary' | 'occupancy' | 'daily-revenue' | 'occupancy-trend' | 'trial-balance' | 'pickup' | 'booking-pace'; - -const REPORT_OPTIONS: { value: ReportType; labelKey: string }[] = [ - { value: 'financial-summary', labelKey: 'financialSummary' }, - { value: 'occupancy', labelKey: 'occupancy' }, - { value: 'daily-revenue', labelKey: 'dailyRevenue' }, - { value: 'trial-balance', labelKey: 'trialBalance' }, - { value: 'occupancy-trend', labelKey: 'occupancyTrend' }, - { value: 'pickup', labelKey: 'pickup' }, - { value: 'booking-pace', labelKey: 'bookingPace' }, -]; +type ReportType = + | 'financial-summary' + | 'occupancy' + | 'daily-revenue' + | 'occupancy-trend' + | 'trial-balance' + | 'pickup' + | 'booking-pace'; const DEMO_FAVORITES_KEY = 'haip.reportFavorites'; +const REPORT_META: { + value: ReportType; + labelKey: string; + descKey: string; + categoryKey: string; + icon: ReportGalleryItem['icon']; + portfolioOk?: boolean; +}[] = [ + { + value: 'financial-summary', + labelKey: 'financialSummary', + descKey: 'descFinancialSummary', + categoryKey: 'catFlash', + icon: DollarSign, + portfolioOk: true, + }, + { + value: 'occupancy', + labelKey: 'occupancy', + descKey: 'descOccupancy', + categoryKey: 'catRooms', + icon: Percent, + portfolioOk: true, + }, + { + value: 'daily-revenue', + labelKey: 'dailyRevenue', + descKey: 'descDailyRevenue', + categoryKey: 'catFlash', + icon: BarChart3, + }, + { + value: 'trial-balance', + labelKey: 'trialBalance', + descKey: 'descTrialBalance', + categoryKey: 'catAccounting', + icon: Scale, + }, + { + value: 'occupancy-trend', + labelKey: 'occupancyTrend', + descKey: 'descOccupancyTrend', + categoryKey: 'catDemand', + icon: LineChartIcon, + }, + { + value: 'pickup', + labelKey: 'pickup', + descKey: 'descPickup', + categoryKey: 'catDemand', + icon: TrendingUp, + }, + { + value: 'booking-pace', + labelKey: 'bookingPace', + descKey: 'descBookingPace', + categoryKey: 'catDemand', + icon: CalendarRange, + }, +]; + export default function Reports() { const { t } = useTranslation(); const { propertyId, isPortfolioMode, properties, currencyCode } = useProperty(); const queryClient = useQueryClient(); - const [searchParams] = useSearchParams(); - // Deep links (e.g. Accounting → live trial balance) may preselect report/date. + const [searchParams, setSearchParams] = useSearchParams(); const linkedReport = searchParams.get('report') as ReportType | null; const linkedDate = searchParams.get('date'); - const [report, setReport] = useState( - linkedReport && REPORT_OPTIONS.some((o) => o.value === linkedReport) + const [report, setReport] = useState( + linkedReport && REPORT_META.some((o) => o.value === linkedReport) ? linkedReport - : 'financial-summary', + : null, ); const [date, setDate] = useState(linkedDate ?? format(new Date(), 'yyyy-MM-dd')); const [startDate, setStartDate] = useState(format(subDays(new Date(), 30), 'yyyy-MM-dd')); @@ -45,6 +121,8 @@ export default function Reports() { const [pickupFrom, setPickupFrom] = useState(format(subDays(new Date(), 7), 'yyyy-MM-dd')); const [pickupTo, setPickupTo] = useState(format(new Date(), 'yyyy-MM-dd')); + const money = (n: number | string) => formatMoney(n, currencyCode); + const { data: prefsData } = useQuery({ queryKey: ['me', 'preferences'], queryFn: () => @@ -53,7 +131,7 @@ export default function Reports() { const favorites: ReportType[] = useMemo(() => { const fromApi = (prefsData?.reportFavorites ?? []) as ReportType[]; - if (fromApi.length) return fromApi.filter((f) => REPORT_OPTIONS.some((o) => o.value === f)); + if (fromApi.length) return fromApi.filter((f) => REPORT_META.some((o) => o.value === f)); try { const raw = localStorage.getItem(DEMO_FAVORITES_KEY); if (raw) return JSON.parse(raw) as ReportType[]; @@ -83,27 +161,53 @@ export default function Reports() { saveFavorites.mutate(next); } - const orderedOptions = useMemo(() => { - const favSet = new Set(favorites); - return [ - ...REPORT_OPTIONS.filter((o) => favSet.has(o.value)), - ...REPORT_OPTIONS.filter((o) => !favSet.has(o.value)), - ]; - }, [favorites]); + function selectReport(id: string) { + const next = id as ReportType; + setReport(next); + const params = new URLSearchParams(searchParams); + params.set('report', next); + setSearchParams(params, { replace: true }); + } useEffect(() => { - if (favorites.length && !favorites.includes(report) && favorites[0]) { - // Keep current selection; do not force-switch. + if (linkedReport && REPORT_META.some((o) => o.value === linkedReport)) { + setReport(linkedReport); } - }, [favorites, report]); + }, [linkedReport]); + + const galleryItems: ReportGalleryItem[] = useMemo(() => { + const favSet = new Set(favorites); + const ordered = [ + ...REPORT_META.filter((o) => favSet.has(o.value)), + ...REPORT_META.filter((o) => !favSet.has(o.value)), + ]; + return ordered.map((o) => ({ + id: o.value, + title: t(`reports.${o.labelKey}`), + description: t(`reports.${o.descKey}`), + category: t(`reports.${o.categoryKey}`), + icon: o.icon, + favorite: favSet.has(o.value), + portfolioOk: o.portfolioOk, + })); + }, [favorites, t]); const portfolioReport = - isPortfolioMode && (report === 'financial-summary' || report === 'occupancy'); + !!report && + isPortfolioMode && + (report === 'financial-summary' || report === 'occupancy'); const usesDateRange = report === 'occupancy-trend' || report === 'booking-pace'; - const { data } = useQuery({ - queryKey: ['reports', portfolioReport ? 'portfolio' : report, propertyId, usesDateRange ? startDate : report === 'pickup' ? stayDate : date, usesDateRange ? endDate : report === 'pickup' ? pickupTo : null, report === 'pickup' ? pickupFrom : null], + const { data, isFetching } = useQuery({ + queryKey: [ + 'reports', + portfolioReport ? 'portfolio' : report, + propertyId, + usesDateRange ? startDate : report === 'pickup' ? stayDate : date, + usesDateRange ? endDate : report === 'pickup' ? pickupTo : null, + report === 'pickup' ? pickupFrom : null, + ], queryFn: () => { if (portfolioReport) { const path = @@ -125,7 +229,7 @@ export default function Reports() { } return api.get(`/v1/reports/${report}`, { params }).then((r) => r.data); }, - enabled: !!propertyId && (!isPortfolioMode || portfolioReport), + enabled: !!propertyId && !!report && (!isPortfolioMode || portfolioReport), }); const reportData = data?.data ?? data ?? {}; @@ -134,20 +238,6 @@ export default function Reports() { const payments = reportData.payments ?? {}; const propertyNameMap = new Map(properties.map((p) => [p.id, p.name])); - if (!propertyId) { - return
{t('reports.selectProperty')}
; - } - - if (isPortfolioMode && !portfolioReport) { - return ( -
- -

{t('reports.portfolioNotice')}

-

{t('reports.selectSingleProperty')}

-
- ); - } - type LedgerRow = { opening: string; netActivity: string; @@ -162,361 +252,514 @@ export default function Reports() { { key: 'ar', labelKey: 'trialBalanceAr' }, ]; + if (!propertyId) { + return ( +
+ {t('reports.selectProperty')} +
+ ); + } + + const activeMeta = REPORT_META.find((o) => o.value === report); + return (
-
- -

- {isPortfolioMode ? t('reports.portfolioTitle') : t('reports.title')} -

-
+ - {favorites.length > 0 && ( -
- {favorites.map((f) => { - const opt = REPORT_OPTIONS.find((o) => o.value === f); - if (!opt) return null; - return ( - - ); - })} + toggleFavorite(id as ReportType)} + favoriteLabel={t('reports.addFavorite')} + unfavoriteLabel={t('reports.removeFavorite')} + /> + + {!report && ( +
+ +

{t('reports.pickReport')}

+

+ {t('reports.pickReportHint')} +

)} - {/* Report Selector + Date */} -
-
- -
- - -
+ {report && isPortfolioMode && !portfolioReport && ( +
+ +

{t('reports.portfolioNotice')}

+

{t('reports.selectSingleProperty')}

- {report !== 'occupancy-trend' && report !== 'booking-pace' && report !== 'pickup' ? ( -
- - setDate(e.target.value)} className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal" /> -
- ) : usesDateRange ? ( - <> -
- - setStartDate(e.target.value)} className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal" /> -
-
- - setEndDate(e.target.value)} className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal" /> -
- - ) : ( - <> -
- - setStayDate(e.target.value)} className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal" /> -
-
- - setPickupFrom(e.target.value)} className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal" /> -
-
- - setPickupTo(e.target.value)} className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal" /> -
- - )} -
+ )} - {/* Financial Summary */} - {report === 'financial-summary' && ( -
-
- - - + {report && (!isPortfolioMode || portfolioReport) && ( + <> +
+ {report !== 'occupancy-trend' && report !== 'booking-pace' && report !== 'pickup' ? ( +
+ + setDate(e.target.value)} + className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal" + /> +
+ ) : usesDateRange ? ( + <> +
+ + setStartDate(e.target.value)} + className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal" + /> +
+
+ + setEndDate(e.target.value)} + className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal" + /> +
+ + ) : ( + <> +
+ + setStayDate(e.target.value)} + className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal" + /> +
+
+ + setPickupFrom(e.target.value)} + className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal" + /> +
+
+ + setPickupTo(e.target.value)} + className="border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-telivity-teal" + /> +
+ + )} + {(report === 'daily-revenue' || report === 'trial-balance') && !isPortfolioMode && ( + + + {t('reports.exportCsv')} + + )} + {isFetching && ( + + {t('reports.loading')} + + )}
- {isPortfolioMode && Array.isArray(reportData.byProperty) && ( -
-

{t('reports.byProperty')}

-
- - - - - - - - - - - - {(reportData.byProperty as Array<{ propertyId: string; totalRevenue: number; occupancyRate: number; adr: number; revpar: number }>).map((row) => ( - - - - - - - - ))} - -
{t('reports.property')}{t('reports.revenue')}{t('reports.occupancy')}ADRRevPAR
{propertyNameMap.get(row.propertyId) ?? row.propertyId}{formatMoney(row.totalRevenue, currencyCode)}{formatOccupancyPercent(row.occupancyRate)}{formatMoney(row.adr, currencyCode)}{formatMoney(row.revpar, currencyCode)}
+ + {report === 'financial-summary' && ( +
+
+ + + +
-
- )} - {!isPortfolioMode && reportData.revenueByType && ( -
-

{t('reports.revenueBreakdown')}

-
- {Object.entries(reportData.revenueByType as Record).map(([k, v]) => ( -
- {k.replace(/_/g, ' ')} - {formatMoney(v, currencyCode)} + {!isPortfolioMode && ( +
+ + , + ).map(([k, v]) => ({ name: k.replace(/_/g, ' '), amount: Number(v) }))} + currencyFmt={(n) => money(n)} + emptyLabel={t('dashboard.panels.noRevenueMix')} + /> + + +
+
+ {t('dashboard.panels.openFolioBalance')} + + {money(Number(reportData.outstandingBalances?.totalBalanceDue ?? 0))} + +
+
+ {t('dashboard.panels.openFolios', { + count: Number(reportData.outstandingBalances?.totalFoliosOpen ?? 0), + })} +
+
+ {t('dashboard.panels.lastAudit')} + + {reportData.auditStatus?.lastAuditDate + ? `${reportData.auditStatus.lastAuditDate} · ${reportData.auditStatus.lastAuditStatus ?? '—'}` + : t('dashboard.panels.noAudit')} + +
+
+
+
+ )} + {isPortfolioMode && Array.isArray(reportData.byProperty) && ( + +
+ + + + + + + + + + + + {( + reportData.byProperty as Array<{ + propertyId: string; + totalRevenue: number; + occupancyRate: number; + adr: number; + revpar: number; + }> + ).map((row) => ( + + + + + + + + ))} + +
{t('reports.property')}{t('reports.revenue')}{t('reports.occupancy')}{t('dashboard.adr')}{t('dashboard.revpar')}
+ {propertyNameMap.get(row.propertyId) ?? row.propertyId} + {money(row.totalRevenue)}{formatOccupancyPercent(row.occupancyRate)}{money(row.adr)}{money(row.revpar)}
- ))} -
+ + )}
)} -
- )} - {/* Occupancy */} - {report === 'occupancy' && ( -
-
- - - {!isPortfolioMode && ( - - )} - {isPortfolioMode && ( - - )} - -
- {isPortfolioMode && Array.isArray(reportData.byProperty) && ( -
-

By Property

-
- - - - - - - - - - - - {(reportData.byProperty as Array<{ propertyId: string; occupiedRooms: number; availableRooms: number; occupancyRate: number; arrivals: number }>).map((row) => ( - - - - - - - - ))} - -
PropertyOccupiedAvailableOccupancyArrivals
{propertyNameMap.get(row.propertyId) ?? row.propertyId}{row.occupiedRooms}{row.availableRooms}{formatOccupancyPercent(row.occupancyRate)}{row.arrivals}
+ {report === 'occupancy' && ( +
+
+ + + {!isPortfolioMode && ( + + )} + {isPortfolioMode && ( + + )} +
+ {isPortfolioMode && Array.isArray(reportData.byProperty) && ( + +
+ + + + + + + + + + + + {( + reportData.byProperty as Array<{ + propertyId: string; + occupiedRooms: number; + availableRooms: number; + occupancyRate: number; + arrivals: number; + }> + ).map((row) => ( + + + + + + + + ))} + +
{t('reports.property')}{t('reports.occupied')}{t('reports.available')}{t('reports.occupancy')}{t('reports.arrivals')}
+ {propertyNameMap.get(row.propertyId) ?? row.propertyId} + {row.occupiedRooms}{row.availableRooms}{formatOccupancyPercent(row.occupancyRate)}{row.arrivals}
+
+
+ )}
)} -
- )} - {/* Daily Revenue */} - {report === 'daily-revenue' && ( -
-
- - - -
- {payments && Object.keys(payments).length > 0 && ( -
-

Revenue by Payment Method

- - ).filter(([k]) => k !== 'total').map(([k, v]) => ({ method: k, amount: v }))}> - - - - + {report === 'daily-revenue' && ( +
+
+ + + +
+ + ) + .filter(([k]) => k !== 'total') + .map(([k, v]) => ({ method: k.replace(/_/g, ' '), amount: Number(v) }))} + currencyFmt={(n) => money(n)} + emptyLabel={t('dashboard.panels.noPayments')} + /> +
)} -
- )} - {/* Occupancy Trend */} - {report === 'occupancy-trend' && ( -
-

{t('reports.occupancyTrend')}

- {Array.isArray(reportData.daily) && reportData.daily.length > 0 ? ( - - ({ - ...d, - occupancyPct: Number(d.occupancyRate) * 100, - }))}> - - - - - ) : ( -

{t('reports.noTrend')}

+ {report === 'occupancy-trend' && ( + + ({ + date: d.date, + occupancyPct: Number(d.occupancyRate) * 100, + }))} + emptyLabel={t('reports.noTrend')} + valueLabel={t('dashboard.occupancy')} + /> + )} -
- )} - {/* Trial Balance */} - {report === 'trial-balance' && ( -
-
-

{t('reports.trialBalance')}

- - - - - - - - - - - - - {ledgerOrder.map(({ key, labelKey }) => { - const row = ledgers[key]; - if (!row) return null; - return ( - - - - - - - - - ); - })} - -
{t('reports.trialBalanceLedger')}{t('reports.trialBalanceOpening')}{t('reports.trialBalanceNetActivity')}{t('reports.trialBalanceTransfersIn')}{t('reports.trialBalanceTransfersOut')}{t('reports.trialBalanceClosing')}
{t(`reports.${labelKey}`)}{formatMoney(row.opening, currencyCode)}{formatMoney(row.netActivity, currencyCode)}{formatMoney(row.transfersIn, currencyCode)}{formatMoney(row.transfersOut, currencyCode)}{formatMoney(row.closing, currencyCode)}
-
- {reportData.interLedgerTransfers != null && ( -
-
- {t('reports.trialBalanceInterLedger')} - {formatMoney(reportData.interLedgerTransfers, currencyCode)} -
+ {report === 'trial-balance' && ( +
+ +
+ + + + + + + + + + + + + {ledgerOrder.map(({ key, labelKey }) => { + const row = ledgers[key]; + if (!row) return null; + return ( + + + + + + + + + ); + })} + +
{t('reports.trialBalanceLedger')} + {t('reports.trialBalanceOpening')} + + {t('reports.trialBalanceNetActivity')} + + {t('reports.trialBalanceTransfersIn')} + + {t('reports.trialBalanceTransfersOut')} + + {t('reports.trialBalanceClosing')} +
+ {t(`reports.${labelKey}`)} + {money(row.opening)}{money(row.netActivity)}{money(row.transfersIn)}{money(row.transfersOut)}{money(row.closing)}
+
+
+ {reportData.interLedgerTransfers != null && ( + +

+ {money(reportData.interLedgerTransfers)} +

+
+ )}
)} -
- )} - {/* Pickup */} - {report === 'pickup' && ( -
-
- - - -
- {Array.isArray(reportData.daily) && reportData.daily.length > 0 ? ( -
-

{t('reports.dailyPickup')}

- - - - - - - - - - - {(reportData.daily as Array<{ date: string; roomNightsAdded: number; roomNightsLost: number; netPickup: number }>).map((row) => ( - - - - - - - ))} - -
{t('reports.date')}{t('reports.added')}{t('reports.lost')}{t('reports.netPickup')}
{row.date}+{row.roomNightsAdded}-{row.roomNightsLost}{row.netPickup >= 0 ? `+${row.netPickup}` : row.netPickup}
+ {report === 'pickup' && ( +
+
+ + + +
+ {Array.isArray(reportData.daily) && reportData.daily.length > 0 ? ( + +
+ + + + + + + + + + + {( + reportData.daily as Array<{ + date: string; + roomNightsAdded: number; + roomNightsLost: number; + netPickup: number; + }> + ).map((row) => ( + + + + + + + ))} + +
{t('reports.date')}{t('reports.added')}{t('reports.lost')}{t('reports.netPickup')}
{row.date} + +{row.roomNightsAdded} + + -{row.roomNightsLost} + + {row.netPickup >= 0 ? `+${row.netPickup}` : row.netPickup} +
+
+
+ ) : ( +

{t('reports.noPickup')}

+ )}
- ) : ( -

{t('reports.noPickup')}

)} -
- )} - {/* Booking Pace */} - {report === 'booking-pace' && ( -
- {reportData.summary && ( -
- - + {report === 'booking-pace' && ( +
+ {reportData.summary && ( +
+ + +
+ )} + + +
)} -
-

{t('reports.bookingPace')}

- {Array.isArray(reportData.daily) && reportData.daily.length > 0 ? ( - - - - - - - - - - - ) : ( -

{t('reports.noPace')}

- )} -
-
+ )} -
); } diff --git a/apps/dashboard/tailwind.config.ts b/apps/dashboard/tailwind.config.ts index dbca85b8..abdb30dc 100644 --- a/apps/dashboard/tailwind.config.ts +++ b/apps/dashboard/tailwind.config.ts @@ -38,10 +38,15 @@ export default { '0%, 100%': { opacity: '0.4' }, '50%': { opacity: '0.7' }, }, + 'bi-fade-up': { + from: { opacity: '0', transform: 'translateY(8px)' }, + to: { opacity: '1', transform: 'translateY(0)' }, + }, }, animation: { 'slide-in': 'slide-in 0.2s ease-out', 'skeleton': 'pulse-skeleton 1.5s ease-in-out infinite', + 'bi-enter': 'bi-fade-up 0.45s ease-out both', }, }, },