{hint ?? 'Check the connection under Invoices → Connection, and that officer-invoiceshelf is running.'}
{error != null && (
{String((error as Error)?.message ?? error).slice(0, 300)}
)}
);
/**
* 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;
}) => (
);
/** Horizontal status tabs above a list, each carrying the count when one is known. */
export const StatusTabs = ({
tabs,
value,
onChange,
}: {
tabs: readonly { id: T | null; label: string; count?: number }[];
value: T | null;
onChange: (v: T | null) => void;
}) => (
{tabs.map((tab) => {
const active = value === tab.id;
return (
);
})}
);
/**
* 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 (
{from ?? 0}–{to ?? 0} of {total}
{current_page} / {last_page}
);
};
/** Column contract for SimpleTable. `width` is a grid track ('1fr', '120px', 'minmax(0,2fr)'). */
export type Column = {
key: string;
label: string;
width: string;
align?: 'left' | 'right' | 'center';
render: (row: T) => ReactNode;
};
/**
* The list table.
*
* A CSS grid rather than a
: 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 = ({
rows,
columns,
rowKey,
selectedKey,
onRowClick,
rowMenu,
empty,
}: {
rows: T[];
columns: Column[];
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 (