3 Commits

Author SHA1 Message Date
589be0bfed docs(uat): annotate U66 SHIPPED in plan + master doc
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m36s
Build & Push Docker Images / build-and-push (push) Has been skipped
Plan item 66 (EOI bundle UX rework) fully closed:
- (a) defaults flip — 05e727f (prior session)
- (b) LinkedBerthsList rename — PR10 (prior session)
- (c) picker inside EoiGenerateDialog — ef37901 (this session)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:08:17 +02:00
ef379013e6 feat(uat-batch): U66 — EOI berth-scope picker inside generate dialog
Closes plan item 66 part (c). Parts (a)+(b) shipped earlier (05e727f
defaults flip + linked-berths-list rename); this is the
picker-inside-generate-dialog that the rep sees at the moment the
"which berths does this EOI cover?" question is actually live in their
head, instead of relying on them having visited LinkedBerthsList
toggles upstream.

EoiGenerateDialog gains:
- A new useQuery against /api/v1/interests/[id]/berths returning every
  linked berth + its current isInEoiBundle / isSpecificInterest flags.
- A local Map<berthId, {isInEoiBundle, isSpecificInterest}> seeded
  once from the server snapshot and isolated from subsequent refetches
  (so a background refetch doesn't wipe pending checks). Resets when
  the dialog closes.
- A new "EOI scope" section in the body listing every linked berth
  with two checkboxes ("In EOI" / "Public map"), primary-marked
  visually, plus a one-line legend explaining the bundle-vs-public
  distinction (matters more post-(a) since the two flags routinely
  diverge).
- handleGenerate diffs the picker state against the server snapshot
  before kicking off the envelope; only changed berths get PATCHed,
  and we wait for all PATCHes to settle (so a 5xx surfaces before the
  EOI fires). Cache invalidation extended to bounce the new
  ['interests', id, 'berths'] queryKey so the LinkedBerthsList tab
  picks up the new state on navigation.

The "Manage linked berths" cross-link below is preserved — the picker
is the in-dialog fast path, not a replacement for the full management
surface.

1454/1454 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:07:29 +02:00
adf4e2ba78 fix(reports): split PDF widget catalogue out of the DB-touching service
export-dashboard-pdf-button.tsx imported PDF_DASHBOARD_WIDGETS +
PdfDashboardWidgetId from dashboard-report-data.service.ts. JS modules
evaluate their imports eagerly, so the button transitively pulled in
that file's top-level `import { getKpis } from './dashboard.service'`,
which pulled in `@/lib/db`, which pulls in `postgres`, which crashed
the client bundle with:

  Module not found: Can't resolve 'fs'
    ./node_modules/.../postgres/src/index.js [Client Component Browser]

Split the pure data + types into the new file
src/lib/services/dashboard-report-widgets.ts and re-export from the
original service for backwards compatibility. The button now imports
from the pure file; the server-only route (reports/generate) keeps
using the resolver as before.

tsc clean, dashboard loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:03:44 +02:00
6 changed files with 247 additions and 59 deletions

View File

@@ -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.
---

View File

@@ -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.

View File

@@ -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&apos;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 &quot;Under Offer&quot; 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">

View File

@@ -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';

View File

@@ -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,

View 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.',
},
];