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>
This commit is contained in:
@@ -11,6 +11,7 @@ import { FilterBar } from '@/components/shared/filter-bar';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { ExportListPdfButton } from '@/components/reports/export-list-pdf-button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
@@ -136,6 +137,7 @@ export function BerthList() {
|
||||
)}
|
||||
</Button>
|
||||
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||
<ExportListPdfButton kind="berths" />
|
||||
{canBulkAdd && (
|
||||
<Button asChild size="sm" variant="default">
|
||||
<Link href={`/${params.portSlug}/admin/berths/bulk-add`}>
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type ClientRow,
|
||||
} from '@/components/clients/client-columns';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { ExportListPdfButton } from '@/components/reports/export-list-pdf-button';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
@@ -202,6 +203,7 @@ export function ClientList() {
|
||||
onChange={setHidden}
|
||||
onSaveView={() => setSaveViewOpen(true)}
|
||||
/>
|
||||
<ExportListPdfButton kind="clients" />
|
||||
{/* New Client moved out of PageHeader actions and into the
|
||||
filter row. Saves a row on mobile (no more dedicated
|
||||
actions strip). ml-auto keeps the primary action at the
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type InterestRow,
|
||||
} from '@/components/interests/interest-columns';
|
||||
import { ColumnPicker } from '@/components/shared/column-picker';
|
||||
import { ExportListPdfButton } from '@/components/reports/export-list-pdf-button';
|
||||
import { SaveViewDialog } from '@/components/shared/save-view-dialog';
|
||||
import { useCreateFromUrl } from '@/hooks/use-create-from-url';
|
||||
import { useTablePreferences } from '@/hooks/use-table-preferences';
|
||||
@@ -268,6 +269,7 @@ export function InterestList() {
|
||||
onChange={setHidden}
|
||||
onSaveView={() => setSaveViewOpen(true)}
|
||||
/>
|
||||
<ExportListPdfButton kind="interests" />
|
||||
</>
|
||||
) : null}
|
||||
<StageLegend />
|
||||
|
||||
147
src/components/reports/export-list-pdf-button.tsx
Normal file
147
src/components/reports/export-list-pdf-button.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'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';
|
||||
|
||||
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">
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user