Files
invoices/web/PdfPane.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

92 lines
3.4 KiB
TypeScript

import type { ReactNode } from 'react';
import { Download, ExternalLink, Loader2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { downloadPdf, usePdfUrl } from './useInvoiceShelfData';
// The right-hand detail pane for invoices, estimates and payments — upstream's document view, which is
// itself just a PDF iframe with an action bar over it.
//
// The PDF is fetched as a blob and handed to the iframe as an object URL rather than being pointed at the
// API path directly. It has to be: the sidecar sits behind Officer's bearer auth and an <iframe src> sends
// no Authorization header, so a direct src would render a 401 body. usePdfUrl owns that fetch and revokes
// the object URL when the document changes, so clicking down a list does not leak a PDF per click.
//
// These PDFs are also the reason the sidecar repairs magic bytes: InvoiceShelf 2.4.2 prepends ~200 bytes of
// a serialized HTTP response to its own PDF body, which every renderer refuses. By the time it arrives here
// it is a clean %PDF.
export const PdfPane = ({
resource,
id,
title,
subtitle,
filename,
actions,
onClose,
}: {
resource: string;
id: number | null;
title: string;
subtitle?: ReactNode;
filename: string;
actions?: ReactNode;
onClose: () => void;
}) => {
const { url, isLoading, error } = usePdfUrl(resource, id);
if (id == null) return null;
return (
<div className="flex h-full min-h-0 w-full flex-col border-l border-border bg-background">
<div className="flex shrink-0 items-start gap-2 border-b border-border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">{title}</div>
{subtitle && <div className="truncate text-[10px] text-muted-foreground">{subtitle}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{actions}
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={!url}
title="Download PDF"
onClick={() => url && downloadPdf(url, filename)}
>
<Download className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 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>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onClose} title="Close">
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 bg-muted/30">
{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" />
Rendering PDF…
</div>
)}
{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 load PDF</div>
<div className="max-w-xs text-[10px] text-muted-foreground">{error}</div>
</div>
)}
{url && !isLoading && <iframe src={url} title={title} className="h-full w-full border-0" />}
</div>
</div>
);
};