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)
|
||||
|
||||
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.
|
||||
>
|
||||
> **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.
|
||||
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';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, ExternalLink, FileSignature, Pencil } from 'lucide-react';
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -231,6 +233,85 @@ export function EoiGenerateDialog({
|
||||
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]);
|
||||
// Only show the template picker when there's a real choice — the
|
||||
// Documenso path is always present, so we show the dropdown once at
|
||||
@@ -397,6 +478,36 @@ export function EoiGenerateDialog({
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
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 url = `/api/v1/document-templates/${encodeURIComponent(selectedTemplate)}/generate-and-sign`;
|
||||
// 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, 'eoi-context'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'timeline'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'berths'] }),
|
||||
]);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
@@ -624,6 +736,66 @@ export function EoiGenerateDialog({
|
||||
/>
|
||||
</dl>
|
||||
</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 && (
|
||||
<div className="border-t pt-2 space-y-1">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import {
|
||||
PDF_DASHBOARD_WIDGETS,
|
||||
type PdfDashboardWidgetId,
|
||||
} from '@/lib/services/dashboard-report-data.service';
|
||||
} from '@/lib/services/dashboard-report-widgets';
|
||||
import { triggerBlobDownload } from '@/lib/utils/download';
|
||||
import { usePermissions } from '@/hooks/use-permissions';
|
||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||
|
||||
@@ -21,61 +21,16 @@ import {
|
||||
} from './dashboard.service';
|
||||
import type { DashboardReportData } from '@/lib/pdf/reports/dashboard-report';
|
||||
|
||||
/**
|
||||
* Maps widget ids the dashboard PDF understands. The id space is
|
||||
* intentionally a subset of the on-screen `DASHBOARD_WIDGETS`
|
||||
* registry — only widgets that have a sensible printable form
|
||||
* appear here. The dialog's widget picker filters its option list
|
||||
* by this set.
|
||||
*/
|
||||
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.',
|
||||
},
|
||||
];
|
||||
// Pure data/types now live in `dashboard-report-widgets.ts` so the
|
||||
// client-side export button can import them without dragging this
|
||||
// file's DB-touching imports into the browser bundle. Re-exported
|
||||
// here so existing consumers keep working.
|
||||
export {
|
||||
PDF_DASHBOARD_WIDGET_IDS,
|
||||
PDF_DASHBOARD_WIDGETS,
|
||||
type PdfDashboardWidgetId,
|
||||
type PdfDashboardWidgetOption,
|
||||
} from './dashboard-report-widgets';
|
||||
|
||||
export async function resolveDashboardReportData(
|
||||
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