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 ( {humanStatus(value)} ); }; export const EmptyState = ({ icon: Icon = Inbox, title, hint, action, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; }) => (
{title}
{hint &&
{hint}
}
{action}
); export const LoadingState = ({ label = 'Loading…' }: { label?: string }) => (
{label}
); export const ErrorState = ({ error, hint }: { error: unknown; hint?: string }) => (
Could not reach InvoiceShelf
{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; }) => (
onChange(e.target.value)} placeholder={placeholder} className="h-8 pl-8 text-xs" />
); /** 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 (
{columns.map((col) => (
{col.label}
))} {rowMenu &&
}
{rows.map((row) => { const key = rowKey(row); const isSelected = selectedKey != null && key === selectedKey; return (
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) => (
{col.render(row)}
))} {rowMenu && (
e.stopPropagation()}> {rowMenu(row)}
)}
); })}
); }; /** Label/value row for the detail panes. */ export const Field = ({ label, children }: { label: string; children: ReactNode }) => (
{label} {children}
); export const SectionShell = ({ toolbar, children, footer, }: { toolbar?: ReactNode; children: ReactNode; footer?: ReactNode; }) => (
{toolbar && (
{toolbar}
)}
{children}
{footer}
);