chore(autonomous-session): consolidate uncommitted work from prior session

Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
This commit is contained in:
2026-05-23 00:52:59 +02:00
parent 43719b49e9
commit 221ae5784e
749 changed files with 7440 additions and 3118 deletions

View File

@@ -5,7 +5,9 @@ import { Eye, FileDown, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox';
import { DatePicker } from '@/components/ui/date-picker';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
@@ -18,7 +20,9 @@ import {
} from '@/components/ui/dialog';
import {
PDF_DASHBOARD_WIDGETS,
PDF_DASHBOARD_CATEGORY_LABELS,
type PdfDashboardWidgetId,
type PdfDashboardWidgetCategory,
} from '@/lib/services/dashboard-report-widgets';
import { triggerBlobDownload } from '@/lib/utils/download';
import { usePermissions } from '@/hooks/use-permissions';
@@ -26,6 +30,18 @@ import { resolvePortIdFromSlug } from '@/lib/api/client';
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
import { PdfPreviewModal } from './pdf-preview-modal';
/**
* Local-timezone YYYY-MM-DD formatter. We deliberately avoid
* `toISOString().slice(0,10)` because it rolls through UTC and would
* land on the previous day for any rep east of GMT after ~14:00 local.
*/
function toIsoLocal(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/**
* Dashboard "Export as PDF" affordance. Per-export dialog lets reps
* pick which sections to include + set a custom title. Saved-template
@@ -35,15 +51,22 @@ import { PdfPreviewModal } from './pdf-preview-modal';
* Permission-gated client-side on `reports.export`; the server route
* re-checks via withPermission so a tampered client can't bypass.
*/
export function ExportDashboardPdfButton() {
export function ExportDashboardPdfButton({ className }: { className?: string } = {}) {
const { can } = usePermissions();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState(
`Dashboard report — ${new Date().toLocaleDateString('en-GB')}`,
);
const [title, setTitle] = useState(`Report - ${new Date().toLocaleDateString('en-GB')}`);
const [selected, setSelected] = useState<PdfDashboardWidgetId[]>(
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
);
// Default report window = last 30 days. Many of the new widgets
// (period cohorts, occupancy timeline) require the window;
// populating with sensible defaults means the rep gets a useful
// report on first export without picking dates.
const today = new Date();
const last30 = new Date(today);
last30.setDate(last30.getDate() - 30);
const [dateFrom, setDateFrom] = useState(toIsoLocal(last30));
const [dateTo, setDateTo] = useState(toIsoLocal(today));
const [loading, setLoading] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
@@ -52,10 +75,15 @@ export function ExportDashboardPdfButton() {
// preview effect doesn't re-fire on unrelated re-renders.
const previewPayload = useMemo(
() => ({
title: title.trim() || 'Dashboard report',
config: { kind: 'dashboard' as const, widgetIds: selected },
title: title.trim() || 'Report',
config: {
kind: 'dashboard' as const,
widgetIds: selected,
...(dateFrom ? { dateFrom } : {}),
...(dateTo ? { dateTo } : {}),
},
}),
[title, selected],
[title, selected, dateFrom, dateTo],
);
if (!can('reports', 'export')) return null;
@@ -88,10 +116,12 @@ export function ExportDashboardPdfButton() {
method: 'POST',
headers,
body: JSON.stringify({
title: title.trim() || 'Dashboard report',
title: title.trim() || 'Report',
config: {
kind: 'dashboard',
widgetIds: selected,
...(dateFrom ? { dateFrom } : {}),
...(dateTo ? { dateTo } : {}),
},
}),
});
@@ -113,9 +143,15 @@ export function ExportDashboardPdfButton() {
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<FileDown className="mr-1.5 h-4 w-4" aria-hidden />
Export PDF
<Button
variant="ghost"
size="sm"
onClick={() => setOpen(true)}
title="Export dashboard as PDF"
aria-label="Export dashboard as PDF"
className={cn('h-9 w-9 p-0 text-muted-foreground hover:text-foreground', className)}
>
<FileDown className="h-4 w-4" aria-hidden />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
@@ -146,25 +182,121 @@ export function ExportDashboardPdfButton() {
<Label htmlFor="export-title">Title</Label>
<Input id="export-title" value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
{/* Date-range filter. Drives every time-period section
(new clients in window, berths sold in window, occupancy
timeline, etc.). Defaulted to the last 30 days so a
first-time export already has a sensible window without
the rep configuring anything. Sections that don't
require a window (KPIs, current pipeline funnel, etc.)
ignore it. */}
<div className="space-y-1">
<Label>Report window</Label>
<div className="flex flex-wrap items-center gap-2">
<DatePicker
id="export-date-from"
value={dateFrom}
onChange={setDateFrom}
placeholder="Start"
size="sm"
className="w-[150px]"
/>
<span className="text-xs text-muted-foreground"></span>
<DatePicker
id="export-date-to"
value={dateTo}
onChange={setDateTo}
placeholder="End"
size="sm"
className="w-[150px]"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const t = new Date();
const start = new Date(t);
start.setDate(start.getDate() - 30);
setDateFrom(toIsoLocal(start));
setDateTo(toIsoLocal(t));
}}
className="h-8 text-xs"
>
Last 30 days
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const t = new Date();
const start = new Date(t);
start.setDate(start.getDate() - 90);
setDateFrom(toIsoLocal(start));
setDateTo(toIsoLocal(t));
}}
className="h-8 text-xs"
>
Last 90 days
</Button>
</div>
<p className="text-xs text-muted-foreground">
Drives time-period sections (new clients, berths sold, occupancy timeline, etc.).
Sections marked &ldquo;needs date range&rdquo; only render when both dates are set.
</p>
</div>
<div className="space-y-2">
<Label>Sections</Label>
<div className="space-y-1.5 rounded-md border p-2">
{PDF_DASHBOARD_WIDGETS.map((w) => (
<label
key={w.id}
className="flex items-start gap-2 cursor-pointer rounded-sm p-1 hover:bg-muted/50"
{/* Grouped checkbox list. Each widget knows its own
category; we render the categories in PDF_DASHBOARD_-
CATEGORY_LABELS' declared order so charts surface
before tables surface before period cohorts. */}
<div className="max-h-[50vh] space-y-3 overflow-y-auto rounded-md border p-2">
{(
Object.entries(PDF_DASHBOARD_CATEGORY_LABELS) as Array<
[PdfDashboardWidgetCategory, string]
>
<Checkbox
checked={selected.includes(w.id)}
onCheckedChange={() => toggle(w.id)}
aria-label={w.label}
/>
<div className="text-sm leading-tight">
<div className="font-medium">{w.label}</div>
<div className="text-xs text-muted-foreground">{w.description}</div>
).map(([category, label]) => {
const items = PDF_DASHBOARD_WIDGETS.filter((w) => w.category === category);
if (items.length === 0) return null;
return (
<div key={category} className="space-y-1">
<div className="px-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="space-y-0.5">
{items.map((w) => (
<label
key={w.id}
className="flex items-start gap-2 cursor-pointer rounded-sm p-1 hover:bg-muted/50"
>
<Checkbox
checked={selected.includes(w.id)}
onCheckedChange={() => toggle(w.id)}
aria-label={w.label}
/>
<div className="text-sm leading-tight">
<div className="flex items-center gap-1.5">
<span className="font-medium">{w.label}</span>
{w.isChart ? (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-primary">
chart
</span>
) : null}
{w.requiresPeriod ? (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-amber-800">
needs date range
</span>
) : null}
</div>
<div className="text-xs text-muted-foreground">{w.description}</div>
</div>
</label>
))}
</div>
</div>
</label>
))}
);
})}
</div>
</div>
</div>
@@ -196,8 +328,8 @@ export function ExportDashboardPdfButton() {
open
onOpenChange={setPreviewOpen}
payload={previewPayload}
filename={`${title.trim().replace(/[\\/]/g, '_') || 'dashboard-report'}.pdf`}
title={`Preview: ${title.trim() || 'Dashboard report'}`}
filename={`${title.trim().replace(/[\\/]/g, '_') || 'report'}.pdf`}
title={`Preview: ${title.trim() || 'Report'}`}
/>
) : null}
</>

View File

@@ -48,7 +48,7 @@ const REPORT_TYPES: Record<string, ReportTypeMeta> = {
subtitle: 'Audit events across the port for a date range',
contents: [
'Audit log entries (create / update / delete) per entity',
'Filtered to the selected date range defaults to last 30 days',
'Filtered to the selected date range - defaults to last 30 days',
'Includes actor name, entity type, and action verb',
],
},

View File

@@ -1,7 +1,10 @@
'use client';
import { useEffect, useState } from 'react';
import { Download, Loader2, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import { Download, ExternalLink, Loader2, X } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -15,6 +18,11 @@ import {
import { triggerBlobDownload } from '@/lib/utils/download';
import { resolvePortIdFromSlug } from '@/lib/api/client';
// Same worker setup as the field-placement step. Required because
// react-pdf needs a parser worker; the CDN worker runs cross-origin
// but we hand it bytes via `{ data }` so it never has to fetch.
pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
interface Props {
open: boolean;
onOpenChange: (next: boolean) => void;
@@ -29,16 +37,18 @@ interface Props {
/**
* Preview-then-download flow for a generated PDF.
*
* The modal POSTs the supplied payload to `/api/v1/reports/generate`
* on open, holds the resulting Blob in component state, and renders
* it in an iframe via `URL.createObjectURL`. The Download button
* reuses the cached Blob — no second network round-trip on commit.
* Renders the PDF via react-pdf (PDF.js → canvas), which is the only
* approach that works reliably across browsers. Both `<iframe src=blob>`
* and `<object data=blob type=application/pdf>` are silently broken in
* WebKit (iOS/iPadOS Safari refuses to embed PDFs in either) - canvas
* rendering works there + everywhere else.
*
* Lifecycle:
* - On open: fetch + revoke any previous URL
* - On payload change: re-fetch (the rep edited config in the
* parent dialog and re-opened preview)
* - On close: revoke the URL so the blob is GC'd
* - On open: POST /api/v1/reports/generate, read the response bytes
* into a Uint8Array, hand to react-pdf via { data }
* - On payload change: re-fetch
* - On close: clear the bytes + the blob-url alias kept for the
* "Open in new tab" + Download buttons
*/
export function PdfPreviewModal({
open,
@@ -47,22 +57,28 @@ export function PdfPreviewModal({
filename,
title = 'Preview report',
}: Props) {
const [url, setUrl] = useState<string | null>(null);
const [fileBytes, setFileBytes] = useState<Uint8Array | null>(null);
const [blob, setBlob] = useState<Blob | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [numPages, setNumPages] = useState<number>(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [pageWidth, setPageWidth] = useState<number>(800);
// Re-fetch the preview whenever the modal opens (or the payload
// changes while open). Revoke the previous object URL inside the
// same effect so we never leak.
// changes while open). Reads the response bytes into a Uint8Array
// up-front so PDF.js can parse synchronously inside its worker - no
// cross-origin fetch from the worker context.
useEffect(() => {
if (!open) return;
let cancelled = false;
let currentUrl: string | null = null;
let createdUrl: string | null = null;
async function loadPreview() {
setLoading(true);
setError(null);
setNumPages(0);
try {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (typeof window !== 'undefined') {
@@ -83,9 +99,12 @@ export function PdfPreviewModal({
}
const b = await res.blob();
if (cancelled) return;
currentUrl = URL.createObjectURL(b);
const buffer = await b.arrayBuffer();
if (cancelled) return;
createdUrl = URL.createObjectURL(b);
setBlob(b);
setUrl(currentUrl);
setFileBytes(new Uint8Array(buffer));
setUrl(createdUrl);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'Preview failed');
@@ -98,12 +117,11 @@ export function PdfPreviewModal({
return () => {
cancelled = true;
if (currentUrl) URL.revokeObjectURL(currentUrl);
if (createdUrl) URL.revokeObjectURL(createdUrl);
};
}, [open, payload]);
// Belt-and-braces revoke on unmount in case the modal closes
// mid-fetch and the effect's cleanup didn't capture the URL yet.
// Belt-and-braces revoke on unmount.
useEffect(
() => () => {
if (url) URL.revokeObjectURL(url);
@@ -111,6 +129,27 @@ export function PdfPreviewModal({
[url],
);
// Track the container width so the rendered pages fit edge-to-edge
// without horizontal overflow. ResizeObserver covers the case where
// the dialog re-flows (e.g. an iPad rotation) after first render.
useEffect(() => {
const node = containerRef.current;
if (!node) return;
const update = () => {
const w = node.clientWidth;
if (w > 0) setPageWidth(Math.min(w - 32, 1200)); // 16px padding on each side
};
update();
const ro = new ResizeObserver(update);
ro.observe(node);
return () => ro.disconnect();
}, [open]);
// Memoise the `{ data }` source - react-pdf reparses every time the
// `file` prop's reference identity changes, so a fresh object literal
// per render would restart parsing and flicker the placeholder.
const pdfFileSource = useMemo(() => (fileBytes ? { data: fileBytes } : null), [fileBytes]);
function handleDownload() {
if (!blob) return;
triggerBlobDownload(blob, filename);
@@ -120,7 +159,7 @@ export function PdfPreviewModal({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl w-[95vw] h-[90vh] flex flex-col">
<DialogContent className="sm:max-w-5xl w-[95vw] h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
@@ -129,7 +168,10 @@ export function PdfPreviewModal({
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-hidden rounded-md border bg-muted/20">
<div
ref={containerRef}
className="flex-1 min-h-0 overflow-auto rounded-md border bg-muted/20"
>
{loading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
@@ -140,17 +182,41 @@ export function PdfPreviewModal({
<div className="text-sm font-medium text-destructive">Preview failed</div>
<div className="font-mono text-xs text-muted-foreground break-all">{error}</div>
</div>
) : url ? (
<iframe
key={url}
src={url}
title="PDF preview"
className="h-full w-full"
// Sandbox keeps any embedded scripts in the PDF viewer
// from reaching our cookies or LocalStorage. allow-
// same-origin lets the iframe load the blob URL.
sandbox="allow-same-origin"
/>
) : pdfFileSource ? (
<div className="flex flex-col items-center gap-4 p-4">
<Document
file={pdfFileSource}
onLoadSuccess={({ numPages: n }) => {
setNumPages(n);
setError(null);
}}
onLoadError={(err) => {
setError(err instanceof Error ? err.message : 'PDF render error');
}}
loading={
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
Parsing PDF
</div>
}
>
{Array.from({ length: numPages }, (_, i) => (
<Page
key={`page-${i + 1}`}
pageNumber={i + 1}
width={pageWidth}
renderAnnotationLayer={false}
renderTextLayer={false}
className="mb-4 shadow-md last:mb-0"
/>
))}
</Document>
{numPages > 0 ? (
<p className="pt-1 text-xs text-muted-foreground">
{numPages} {numPages === 1 ? 'page' : 'pages'}
</p>
) : null}
</div>
) : null}
</div>
@@ -159,6 +225,14 @@ export function PdfPreviewModal({
<X className="mr-1.5 h-4 w-4" aria-hidden />
Close
</Button>
{url ? (
<Button asChild variant="outline" disabled={loading}>
<a href={url} target="_blank" rel="noreferrer">
<ExternalLink className="mr-1.5 h-4 w-4" aria-hidden />
Open in new tab
</a>
</Button>
) : null}
<Button onClick={handleDownload} disabled={!blob || loading}>
{loading ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />

View File

@@ -67,7 +67,7 @@ export function ReportsList() {
const result = await apiFetch<{ url: string }>(`/api/v1/reports/${reportId}/download`);
window.open(result.url, '_blank');
} catch (err) {
// Surface the failure to the user was previously console-only,
// Surface the failure to the user - was previously console-only,
// so the rep clicked Download and nothing happened (auditor-H §35).
toastError(err, 'Download failed');
} finally {

View File

@@ -31,7 +31,7 @@ export interface SavedTemplate {
interface Props {
kind: 'dashboard' | 'clients' | 'berths' | 'interests';
/** Called when the rep picks a template from the dropdown the
/** Called when the rep picks a template from the dropdown - the
* parent hydrates its form from the returned config. */
onApply: (template: SavedTemplate) => void;
/** Used by the "Save as template" toggle to capture the current