Files
invoices/web/ReportsView.tsx
T
officer 6c70eb8eb3 invoices, extracted from the platform as a plugin
Ninth plugin out, third resurrection: switched off since 2026-08-13 with the
permission reduced to api: [], so the server booted clean and every panel
request 404'd. invoiceshelf/schema was also commented out of the drizzle
barrel.

Named `invoices`, not `invoiceshelf`. The appName drives the API prefix, the
frontend route, the table prefix and the install key all at once, and before
extraction those had drifted: the API was /api/invoiceshelf while the screen
was /invoices. The URL is what a person sees and should name what the plugin
shows rather than the daemon behind it — the permission key and label were
already `invoices`, so the API prefix was the odd one out.

Cost: three call sites, and renaming invoiceshelf_accounts to invoices_accounts
to satisfy the table-prefix rule. That rename was free ONLY because the table
existed on no server, which was checked before deciding. With rows in it,
keeping the vendor name would have been the better trade: db:push sees a rename
as a drop plus a create and this project has no migration mechanism.

The sidecar keeps its Invoiceshelf-named functions deliberately — they name the
upstream API it actually speaks.

91 palette classes converted, 52 of them in STATUS_TONE, which was already the
single source of status colour but held raw palette strings. SENT and VIEWED
now share `info`: the closed six-tone set has no second shade of "in flight,
not yet actioned". Recorded in PLUGIN.md as a real loss.
2026-08-19 03:47:44 +00:00

212 lines
8.0 KiB
TypeScript

import type { ReportKind } from './shared';
import { useMemo, useState } from 'react';
import { BarChart3, Download, ExternalLink, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { EmptyState } from './components';
import { REPORT_KINDS } from './shared';
import { downloadPdf, useReportUrl } from './useInvoiceShelfData';
// Reports. Every one of these is a server-rendered PDF — InvoiceShelf builds them in Blade and there is no
// JSON equivalent, so this screen is a range picker and a viewer, which is exactly what upstream's is.
//
// from_date and to_date are MANDATORY, not optional filters: each controller does
// `Carbon::createFromFormat('Y-m-d', $request->from_date)` unguarded, so omitting either is a 500 rather than
// an unbounded report. The range therefore always has a value here and the presets always resolve to a pair.
type PresetId =
| 'today'
| 'this-week'
| 'this-month'
| 'this-quarter'
| 'this-year'
| 'previous-week'
| 'previous-month'
| 'previous-quarter'
| 'previous-year'
| 'custom';
const PRESETS: { id: PresetId; label: string }[] = [
{ id: 'today', label: 'Today' },
{ id: 'this-week', label: 'This week' },
{ id: 'this-month', label: 'This month' },
{ id: 'this-quarter', label: 'This quarter' },
{ id: 'this-year', label: 'This year' },
{ id: 'previous-week', label: 'Previous week' },
{ id: 'previous-month', label: 'Previous month' },
{ id: 'previous-quarter', label: 'Previous quarter' },
{ id: 'previous-year', label: 'Previous year' },
{ id: 'custom', label: 'Custom' },
];
const iso = (d: Date): string => {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
/** Monday-start, matching Carbon's default `startOfWeek()` on this instance. */
const startOfWeek = (d: Date): Date => {
const out = new Date(d);
const offset = (out.getDay() + 6) % 7;
out.setDate(out.getDate() - offset);
return out;
};
function resolvePreset(id: PresetId): { from: string; to: string } | null {
if (id === 'custom') return null;
const now = new Date();
const y = now.getFullYear();
const q = Math.floor(now.getMonth() / 3);
switch (id) {
case 'today':
return { from: iso(now), to: iso(now) };
case 'this-week': {
const start = startOfWeek(now);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return { from: iso(start), to: iso(end) };
}
case 'previous-week': {
const start = startOfWeek(now);
start.setDate(start.getDate() - 7);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return { from: iso(start), to: iso(end) };
}
case 'this-month':
return { from: iso(new Date(y, now.getMonth(), 1)), to: iso(new Date(y, now.getMonth() + 1, 0)) };
case 'previous-month':
return { from: iso(new Date(y, now.getMonth() - 1, 1)), to: iso(new Date(y, now.getMonth(), 0)) };
case 'this-quarter':
return { from: iso(new Date(y, q * 3, 1)), to: iso(new Date(y, q * 3 + 3, 0)) };
case 'previous-quarter':
return { from: iso(new Date(y, q * 3 - 3, 1)), to: iso(new Date(y, q * 3, 0)) };
case 'this-year':
return { from: iso(new Date(y, 0, 1)), to: iso(new Date(y, 11, 31)) };
case 'previous-year':
return { from: iso(new Date(y - 1, 0, 1)), to: iso(new Date(y - 1, 11, 31)) };
default:
return null;
}
}
export const ReportsView = () => {
const [kind, setKind] = useState<ReportKind>('sales-customers');
const [preset, setPreset] = useState<PresetId>('this-month');
// Seeded from the default preset so the first render already has a valid range to request.
const [custom, setCustom] = useState(() => resolvePreset('this-month') ?? { from: '', to: '' });
const range = useMemo(() => resolvePreset(preset) ?? custom, [preset, custom]);
const ready = Boolean(range.from && range.to);
const { url, isLoading, error } = useReportUrl(kind, { from_date: range.from, to_date: range.to }, ready);
const label = REPORT_KINDS.find((r) => r.id === kind)?.label ?? 'Report';
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-1 border-b border-border px-3 py-2">
{REPORT_KINDS.map((r) => (
<button
key={r.id}
type="button"
onClick={() => setKind(r.id)}
className={`shrink-0 rounded-md px-2.5 py-1 text-xs transition-colors ${
kind === r.id ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/60'
}`}
>
{r.label}
</button>
))}
</div>
<div className="flex shrink-0 flex-wrap items-end gap-3 border-b border-border px-3 py-2">
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
Range
<select
value={preset}
onChange={(e) => {
const next = e.target.value as PresetId;
// Carry the resolved dates into the custom fields so switching to Custom starts from what is
// already on screen instead of blanking the report.
const resolved = resolvePreset(next);
if (resolved) setCustom(resolved);
setPreset(next);
}}
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
>
{PRESETS.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
From
<Input
type="date"
value={range.from}
disabled={preset !== 'custom'}
onChange={(e) => setCustom((c) => ({ ...c, from: e.target.value }))}
className="h-8 w-36 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
To
<Input
type="date"
value={range.to}
disabled={preset !== 'custom'}
onChange={(e) => setCustom((c) => ({ ...c, to: e.target.value }))}
className="h-8 w-36 text-xs"
/>
</label>
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-8"
disabled={!url}
onClick={() => url && downloadPdf(url, `${kind}-${range.from}_${range.to}`)}
>
<Download className="mr-1.5 h-3.5 w-3.5" />
Download
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
disabled={!url}
title="Open in new tab"
onClick={() => url && window.open(url, '_blank', 'noopener')}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 bg-muted/30">
{!ready && (
<EmptyState icon={BarChart3} title="Pick a date range" hint="Reports need both a start and an end." />
)}
{ready && isLoading && (
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Building {label.toLowerCase()}…
</div>
)}
{ready && error && !isLoading && (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<div className="text-xs font-medium text-destructive">Could not build the report</div>
<div className="max-w-md text-[10px] text-muted-foreground">{error}</div>
</div>
)}
{ready && url && !isLoading && <iframe src={url} title={label} className="h-full w-full border-0" />}
</div>
</div>
);
};