160 lines
5.7 KiB
TypeScript
160 lines
5.7 KiB
TypeScript
|
|
'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 {
|
||
|
|
PDF_DASHBOARD_WIDGETS,
|
||
|
|
type PdfDashboardWidgetId,
|
||
|
|
} from '@/lib/services/dashboard-report-data.service';
|
||
|
|
import { triggerBlobDownload } from '@/lib/utils/download';
|
||
|
|
import { usePermissions } from '@/hooks/use-permissions';
|
||
|
|
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Dashboard "Export as PDF" affordance. Per-export dialog lets reps
|
||
|
|
* pick which sections to include + set a custom title. Saved-template
|
||
|
|
* support lands in Phase C; for now, the dialog defaults all widgets
|
||
|
|
* checked + the current date in the title.
|
||
|
|
*
|
||
|
|
* Permission-gated client-side on `reports.export`; the server route
|
||
|
|
* re-checks via withPermission so a tampered client can't bypass.
|
||
|
|
*/
|
||
|
|
export function ExportDashboardPdfButton() {
|
||
|
|
const { can } = usePermissions();
|
||
|
|
const [open, setOpen] = useState(false);
|
||
|
|
const [title, setTitle] = useState(
|
||
|
|
`Dashboard report — ${new Date().toLocaleDateString('en-GB')}`,
|
||
|
|
);
|
||
|
|
const [selected, setSelected] = useState<PdfDashboardWidgetId[]>(
|
||
|
|
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
|
||
|
|
);
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
|
||
|
|
if (!can('reports', 'export')) return null;
|
||
|
|
|
||
|
|
function toggle(id: PdfDashboardWidgetId) {
|
||
|
|
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleExport() {
|
||
|
|
if (selected.length === 0) {
|
||
|
|
toast.error('Pick at least one section to include.');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
// FormData isn't required (this is a JSON body), but we DO need
|
||
|
|
// to forward the X-Port-Id header so the server-side resolver
|
||
|
|
// knows which port's data to use. apiFetch is JSON-only and
|
||
|
|
// doesn't expose the raw response body; we need the buffer here
|
||
|
|
// so do a raw fetch with the same header convention.
|
||
|
|
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() || 'Dashboard report',
|
||
|
|
config: {
|
||
|
|
kind: 'dashboard',
|
||
|
|
widgetIds: selected,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
if (!res.ok) {
|
||
|
|
const text = await res.text();
|
||
|
|
throw new Error(text || `Export failed (${res.status})`);
|
||
|
|
}
|
||
|
|
const blob = await res.blob();
|
||
|
|
const filename = title.trim().replace(/[\\/]/g, '_') + '.pdf';
|
||
|
|
triggerBlobDownload(blob, filename);
|
||
|
|
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 />
|
||
|
|
Export PDF
|
||
|
|
</Button>
|
||
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
||
|
|
<DialogContent className="max-w-md">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Export dashboard as PDF</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Pick which sections to include and set a title. The PDF inherits the active
|
||
|
|
port's logo and primary color.
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="space-y-1">
|
||
|
|
<Label htmlFor="export-title">Title</Label>
|
||
|
|
<Input id="export-title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||
|
|
</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"
|
||
|
|
>
|
||
|
|
<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>
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleExport} disabled={loading || selected.length === 0}>
|
||
|
|
{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>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|