feat(tenancies-p7): 4 module-gated dashboard widgets
- tenancy-reports.service.ts: 4 read-only query functions backing the
widgets. Heatmap uses a months×areas SQL grid with date-range overlap;
renewals-at-risk filters active tenancies whose end_date is inside a
90d window with NO successor pending/active row already minted on the
same berth; revenue forecast buckets active tenancies by their
end-date quarter; tenure breakdown is a simple GROUP BY status='active'.
- 4 new API routes under /api/v1/dashboard/tenancy-*:
- tenancy-occupancy (heatmap)
- tenancy-renewals (at-risk list)
- tenancy-revenue (forecast)
- tenancy-tenure (breakdown)
Each prepended with assertTenanciesModuleEnabled so a port without
the module gets 404 instead of an empty payload.
- 4 widget components:
- TenancyOccupancyHeatmapWidget — areas × months table with shaded
cells (5-tier emerald ramp by occupancy %)
- TenancyRenewalsAtRiskWidget — top-10 list, 30-day urgency badge
- TenancyRevenueForecastWidget — horizontal bar list by quarter,
currency-formatted totals
- TenancyByTenureTypeWidget — proportional bars, color-coded per
tenure type
- WidgetIntegration union extended with 'tenancies_module'; the
useDashboardIntegrations hook reads it off PortProvider (no extra
fetch). All four widgets register with selfGates=true +
requires='tenancies_module' so the picker AND render path filter
them out when the module is off.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,34 @@ const SourceConversionChart = dynamic(
|
||||
() => import('./source-conversion-chart').then((m) => ({ default: m.SourceConversionChart })),
|
||||
{ loading: ChartFallback, ssr: false },
|
||||
);
|
||||
const TenancyOccupancyHeatmapWidget = dynamic(
|
||||
() =>
|
||||
import('./tenancy-occupancy-heatmap').then((m) => ({
|
||||
default: m.TenancyOccupancyHeatmapWidget,
|
||||
})),
|
||||
{ loading: ChartFallback, ssr: false },
|
||||
);
|
||||
const TenancyRenewalsAtRiskWidget = dynamic(
|
||||
() =>
|
||||
import('./tenancy-renewals-at-risk').then((m) => ({
|
||||
default: m.TenancyRenewalsAtRiskWidget,
|
||||
})),
|
||||
{ loading: ChartFallback, ssr: false },
|
||||
);
|
||||
const TenancyRevenueForecastWidget = dynamic(
|
||||
() =>
|
||||
import('./tenancy-revenue-forecast').then((m) => ({
|
||||
default: m.TenancyRevenueForecastWidget,
|
||||
})),
|
||||
{ loading: ChartFallback, ssr: false },
|
||||
);
|
||||
const TenancyByTenureTypeWidget = dynamic(
|
||||
() =>
|
||||
import('./tenancy-by-tenure-type').then((m) => ({
|
||||
default: m.TenancyByTenureTypeWidget,
|
||||
})),
|
||||
{ loading: ChartFallback, ssr: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* Where a widget lives on the dashboard. The shell renders three
|
||||
@@ -74,7 +102,7 @@ export type WidgetGroup = 'chart' | 'rail' | 'feed';
|
||||
* something that would render nothing. Wire new integrations through
|
||||
* `useDashboardIntegrations()`.
|
||||
*/
|
||||
export type WidgetIntegration = 'umami' | 'documenso';
|
||||
export type WidgetIntegration = 'umami' | 'documenso' | 'tenancies_module';
|
||||
|
||||
export interface DashboardWidget {
|
||||
/** Stable persistence key. Don't rename - old preferences would break. */
|
||||
@@ -251,6 +279,50 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
|
||||
group: 'feed',
|
||||
defaultVisible: true,
|
||||
},
|
||||
|
||||
// ── Tenancies module widgets ───────────────────────────────────────────
|
||||
// All four self-gate on `tenancies_module`. Hidden from picker + render
|
||||
// when the module isn't enabled for the active port.
|
||||
{
|
||||
id: 'tenancy_occupancy_heatmap',
|
||||
label: 'Occupancy heatmap',
|
||||
description: 'Per-(berth area × month) occupancy across the year.',
|
||||
render: () => <TenancyOccupancyHeatmapWidget />,
|
||||
group: 'chart',
|
||||
defaultVisible: true,
|
||||
selfGates: true,
|
||||
requires: 'tenancies_module',
|
||||
},
|
||||
{
|
||||
id: 'tenancy_renewals_at_risk',
|
||||
label: 'Renewals at risk',
|
||||
description: 'Active tenancies expiring in the next 90 days without a successor.',
|
||||
render: () => <TenancyRenewalsAtRiskWidget />,
|
||||
group: 'rail',
|
||||
defaultVisible: true,
|
||||
selfGates: true,
|
||||
requires: 'tenancies_module',
|
||||
},
|
||||
{
|
||||
id: 'tenancy_revenue_forecast',
|
||||
label: 'Tenancy revenue forecast',
|
||||
description: 'Berth value tied to tenancies ending each quarter, projected forward.',
|
||||
render: () => <TenancyRevenueForecastWidget />,
|
||||
group: 'chart',
|
||||
defaultVisible: true,
|
||||
selfGates: true,
|
||||
requires: 'tenancies_module',
|
||||
},
|
||||
{
|
||||
id: 'tenancy_by_tenure_type',
|
||||
label: 'Tenancies by tenure type',
|
||||
description: 'Active tenancy mix by tenure (permanent / fixed-term / seasonal …).',
|
||||
render: () => <TenancyByTenureTypeWidget />,
|
||||
group: 'rail',
|
||||
defaultVisible: true,
|
||||
selfGates: true,
|
||||
requires: 'tenancies_module',
|
||||
},
|
||||
];
|
||||
|
||||
/** Lookup helper so consumers don't have to scan the array. */
|
||||
|
||||
Reference in New Issue
Block a user