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:
@@ -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 “needs date range” 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}
|
||||
</>
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user