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

283 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ReactNode } from 'react';
import type { LucideIcon } from 'lucide-react';
import type { PageMeta } from './shared';
import { ChevronLeft, ChevronRight, Inbox, Loader2, Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { toneFill } from '@/components/Data/tone';
import { STATUS_TONE, humanStatus } from './shared';
// Presentational furniture shared by every /invoices section. Nothing here fetches or mutates — these are
// the pieces the section views arrange.
/** Status pill. Unknown tokens fall through to the neutral tone rather than rendering unstyled. */
export const StatusBadge = ({ value, className = '' }: { value: string | null | undefined; className?: string }) => {
if (!value) return null;
const fill = toneFill[STATUS_TONE[value] ?? 'neutral'];
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${fill} ${className}`}
>
{humanStatus(value)}
</span>
);
};
export const EmptyState = ({
icon: Icon = Inbox,
title,
hint,
action,
}: {
icon?: LucideIcon;
title: string;
hint?: string;
action?: ReactNode;
}) => (
<div className="flex h-full flex-col items-center justify-center gap-3 p-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Icon className="h-6 w-6" />
</div>
<div>
<div className="text-sm font-medium">{title}</div>
{hint && <div className="mt-1 max-w-sm text-xs text-muted-foreground">{hint}</div>}
</div>
{action}
</div>
);
export const LoadingState = ({ label = 'Loading…' }: { label?: string }) => (
<div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{label}
</div>
);
export const ErrorState = ({ error, hint }: { error: unknown; hint?: string }) => (
<div className="flex h-full flex-col items-center justify-center gap-2 p-10 text-center">
<div className="text-sm font-medium text-destructive">Could not reach InvoiceShelf</div>
<div className="max-w-md text-xs text-muted-foreground">
{hint ?? 'Check the connection under Invoices → Connection, and that officer-invoiceshelf is running.'}
</div>
{error != null && (
<pre className="max-w-md overflow-hidden text-ellipsis text-[10px] text-muted-foreground/70">
{String((error as Error)?.message ?? error).slice(0, 300)}
</pre>
)}
</div>
);
/**
* Search box that writes straight through on every keystroke.
*
* Not debounced, deliberately: the query string is the source of truth, and react-query keys each distinct
* search as its own cache entry, so backspacing through a term replays cached responses rather than
* refiring requests. A debounce would only add lag to the first character.
*/
export const SearchBox = ({
value,
onChange,
placeholder = 'Search…',
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) => (
<div className="relative min-w-0 flex-1 sm:max-w-xs">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-8 pl-8 text-xs"
/>
</div>
);
/** Horizontal status tabs above a list, each carrying the count when one is known. */
export const StatusTabs = <T extends string>({
tabs,
value,
onChange,
}: {
tabs: readonly { id: T | null; label: string; count?: number }[];
value: T | null;
onChange: (v: T | null) => void;
}) => (
<div className="flex items-center gap-1 overflow-x-auto">
{tabs.map((tab) => {
const active = value === tab.id;
return (
<button
key={tab.id ?? 'all'}
type="button"
onClick={() => onChange(tab.id)}
className={`shrink-0 rounded-md px-2.5 py-1 text-xs transition-colors ${
active ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/60'
}`}
>
{tab.label}
{tab.count != null && <span className="ml-1.5 tabular-nums opacity-60">{tab.count}</span>}
</button>
);
})}
</div>
);
/**
* Pager for a Laravel-paginated list. Renders nothing on a single page — a lone disabled "1 of 1" is noise.
*/
export const Pagination = ({ meta, onPage }: { meta: PageMeta | null; onPage: (page: number) => void }) => {
if (!meta || meta.last_page <= 1) return null;
const { current_page, last_page, from, to, total } = meta;
return (
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted-foreground">
<span className="tabular-nums">
{from ?? 0}–{to ?? 0} of {total}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={current_page <= 1}
onClick={() => onPage(current_page - 1)}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<span className="tabular-nums">
{current_page} / {last_page}
</span>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
disabled={current_page >= last_page}
onClick={() => onPage(current_page + 1)}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
};
/** Column contract for SimpleTable. `width` is a grid track ('1fr', '120px', 'minmax(0,2fr)'). */
export type Column<T> = {
key: string;
label: string;
width: string;
align?: 'left' | 'right' | 'center';
render: (row: T) => ReactNode;
};
/**
* The list table.
*
* A CSS grid rather than a <table>: every list here shares a sticky header and a single scroller, and grid
* tracks keep the two aligned without the colgroup gymnastics a real table needs. Not virtualised — these
* lists are server-paginated, so the row count on screen is bounded by `limit`.
*/
export const SimpleTable = <T,>({
rows,
columns,
rowKey,
selectedKey,
onRowClick,
rowMenu,
empty,
}: {
rows: T[];
columns: Column<T>[];
rowKey: (row: T) => number | string;
selectedKey?: number | string | null;
onRowClick?: (row: T) => void;
rowMenu?: (row: T) => ReactNode;
empty?: ReactNode;
}) => {
const template = columns.map((c) => c.width).join(' ') + (rowMenu ? ' 40px' : '');
if (!rows.length && empty) return <>{empty}</>;
return (
<div className="h-full overflow-auto">
<div
className="sticky top-0 z-10 grid border-b border-border bg-background/95 backdrop-blur"
style={{ gridTemplateColumns: template }}
>
{columns.map((col) => (
<div
key={col.key}
className={`overflow-hidden px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground ${
col.align === 'right' ? 'text-right' : col.align === 'center' ? 'text-center' : ''
}`}
>
<span className="truncate">{col.label}</span>
</div>
))}
{rowMenu && <div />}
</div>
<div>
{rows.map((row) => {
const key = rowKey(row);
const isSelected = selectedKey != null && key === selectedKey;
return (
<div
key={key}
onClick={() => onRowClick?.(row)}
className={`grid items-center border-b border-border/40 text-xs transition-colors ${
onRowClick ? 'cursor-pointer' : ''
} ${isSelected ? 'bg-primary/10' : 'hover:bg-muted/50'}`}
style={{ gridTemplateColumns: template }}
>
{columns.map((col) => (
<div
key={col.key}
className={`min-w-0 overflow-hidden px-3 py-2 ${
col.align === 'right' ? 'text-right tabular-nums' : col.align === 'center' ? 'text-center' : ''
}`}
>
{col.render(row)}
</div>
))}
{rowMenu && (
<div className="flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
{rowMenu(row)}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
/** Label/value row for the detail panes. */
export const Field = ({ label, children }: { label: string; children: ReactNode }) => (
<div className="flex items-baseline justify-between gap-3 py-1 text-xs">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="min-w-0 truncate text-right">{children}</span>
</div>
);
export const SectionShell = ({
toolbar,
children,
footer,
}: {
toolbar?: ReactNode;
children: ReactNode;
footer?: ReactNode;
}) => (
<div className="flex h-full min-h-0 flex-col">
{toolbar && (
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2">{toolbar}</div>
)}
<div className="min-h-0 flex-1">{children}</div>
{footer}
</div>
);