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}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user