Compare commits
3 Commits
52493801e0
...
589be0bfed
| Author | SHA1 | Date | |
|---|---|---|---|
| 589be0bfed | |||
| ef379013e6 | |||
| adf4e2ba78 |
@@ -225,7 +225,7 @@ Surfaces all touch `interest-tabs.tsx` / `interest-overview` / linked-berths. Gr
|
|||||||
|
|
||||||
## Group U — EOI bundle UX rework (~10-14 h)
|
## Group U — EOI bundle UX rework (~10-14 h)
|
||||||
|
|
||||||
66. **[L] EOI bundle UX rework (multi-berth interests)** — _src/lib/services/interest-berths.service.ts_, _src/components/interests/linked-berths-list.tsx_, _src/components/documents/eoi-generate-dialog.tsx_. Per the locked design notes in master line ~492. Refines the "which berths sign the EOI?" UX now that the underlying schema supports it. ~10-14 h. Partially shipped (a) in `05e727f`; remaining is the picker-inside-generate-dialog.
|
66. **[SHIPPED in ef37901] EOI bundle UX rework (multi-berth interests)** — (a) defaults flip shipped in `05e727f`, (b) LinkedBerthsList rename shipped in PR10, (c) picker inside EoiGenerateDialog shipped in `ef37901`: new "EOI scope" section lists every linked berth with "In EOI" + "Public map" checkboxes pre-filled from current flag state; handleGenerate diffs vs server snapshot and PATCHes only changed rows in parallel before kicking off the envelope. Plan item closed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -499,7 +499,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
|||||||
>
|
>
|
||||||
> Total ~2.5-3 h end-to-end. Closes the multi-berth EOI discoverability gap (plan §1 + §4.6) and matches the documented workflow expectation that public map visibility is a _subset_ of EOI bundle coverage.
|
> Total ~2.5-3 h end-to-end. Closes the multi-berth EOI discoverability gap (plan §1 + §4.6) and matches the documented workflow expectation that public map visibility is a _subset_ of EOI bundle coverage.
|
||||||
>
|
>
|
||||||
> **SHIPPED (a) in 05e727f:** `addInterestBerth` defaults flipped: `is_in_eoi_bundle: true`, `is_specific_interest: matches isPrimary`. (b) `linked-berths-list.tsx` rename + tooltip shipped in PR10. (c) EOI-berth-scope picker inside generate dialog parked.
|
> **SHIPPED (a) in 05e727f:** `addInterestBerth` defaults flipped: `is_in_eoi_bundle: true`, `is_specific_interest: matches isPrimary`. (b) `linked-berths-list.tsx` rename + tooltip shipped in PR10. **(c) SHIPPED in ef37901:** EoiGenerateDialog gains an "EOI scope" section listing every linked berth with "In EOI" + "Public map" checkboxes; handleGenerate diffs vs server snapshot and PATCHes only changed rows in parallel before kicking off the envelope. Cache invalidation extended to `['interests', id, 'berths']` so LinkedBerthsList stays consistent.
|
||||||
|
|
||||||
1. **Berth-demand widget visual overhaul** — _src/components/dashboard/berth-heat-widget.tsx_ — original "Berth heat" widget was a generic table that read as uninspired. First pass added an editorial hero + gradient — that strayed from the standard `CardHeader`/`CardContent` idiom and looked out of place next to siblings. Final version matches `hot-deals-card.tsx`'s layout exactly (icon + title + description in CardHeader, list of `-mx-2 hover:bg-accent/60` rows in CardContent); the visual upgrade is the per-row status-coloured magnitude bar. UI label renamed "Berth Heat" → "Berth Demand" in `widget-registry.tsx`. Fixed in this session.
|
1. **Berth-demand widget visual overhaul** — _src/components/dashboard/berth-heat-widget.tsx_ — original "Berth heat" widget was a generic table that read as uninspired. First pass added an editorial hero + gradient — that strayed from the standard `CardHeader`/`CardContent` idiom and looked out of place next to siblings. Final version matches `hot-deals-card.tsx`'s layout exactly (icon + title + description in CardHeader, list of `-mx-2 hover:bg-accent/60` rows in CardContent); the visual upgrade is the per-row status-coloured magnitude bar. UI label renamed "Berth Heat" → "Berth Demand" in `widget-registry.tsx`. Fixed in this session.
|
||||||
2. **First-class "demand" sort on the berths list** — _src/lib/services/berths.service.ts_, _src/components/berths/berth-columns.tsx_, _src/lib/validators_ — added `?sort=activeInterestCount` to the berths-list service via a correlated subquery in `customOrderBy`; attached `activeInterestCount` per row using the existing two-pass post-fetch pattern (alongside tags/latestInterestStage); added the "Active interests" column to `BERTH_COLUMN_OPTIONS` (default-visible, sortable). Widget's "View all by demand →" link deep-links to `/berths?sort=activeInterestCount&order=desc`. Saved views and the column picker can now use the same lens. Fixed in this session.
|
2. **First-class "demand" sort on the berths list** — _src/lib/services/berths.service.ts_, _src/components/berths/berth-columns.tsx_, _src/lib/validators_ — added `?sort=activeInterestCount` to the berths-list service via a correlated subquery in `customOrderBy`; attached `activeInterestCount` per row using the existing two-pass post-fetch pattern (alongside tags/latestInterestStage); added the "Active interests" column to `BERTH_COLUMN_OPTIONS` (default-visible, sortable). Widget's "View all by demand →" link deep-links to `/berths?sort=activeInterestCount&order=desc`. Saved views and the column picker can now use the same lens. Fixed in this session.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { AlertTriangle, ExternalLink, FileSignature, Pencil } from 'lucide-react';
|
import { AlertTriangle, ExternalLink, FileSignature, Pencil } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -231,6 +233,85 @@ export function EoiGenerateDialog({
|
|||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// U66 (c) — EOI berth-scope picker. Pulls every linked berth so the
|
||||||
|
// rep can confirm signature scope (`isInEoiBundle`) and public-map
|
||||||
|
// visibility (`isSpecificInterest`) at the moment of EOI generation
|
||||||
|
// — the moment the "which berths does this EOI cover?" question is
|
||||||
|
// actually live in their head — instead of relying on them having
|
||||||
|
// visited the LinkedBerthsList toggles upstream. Post-(a) defaults
|
||||||
|
// (in_bundle=true; specific=primary) mean the picker is mostly
|
||||||
|
// already correct; this surface lets them carve exceptions.
|
||||||
|
const { data: linkedBerthsRes } = useQuery<{
|
||||||
|
data: Array<{
|
||||||
|
berthId: string;
|
||||||
|
mooringNumber: string | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
isInEoiBundle: boolean;
|
||||||
|
isSpecificInterest: boolean;
|
||||||
|
}>;
|
||||||
|
}>({
|
||||||
|
queryKey: ['interests', interestId, 'berths'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{
|
||||||
|
data: Array<{
|
||||||
|
berthId: string;
|
||||||
|
mooringNumber: string | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
isInEoiBundle: boolean;
|
||||||
|
isSpecificInterest: boolean;
|
||||||
|
}>;
|
||||||
|
}>(`/api/v1/interests/${interestId}/berths`),
|
||||||
|
enabled: open,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Local draft of the two flags per berth. Initialized from the server
|
||||||
|
// values once the query resolves; subsequent server updates after that
|
||||||
|
// point are ignored (the rep is actively editing this surface, and we
|
||||||
|
// don't want a background refetch to wipe their pending checks).
|
||||||
|
const [berthScope, setBerthScope] = useState<
|
||||||
|
Map<string, { isInEoiBundle: boolean; isSpecificInterest: boolean }>
|
||||||
|
>(new Map());
|
||||||
|
const [berthScopeInitialized, setBerthScopeInitialized] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (berthScopeInitialized || !linkedBerthsRes) return;
|
||||||
|
const next = new Map<string, { isInEoiBundle: boolean; isSpecificInterest: boolean }>();
|
||||||
|
for (const link of linkedBerthsRes.data) {
|
||||||
|
next.set(link.berthId, {
|
||||||
|
isInEoiBundle: link.isInEoiBundle,
|
||||||
|
isSpecificInterest: link.isSpecificInterest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setBerthScope(next);
|
||||||
|
|
||||||
|
setBerthScopeInitialized(true);
|
||||||
|
}, [linkedBerthsRes, berthScopeInitialized]);
|
||||||
|
|
||||||
|
// Reset the picker when the dialog closes so a re-open against a
|
||||||
|
// different interest doesn't show stale rows.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) return;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setBerthScopeInitialized(false);
|
||||||
|
|
||||||
|
setBerthScope(new Map());
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function setBerthFlag(
|
||||||
|
berthId: string,
|
||||||
|
flag: 'isInEoiBundle' | 'isSpecificInterest',
|
||||||
|
value: boolean,
|
||||||
|
) {
|
||||||
|
setBerthScope((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const current = next.get(berthId) ?? { isInEoiBundle: false, isSpecificInterest: false };
|
||||||
|
next.set(berthId, { ...current, [flag]: value });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
|
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
|
||||||
// Only show the template picker when there's a real choice — the
|
// Only show the template picker when there's a real choice — the
|
||||||
// Documenso path is always present, so we show the dropdown once at
|
// Documenso path is always present, so we show the dropdown once at
|
||||||
@@ -397,6 +478,36 @@ export function EoiGenerateDialog({
|
|||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
// U66 (c) — persist any berth-scope edits BEFORE kicking off the
|
||||||
|
// envelope so the EOI/public-map state is consistent with what the
|
||||||
|
// rep just confirmed. Diff against the server snapshot so an
|
||||||
|
// unchanged scope is a no-op (avoids spurious audit-log rows).
|
||||||
|
const initial = new Map<string, { isInEoiBundle: boolean; isSpecificInterest: boolean }>();
|
||||||
|
for (const link of linkedBerthsRes?.data ?? []) {
|
||||||
|
initial.set(link.berthId, {
|
||||||
|
isInEoiBundle: link.isInEoiBundle,
|
||||||
|
isSpecificInterest: link.isSpecificInterest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const patches: Array<Promise<unknown>> = [];
|
||||||
|
for (const [berthId, draft] of berthScope.entries()) {
|
||||||
|
const orig = initial.get(berthId);
|
||||||
|
if (!orig) continue;
|
||||||
|
const body: { isInEoiBundle?: boolean; isSpecificInterest?: boolean } = {};
|
||||||
|
if (orig.isInEoiBundle !== draft.isInEoiBundle) body.isInEoiBundle = draft.isInEoiBundle;
|
||||||
|
if (orig.isSpecificInterest !== draft.isSpecificInterest)
|
||||||
|
body.isSpecificInterest = draft.isSpecificInterest;
|
||||||
|
if (Object.keys(body).length > 0) {
|
||||||
|
patches.push(
|
||||||
|
apiFetch(`/api/v1/interests/${interestId}/berths/${berthId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patches.length > 0) await Promise.all(patches);
|
||||||
|
|
||||||
const isDocumenso = selectedTemplate === DOCUMENSO_TEMPLATE_VALUE;
|
const isDocumenso = selectedTemplate === DOCUMENSO_TEMPLATE_VALUE;
|
||||||
const url = `/api/v1/document-templates/${encodeURIComponent(selectedTemplate)}/generate-and-sign`;
|
const url = `/api/v1/document-templates/${encodeURIComponent(selectedTemplate)}/generate-and-sign`;
|
||||||
// Phase 3b — pack the per-field overrides the rep selected. Each
|
// Phase 3b — pack the per-field overrides the rep selected. Each
|
||||||
@@ -463,6 +574,7 @@ export function EoiGenerateDialog({
|
|||||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] }),
|
queryClient.invalidateQueries({ queryKey: ['interests', interestId] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] }),
|
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'timeline'] }),
|
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'timeline'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'berths'] }),
|
||||||
]);
|
]);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -624,6 +736,66 @@ export function EoiGenerateDialog({
|
|||||||
/>
|
/>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
{linkedBerthsRes && linkedBerthsRes.data.length > 0 ? (
|
||||||
|
<div className="border-t pt-2 space-y-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
EOI scope ({linkedBerthsRes.data.length} linked berth
|
||||||
|
{linkedBerthsRes.data.length === 1 ? '' : 's'})
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Confirm signature scope and public-map visibility for each berth before
|
||||||
|
generating. Defaults reflect what's saved on the interest.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-muted/20 divide-y">
|
||||||
|
{linkedBerthsRes.data.map((link) => {
|
||||||
|
const draft = berthScope.get(link.berthId) ?? {
|
||||||
|
isInEoiBundle: link.isInEoiBundle,
|
||||||
|
isSpecificInterest: link.isSpecificInterest,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={link.berthId}
|
||||||
|
className="grid grid-cols-[1fr_auto_auto] items-center gap-3 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="font-mono text-sm">{link.mooringNumber ?? '—'}</span>
|
||||||
|
{link.isPrimary ? (
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-primary">
|
||||||
|
Primary
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={draft.isInEoiBundle}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setBerthFlag(link.berthId, 'isInEoiBundle', v === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>In EOI</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={draft.isSpecificInterest}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setBerthFlag(link.berthId, 'isSpecificInterest', v === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>Public map</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
<strong>In EOI</strong>: covered by this signed envelope.{' '}
|
||||||
|
<strong>Public map</strong>: shown as "Under Offer" on the marketing
|
||||||
|
site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{portSlug && clientId && (
|
{portSlug && clientId && (
|
||||||
<div className="border-t pt-2 space-y-1">
|
<div className="border-t pt-2 space-y-1">
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
PDF_DASHBOARD_WIDGETS,
|
PDF_DASHBOARD_WIDGETS,
|
||||||
type PdfDashboardWidgetId,
|
type PdfDashboardWidgetId,
|
||||||
} from '@/lib/services/dashboard-report-data.service';
|
} from '@/lib/services/dashboard-report-widgets';
|
||||||
import { triggerBlobDownload } from '@/lib/utils/download';
|
import { triggerBlobDownload } from '@/lib/utils/download';
|
||||||
import { usePermissions } from '@/hooks/use-permissions';
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||||
|
|||||||
@@ -21,61 +21,16 @@ import {
|
|||||||
} from './dashboard.service';
|
} from './dashboard.service';
|
||||||
import type { DashboardReportData } from '@/lib/pdf/reports/dashboard-report';
|
import type { DashboardReportData } from '@/lib/pdf/reports/dashboard-report';
|
||||||
|
|
||||||
/**
|
// Pure data/types now live in `dashboard-report-widgets.ts` so the
|
||||||
* Maps widget ids the dashboard PDF understands. The id space is
|
// client-side export button can import them without dragging this
|
||||||
* intentionally a subset of the on-screen `DASHBOARD_WIDGETS`
|
// file's DB-touching imports into the browser bundle. Re-exported
|
||||||
* registry — only widgets that have a sensible printable form
|
// here so existing consumers keep working.
|
||||||
* appear here. The dialog's widget picker filters its option list
|
export {
|
||||||
* by this set.
|
PDF_DASHBOARD_WIDGET_IDS,
|
||||||
*/
|
PDF_DASHBOARD_WIDGETS,
|
||||||
export const PDF_DASHBOARD_WIDGET_IDS = [
|
type PdfDashboardWidgetId,
|
||||||
'kpi_overview',
|
type PdfDashboardWidgetOption,
|
||||||
'pipeline_funnel',
|
} from './dashboard-report-widgets';
|
||||||
'berth_status',
|
|
||||||
'source_conversion',
|
|
||||||
'hot_deals',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type PdfDashboardWidgetId = (typeof PDF_DASHBOARD_WIDGET_IDS)[number];
|
|
||||||
|
|
||||||
export interface PdfDashboardWidgetOption {
|
|
||||||
id: PdfDashboardWidgetId;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public widget list (label + description) for the export dialog.
|
|
||||||
* Mirrored from the on-screen widget-registry but with PDF-friendly
|
|
||||||
* copy: a "Berth heat" chart is "Berth demand ranking" in print.
|
|
||||||
*/
|
|
||||||
export const PDF_DASHBOARD_WIDGETS: readonly PdfDashboardWidgetOption[] = [
|
|
||||||
{
|
|
||||||
id: 'kpi_overview',
|
|
||||||
label: 'Key metrics',
|
|
||||||
description: 'Total clients, active interests, pipeline value, occupancy %.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pipeline_funnel',
|
|
||||||
label: 'Pipeline funnel',
|
|
||||||
description: 'Active interests grouped by pipeline stage.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'berth_status',
|
|
||||||
label: 'Berth status distribution',
|
|
||||||
description: 'Available / under offer / reserved / sold counts.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'source_conversion',
|
|
||||||
label: 'Source conversion',
|
|
||||||
description: 'Inquiries → Clients → Interests → Won, by lead source.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hot_deals',
|
|
||||||
label: 'Hot deals',
|
|
||||||
description: 'Top 5 active interests by deal-health score.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function resolveDashboardReportData(
|
export async function resolveDashboardReportData(
|
||||||
portId: string,
|
portId: string,
|
||||||
|
|||||||
61
src/lib/services/dashboard-report-widgets.ts
Normal file
61
src/lib/services/dashboard-report-widgets.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Pure data + types for the dashboard-PDF report's widget catalogue.
|
||||||
|
*
|
||||||
|
* Lives in its own file so client-side surfaces (the "Export as PDF"
|
||||||
|
* dialog) can import the catalogue without dragging the server-side
|
||||||
|
* resolver — which imports the DB layer — into the client bundle.
|
||||||
|
* Before this split, `export-dashboard-pdf-button.tsx` pulled
|
||||||
|
* `PDF_DASHBOARD_WIDGETS` from `dashboard-report-data.service.ts`,
|
||||||
|
* whose top-level `import { getKpis } from './dashboard.service'`
|
||||||
|
* dragged in `postgres` and crashed the client build with
|
||||||
|
* "Module not found: Can't resolve 'fs'".
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PDF_DASHBOARD_WIDGET_IDS = [
|
||||||
|
'kpi_overview',
|
||||||
|
'pipeline_funnel',
|
||||||
|
'berth_status',
|
||||||
|
'source_conversion',
|
||||||
|
'hot_deals',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PdfDashboardWidgetId = (typeof PDF_DASHBOARD_WIDGET_IDS)[number];
|
||||||
|
|
||||||
|
export interface PdfDashboardWidgetOption {
|
||||||
|
id: PdfDashboardWidgetId;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public widget list (label + description) for the export dialog.
|
||||||
|
* Mirrored from the on-screen widget-registry but with PDF-friendly
|
||||||
|
* copy: a "Berth heat" chart is "Berth demand ranking" in print.
|
||||||
|
*/
|
||||||
|
export const PDF_DASHBOARD_WIDGETS: readonly PdfDashboardWidgetOption[] = [
|
||||||
|
{
|
||||||
|
id: 'kpi_overview',
|
||||||
|
label: 'Key metrics',
|
||||||
|
description: 'Total clients, active interests, pipeline value, occupancy %.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pipeline_funnel',
|
||||||
|
label: 'Pipeline funnel',
|
||||||
|
description: 'Active interests grouped by pipeline stage.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'berth_status',
|
||||||
|
label: 'Berth status distribution',
|
||||||
|
description: 'Available / under offer / reserved / sold counts.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'source_conversion',
|
||||||
|
label: 'Source conversion',
|
||||||
|
description: 'Inquiries → Clients → Interests → Won, by lead source.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hot_deals',
|
||||||
|
label: 'Hot deals',
|
||||||
|
description: 'Top 5 active interests by deal-health score.',
|
||||||
|
},
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user