feat(reports): client / berth / interest list-export PDF reports (phase B)
Extends the report exporter with three list-style report kinds —
clients, berths, interests. Each shares the BrandedReportDocument
layout + the new ReportTable primitive (zebra-striped rows,
proportional widths, no-break rows to keep records together across
page boundaries).
Data fetchers in `src/lib/services/list-report-data.service.ts`:
- resolveClientReportData: clients table joined to per-client
primary email + phone via DISTINCT-style subqueries (matches the
canonical listClients ordering: is_primary DESC, created_at DESC
per channel).
- resolveBerthReportData: berths table, default sort by mooring
number for printed familiarity.
- resolveInterestReportData: interests left-joined to clients +
primary berth, sort by updatedAt desc.
All three cap at 1 000 rows per export with a clear "Showing top N
of <total>" notice rendered when the cap is hit. Above that, the PDF
becomes unreadable (hundreds of pages); reps wanting larger exports
use CSV.
Route schema widened to a 4-arm discriminated union; the dispatch
switch in render-report.ts uses `satisfies` for compile-time variant
narrowing and a `_exhaustive: never` check at the bottom.
UI: each list page (BerthList, ClientList, InterestList) gains an
ExportListPdfButton next to the existing ColumnPicker. Permission-
gated client-side on reports.export; server route re-enforces.
Tests: 3 new render fixtures (1 per kind), all hit the same
%PDF-magic + byte-length assertions. Total render tests now 6/6;
full vitest sweep 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useState } from 'react';
|
|
|
|
|
import { FileDown, Loader2 } from 'lucide-react';
|
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from '@/components/ui/dialog';
|
|
|
|
|
import { triggerBlobDownload } from '@/lib/utils/download';
|
|
|
|
|
import { usePermissions } from '@/hooks/use-permissions';
|
|
|
|
|
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
feat(reports): saved-template store + CRUD + dialog integration (phase C)
Saves rep-configured export setups so a "Monthly board report" or
"Weekly pipeline review" template only has to be assembled once.
Schema (migration 0079_report_templates.sql + drizzle entry):
- report_templates: id, port_id, kind, name, description, config
(jsonb), created_by, created_at, updated_at.
- Sibling-name uniqueness scoped (port_id, kind, LOWER(name)) so
Port A and Port B can both have "Quarterly review" without
colliding, and two different KINDS in the same port can share a
name (a clients "Quarterly review" + an interests "Quarterly
review" coexist).
- port_id FK cascades on delete; templates evaporate with the
parent port. No cross-port enumeration risk since every query
filters by port_id.
Service (src/lib/services/report-templates.service.ts):
- createReportTemplate / listReportTemplates / getReportTemplate /
updateReportTemplate / deleteReportTemplate.
- Audit-logs every write with old/new values for the rename case.
- Surfaces sibling-name collisions as ConflictError with a
rep-readable message ('A "Monthly board report" template
already exists for the dashboard kind').
Routes:
- GET /api/v1/reports/templates?kind=clients
- POST /api/v1/reports/templates
- GET /api/v1/reports/templates/[id]
- PATCH /api/v1/reports/templates/[id]
- DELETE /api/v1/reports/templates/[id]
All gated on `reports.export` — same permission as generating
reports lets the rep manage the templates that drive them.
POST cross-validates that `body.kind === body.config.kind` so a
rep can't sneak a dashboard config into a clients template and
confuse the rendering path at use time.
UI:
- SavedTemplatesPicker reusable component — dropdown of templates
for this port + kind, inline "Save as template" toggle that
expands to a name input + Save button, delete button next to
the picker once a template is selected.
- Wired into both ExportDashboardPdfButton + ExportListPdfButton.
Applying a saved template hydrates the dialog's form (selected
widgets / filters / title) from the saved config.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:46:52 +02:00
|
|
|
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
|
feat(reports): client / berth / interest list-export PDF reports (phase B)
Extends the report exporter with three list-style report kinds —
clients, berths, interests. Each shares the BrandedReportDocument
layout + the new ReportTable primitive (zebra-striped rows,
proportional widths, no-break rows to keep records together across
page boundaries).
Data fetchers in `src/lib/services/list-report-data.service.ts`:
- resolveClientReportData: clients table joined to per-client
primary email + phone via DISTINCT-style subqueries (matches the
canonical listClients ordering: is_primary DESC, created_at DESC
per channel).
- resolveBerthReportData: berths table, default sort by mooring
number for printed familiarity.
- resolveInterestReportData: interests left-joined to clients +
primary berth, sort by updatedAt desc.
All three cap at 1 000 rows per export with a clear "Showing top N
of <total>" notice rendered when the cap is hit. Above that, the PDF
becomes unreadable (hundreds of pages); reps wanting larger exports
use CSV.
Route schema widened to a 4-arm discriminated union; the dispatch
switch in render-report.ts uses `satisfies` for compile-time variant
narrowing and a `_exhaustive: never` check at the bottom.
UI: each list page (BerthList, ClientList, InterestList) gains an
ExportListPdfButton next to the existing ColumnPicker. Permission-
gated client-side on reports.export; server route re-enforces.
Tests: 3 new render fixtures (1 per kind), all hit the same
%PDF-magic + byte-length assertions. Total render tests now 6/6;
full vitest sweep 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
|
|
|
|
|
|
|
|
type ListKind = 'clients' | 'berths' | 'interests';
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
kind: ListKind;
|
|
|
|
|
/** Label shown on the trigger button (e.g. "Export PDF"). */
|
|
|
|
|
buttonLabel?: string;
|
|
|
|
|
/** Default title pre-populated in the dialog. */
|
|
|
|
|
defaultTitle?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const KIND_LABEL: Record<ListKind, string> = {
|
|
|
|
|
clients: 'clients',
|
|
|
|
|
berths: 'berths',
|
|
|
|
|
interests: 'interests',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generic list-report export button. Renders a small dialog with
|
|
|
|
|
* a title input + "include archived" toggle, then POSTs to the
|
|
|
|
|
* report-generate endpoint. The kind discriminator picks the
|
|
|
|
|
* matching server-side data resolver and React-PDF template.
|
|
|
|
|
*
|
|
|
|
|
* Permission-gated client-side on `reports.export`; the server
|
|
|
|
|
* route enforces the same.
|
|
|
|
|
*/
|
|
|
|
|
export function ExportListPdfButton({ kind, buttonLabel = 'Export PDF', defaultTitle }: Props) {
|
|
|
|
|
const { can } = usePermissions();
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const [title, setTitle] = useState(
|
|
|
|
|
defaultTitle ??
|
|
|
|
|
`${KIND_LABEL[kind].charAt(0).toUpperCase() + KIND_LABEL[kind].slice(1)} report - ${new Date().toLocaleDateString('en-GB')}`,
|
|
|
|
|
);
|
|
|
|
|
const [includeArchived, setIncludeArchived] = useState(false);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
if (!can('reports', 'export')) return null;
|
|
|
|
|
|
|
|
|
|
async function handleExport() {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const headers = new Headers({ 'Content-Type': 'application/json' });
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
const slug = window.location.pathname.split('/').filter(Boolean)[0];
|
|
|
|
|
if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api') {
|
|
|
|
|
const portId = await resolvePortIdFromSlug(slug);
|
|
|
|
|
if (portId) headers.set('X-Port-Id', portId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const res = await fetch('/api/v1/reports/generate', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers,
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
title: title.trim() || `${kind} report`,
|
|
|
|
|
config: {
|
|
|
|
|
kind,
|
|
|
|
|
filters: { includeArchived },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const text = await res.text();
|
|
|
|
|
throw new Error(text || `Export failed (${res.status})`);
|
|
|
|
|
}
|
|
|
|
|
const blob = await res.blob();
|
|
|
|
|
triggerBlobDownload(blob, `${title.trim().replace(/[\\/]/g, '_')}.pdf`);
|
|
|
|
|
toast.success('Report downloaded');
|
|
|
|
|
setOpen(false);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err instanceof Error ? err.message : 'Export failed');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
|
|
|
|
<FileDown className="mr-1.5 h-4 w-4" aria-hidden />
|
|
|
|
|
{buttonLabel}
|
|
|
|
|
</Button>
|
|
|
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
|
|
|
<DialogContent className="max-w-md">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>Export {KIND_LABEL[kind]} as PDF</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
The PDF inherits the active port's logo and primary color. Up to 1 000 rows are
|
|
|
|
|
exported; for larger exports use CSV.
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="space-y-4">
|
feat(reports): saved-template store + CRUD + dialog integration (phase C)
Saves rep-configured export setups so a "Monthly board report" or
"Weekly pipeline review" template only has to be assembled once.
Schema (migration 0079_report_templates.sql + drizzle entry):
- report_templates: id, port_id, kind, name, description, config
(jsonb), created_by, created_at, updated_at.
- Sibling-name uniqueness scoped (port_id, kind, LOWER(name)) so
Port A and Port B can both have "Quarterly review" without
colliding, and two different KINDS in the same port can share a
name (a clients "Quarterly review" + an interests "Quarterly
review" coexist).
- port_id FK cascades on delete; templates evaporate with the
parent port. No cross-port enumeration risk since every query
filters by port_id.
Service (src/lib/services/report-templates.service.ts):
- createReportTemplate / listReportTemplates / getReportTemplate /
updateReportTemplate / deleteReportTemplate.
- Audit-logs every write with old/new values for the rename case.
- Surfaces sibling-name collisions as ConflictError with a
rep-readable message ('A "Monthly board report" template
already exists for the dashboard kind').
Routes:
- GET /api/v1/reports/templates?kind=clients
- POST /api/v1/reports/templates
- GET /api/v1/reports/templates/[id]
- PATCH /api/v1/reports/templates/[id]
- DELETE /api/v1/reports/templates/[id]
All gated on `reports.export` — same permission as generating
reports lets the rep manage the templates that drive them.
POST cross-validates that `body.kind === body.config.kind` so a
rep can't sneak a dashboard config into a clients template and
confuse the rendering path at use time.
UI:
- SavedTemplatesPicker reusable component — dropdown of templates
for this port + kind, inline "Save as template" toggle that
expands to a name input + Save button, delete button next to
the picker once a template is selected.
- Wired into both ExportDashboardPdfButton + ExportListPdfButton.
Applying a saved template hydrates the dialog's form (selected
widgets / filters / title) from the saved config.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:46:52 +02:00
|
|
|
<SavedTemplatesPicker
|
|
|
|
|
kind={kind}
|
|
|
|
|
currentConfig={{ filters: { includeArchived } }}
|
|
|
|
|
onApply={(t: SavedTemplate) => {
|
|
|
|
|
const cfg = t.config as { filters?: { includeArchived?: boolean } };
|
|
|
|
|
if (cfg.filters?.includeArchived !== undefined) {
|
|
|
|
|
setIncludeArchived(Boolean(cfg.filters.includeArchived));
|
|
|
|
|
}
|
|
|
|
|
if (t.name) setTitle(t.name);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
feat(reports): client / berth / interest list-export PDF reports (phase B)
Extends the report exporter with three list-style report kinds —
clients, berths, interests. Each shares the BrandedReportDocument
layout + the new ReportTable primitive (zebra-striped rows,
proportional widths, no-break rows to keep records together across
page boundaries).
Data fetchers in `src/lib/services/list-report-data.service.ts`:
- resolveClientReportData: clients table joined to per-client
primary email + phone via DISTINCT-style subqueries (matches the
canonical listClients ordering: is_primary DESC, created_at DESC
per channel).
- resolveBerthReportData: berths table, default sort by mooring
number for printed familiarity.
- resolveInterestReportData: interests left-joined to clients +
primary berth, sort by updatedAt desc.
All three cap at 1 000 rows per export with a clear "Showing top N
of <total>" notice rendered when the cap is hit. Above that, the PDF
becomes unreadable (hundreds of pages); reps wanting larger exports
use CSV.
Route schema widened to a 4-arm discriminated union; the dispatch
switch in render-report.ts uses `satisfies` for compile-time variant
narrowing and a `_exhaustive: never` check at the bottom.
UI: each list page (BerthList, ClientList, InterestList) gains an
ExportListPdfButton next to the existing ColumnPicker. Permission-
gated client-side on reports.export; server route re-enforces.
Tests: 3 new render fixtures (1 per kind), all hit the same
%PDF-magic + byte-length assertions. Total render tests now 6/6;
full vitest sweep 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:42:55 +02:00
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label htmlFor={`export-title-${kind}`}>Title</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id={`export-title-${kind}`}
|
|
|
|
|
value={title}
|
|
|
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={includeArchived}
|
|
|
|
|
onCheckedChange={(c) => setIncludeArchived(Boolean(c))}
|
|
|
|
|
aria-label="Include archived"
|
|
|
|
|
/>
|
|
|
|
|
Include archived
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button onClick={handleExport} disabled={loading}>
|
|
|
|
|
{loading ? (
|
|
|
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
|
|
|
|
|
) : (
|
|
|
|
|
<FileDown className="mr-1.5 h-4 w-4" aria-hidden />
|
|
|
|
|
)}
|
|
|
|
|
Download PDF
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|