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.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { Link } from 'react-router';
|
||||
import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { invoicesSectionPath } from './shared';
|
||||
import { useInvoiceShelfAccountActions, useInvoiceShelfAccounts, useInvoiceShelfHealth } from './useInvoiceShelfData';
|
||||
|
||||
// Which books you are looking at, and how to change them.
|
||||
//
|
||||
// The nav header's subtitle was already the connection state, so the switcher takes that line rather than
|
||||
// adding a second control saying nearly the same thing. With one account it stays exactly what it was: a
|
||||
// line of text plus a way into the settings screen.
|
||||
//
|
||||
// Each entry shows its company under the label, because two accounts on the same instance differing only by
|
||||
// company is the normal case here — the label alone would not tell them apart.
|
||||
//
|
||||
// Switching invalidates the whole ['invoiceshelf'] key: it changes the answer to every query in the
|
||||
// workspace without changing any of their inputs.
|
||||
|
||||
export const AccountSwitcher = () => {
|
||||
const { data } = useInvoiceShelfAccounts();
|
||||
const { data: health } = useInvoiceShelfHealth();
|
||||
const { activate } = useInvoiceShelfAccountActions();
|
||||
|
||||
const accounts = data?.accounts ?? [];
|
||||
const active = accounts.find((account) => account.isActive) ?? null;
|
||||
|
||||
// Before the registry answers, fall back to health — it is the query that was already driving this line.
|
||||
const status = active?.label ?? (health?.ok ? (health.version ?? 'connected') : 'not connected');
|
||||
|
||||
if (accounts.length < 2) {
|
||||
return (
|
||||
<Link
|
||||
to={invoicesSectionPath('settings')}
|
||||
className="block truncate text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{status}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
<span className="truncate">{status}</span>
|
||||
{activate.isPending ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
InvoiceShelf accounts
|
||||
</DropdownMenuLabel>
|
||||
{accounts.map((account) => (
|
||||
<DropdownMenuItem
|
||||
key={account.id}
|
||||
disabled={account.isActive || activate.isPending}
|
||||
onSelect={() => void activate.mutateAsync(account.id).catch(() => undefined)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${account.isActive ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{account.label}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{account.companyName ?? account.url}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={invoicesSectionPath('settings')} className="gap-2">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Manage accounts
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
// Confirmation for the destructive and the irreversible-ish. Upstream routes every one of these through a
|
||||
// single dialog store with the same "are you sure" copy; this keeps the single gate but says what will
|
||||
// actually happen, because "are you sure?" on its own never told anyone anything.
|
||||
|
||||
export type ConfirmState = {
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
confirmLabel?: string;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
} | null;
|
||||
|
||||
export const ConfirmDialog = ({ state, onClose }: { state: ConfirmState; onClose: () => void }) => {
|
||||
if (!state) return null;
|
||||
|
||||
return (
|
||||
<AlertDialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{state.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{state.description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
state.onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
className={state.destructive ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
|
||||
>
|
||||
{state.confirmLabel ?? 'Confirm'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,449 @@
|
||||
import type { CompanyOption, Credential, InvoiceShelfAccount } from './useInvoiceShelfData';
|
||||
import { useState } from 'react';
|
||||
import { Building2, Check, CheckCircle2, Loader2, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
parseAccountError,
|
||||
useAccountCompanies,
|
||||
useInvoiceShelfAccountActions,
|
||||
useInvoiceShelfAccounts,
|
||||
useInvoiceShelfHealth,
|
||||
} from './useInvoiceShelfData';
|
||||
|
||||
// Connecting InvoiceShelf instances to Officer, from the app.
|
||||
//
|
||||
// This screen is BOTH the setup wizard and the permanent settings page: InvoicesView renders it in place of
|
||||
// whatever section the URL asks for while nothing is connected, and /invoices/settings renders it for good.
|
||||
//
|
||||
// The company picker is the part that has no equivalent in the photos version, and it is not a nicety: the
|
||||
// `company` header scopes almost every InvoiceShelf route, and a wrong or missing value does NOT error — it
|
||||
// silently returns another company's books. So the account pins one, chosen once, visibly. Two accounts on
|
||||
// the same URL with the same token and different companies is an ordinary thing to have, which is why the
|
||||
// LABEL is what has to be unique.
|
||||
//
|
||||
// The credential is never displayed, because Officer cannot display it: the token is encrypted at rest and
|
||||
// the sidecar's GET has no field that could carry it back, and the password is not stored at all.
|
||||
|
||||
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
|
||||
|
||||
/** InvoiceShelf's own default — its production compose publishes 8090, and the sidecar dials from this machine. */
|
||||
const DEFAULT_URL = 'http://localhost:8090';
|
||||
|
||||
const URL_HINT =
|
||||
"The instance's base URL, without /api. Officer reaches it from the server, not from this browser — so " +
|
||||
'localhost here means the machine Officer runs on, and a local instance needs no TLS.';
|
||||
|
||||
const SIGN_IN_HINT =
|
||||
'Your InvoiceShelf login. It is used once, by the sidecar, to mint an API token — the password is never ' +
|
||||
'stored and never reaches the browser again. Each sign-in creates a new token in InvoiceShelf; revoke old ' +
|
||||
'ones there.';
|
||||
|
||||
const TOKEN_HINT =
|
||||
'A Sanctum token you already have. Paste it whole, including the leading "1|". It can read and write the ' +
|
||||
'entire books, so Officer stores it encrypted and never shows it again.';
|
||||
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
type?: string;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
autoFocus={autoFocus}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{hint && <span className={HINT}>{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
|
||||
// ── credentials ──────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Two ways to prove the same thing, so they share one piece of state rather than two forms. Sign-in is the
|
||||
// default because InvoiceShelf 2.4.2 ships no screen that issues tokens — before this, connecting Officer
|
||||
// meant running a curl command against /auth/login by hand, which is exactly what the sidecar now does.
|
||||
|
||||
type CredentialMode = 'login' | 'token';
|
||||
|
||||
function useCredential() {
|
||||
const [mode, setMode] = useState<CredentialMode>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
|
||||
const filled = mode === 'token' ? !!token.trim() : !!email.trim() && !!password;
|
||||
const payload: Credential = mode === 'token' ? { token: token.trim() } : { email: email.trim(), password };
|
||||
|
||||
const reset = () => {
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
setToken('');
|
||||
};
|
||||
|
||||
return { mode, setMode, email, setEmail, password, setPassword, token, setToken, filled, payload, reset };
|
||||
}
|
||||
|
||||
type CredentialState = ReturnType<typeof useCredential>;
|
||||
|
||||
const TAB = 'rounded-md px-2 py-1 text-[11px] transition-colors';
|
||||
|
||||
const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium">Credential</span>
|
||||
<div className="ml-auto flex items-center gap-1 rounded-lg bg-muted p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cred.setMode('login')}
|
||||
className={`${TAB} ${cred.mode === 'login' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cred.setMode('token')}
|
||||
className={`${TAB} ${cred.mode === 'token' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
|
||||
>
|
||||
API token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cred.mode === 'login' ? (
|
||||
<>
|
||||
<Field
|
||||
label="Email"
|
||||
value={cred.email}
|
||||
onChange={cred.setEmail}
|
||||
placeholder="you@example.com"
|
||||
type="email"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
value={cred.password}
|
||||
onChange={cred.setPassword}
|
||||
placeholder="••••••••"
|
||||
type="password"
|
||||
hint={SIGN_IN_HINT}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Field
|
||||
label="API token"
|
||||
value={cred.token}
|
||||
onChange={cred.setToken}
|
||||
placeholder="1|xxxxxxxx"
|
||||
type="password"
|
||||
hint={TOKEN_HINT}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
type CompanyPickerProps = {
|
||||
companies: CompanyOption[];
|
||||
value: number | null;
|
||||
onChange: (id: number) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/** A plain select — the list is short, and a combobox would be machinery for four options. */
|
||||
const CompanyPicker = ({ companies, value, onChange, disabled }: CompanyPickerProps) => (
|
||||
<select
|
||||
value={value ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(ev) => onChange(Number(ev.target.value))}
|
||||
className="h-8 rounded-md border bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Choose a company…
|
||||
</option>
|
||||
{companies.map((company) => (
|
||||
<option key={company.id} value={company.id}>
|
||||
{company.name ?? `Company ${company.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
type AccountRowProps = { account: InvoiceShelfAccount };
|
||||
|
||||
/**
|
||||
* One stored account. The active one carries the live health line, because health only ever describes the
|
||||
* account actually being used — showing a status next to the others would be inventing one.
|
||||
*/
|
||||
const AccountRow = ({ account }: AccountRowProps) => {
|
||||
const { data: health, refetch: recheck, isFetching: checking } = useInvoiceShelfHealth();
|
||||
const { edit, activate, remove } = useInvoiceShelfAccountActions();
|
||||
|
||||
const cred = useCredential();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
const [changingCompany, setChangingCompany] = useState(false);
|
||||
// Removing an account throws away a token Officer can never show again, so the bin asks once.
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const companies = useAccountCompanies(changingCompany ? account.id : null);
|
||||
|
||||
const run = async (action: Promise<unknown>) => {
|
||||
setError(null);
|
||||
try {
|
||||
await action;
|
||||
cred.reset();
|
||||
setReplacing(false);
|
||||
setChangingCompany(false);
|
||||
} catch (err) {
|
||||
setError(parseAccountError(err).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 rounded-lg border p-4 text-xs ${account.isActive ? 'border-primary/40' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{account.isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : <span className="h-4 w-4 shrink-0" />}
|
||||
<span className="truncate font-medium">{account.label}</span>
|
||||
{account.isActive && (
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">in use</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{!account.isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
disabled={activate.isPending}
|
||||
onClick={() => void run(activate.mutateAsync(account.id))}
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
)}
|
||||
{account.isActive && (
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={() => void recheck()}>
|
||||
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Test'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-7 ${confirming ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}`}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!confirming) return setConfirming(true);
|
||||
setConfirming(false);
|
||||
void run(remove.mutateAsync(account.id));
|
||||
}}
|
||||
>
|
||||
{confirming ? 'Remove?' : <Trash2 className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="truncate text-muted-foreground">{account.url}</p>
|
||||
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0" />
|
||||
{changingCompany ? (
|
||||
<>
|
||||
{companies.isLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<CompanyPicker
|
||||
companies={companies.data?.companies ?? []}
|
||||
value={account.companyId}
|
||||
disabled={edit.isPending}
|
||||
onChange={(companyId) => void run(edit.mutateAsync({ id: account.id, companyId }))}
|
||||
/>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={() => setChangingCompany(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="truncate">{account.companyName ?? `Company ${account.companyId ?? '?'}`}</span>
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={() => setChangingCompany(true)}>
|
||||
Change
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{account.isActive && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
{health?.ok ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
|
||||
) : (
|
||||
<TriangleAlert className="h-3.5 w-3.5 text-warning" />
|
||||
)}
|
||||
<span>{health?.ok ? `InvoiceShelf ${health.version ?? '?'}` : (health?.error ?? 'not checked yet')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{replacing ? (
|
||||
<div className="flex flex-col gap-3 rounded-lg border p-3">
|
||||
<CredentialFields cred={cred} autoFocus />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!cred.filled || edit.isPending}
|
||||
onClick={() => void run(edit.mutateAsync({ id: account.id, ...cred.payload }))}
|
||||
>
|
||||
{edit.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
cred.reset();
|
||||
setReplacing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplacing(true)}
|
||||
className="self-start text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Replace credential
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectionSection = () => {
|
||||
const { data, isLoading } = useInvoiceShelfAccounts();
|
||||
const { add } = useInvoiceShelfAccountActions();
|
||||
|
||||
const cred = useCredential();
|
||||
const [label, setLabel] = useState('');
|
||||
const [url, setUrl] = useState(DEFAULT_URL);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Populated only when the instance answers with more than one company — see parseAccountError.
|
||||
const [choices, setChoices] = useState<CompanyOption[]>([]);
|
||||
const [companyId, setCompanyId] = useState<number | null>(null);
|
||||
|
||||
const accounts = data?.accounts ?? [];
|
||||
const hasAccounts = accounts.length > 0;
|
||||
|
||||
const submit = async (withCompany: number | null) => {
|
||||
setError(null);
|
||||
if (!url.trim()) return setError('The InvoiceShelf URL is required');
|
||||
if (!cred.filled) {
|
||||
return setError(cred.mode === 'token' ? 'An API token is required' : 'An email and password are required');
|
||||
}
|
||||
|
||||
try {
|
||||
await add.mutateAsync({ label: label.trim(), url: url.trim(), companyId: withCompany, ...cred.payload });
|
||||
setLabel('');
|
||||
setUrl(DEFAULT_URL);
|
||||
cred.reset();
|
||||
setChoices([]);
|
||||
setCompanyId(null);
|
||||
} catch (err) {
|
||||
const parsed = parseAccountError(err);
|
||||
// A 409 is not a failure — the credential can act for several companies and one has to be picked. Keep
|
||||
// the form filled in and show the list rather than making them type it all again.
|
||||
if (parsed.needsChoice && parsed.companies.length) {
|
||||
setChoices(parsed.companies);
|
||||
setCompanyId(parsed.companies[0]?.id ?? null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setError(parsed.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto flex max-w-xl flex-col gap-4 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||||
<Building2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{hasAccounts ? 'InvoiceShelf accounts' : 'Connect InvoiceShelf'}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{hasAccounts
|
||||
? 'Officer stores each instance and its token encrypted, for this account only. One is in use at a time.'
|
||||
: 'Invoices needs an InvoiceShelf instance and a login before it can show anything.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLoading && accounts.map((account) => <AccountRow key={account.id} account={account} />)}
|
||||
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void submit(companyId);
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<p className="text-xs font-medium">{hasAccounts ? 'Add another account' : 'Add an account'}</p>
|
||||
<Field
|
||||
label="Label"
|
||||
value={label}
|
||||
onChange={setLabel}
|
||||
placeholder="Optional — defaults to the company name"
|
||||
hint="What the switcher calls this account. Two accounts cannot share a label; two can share a URL, a login and everything but the company."
|
||||
autoFocus={hasAccounts}
|
||||
/>
|
||||
<Field
|
||||
label="InvoiceShelf URL"
|
||||
value={url}
|
||||
onChange={setUrl}
|
||||
placeholder={DEFAULT_URL}
|
||||
hint={URL_HINT}
|
||||
autoFocus={!hasAccounts}
|
||||
/>
|
||||
<CredentialFields cred={cred} />
|
||||
|
||||
{choices.length > 0 && (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium">Company</span>
|
||||
<CompanyPicker companies={choices} value={companyId} onChange={setCompanyId} disabled={add.isPending} />
|
||||
<span className={HINT}>
|
||||
This login can act for more than one company. Whichever you pick is pinned to this account — add a
|
||||
second account for the other one.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm" disabled={add.isPending || (choices.length > 0 && companyId == null)}>
|
||||
{add.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
{add.isPending ? 'Verifying…' : hasAccounts ? 'Add account' : 'Connect'}
|
||||
</Button>
|
||||
{add.isPending && <span className={HINT}>Checking the instance and the credential…</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,348 @@
|
||||
import type { Column } from './components';
|
||||
import type { ConfirmState } from './ConfirmDialog';
|
||||
import type { Address, Customer } from './shared';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Filter, MoreHorizontal, Plus, Trash2, Users, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { EmptyState, ErrorState, Field, Pagination, SearchBox, SectionShell, SimpleTable } from './components';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { formatDate, formatMoney } from './format';
|
||||
import { invoicesCustomerPath, PAGE_SIZE } from './shared';
|
||||
import { useListFilters } from './useInvoicesSection';
|
||||
import { useCurrency, useCustomerStats, useResourceList, useResourceMutations } from './useInvoiceShelfData';
|
||||
|
||||
// Customers. Upstream splits the search into three separate fields rather than one box — display name,
|
||||
// contact name and phone are distinct API filters — so the toolbar search maps to display name and the
|
||||
// other two live behind the filter row, which is where upstream puts them too.
|
||||
|
||||
type Props = { onEdit: (id: number | 'new') => void };
|
||||
|
||||
export const CustomersListView = ({ onEdit }: Props) => {
|
||||
const { filters, patch, select, setPage, clearFilters } = useListFilters();
|
||||
const currency = useCurrency();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selection, setSelection] = useState<Set<number>>(new Set());
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
|
||||
const { rows, meta, isLoading, error } = useResourceList<Customer>('customers', {
|
||||
page: filters.page,
|
||||
limit: PAGE_SIZE,
|
||||
display_name: filters.search || undefined,
|
||||
// `status` and `from` carry contact name and phone here — one query-string slot per filter, and
|
||||
// customers have no status or date range to compete for them.
|
||||
contact_name: filters.status || undefined,
|
||||
phone: filters.fromDate || undefined,
|
||||
orderByField: 'created_at',
|
||||
orderBy: 'desc',
|
||||
});
|
||||
|
||||
const { remove, removeMany } = useResourceMutations('customers');
|
||||
const selected = rows.find((r) => r.id === filters.selected) ?? null;
|
||||
|
||||
const toggleRow = (id: number) =>
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
|
||||
|
||||
const columns: Column<Customer>[] = [
|
||||
{
|
||||
key: 'check',
|
||||
label: '',
|
||||
width: '36px',
|
||||
render: (row) => (
|
||||
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
|
||||
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
width: 'minmax(150px, 2fr)',
|
||||
render: (row) => (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{row.name}</div>
|
||||
{row.contact_name && <div className="truncate text-[10px] text-muted-foreground">{row.contact_name}</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
width: 'minmax(120px, 1.4fr)',
|
||||
render: (row) => <span className="truncate text-muted-foreground">{row.email ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
label: 'Phone',
|
||||
width: 'minmax(100px, 1fr)',
|
||||
render: (row) => row.phone ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'due_amount',
|
||||
label: 'Amount due',
|
||||
width: 'minmax(100px, 1fr)',
|
||||
align: 'right',
|
||||
render: (row) => formatMoney(row.due_amount, currency),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Added',
|
||||
width: 'minmax(90px, 0.9fr)',
|
||||
render: (row) => formatDate(row.created_at, row.formatted_created_at),
|
||||
},
|
||||
];
|
||||
|
||||
const rowMenu = (row: Customer) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => select(row.id)}>View</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Both go somewhere and change nothing, so they are anchors wearing a menu item — `asChild` hands
|
||||
Radix's item semantics to the `<Link>` rather than nesting one inside the other. Middle-click
|
||||
opens the filtered list in a tab, which is the point of the filter living in the URL. */}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={invoicesCustomerPath('invoices', row.id)}>Their invoices</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={invoicesCustomerPath('payments', row.id)}>Their payments</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onSelect={() =>
|
||||
setConfirm({
|
||||
title: `Delete ${row.name}?`,
|
||||
description:
|
||||
'InvoiceShelf refuses this while the customer still has invoices, estimates or payments — delete those first.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
remove.mutate(row.id);
|
||||
if (filters.selected === row.id) select(null);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} />;
|
||||
|
||||
const toolbar = (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
|
||||
/>
|
||||
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Display name…" />
|
||||
<Button
|
||||
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
>
|
||||
<Filter className="mr-1.5 h-3.5 w-3.5" />
|
||||
Filter
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{selection.size > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: `Delete ${selection.size} customers?`,
|
||||
description: 'Any that still have documents against them are refused.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
removeMany.mutate([...selection]);
|
||||
setSelection(new Set());
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete {selection.size}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New customer
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
Contact name
|
||||
<Input
|
||||
value={filters.status ?? ''}
|
||||
onChange={(e) => patch({ status: e.target.value || null })}
|
||||
className="h-8 w-40 text-xs"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
Phone
|
||||
<Input
|
||||
value={filters.fromDate ?? ''}
|
||||
onChange={(e) => patch({ from: e.target.value || null })}
|
||||
className="h-8 w-40 text-xs"
|
||||
/>
|
||||
</label>
|
||||
{filters.isFiltered && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SimpleTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(r) => r.id}
|
||||
selectedKey={filters.selected}
|
||||
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
|
||||
rowMenu={rowMenu}
|
||||
empty={
|
||||
isLoading ? (
|
||||
<EmptyState icon={Users} title="Loading…" />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={filters.isFiltered ? 'No customers match' : 'No customers yet'}
|
||||
hint={filters.isFiltered ? 'Try clearing the filters.' : 'Every invoice needs one — add the first.'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SectionShell>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="hidden w-[38%] min-w-[320px] shrink-0 lg:block">
|
||||
<CustomerDetail customer={selected} onEdit={() => onEdit(selected.id)} onClose={() => select(null)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomerDetail = ({
|
||||
customer,
|
||||
onEdit,
|
||||
onClose,
|
||||
}: {
|
||||
customer: Customer;
|
||||
onEdit: () => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const currency = useCurrency();
|
||||
const { stats } = useCustomerStats(customer.id);
|
||||
const chart = stats?.meta?.chartData;
|
||||
|
||||
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">{customer.name}</div>
|
||||
{customer.email && <div className="truncate text-[10px] text-muted-foreground">{customer.email}</div>}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={onEdit}>
|
||||
Edit
|
||||
</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 className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{chart && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Totals label="Sales" value={formatMoney(chart.salesTotal, currency)} />
|
||||
<Totals label="Receipts" value={formatMoney(chart.totalReceipts, currency)} />
|
||||
<Totals label="Expenses" value={formatMoney(chart.totalExpenses, currency)} />
|
||||
<Totals label="Net profit" value={formatMoney(chart.netProfit, currency)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 rounded-lg border border-border px-3 py-1">
|
||||
<Field label="Amount due">{formatMoney(customer.due_amount, currency)}</Field>
|
||||
{customer.contact_name && <Field label="Contact">{customer.contact_name}</Field>}
|
||||
{customer.phone && <Field label="Phone">{customer.phone}</Field>}
|
||||
{customer.website && <Field label="Website">{customer.website}</Field>}
|
||||
{customer.tax_id && <Field label="Tax ID">{customer.tax_id}</Field>}
|
||||
<Field label="Customer since">{formatDate(customer.created_at, customer.formatted_created_at)}</Field>
|
||||
</div>
|
||||
|
||||
<AddressCard title="Billing address" address={customer.billing} />
|
||||
<AddressCard title="Shipping address" address={customer.shipping} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Totals = ({ label, value }: { label: string; value: string }) => (
|
||||
<div className="rounded-lg border border-border p-2">
|
||||
<div className="text-[10px] text-muted-foreground">{label}</div>
|
||||
<div className="truncate text-sm font-semibold tabular-nums" title={value}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AddressCard = ({ title, address }: { title: string; address?: Address | null }) => {
|
||||
const lines = [
|
||||
address?.address_street_1,
|
||||
address?.address_street_2,
|
||||
[address?.city, address?.state, address?.zip].filter(Boolean).join(' '),
|
||||
address?.country?.name,
|
||||
].filter((l): l is string => Boolean(l && l.trim()));
|
||||
|
||||
if (!lines.length) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-border p-3">
|
||||
<div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{title}</div>
|
||||
<div className="whitespace-pre-line text-xs">{lines.join('\n')}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
import type { Estimate, Invoice } from './shared';
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import { ArrowDownRight, ArrowUpRight, CircleDollarSign, FileText, Receipt, Wallet } from 'lucide-react';
|
||||
import { toneFill } from '@/components/Data/tone';
|
||||
import { formatAmount, formatDate, formatMoney, relativeDue, toMajor } from './format';
|
||||
import { EmptyState, ErrorState, LoadingState, StatusBadge } from './components';
|
||||
import { invoicesRecordPath, invoicesSectionPath } from './shared';
|
||||
import { useSummary } from './useInvoiceShelfData';
|
||||
|
||||
// The /invoices landing section — InvoiceShelf's own dashboard, rebuilt.
|
||||
//
|
||||
// Every figure here comes from the single /summary call the nav already makes, so arriving on this section
|
||||
// costs no extra request. Upstream hands the chart over as five parallel arrays (months + four series)
|
||||
// rather than as rows, so they are zipped into recharts' row shape below.
|
||||
|
||||
export const DashboardView = () => {
|
||||
const { dashboard, currency, isLoading, error } = useSummary();
|
||||
|
||||
const chartRows = useMemo(() => {
|
||||
const chart = dashboard?.chart_data;
|
||||
if (!chart?.months?.length) return [];
|
||||
return chart.months.map((month, i) => ({
|
||||
month,
|
||||
// recharts needs major units or the axis reads in cents; the tooltip re-formats from these.
|
||||
// toMajor divides by 100, matching formatMoney — see the note on MINOR_PER_MAJOR in format.ts.
|
||||
sales: toMajor(chart.invoice_totals?.[i]),
|
||||
receipts: toMajor(chart.receipt_totals?.[i]),
|
||||
expenses: toMajor(chart.expense_totals?.[i]),
|
||||
net: toMajor(chart.net_income_totals?.[i]),
|
||||
}));
|
||||
}, [dashboard]);
|
||||
|
||||
if (error) return <ErrorState error={error} />;
|
||||
if (isLoading) return <LoadingState />;
|
||||
if (!dashboard) return <EmptyState title="No dashboard data" />;
|
||||
|
||||
const hasChart = chartRows.some((r) => r.sales || r.receipts || r.expenses || r.net);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={CircleDollarSign}
|
||||
tone={toneFill.warning}
|
||||
label="Amount due"
|
||||
value={formatMoney(dashboard.total_amount_due, currency)}
|
||||
hint={`${dashboard.total_invoice_count} invoices`}
|
||||
to={`${invoicesSectionPath('invoices')}?status=UNPAID`}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={FileText}
|
||||
tone={toneFill.info}
|
||||
label="Sales"
|
||||
value={formatMoney(dashboard.total_sales, currency)}
|
||||
to={invoicesSectionPath('invoices')}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Wallet}
|
||||
tone={toneFill.success}
|
||||
label="Receipts"
|
||||
value={formatMoney(dashboard.total_receipts, currency)}
|
||||
to={invoicesSectionPath('payments')}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Receipt}
|
||||
tone="bg-primary/15 text-primary"
|
||||
label="Net income"
|
||||
value={formatMoney(dashboard.total_net_income, currency)}
|
||||
hint={`${formatMoney(dashboard.total_expenses, currency)} expenses`}
|
||||
to={invoicesSectionPath('expenses')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasChart && (
|
||||
<div className="mt-4 rounded-xl border border-border p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium">Last 12 months</span>
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||
<LegendDot className="bg-info" label="Sales" />
|
||||
<LegendDot className="bg-success" label="Receipts" />
|
||||
<LegendDot className="bg-destructive" label="Expenses" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-56 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartRows} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||
<defs>
|
||||
<Gradient id="isSales" color="var(--info)" />
|
||||
<Gradient id="isReceipts" color="var(--success)" />
|
||||
<Gradient id="isExpenses" color="var(--destructive)" />
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 10 }} tickLine={false} axisLine={false} />
|
||||
<YAxis
|
||||
tick={{ fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={56}
|
||||
tickFormatter={(v: number) => compact(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ fontSize: 11, borderRadius: 8 }}
|
||||
// recharts types both arguments as possibly-undefined — a series can have gaps, and a
|
||||
// datum can carry no name — so neither may be annotated as required here.
|
||||
formatter={(value: number | undefined, name: string | undefined) => [
|
||||
formatAmount(Math.round((value ?? 0) * 100), currency),
|
||||
name ?? '',
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="sales"
|
||||
name="Sales"
|
||||
stroke="var(--info)"
|
||||
fill="url(#isSales)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="receipts"
|
||||
name="Receipts"
|
||||
stroke="var(--success)"
|
||||
fill="url(#isReceipts)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="expenses"
|
||||
name="Expenses"
|
||||
stroke="var(--destructive)"
|
||||
fill="url(#isExpenses)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
<RecentPanel
|
||||
title="Due invoices"
|
||||
empty="Nothing outstanding."
|
||||
rows={dashboard.recent_due_invoices ?? []}
|
||||
href={(row) => invoicesRecordPath('invoices', row.id)}
|
||||
render={(row: Invoice) => ({
|
||||
primary: row.invoice_number,
|
||||
secondary: row.customer?.name ?? '—',
|
||||
amount: formatMoney(row.due_amount, currency),
|
||||
status: row.overdue ? 'OVERDUE' : row.paid_status,
|
||||
note: relativeDue(row.due_date),
|
||||
})}
|
||||
/>
|
||||
<RecentPanel
|
||||
title="Recent estimates"
|
||||
empty="No estimates yet."
|
||||
rows={dashboard.recent_estimates ?? []}
|
||||
href={(row) => invoicesRecordPath('estimates', row.id)}
|
||||
render={(row: Estimate) => ({
|
||||
primary: row.estimate_number,
|
||||
secondary: row.customer?.name ?? '—',
|
||||
amount: formatMoney(row.total, currency),
|
||||
status: row.status,
|
||||
note: formatDate(row.estimate_date, row.formatted_estimate_date),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Gradient = ({ id, color }: { id: string; color: string }) => (
|
||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
);
|
||||
|
||||
const LegendDot = ({ className, label }: { className: string; label: string }) => (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${className}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
/** Axis labels only — a full currency render would not fit and the tooltip carries the exact figure. */
|
||||
const compact = (v: number): string => {
|
||||
const abs = Math.abs(v);
|
||||
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||
if (abs >= 1_000) return `${(v / 1_000).toFixed(0)}k`;
|
||||
return String(v);
|
||||
};
|
||||
|
||||
// Every tile here only ever *goes somewhere* — no tile mutates anything — so they are anchors, not
|
||||
// buttons. That is what makes the figures middle-clickable into a new tab and copyable as a link,
|
||||
// which is the whole point of the section reading its filter out of the query string.
|
||||
const MetricCard = ({
|
||||
icon: Icon,
|
||||
tone,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
to,
|
||||
}: {
|
||||
icon: typeof Wallet;
|
||||
tone: string;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
to: string;
|
||||
}) => (
|
||||
<Link
|
||||
to={to}
|
||||
className="flex flex-col gap-2 rounded-xl border border-border p-3 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`flex h-7 w-7 items-center justify-center rounded-lg ${tone}`}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<div className="truncate text-lg font-semibold tabular-nums" title={value}>
|
||||
{value}
|
||||
</div>
|
||||
{hint && <div className="truncate text-[10px] text-muted-foreground">{hint}</div>}
|
||||
</Link>
|
||||
);
|
||||
|
||||
type RecentRender = { primary: string; secondary: string; amount: string; status: string; note: string };
|
||||
|
||||
const RecentPanel = <T extends { id: number }>({
|
||||
title,
|
||||
rows,
|
||||
render,
|
||||
href,
|
||||
empty,
|
||||
}: {
|
||||
title: string;
|
||||
rows: T[];
|
||||
render: (row: T) => RecentRender;
|
||||
href: (row: T) => string;
|
||||
empty: string;
|
||||
}) => (
|
||||
<div className="rounded-xl border border-border">
|
||||
<div className="border-b border-border px-3 py-2 text-xs font-medium">{title}</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">{empty}</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{rows.map((row) => {
|
||||
const r = render(row);
|
||||
return (
|
||||
<Link
|
||||
key={row.id}
|
||||
to={href(row)}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-xs font-medium">{r.primary}</span>
|
||||
<StatusBadge value={r.status} />
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{r.secondary}
|
||||
{r.note && ` · ${r.note}`}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs tabular-nums">{r.amount}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export { ArrowDownRight, ArrowUpRight };
|
||||
@@ -0,0 +1,732 @@
|
||||
import type { DocumentItem, DocumentTax, Estimate, Invoice, RecurringInvoice, TaxType } from './shared';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Trash2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ErrorState, LoadingState } from './components';
|
||||
import { formatMoney, fromMajor, toInputDate, toMajor } from './format';
|
||||
import { FREQUENCY_PRESETS } from './shared';
|
||||
import {
|
||||
useCurrency,
|
||||
useLookups,
|
||||
useNextNumber,
|
||||
useResourceDetail,
|
||||
useResourceMutations,
|
||||
useTemplates,
|
||||
} from './useInvoiceShelfData';
|
||||
|
||||
// The invoice / estimate / recurring-invoice form. One component for all three: they share every hard part
|
||||
// — the line-item table, the discounts, the taxes and the totals — and differ only in a header strip.
|
||||
// An invoice has a number and two dates; an estimate the same with different labels; a recurring invoice
|
||||
// has no number at all (each generated invoice takes the next one) and carries a schedule instead of dates.
|
||||
//
|
||||
// ── the arithmetic ───────────────────────────────────────────────────────────────────────────────
|
||||
// Ported from upstream's `use-document-calculations.ts`, and the rounding points are load-bearing because
|
||||
// the server re-validates the totals we send:
|
||||
//
|
||||
// line sub_total = round(price × quantity) (price already in minor units)
|
||||
// line discount = percentage → round(|sub_total| × d / 100)
|
||||
// fixed → min(round(d × 100), |sub_total|) ← d is entered in MAJOR units
|
||||
// line total = sub_total − discount
|
||||
// document sub_total = Σ line totals
|
||||
// tax base = document sub_total − document discount
|
||||
// compound tax base = tax base + simple taxes
|
||||
// total = tax_included ? tax base : tax base + total tax
|
||||
//
|
||||
// Every value posted is an integer of minor units. Amounts are shown to the user in major units and
|
||||
// converted at the edges only — a float never survives past a keystroke.
|
||||
|
||||
type Resource = 'invoices' | 'estimates' | 'recurring-invoices';
|
||||
|
||||
type Line = {
|
||||
key: string;
|
||||
item_id: number | null;
|
||||
name: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
/** Minor units. */
|
||||
price: number;
|
||||
discount: number;
|
||||
discount_type: 'fixed' | 'percentage';
|
||||
tax_type_ids: number[];
|
||||
};
|
||||
|
||||
type Props = { resource: Resource; id: number | 'new'; onClose: () => void };
|
||||
|
||||
const KIND_NOUN: Record<Resource, string> = {
|
||||
invoices: 'invoice',
|
||||
estimates: 'estimate',
|
||||
'recurring-invoices': 'recurring invoice',
|
||||
};
|
||||
|
||||
const newLine = (index: number): Line => ({
|
||||
key: `new-${index}`,
|
||||
item_id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
quantity: 1,
|
||||
price: 0,
|
||||
discount: 0,
|
||||
discount_type: 'fixed',
|
||||
tax_type_ids: [],
|
||||
});
|
||||
|
||||
export const DocumentEditor = ({ resource, id, onClose }: Props) => {
|
||||
const isNew = id === 'new';
|
||||
const isRecurring = resource === 'recurring-invoices';
|
||||
const isInvoice = resource === 'invoices';
|
||||
const currency = useCurrency();
|
||||
const { customers, items, taxTypes } = useLookups();
|
||||
// A recurring invoice renders through the invoice templates — it has no gallery of its own.
|
||||
const templates = useTemplates(isRecurring ? 'invoices' : resource);
|
||||
const nextNumber = useNextNumber(isInvoice ? 'invoice' : 'estimate', isNew && !isRecurring);
|
||||
const { record, isLoading, error } = useResourceDetail<Invoice & Estimate & RecurringInvoice>(
|
||||
resource,
|
||||
isNew ? null : (id as number),
|
||||
);
|
||||
const { create, update } = useResourceMutations(resource, isRecurring ? 'recurring invoices' : resource);
|
||||
|
||||
const [customerId, setCustomerId] = useState<number | null>(null);
|
||||
const [number, setNumber] = useState('');
|
||||
const [primaryDate, setPrimaryDate] = useState(() => toInputDate(new Date().toISOString()));
|
||||
const [secondaryDate, setSecondaryDate] = useState('');
|
||||
const [reference, setReference] = useState('');
|
||||
const [template, setTemplate] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [lines, setLines] = useState<Line[]>([newLine(0)]);
|
||||
const [discount, setDiscount] = useState(0);
|
||||
const [discountType, setDiscountType] = useState<'fixed' | 'percentage'>('fixed');
|
||||
const [docTaxIds, setDocTaxIds] = useState<number[]>([]);
|
||||
const [taxPerItem, setTaxPerItem] = useState<'YES' | 'NO'>('NO');
|
||||
const [taxIncluded, setTaxIncluded] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Recurring-only. `frequency` is a raw cron expression upstream; a schedule authored outside the preset
|
||||
// list keeps its own expression and is offered back as an extra option rather than silently rewritten.
|
||||
const [frequency, setFrequency] = useState<string>(FREQUENCY_PRESETS[2].cron);
|
||||
const [limitBy, setLimitBy] = useState<'NONE' | 'COUNT' | 'DATE'>('NONE');
|
||||
const [limitCount, setLimitCount] = useState(1);
|
||||
const [limitDate, setLimitDate] = useState('');
|
||||
const [sendAutomatically, setSendAutomatically] = useState(false);
|
||||
const [recurringStatus, setRecurringStatus] = useState<'ACTIVE' | 'ON_HOLD'>('ACTIVE');
|
||||
|
||||
// Seed the form once, from either the loaded record or the reserved next number. Guarded by `loaded`
|
||||
// rather than by a dependency list: react-query re-delivers `record` on every background refetch, and
|
||||
// without the guard a refetch mid-edit would silently discard what the user had typed.
|
||||
useEffect(() => {
|
||||
if (loaded) return;
|
||||
|
||||
if (isNew) {
|
||||
// A recurring invoice has no number of its own, so it does not wait on one.
|
||||
if (!isRecurring && !nextNumber) return;
|
||||
if (nextNumber) setNumber(nextNumber);
|
||||
setTemplate(templates[0]?.name ?? (isInvoice || isRecurring ? 'invoice1' : 'estimate1'));
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!record) return;
|
||||
setCustomerId(record.customer_id ?? null);
|
||||
setNumber(isInvoice ? (record.invoice_number ?? '') : (record.estimate_number ?? ''));
|
||||
if (isRecurring) {
|
||||
setPrimaryDate(toInputDate(record.starts_at));
|
||||
setFrequency(record.frequency ?? FREQUENCY_PRESETS[2].cron);
|
||||
setLimitBy(record.limit_by === 'COUNT' || record.limit_by === 'DATE' ? record.limit_by : 'NONE');
|
||||
setLimitCount(record.limit_count ?? 1);
|
||||
setLimitDate(toInputDate(record.limit_date));
|
||||
setSendAutomatically(Boolean(record.send_automatically));
|
||||
setRecurringStatus(record.status === 'ON_HOLD' ? 'ON_HOLD' : 'ACTIVE');
|
||||
} else {
|
||||
setPrimaryDate(toInputDate(isInvoice ? record.invoice_date : record.estimate_date));
|
||||
setSecondaryDate(toInputDate(isInvoice ? record.due_date : record.expiry_date));
|
||||
}
|
||||
setReference(record.reference_number ?? '');
|
||||
setTemplate(record.template_name ?? templates[0]?.name ?? '');
|
||||
setNotes(record.notes ?? '');
|
||||
setTaxPerItem(record.tax_per_item === 'YES' ? 'YES' : 'NO');
|
||||
setTaxIncluded(Boolean((record as { tax_included?: boolean }).tax_included));
|
||||
setDiscountType(record.discount_type === 'percentage' ? 'percentage' : 'fixed');
|
||||
// A fixed document discount is stored in minor units but entered in major ones; a percentage is a
|
||||
// plain number either way.
|
||||
setDiscount(record.discount_type === 'percentage' ? Number(record.discount) : toMajor(record.discount_val));
|
||||
setDocTaxIds((record.taxes ?? []).map((t) => t.tax_type_id).filter((t): t is number => t != null));
|
||||
setLines(
|
||||
(record.items ?? []).map((line, i) => ({
|
||||
key: `row-${line.id ?? i}`,
|
||||
item_id: line.item_id ?? null,
|
||||
name: line.name,
|
||||
description: line.description ?? '',
|
||||
quantity: Number(line.quantity) || 0,
|
||||
price: Number(line.price) || 0,
|
||||
discount: line.discount_type === 'percentage' ? Number(line.discount) : toMajor(line.discount_val),
|
||||
discount_type: line.discount_type === 'percentage' ? 'percentage' : 'fixed',
|
||||
tax_type_ids: (line.taxes ?? []).map((t) => t.tax_type_id).filter((t): t is number => t != null),
|
||||
})),
|
||||
);
|
||||
setLoaded(true);
|
||||
}, [record, isNew, nextNumber, templates, isInvoice, isRecurring, loaded]);
|
||||
|
||||
const totals = useMemo(
|
||||
() => computeTotals({ lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes }),
|
||||
[lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes],
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} />;
|
||||
if (!isNew && isLoading) return <LoadingState />;
|
||||
|
||||
const patchLine = (key: string, changes: Partial<Line>) =>
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...changes } : l)));
|
||||
|
||||
const pickItem = (key: string, itemId: number) => {
|
||||
const source = items.find((i) => i.id === itemId);
|
||||
if (!source) return;
|
||||
patchLine(key, {
|
||||
item_id: source.id,
|
||||
name: source.name,
|
||||
description: source.description ?? '',
|
||||
price: Number(source.price) || 0,
|
||||
});
|
||||
};
|
||||
|
||||
const canSave =
|
||||
customerId != null &&
|
||||
(isRecurring || number.trim() !== '') &&
|
||||
primaryDate !== '' &&
|
||||
template !== '' &&
|
||||
lines.some((l) => l.name.trim());
|
||||
|
||||
const save = () => {
|
||||
// The three resources agree on everything below the header, and disagree on nothing but it.
|
||||
const header = isRecurring
|
||||
? {
|
||||
starts_at: primaryDate,
|
||||
frequency,
|
||||
limit_by: limitBy,
|
||||
limit_count: limitBy === 'COUNT' ? limitCount : null,
|
||||
limit_date: limitBy === 'DATE' ? limitDate : null,
|
||||
send_automatically: sendAutomatically,
|
||||
status: recurringStatus,
|
||||
}
|
||||
: {
|
||||
[isInvoice ? 'invoice_date' : 'estimate_date']: primaryDate,
|
||||
[isInvoice ? 'due_date' : 'expiry_date']: secondaryDate || null,
|
||||
[isInvoice ? 'invoice_number' : 'estimate_number']: number.trim(),
|
||||
};
|
||||
|
||||
const payload = {
|
||||
...header,
|
||||
customer_id: customerId,
|
||||
reference_number: reference || null,
|
||||
template_name: template,
|
||||
notes: notes || null,
|
||||
currency_id: record?.currency_id ?? currency?.id ?? null,
|
||||
exchange_rate: 1,
|
||||
tax_per_item: taxPerItem,
|
||||
discount_per_item: lines.some((l) => l.discount) ? 'YES' : 'NO',
|
||||
// The raw figure the user typed; `discount_val` alongside it is the resolved minor-unit amount.
|
||||
discount,
|
||||
discount_type: discountType,
|
||||
discount_val: totals.documentDiscount,
|
||||
sub_total: totals.subTotal,
|
||||
total: totals.total,
|
||||
tax: totals.totalTax,
|
||||
taxes: taxPerItem === 'NO' ? docTaxIds.map((tid) => taxLine(tid, taxTypes, totals.taxBase)) : [],
|
||||
items: lines
|
||||
.filter((l) => l.name.trim())
|
||||
.map((l) => {
|
||||
const line = computeLine(l, taxTypes, taxPerItem);
|
||||
return {
|
||||
item_id: l.item_id,
|
||||
name: l.name.trim(),
|
||||
description: l.description || null,
|
||||
quantity: l.quantity,
|
||||
price: l.price,
|
||||
discount: l.discount,
|
||||
discount_type: l.discount_type,
|
||||
discount_val: line.discountVal,
|
||||
tax: line.tax,
|
||||
total: line.total,
|
||||
taxes: taxPerItem === 'YES' ? l.tax_type_ids.map((tid) => taxLine(tid, taxTypes, line.total)) : [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
if (isNew) create.mutate(payload, { onSuccess: onClose });
|
||||
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
|
||||
};
|
||||
|
||||
const busy = create.isPending || update.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
|
||||
<div className="min-w-0 flex-1 text-xs font-semibold">
|
||||
{isNew ? `New ${KIND_NOUN[resource]}` : `Edit ${number || KIND_NOUN[resource]}`}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 text-xs" onClick={save} disabled={!canSave || busy}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</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 className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Labelled label="Customer">
|
||||
<select
|
||||
value={customerId ?? ''}
|
||||
onChange={(ev) => setCustomerId(ev.target.value ? Number(ev.target.value) : null)}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Labelled>
|
||||
{!isRecurring && (
|
||||
<Labelled label={isInvoice ? 'Invoice number' : 'Estimate number'}>
|
||||
<Input value={number} onChange={(ev) => setNumber(ev.target.value)} className="h-8 text-xs" />
|
||||
</Labelled>
|
||||
)}
|
||||
<Labelled label={isRecurring ? 'Starts on' : isInvoice ? 'Invoice date' : 'Estimate date'}>
|
||||
<Input
|
||||
type="date"
|
||||
value={primaryDate}
|
||||
onChange={(ev) => setPrimaryDate(ev.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Labelled>
|
||||
{!isRecurring && (
|
||||
<Labelled label={isInvoice ? 'Due date' : 'Expiry date'}>
|
||||
<Input
|
||||
type="date"
|
||||
value={secondaryDate}
|
||||
onChange={(ev) => setSecondaryDate(ev.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Labelled>
|
||||
)}
|
||||
|
||||
{isRecurring && (
|
||||
<>
|
||||
<Labelled label="Repeats">
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={(ev) => setFrequency(ev.target.value)}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
{FREQUENCY_PRESETS.map((f) => (
|
||||
<option key={f.cron} value={f.cron}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
{!FREQUENCY_PRESETS.some((f) => f.cron === frequency) && (
|
||||
<option value={frequency}>Custom ({frequency})</option>
|
||||
)}
|
||||
</select>
|
||||
</Labelled>
|
||||
<Labelled label="Ends">
|
||||
<select
|
||||
value={limitBy}
|
||||
onChange={(ev) => setLimitBy(ev.target.value as 'NONE' | 'COUNT' | 'DATE')}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="NONE">Never</option>
|
||||
<option value="COUNT">After a number of invoices</option>
|
||||
<option value="DATE">On a date</option>
|
||||
</select>
|
||||
</Labelled>
|
||||
{limitBy === 'COUNT' && (
|
||||
<Labelled label="How many">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={limitCount}
|
||||
onChange={(ev) => setLimitCount(Number(ev.target.value))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Labelled>
|
||||
)}
|
||||
{limitBy === 'DATE' && (
|
||||
<Labelled label="Until">
|
||||
<Input
|
||||
type="date"
|
||||
value={limitDate}
|
||||
onChange={(ev) => setLimitDate(ev.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Labelled>
|
||||
)}
|
||||
<Labelled label="Status">
|
||||
<select
|
||||
value={recurringStatus}
|
||||
onChange={(ev) => setRecurringStatus(ev.target.value as 'ACTIVE' | 'ON_HOLD')}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="ON_HOLD">On hold</option>
|
||||
</select>
|
||||
</Labelled>
|
||||
</>
|
||||
)}
|
||||
<Labelled label="Reference">
|
||||
<Input value={reference} onChange={(ev) => setReference(ev.target.value)} className="h-8 text-xs" />
|
||||
</Labelled>
|
||||
<Labelled label="Template">
|
||||
{/* Only the names: each template also carries a `path` that is a full base64-encoded PNG
|
||||
preview, several hundred KB apiece, and nothing here needs to render one. */}
|
||||
<select
|
||||
value={template}
|
||||
onChange={(ev) => setTemplate(ev.target.value)}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
{templates.map((t) => (
|
||||
<option key={t.name} value={t.name}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
{template && !templates.some((t) => t.name === template) && <option value={template}>{template}</option>}
|
||||
</select>
|
||||
</Labelled>
|
||||
<Labelled label="Tax">
|
||||
<select
|
||||
value={taxPerItem}
|
||||
onChange={(ev) => setTaxPerItem(ev.target.value as 'YES' | 'NO')}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="NO">On the whole document</option>
|
||||
<option value="YES">Per line</option>
|
||||
</select>
|
||||
</Labelled>
|
||||
<Labelled label="Prices">
|
||||
<select
|
||||
value={taxIncluded ? 'incl' : 'excl'}
|
||||
onChange={(ev) => setTaxIncluded(ev.target.value === 'incl')}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="excl">Tax exclusive</option>
|
||||
<option value="incl">Tax inclusive</option>
|
||||
</select>
|
||||
</Labelled>
|
||||
</div>
|
||||
|
||||
{isRecurring && (
|
||||
<label className="mt-3 flex items-center gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={sendAutomatically}
|
||||
onCheckedChange={(checked) => setSendAutomatically(checked === true)}
|
||||
/>
|
||||
Email each generated invoice to the customer automatically
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="mt-4 rounded-lg border border-border">
|
||||
<div className="grid grid-cols-[1fr_70px_110px_110px_110px_32px] items-center gap-2 border-b border-border px-2 py-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span>Item</span>
|
||||
<span className="text-right">Qty</span>
|
||||
<span className="text-right">Price</span>
|
||||
<span className="text-right">Discount</span>
|
||||
<span className="text-right">Total</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{lines.map((line) => {
|
||||
const computed = computeLine(line, taxTypes, taxPerItem);
|
||||
return (
|
||||
<div key={line.key} className="border-b border-border/40 px-2 py-2">
|
||||
<div className="grid grid-cols-[1fr_70px_110px_110px_110px_32px] items-start gap-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={line.name}
|
||||
onChange={(ev) => patchLine(line.key, { name: ev.target.value, item_id: null })}
|
||||
placeholder="Description of work"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<select
|
||||
value=""
|
||||
onChange={(ev) => ev.target.value && pickItem(line.key, Number(ev.target.value))}
|
||||
title="Fill from the item catalogue"
|
||||
className="h-8 w-8 shrink-0 rounded-md border border-input bg-background text-xs"
|
||||
>
|
||||
<option value="">+</option>
|
||||
{items.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Input
|
||||
value={line.description}
|
||||
onChange={(ev) => patchLine(line.key, { description: ev.target.value })}
|
||||
placeholder="Notes (optional)"
|
||||
className="h-7 text-[11px]"
|
||||
/>
|
||||
{taxPerItem === 'YES' && (
|
||||
<TaxPicker
|
||||
taxTypes={taxTypes}
|
||||
selected={line.tax_type_ids}
|
||||
onChange={(ids) => patchLine(line.key, { tax_type_ids: ids })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
value={line.quantity}
|
||||
onChange={(ev) => patchLine(line.key, { quantity: Number(ev.target.value) })}
|
||||
className="h-8 text-right text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={toMajor(line.price)}
|
||||
onChange={(ev) => patchLine(line.key, { price: fromMajor(ev.target.value) })}
|
||||
className="h-8 text-right text-xs"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={line.discount}
|
||||
onChange={(ev) => patchLine(line.key, { discount: Number(ev.target.value) })}
|
||||
className="h-8 text-right text-xs"
|
||||
/>
|
||||
<select
|
||||
value={line.discount_type}
|
||||
onChange={(ev) =>
|
||||
patchLine(line.key, { discount_type: ev.target.value as 'fixed' | 'percentage' })
|
||||
}
|
||||
className="h-8 w-9 shrink-0 rounded-md border border-input bg-background text-xs"
|
||||
>
|
||||
<option value="fixed">{currency?.symbol ?? '#'}</option>
|
||||
<option value="percentage">%</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="pt-2 text-right text-xs tabular-nums">{formatMoney(computed.total, currency)}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground"
|
||||
disabled={lines.length === 1}
|
||||
onClick={() => setLines((prev) => prev.filter((l) => l.key !== line.key))}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="px-2 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setLines((prev) => [...prev, newLine(prev.length)])}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
<div>
|
||||
<Labelled label="Notes">
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(ev) => setNotes(ev.target.value)}
|
||||
rows={6}
|
||||
className="text-xs"
|
||||
placeholder="Shown on the document."
|
||||
/>
|
||||
</Labelled>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<TotalRow label="Subtotal" value={formatMoney(totals.subTotal, currency)} />
|
||||
|
||||
<div className="flex items-center justify-between gap-2 py-1 text-xs">
|
||||
<span className="text-muted-foreground">Discount</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={discount}
|
||||
onChange={(ev) => setDiscount(Number(ev.target.value))}
|
||||
className="h-7 w-24 text-right text-xs"
|
||||
/>
|
||||
<select
|
||||
value={discountType}
|
||||
onChange={(ev) => setDiscountType(ev.target.value as 'fixed' | 'percentage')}
|
||||
className="h-7 w-10 rounded-md border border-input bg-background text-xs"
|
||||
>
|
||||
<option value="fixed">{currency?.symbol ?? '#'}</option>
|
||||
<option value="percentage">%</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{taxPerItem === 'NO' && (
|
||||
<div className="py-1">
|
||||
<div className="mb-1 text-[10px] text-muted-foreground">Taxes</div>
|
||||
<TaxPicker taxTypes={taxTypes} selected={docTaxIds} onChange={setDocTaxIds} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TotalRow label="Tax" value={formatMoney(totals.totalTax, currency)} />
|
||||
<div className="mt-1 border-t border-border pt-1">
|
||||
<TotalRow label="Total" value={formatMoney(totals.total, currency)} strong />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Labelled = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
{label}
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
const TotalRow = ({ label, value, strong }: { label: string; value: string; strong?: boolean }) => (
|
||||
<div className="flex items-baseline justify-between gap-3 py-1 text-xs">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className={`tabular-nums ${strong ? 'text-sm font-semibold' : ''}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Multi-select over the company's tax types, rendered as toggles because there are only ever a handful. */
|
||||
const TaxPicker = ({
|
||||
taxTypes,
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
taxTypes: TaxType[];
|
||||
selected: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}) => {
|
||||
if (!taxTypes.length) return <span className="text-[10px] text-muted-foreground">No tax types defined.</span>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{taxTypes.map((t) => {
|
||||
const on = selected.includes(t.id);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => onChange(on ? selected.filter((i) => i !== t.id) : [...selected, t.id])}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] ring-1 ring-inset transition-colors ${
|
||||
on ? 'bg-primary/10 text-primary ring-primary/30' : 'text-muted-foreground ring-border hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{t.name} {Number(t.percent)}%
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── arithmetic ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type LineTotals = { subTotal: number; discountVal: number; total: number; tax: number };
|
||||
|
||||
function computeLine(line: Line, taxTypes: TaxType[], taxPerItem: 'YES' | 'NO'): LineTotals {
|
||||
const subTotal = Math.round(line.price * (Number(line.quantity) || 0));
|
||||
const abs = Math.abs(subTotal);
|
||||
const discountVal =
|
||||
line.discount_type === 'percentage'
|
||||
? Math.round((abs * (Number(line.discount) || 0)) / 100)
|
||||
: Math.min(fromMajor(line.discount), abs);
|
||||
const total = subTotal - discountVal;
|
||||
|
||||
const tax =
|
||||
taxPerItem === 'YES'
|
||||
? line.tax_type_ids.reduce(
|
||||
(sum, tid) =>
|
||||
sum +
|
||||
percentOf(
|
||||
total,
|
||||
taxTypes.find((t) => t.id === tid),
|
||||
),
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return { subTotal, discountVal, total, tax };
|
||||
}
|
||||
|
||||
const percentOf = (base: number, taxType?: TaxType): number =>
|
||||
taxType ? Math.round((base * (Number(taxType.percent) || 0)) / 100) : 0;
|
||||
|
||||
type TotalsInput = {
|
||||
lines: Line[];
|
||||
discount: number;
|
||||
discountType: 'fixed' | 'percentage';
|
||||
docTaxIds: number[];
|
||||
taxPerItem: 'YES' | 'NO';
|
||||
taxIncluded: boolean;
|
||||
taxTypes: TaxType[];
|
||||
};
|
||||
|
||||
function computeTotals(input: TotalsInput) {
|
||||
const { lines, discount, discountType, docTaxIds, taxPerItem, taxIncluded, taxTypes } = input;
|
||||
|
||||
const computed = lines.map((l) => computeLine(l, taxTypes, taxPerItem));
|
||||
const subTotal = computed.reduce((sum, c) => sum + c.total, 0);
|
||||
|
||||
const documentDiscount =
|
||||
discountType === 'percentage'
|
||||
? Math.round((Math.abs(subTotal) * (Number(discount) || 0)) / 100)
|
||||
: Math.min(fromMajor(discount), Math.abs(subTotal));
|
||||
|
||||
const taxBase = subTotal - documentDiscount;
|
||||
|
||||
let totalTax: number;
|
||||
if (taxPerItem === 'YES') {
|
||||
totalTax = computed.reduce((sum, c) => sum + c.tax, 0);
|
||||
} else {
|
||||
const chosen = docTaxIds.map((tid) => taxTypes.find((t) => t.id === tid)).filter((t): t is TaxType => Boolean(t));
|
||||
const simple = chosen.filter((t) => !t.compound_tax).reduce((sum, t) => sum + percentOf(taxBase, t), 0);
|
||||
// A compound tax is charged on the base plus the simple taxes already added — the order matters and
|
||||
// reversing it silently under-charges.
|
||||
const compound = chosen.filter((t) => t.compound_tax).reduce((sum, t) => sum + percentOf(taxBase + simple, t), 0);
|
||||
totalTax = simple + compound;
|
||||
}
|
||||
|
||||
return {
|
||||
subTotal,
|
||||
documentDiscount,
|
||||
taxBase,
|
||||
totalTax,
|
||||
total: taxIncluded ? taxBase : taxBase + totalTax,
|
||||
};
|
||||
}
|
||||
|
||||
/** The tax row shape upstream persists alongside a document or a line. */
|
||||
function taxLine(taxTypeId: number, taxTypes: TaxType[], base: number): Partial<DocumentTax> & { tax_type_id: number } {
|
||||
const t = taxTypes.find((x) => x.id === taxTypeId);
|
||||
return {
|
||||
tax_type_id: taxTypeId,
|
||||
name: t?.name,
|
||||
percent: Number(t?.percent) || 0,
|
||||
compound_tax: t?.compound_tax ?? false,
|
||||
amount: percentOf(base, t),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import type { Column } from './components';
|
||||
import type { ConfirmState } from './ConfirmDialog';
|
||||
import type { SendTarget } from './SendDocumentDialog';
|
||||
import type { Estimate } from './shared';
|
||||
import { useState } from 'react';
|
||||
import { Check, Copy, FileSpreadsheet, FileUp, Filter, MoreHorizontal, Plus, Send, Trash2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Pagination,
|
||||
SearchBox,
|
||||
SectionShell,
|
||||
SimpleTable,
|
||||
StatusBadge,
|
||||
StatusTabs,
|
||||
} from './components';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { SendDocumentDialog } from './SendDocumentDialog';
|
||||
import { PdfPane } from './PdfPane';
|
||||
import { formatDate, formatMoney } from './format';
|
||||
import { ESTIMATE_TABS, PAGE_SIZE } from './shared';
|
||||
import { useListFilters } from './useInvoicesSection';
|
||||
import {
|
||||
useCurrency,
|
||||
useDocumentAction,
|
||||
useLookups,
|
||||
useResourceList,
|
||||
useResourceMutations,
|
||||
} from './useInvoiceShelfData';
|
||||
|
||||
// Estimates — upstream's EstimateIndexView. Same skeleton as the invoices list, different verbs: an estimate
|
||||
// is accepted or rejected rather than paid, and its terminal move is converting into an invoice.
|
||||
//
|
||||
// Convert is one-way and upstream refuses it on a REJECTED estimate, so the item is hidden in that state
|
||||
// rather than offered and rejected. After a successful convert the new invoice exists alongside this
|
||||
// estimate; the toast says so, because nothing on this screen would otherwise show where it went.
|
||||
|
||||
type Props = { onEdit: (id: number | 'new') => void; onConverted: (invoiceId: number) => void };
|
||||
|
||||
export const EstimatesListView = ({ onEdit, onConverted }: Props) => {
|
||||
const { filters, patch, select, setPage, clearFilters } = useListFilters();
|
||||
const currency = useCurrency();
|
||||
const { customers } = useLookups();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selection, setSelection] = useState<Set<number>>(new Set());
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
const [sendTarget, setSendTarget] = useState<SendTarget | null>(null);
|
||||
|
||||
const { rows, meta, isLoading, error } = useResourceList<Estimate>('estimates', {
|
||||
page: filters.page,
|
||||
limit: PAGE_SIZE,
|
||||
estimate_number: filters.search || undefined,
|
||||
status: filters.status || undefined,
|
||||
customer_id: filters.customerId || undefined,
|
||||
from_date: filters.fromDate || undefined,
|
||||
to_date: filters.toDate || undefined,
|
||||
orderByField: 'created_at',
|
||||
orderBy: 'desc',
|
||||
});
|
||||
|
||||
const { remove, removeMany } = useResourceMutations('estimates');
|
||||
const action = useDocumentAction('estimates');
|
||||
|
||||
const selected = rows.find((r) => r.id === filters.selected) ?? null;
|
||||
|
||||
const toggleRow = (id: number) =>
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
|
||||
|
||||
const columns: Column<Estimate>[] = [
|
||||
{
|
||||
key: 'check',
|
||||
label: '',
|
||||
width: '36px',
|
||||
render: (row) => (
|
||||
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
|
||||
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'estimate_date',
|
||||
label: 'Date',
|
||||
width: 'minmax(90px, 0.8fr)',
|
||||
render: (row) => formatDate(row.estimate_date, row.formatted_estimate_date),
|
||||
},
|
||||
{
|
||||
key: 'estimate_number',
|
||||
label: 'Number',
|
||||
width: 'minmax(90px, 0.8fr)',
|
||||
render: (row) => <span className="font-medium">{row.estimate_number}</span>,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Customer',
|
||||
width: 'minmax(120px, 1.4fr)',
|
||||
render: (row) => <span className="truncate">{row.customer?.name ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
width: 'minmax(90px, 0.9fr)',
|
||||
render: (row) => <StatusBadge value={row.status} />,
|
||||
},
|
||||
{
|
||||
key: 'total',
|
||||
label: 'Total',
|
||||
width: 'minmax(100px, 1fr)',
|
||||
align: 'right',
|
||||
render: (row) => formatMoney(row.total, currency),
|
||||
},
|
||||
];
|
||||
|
||||
const setStatus = (row: Estimate, status: string, label: string) =>
|
||||
setConfirm({
|
||||
title: `Mark ${row.estimate_number} as ${label.toLowerCase()}?`,
|
||||
description: 'This changes the status only. Nothing is emailed to the customer.',
|
||||
confirmLabel: `Mark as ${label.toLowerCase()}`,
|
||||
onConfirm: () => action.mutate({ id: row.id, action: 'status', payload: { status } }),
|
||||
});
|
||||
|
||||
const rowMenu = (row: Estimate) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => select(row.id)}>View PDF</DropdownMenuItem>
|
||||
|
||||
{row.status === 'DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
setSendTarget({
|
||||
resource: 'estimates',
|
||||
id: row.id,
|
||||
number: row.estimate_number,
|
||||
customerName: row.customer?.name ?? '',
|
||||
customerEmail: row.customer?.email ?? null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Send className="mr-2 h-3.5 w-3.5" />
|
||||
Send estimate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(row.status === 'SENT' || row.status === 'VIEWED') && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
setSendTarget({
|
||||
resource: 'estimates',
|
||||
id: row.id,
|
||||
number: row.estimate_number,
|
||||
customerName: row.customer?.name ?? '',
|
||||
customerEmail: row.customer?.email ?? null,
|
||||
resend: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Send className="mr-2 h-3.5 w-3.5" />
|
||||
Resend estimate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{row.status !== 'SENT' && (
|
||||
<DropdownMenuItem onSelect={() => setStatus(row, 'SENT', 'Sent')}>Mark as sent</DropdownMenuItem>
|
||||
)}
|
||||
{row.status !== 'ACCEPTED' && row.status !== 'REJECTED' && (
|
||||
<DropdownMenuItem onSelect={() => setStatus(row, 'ACCEPTED', 'Accepted')}>
|
||||
<Check className="mr-2 h-3.5 w-3.5" />
|
||||
Mark as accepted
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{row.status !== 'REJECTED' && row.status !== 'ACCEPTED' && (
|
||||
<DropdownMenuItem onSelect={() => setStatus(row, 'REJECTED', 'Rejected')}>
|
||||
<X className="mr-2 h-3.5 w-3.5" />
|
||||
Mark as rejected
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => action.mutate({ id: row.id, action: 'clone' })}>
|
||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||
Clone estimate
|
||||
</DropdownMenuItem>
|
||||
{row.status !== 'REJECTED' && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
setConfirm({
|
||||
title: `Convert ${row.estimate_number} into an invoice?`,
|
||||
description:
|
||||
'A new invoice is created from this estimate. The estimate itself stays where it is — this is not a move.',
|
||||
confirmLabel: 'Convert',
|
||||
onConfirm: () =>
|
||||
action.mutate(
|
||||
{ id: row.id, action: 'convert-to-invoice' },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
const newId = (data as { data?: { id?: number } })?.data?.id;
|
||||
if (newId) onConverted(newId);
|
||||
},
|
||||
},
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<FileUp className="mr-2 h-3.5 w-3.5" />
|
||||
Convert into invoice
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onSelect={() =>
|
||||
setConfirm({
|
||||
title: `Delete estimate ${row.estimate_number}?`,
|
||||
description: 'This permanently removes the estimate.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
remove.mutate(row.id);
|
||||
if (filters.selected === row.id) select(null);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} />;
|
||||
|
||||
const toolbar = (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
|
||||
/>
|
||||
<StatusTabs
|
||||
tabs={ESTIMATE_TABS}
|
||||
value={filters.status}
|
||||
onChange={(v) => {
|
||||
patch({ status: v });
|
||||
setSelection(new Set());
|
||||
}}
|
||||
/>
|
||||
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Estimate number…" />
|
||||
<Button
|
||||
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
>
|
||||
<Filter className="mr-1.5 h-3.5 w-3.5" />
|
||||
Filter
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{selection.size > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: `Delete ${selection.size} estimates?`,
|
||||
description: 'This permanently removes them.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
removeMany.mutate([...selection]);
|
||||
setSelection(new Set());
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete {selection.size}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New estimate
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
Customer
|
||||
<select
|
||||
value={filters.customerId ?? ''}
|
||||
onChange={(e) => patch({ customer: e.target.value || null })}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
From
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.fromDate ?? ''}
|
||||
onChange={(e) => patch({ from: e.target.value || null })}
|
||||
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={filters.toDate ?? ''}
|
||||
onChange={(e) => patch({ to: e.target.value || null })}
|
||||
className="h-8 w-36 text-xs"
|
||||
/>
|
||||
</label>
|
||||
{filters.isFiltered && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SimpleTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(r) => r.id}
|
||||
selectedKey={filters.selected}
|
||||
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
|
||||
rowMenu={rowMenu}
|
||||
empty={
|
||||
isLoading ? (
|
||||
<EmptyState icon={FileSpreadsheet} title="Loading…" />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={FileSpreadsheet}
|
||||
title={filters.isFiltered ? 'No estimates match' : 'No estimates yet'}
|
||||
hint={
|
||||
filters.isFiltered
|
||||
? 'Try clearing the filters.'
|
||||
: 'Estimates become invoices once a customer accepts.'
|
||||
}
|
||||
action={
|
||||
<Button size="sm" onClick={() => onEdit('new')}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New estimate
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SectionShell>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="hidden w-[46%] min-w-[380px] shrink-0 lg:block">
|
||||
<PdfPane
|
||||
resource="estimates"
|
||||
id={selected.id}
|
||||
title={`Estimate ${selected.estimate_number}`}
|
||||
subtitle={selected.customer?.name}
|
||||
filename={`estimate-${selected.estimate_number}`}
|
||||
onClose={() => select(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
|
||||
<SendDocumentDialog target={sendTarget} currency={currency} onClose={() => setSendTarget(null)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,313 @@
|
||||
import type { Column } from './components';
|
||||
import type { ConfirmState } from './ConfirmDialog';
|
||||
import type { Expense } from './shared';
|
||||
import { useState } from 'react';
|
||||
import { Copy, Filter, MoreHorizontal, Paperclip, Plus, Receipt, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { EmptyState, ErrorState, Pagination, SearchBox, SectionShell, SimpleTable } from './components';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { formatDate, formatMoney } from './format';
|
||||
import { PAGE_SIZE } from './shared';
|
||||
import { useListFilters } from './useInvoicesSection';
|
||||
import {
|
||||
useCurrency,
|
||||
useDocumentAction,
|
||||
useLookups,
|
||||
useResourceList,
|
||||
useResourceMutations,
|
||||
} from './useInvoiceShelfData';
|
||||
|
||||
// Expenses — money going the other way, and the only list with a file attached to its rows.
|
||||
//
|
||||
// The receipt link opens `attachment_receipt_url` directly. That URL is InvoiceShelf's own, rendered from the
|
||||
// instance's APP_URL, so it is a browser-resolvable link rather than something proxied through the sidecar —
|
||||
// which means it only works from a machine that can reach the InvoiceShelf host. Left as-is deliberately:
|
||||
// proxying arbitrary uploaded files is a wider surface than this screen needs.
|
||||
|
||||
type Props = { onEdit: (id: number | 'new') => void };
|
||||
|
||||
export const ExpensesListView = ({ onEdit }: Props) => {
|
||||
const { filters, patch, setPage, clearFilters } = useListFilters();
|
||||
const currency = useCurrency();
|
||||
const { customers, categories } = useLookups();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selection, setSelection] = useState<Set<number>>(new Set());
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
|
||||
const { rows, meta, isLoading, error } = useResourceList<Expense>('expenses', {
|
||||
page: filters.page,
|
||||
limit: PAGE_SIZE,
|
||||
search: filters.search || undefined,
|
||||
customer_id: filters.customerId || undefined,
|
||||
expense_category_id: filters.status || undefined,
|
||||
from_date: filters.fromDate || undefined,
|
||||
to_date: filters.toDate || undefined,
|
||||
orderByField: 'expense_date',
|
||||
orderBy: 'desc',
|
||||
});
|
||||
|
||||
const { remove, removeMany } = useResourceMutations('expenses');
|
||||
const action = useDocumentAction('expenses');
|
||||
|
||||
const toggleRow = (id: number) =>
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
|
||||
|
||||
const columns: Column<Expense>[] = [
|
||||
{
|
||||
key: 'check',
|
||||
label: '',
|
||||
width: '36px',
|
||||
render: (row) => (
|
||||
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
|
||||
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'expense_date',
|
||||
label: 'Date',
|
||||
width: 'minmax(90px, 0.8fr)',
|
||||
render: (row) => formatDate(row.expense_date, row.formatted_expense_date),
|
||||
},
|
||||
{
|
||||
key: 'expense_number',
|
||||
label: 'Number',
|
||||
width: 'minmax(90px, 0.8fr)',
|
||||
render: (row) => <span className="font-medium">{row.expense_number ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
width: 'minmax(100px, 1fr)',
|
||||
render: (row) => <span className="truncate">{row.expense_category?.name ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'customer',
|
||||
label: 'Customer',
|
||||
width: 'minmax(100px, 1fr)',
|
||||
render: (row) => <span className="truncate text-muted-foreground">{row.customer?.name ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'notes',
|
||||
label: 'Notes',
|
||||
width: 'minmax(120px, 1.4fr)',
|
||||
render: (row) => (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{row.attachment_receipt_url && (
|
||||
<a
|
||||
href={row.attachment_receipt_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Open receipt"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Paperclip className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
<span className="truncate text-muted-foreground">{row.notes ?? '—'}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: 'Amount',
|
||||
width: 'minmax(100px, 1fr)',
|
||||
align: 'right',
|
||||
render: (row) => formatMoney(row.amount, currency),
|
||||
},
|
||||
];
|
||||
|
||||
const rowMenu = (row: Expense) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => action.mutate({ id: row.id, action: 'duplicate' })}>
|
||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
{row.attachment_receipt_url && (
|
||||
<DropdownMenuItem onSelect={() => window.open(row.attachment_receipt_url ?? '', '_blank', 'noopener')}>
|
||||
<Paperclip className="mr-2 h-3.5 w-3.5" />
|
||||
Open receipt
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onSelect={() =>
|
||||
setConfirm({
|
||||
title: 'Delete this expense?',
|
||||
description: 'It disappears from the profit & loss and expense reports.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => remove.mutate(row.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} />;
|
||||
|
||||
const toolbar = (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
|
||||
/>
|
||||
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Notes…" />
|
||||
<Button
|
||||
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
>
|
||||
<Filter className="mr-1.5 h-3.5 w-3.5" />
|
||||
Filter
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{selection.size > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: `Delete ${selection.size} expenses?`,
|
||||
description: 'They disappear from the profit & loss and expense reports.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
removeMany.mutate([...selection]);
|
||||
setSelection(new Set());
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete {selection.size}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New expense
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
Category
|
||||
<select
|
||||
value={filters.status ?? ''}
|
||||
onChange={(e) => patch({ status: e.target.value || null })}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
Customer
|
||||
<select
|
||||
value={filters.customerId ?? ''}
|
||||
onChange={(e) => patch({ customer: e.target.value || null })}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
From
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.fromDate ?? ''}
|
||||
onChange={(e) => patch({ from: e.target.value || null })}
|
||||
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={filters.toDate ?? ''}
|
||||
onChange={(e) => patch({ to: e.target.value || null })}
|
||||
className="h-8 w-36 text-xs"
|
||||
/>
|
||||
</label>
|
||||
{filters.isFiltered && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SimpleTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={(r) => onEdit(r.id)}
|
||||
rowMenu={rowMenu}
|
||||
empty={
|
||||
isLoading ? (
|
||||
<EmptyState icon={Receipt} title="Loading…" />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Receipt}
|
||||
title={filters.isFiltered ? 'No expenses match' : 'No expenses yet'}
|
||||
hint={
|
||||
filters.isFiltered
|
||||
? 'Try clearing the filters.'
|
||||
: 'Recording expenses is what makes the net income figure mean anything.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SectionShell>
|
||||
|
||||
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,406 @@
|
||||
import type { Column } from './components';
|
||||
import type { ConfirmState } from './ConfirmDialog';
|
||||
import type { SendTarget } from './SendDocumentDialog';
|
||||
import type { Invoice } from './shared';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Copy, FileText, Filter, MoreHorizontal, Plus, Send, Trash2, Wallet } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Pagination,
|
||||
SearchBox,
|
||||
SectionShell,
|
||||
SimpleTable,
|
||||
StatusBadge,
|
||||
StatusTabs,
|
||||
} from './components';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { SendDocumentDialog } from './SendDocumentDialog';
|
||||
import { PdfPane } from './PdfPane';
|
||||
import { formatDate, formatMoney, relativeDue } from './format';
|
||||
import { INVOICE_TABS, PAGE_SIZE, type Invoice as InvoiceType } from './shared';
|
||||
import { useListFilters } from './useInvoicesSection';
|
||||
import {
|
||||
useCurrency,
|
||||
useDocumentAction,
|
||||
useLookups,
|
||||
useResourceMutations,
|
||||
useResourceList,
|
||||
} from './useInvoiceShelfData';
|
||||
|
||||
// The invoices list — upstream's InvoiceIndexView, with its column order, its four tabs and its filter set.
|
||||
//
|
||||
// Two deliberate departures from the map of the Vue app:
|
||||
//
|
||||
// * "Convert to Estimate" is absent. That row exists in the 3.0 source, but this instance runs 2.4.2 and
|
||||
// has no invoices/{id}/convert-to-estimate route — the sidecar's allow-list reflects the live routes, so
|
||||
// offering it here would render a menu item that 404s.
|
||||
// * "Edit" and "Record Payment" open the editors in this app rather than routing to InvoiceShelf.
|
||||
//
|
||||
// `allow_edit` is honoured rather than ignored: once an invoice has payments against it, upstream locks
|
||||
// editing, and letting a user open a form whose save will be rejected is worse than hiding the button.
|
||||
|
||||
type Props = { onEdit: (id: number | 'new') => void; recordPaymentHref: (invoiceId: number) => string };
|
||||
|
||||
export const InvoicesListView = ({ onEdit, recordPaymentHref }: Props) => {
|
||||
const { filters, patch, select, setPage, clearFilters } = useListFilters();
|
||||
const currency = useCurrency();
|
||||
const { customers } = useLookups();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selection, setSelection] = useState<Set<number>>(new Set());
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
const [sendTarget, setSendTarget] = useState<SendTarget | null>(null);
|
||||
|
||||
const { rows, meta, isLoading, error } = useResourceList<InvoiceType>('invoices', {
|
||||
page: filters.page,
|
||||
limit: PAGE_SIZE,
|
||||
invoice_number: filters.search || undefined,
|
||||
status: filters.status || undefined,
|
||||
customer_id: filters.customerId || undefined,
|
||||
from_date: filters.fromDate || undefined,
|
||||
to_date: filters.toDate || undefined,
|
||||
orderByField: 'created_at',
|
||||
orderBy: 'desc',
|
||||
});
|
||||
|
||||
const { removeMany, remove } = useResourceMutations('invoices');
|
||||
const action = useDocumentAction('invoices');
|
||||
|
||||
const selected = rows.find((r) => r.id === filters.selected) ?? null;
|
||||
|
||||
const toggleRow = (id: number) =>
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
|
||||
|
||||
const columns: Column<Invoice>[] = [
|
||||
{
|
||||
key: 'check',
|
||||
label: '',
|
||||
width: '36px',
|
||||
render: (row) => (
|
||||
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
|
||||
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'invoice_date',
|
||||
label: 'Date',
|
||||
width: 'minmax(90px, 0.8fr)',
|
||||
render: (row) => formatDate(row.invoice_date, row.formatted_invoice_date),
|
||||
},
|
||||
{
|
||||
key: 'invoice_number',
|
||||
label: 'Number',
|
||||
width: 'minmax(90px, 0.8fr)',
|
||||
render: (row) => <span className="font-medium">{row.invoice_number}</span>,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Customer',
|
||||
width: 'minmax(120px, 1.4fr)',
|
||||
render: (row) => <span className="truncate">{row.customer?.name ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
width: 'minmax(90px, 0.9fr)',
|
||||
render: (row) => <StatusBadge value={row.status} />,
|
||||
},
|
||||
{
|
||||
key: 'due_amount',
|
||||
label: 'Amount due',
|
||||
width: 'minmax(120px, 1.1fr)',
|
||||
align: 'right',
|
||||
render: (row) => (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{row.overdue && <StatusBadge value="OVERDUE" />}
|
||||
<StatusBadge value={row.paid_status} />
|
||||
<span>{formatMoney(row.due_amount, currency)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'total',
|
||||
label: 'Total',
|
||||
width: 'minmax(90px, 0.9fr)',
|
||||
align: 'right',
|
||||
render: (row) => formatMoney(row.total, currency),
|
||||
},
|
||||
];
|
||||
|
||||
const rowMenu = (row: Invoice) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
{row.allow_edit && <DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>}
|
||||
<DropdownMenuItem onSelect={() => select(row.id)}>View PDF</DropdownMenuItem>
|
||||
|
||||
{row.status === 'DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
setSendTarget({
|
||||
resource: 'invoices',
|
||||
id: row.id,
|
||||
number: row.invoice_number,
|
||||
customerName: row.customer?.name ?? '',
|
||||
customerEmail: row.customer?.email ?? null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Send className="mr-2 h-3.5 w-3.5" />
|
||||
Send invoice
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(row.status === 'SENT' || row.status === 'VIEWED') && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
setSendTarget({
|
||||
resource: 'invoices',
|
||||
id: row.id,
|
||||
number: row.invoice_number,
|
||||
customerName: row.customer?.name ?? '',
|
||||
customerEmail: row.customer?.email ?? null,
|
||||
resend: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Send className="mr-2 h-3.5 w-3.5" />
|
||||
Resend invoice
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Opening the payment editor is an address (`?edit=new&pay=<id>`), not a side effect — the editor
|
||||
prefills itself from those params — so it is an anchor, not a callback up to the parent and
|
||||
back down through `navigate`. */}
|
||||
{(row.status === 'SENT' || row.status === 'VIEWED') && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={recordPaymentHref(row.id)}>
|
||||
<Wallet className="mr-2 h-3.5 w-3.5" />
|
||||
Record payment
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{row.status === 'DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
setConfirm({
|
||||
title: `Mark ${row.invoice_number} as sent?`,
|
||||
// Worth spelling out — the wording is one word away from the action that does email it.
|
||||
description:
|
||||
'This only changes the status to Sent. Nothing is emailed to the customer. Use “Send invoice” for that.',
|
||||
confirmLabel: 'Mark as sent',
|
||||
onConfirm: () => action.mutate({ id: row.id, action: 'status', payload: { status: 'SENT' } }),
|
||||
})
|
||||
}
|
||||
>
|
||||
Mark as sent
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onSelect={() => action.mutate({ id: row.id, action: 'clone' })}>
|
||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||
Clone invoice
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onSelect={() =>
|
||||
setConfirm({
|
||||
title: `Delete invoice ${row.invoice_number}?`,
|
||||
description: 'This permanently removes the invoice and any payments recorded against it.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
remove.mutate(row.id);
|
||||
if (filters.selected === row.id) select(null);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} />;
|
||||
|
||||
const toolbar = (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
|
||||
title="Select all on this page"
|
||||
/>
|
||||
<StatusTabs
|
||||
tabs={INVOICE_TABS}
|
||||
value={filters.status}
|
||||
onChange={(v) => {
|
||||
patch({ status: v });
|
||||
setSelection(new Set());
|
||||
}}
|
||||
/>
|
||||
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Invoice number…" />
|
||||
<Button
|
||||
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
>
|
||||
<Filter className="mr-1.5 h-3.5 w-3.5" />
|
||||
Filter
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{selection.size > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: `Delete ${selection.size} invoices?`,
|
||||
description: 'This permanently removes them and any payments recorded against them.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
removeMany.mutate([...selection]);
|
||||
setSelection(new Set());
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete {selection.size}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New invoice
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
Customer
|
||||
<select
|
||||
value={filters.customerId ?? ''}
|
||||
onChange={(e) => patch({ customer: e.target.value || null })}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
From
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.fromDate ?? ''}
|
||||
onChange={(e) => patch({ from: e.target.value || null })}
|
||||
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={filters.toDate ?? ''}
|
||||
onChange={(e) => patch({ to: e.target.value || null })}
|
||||
className="h-8 w-36 text-xs"
|
||||
/>
|
||||
</label>
|
||||
{filters.isFiltered && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SimpleTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(r) => r.id}
|
||||
selectedKey={filters.selected}
|
||||
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
|
||||
rowMenu={rowMenu}
|
||||
empty={
|
||||
isLoading ? (
|
||||
<EmptyState icon={FileText} title="Loading…" />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title={filters.isFiltered ? 'No invoices match' : 'No invoices yet'}
|
||||
hint={filters.isFiltered ? 'Try clearing the filters.' : 'Create the first one to get started.'}
|
||||
action={
|
||||
<Button size="sm" onClick={() => onEdit('new')}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New invoice
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SectionShell>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="hidden w-[46%] min-w-[380px] shrink-0 lg:block">
|
||||
<PdfPane
|
||||
resource="invoices"
|
||||
id={selected.id}
|
||||
title={`Invoice ${selected.invoice_number}`}
|
||||
subtitle={
|
||||
<>
|
||||
{selected.customer?.name}
|
||||
{selected.due_date && ` · ${relativeDue(selected.due_date)}`}
|
||||
</>
|
||||
}
|
||||
filename={`invoice-${selected.invoice_number}`}
|
||||
onClose={() => select(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
|
||||
<SendDocumentDialog target={sendTarget} currency={currency} onClose={() => setSendTarget(null)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { NavLink } from 'react-router';
|
||||
import {
|
||||
Building2,
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
PieChart,
|
||||
Plug,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Users,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import { INVOICES_SECTIONS, invoicesSectionPath, type InvoicesSectionId } from './shared';
|
||||
import { AccountSwitcher } from './AccountSwitcher';
|
||||
import { formatMoney } from './format';
|
||||
import { useSummary } from './useInvoiceShelfData';
|
||||
|
||||
// Left panel of /invoices: the company it is pointed at, which account that is, then the sections.
|
||||
//
|
||||
// Sections are real links so cmd-click, back and reload behave. Counts come from the dashboard totals that
|
||||
// useSummary already holds — no section fetches a count of its own just to render a badge.
|
||||
//
|
||||
// The subtitle is the account switcher rather than the amount outstanding, because with several sets of
|
||||
// books on screen "whose books are these" is the question the header has to answer. Outstanding moved into
|
||||
// the totals at the bottom, where it sits with the rest of the money.
|
||||
|
||||
const ICONS: Record<InvoicesSectionId, LucideIcon> = {
|
||||
dashboard: LayoutDashboard,
|
||||
invoices: FileText,
|
||||
estimates: FileSpreadsheet,
|
||||
recurring: RefreshCw,
|
||||
payments: Wallet,
|
||||
expenses: Receipt,
|
||||
customers: Users,
|
||||
items: Package,
|
||||
reports: PieChart,
|
||||
settings: Plug,
|
||||
};
|
||||
|
||||
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||
|
||||
export const InvoicesNav = () => {
|
||||
const { company, currency, dashboard, isLoading } = useSummary();
|
||||
|
||||
const counts: Partial<Record<InvoicesSectionId, number>> = {
|
||||
invoices: dashboard?.total_invoice_count,
|
||||
estimates: dashboard?.total_estimate_count,
|
||||
customers: dashboard?.total_customer_count,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary ring-1 ring-inset ring-border">
|
||||
<Building2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight" title={company?.name ?? undefined}>
|
||||
{company?.name ?? (isLoading ? 'Loading…' : 'InvoiceShelf')}
|
||||
</div>
|
||||
<AccountSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
{INVOICES_SECTIONS.map(({ id, label }) => {
|
||||
const Icon = ICONS[id];
|
||||
const count = counts[id];
|
||||
return (
|
||||
<NavLink
|
||||
key={id}
|
||||
to={invoicesSectionPath(id)}
|
||||
className={({ isActive }) =>
|
||||
`${ROW} ${
|
||||
isActive
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
||||
)}
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
<span className="flex-1">{label}</span>
|
||||
{count != null && count > 0 && (
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{dashboard && (
|
||||
<div className="mt-auto space-y-1.5 border-t border-border/60 px-4 py-3 text-[11px]">
|
||||
<NavRowStat label="Outstanding" value={formatMoney(dashboard.total_amount_due, currency)} />
|
||||
<NavRowStat label="Sales" value={formatMoney(dashboard.total_sales, currency)} />
|
||||
<NavRowStat label="Received" value={formatMoney(dashboard.total_receipts, currency)} />
|
||||
<NavRowStat label="Expenses" value={formatMoney(dashboard.total_expenses, currency)} />
|
||||
<NavRowStat label="Net" value={formatMoney(dashboard.total_net_income, currency)} strong />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NavRowStat = ({ label, value, strong }: { label: string; value: string; strong?: boolean }) => (
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className={`tabular-nums ${strong ? 'font-semibold text-foreground' : 'text-foreground/80'}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||
import { ConnectionSection } from './ConnectionSection';
|
||||
import { CustomersListView } from './CustomersListView';
|
||||
import { DashboardView } from './DashboardView';
|
||||
import { DocumentEditor } from './DocumentEditor';
|
||||
import { EstimatesListView } from './EstimatesListView';
|
||||
import { ExpensesListView } from './ExpensesListView';
|
||||
import { InvoicesListView } from './InvoicesListView';
|
||||
import { ItemsListView } from './ItemsListView';
|
||||
import { PaymentsListView } from './PaymentsListView';
|
||||
import { RecurringListView } from './RecurringListView';
|
||||
import { ReportsView } from './ReportsView';
|
||||
import { CustomerEditor, ExpenseEditor, ItemEditor, PaymentEditor } from './RecordEditors';
|
||||
import { invoicesNewPaymentPath, invoicesSectionPath } from './shared';
|
||||
import { useInvoiceShelfHealth } from './useInvoiceShelfData';
|
||||
import { useInvoicesSection } from './useInvoicesSection';
|
||||
|
||||
// Right panel of the /invoices workspace: renders the section named by the URL, and owns the one piece of
|
||||
// state the sections share — which record is being edited.
|
||||
//
|
||||
// That lives in the query string (`?edit=42`, `?edit=new`) rather than in component state, for the same
|
||||
// reason selection does: it is addressable. Back closes the editor instead of leaving the screen, a
|
||||
// half-written form survives a reload of the tab, and "record a payment against invoice 12" is a link
|
||||
// (`?edit=new&pay=12`) rather than a click path.
|
||||
|
||||
type EditTarget = number | 'new' | null;
|
||||
|
||||
const parseEdit = (raw: string | null): EditTarget => {
|
||||
if (raw === 'new') return 'new';
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
};
|
||||
|
||||
export const InvoicesView = () => {
|
||||
const section = useInvoicesSection();
|
||||
const { data: health, isLoading: healthLoading } = useInvoiceShelfHealth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const edit = parseEdit(params.get('edit'));
|
||||
const payFor = Number(params.get('pay')) || null;
|
||||
|
||||
const openEditor = useCallback(
|
||||
(id: number | 'new', extra?: Record<string, string>) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set('edit', String(id));
|
||||
for (const [key, value] of Object.entries(extra ?? {})) next.set(key, value);
|
||||
return next;
|
||||
},
|
||||
{ replace: false },
|
||||
),
|
||||
[setParams],
|
||||
);
|
||||
|
||||
const closeEditor = useCallback(
|
||||
() =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('edit');
|
||||
next.delete('pay');
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
),
|
||||
[setParams],
|
||||
);
|
||||
|
||||
// Health gates every section, and its two failure modes are answered differently: nothing configured yet is
|
||||
// the setup form, whatever section was asked for, because there is nothing else useful to show; a stored
|
||||
// instance that is failing keeps its own message and a way back, since replacing a working token by
|
||||
// accident is worse than a wall of text.
|
||||
if (section === 'settings') return <ConnectionSection />;
|
||||
|
||||
if (!healthLoading && health && !health.ok) {
|
||||
if (health.configured === false) return <ConnectionSection />;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
|
||||
<p className="text-sm font-medium">InvoiceShelf is not answering</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
{health.error ?? 'The invoiceshelf sidecar could not reach the configured instance.'}
|
||||
</p>
|
||||
<Link to={invoicesSectionPath('settings')} className="mt-2 text-xs font-medium text-primary hover:underline">
|
||||
Check the connection
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Documents get a full-height editor rather than a dialog — a line-item table does not fit in one, and
|
||||
// upstream gives them a whole page too.
|
||||
if (edit != null && (section === 'invoices' || section === 'estimates' || section === 'recurring')) {
|
||||
const resource = section === 'recurring' ? 'recurring-invoices' : section;
|
||||
return <DocumentEditor resource={resource} id={edit} onClose={closeEditor} />;
|
||||
}
|
||||
|
||||
switch (section) {
|
||||
case 'invoices':
|
||||
return <InvoicesListView onEdit={openEditor} recordPaymentHref={invoicesNewPaymentPath} />;
|
||||
|
||||
case 'estimates':
|
||||
return (
|
||||
<EstimatesListView
|
||||
onEdit={openEditor}
|
||||
onConverted={(invoiceId) => navigate(`/invoices/invoices?selected=${invoiceId}`)}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'recurring':
|
||||
return <RecurringListView onEdit={openEditor} />;
|
||||
|
||||
case 'payments':
|
||||
return (
|
||||
<>
|
||||
<PaymentsListView onEdit={openEditor} />
|
||||
{edit != null && <PaymentEditor id={edit} forInvoiceId={payFor ?? undefined} onClose={closeEditor} />}
|
||||
</>
|
||||
);
|
||||
|
||||
case 'expenses':
|
||||
return (
|
||||
<>
|
||||
<ExpensesListView onEdit={openEditor} />
|
||||
{edit != null && <ExpenseEditor id={edit} onClose={closeEditor} />}
|
||||
</>
|
||||
);
|
||||
|
||||
case 'customers':
|
||||
return (
|
||||
<>
|
||||
<CustomersListView onEdit={openEditor} />
|
||||
{edit != null && <CustomerEditor id={edit} onClose={closeEditor} />}
|
||||
</>
|
||||
);
|
||||
|
||||
case 'items':
|
||||
return (
|
||||
<>
|
||||
<ItemsListView onEdit={openEditor} />
|
||||
{edit != null && <ItemEditor id={edit} onClose={closeEditor} />}
|
||||
</>
|
||||
);
|
||||
|
||||
case 'reports':
|
||||
return <ReportsView />;
|
||||
|
||||
default:
|
||||
return <DashboardView />;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AlertTriangle, Receipt } from 'lucide-react';
|
||||
import { formatMoney } from './format';
|
||||
import { INVOICES_SECTIONS } from './shared';
|
||||
import { useInvoicesSection } from './useInvoicesSection';
|
||||
import { useSummary } from './useInvoiceShelfData';
|
||||
|
||||
// Panel header for the right (invoices-view) panel: which section, which company, and the one figure that
|
||||
// is worth seeing from every section — how much is still owed.
|
||||
|
||||
export const InvoicesViewHeader = () => {
|
||||
const section = useInvoicesSection();
|
||||
const { company, currency, dashboard, error } = useSummary();
|
||||
const label = INVOICES_SECTIONS.find((s) => s.id === section)?.label ?? 'Invoices';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Receipt className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-xs font-medium">
|
||||
{label}
|
||||
{company?.name && <span className="ml-1.5 font-normal text-muted-foreground">· {company.name}</span>}
|
||||
</span>
|
||||
{error ? (
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] text-destructive">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
unreachable
|
||||
</span>
|
||||
) : (
|
||||
dashboard && (
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{formatMoney(dashboard.total_amount_due, currency)} due
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { Column } from './components';
|
||||
import type { ConfirmState } from './ConfirmDialog';
|
||||
import type { Item } from './shared';
|
||||
import { useState } from 'react';
|
||||
import { Filter, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { EmptyState, ErrorState, Pagination, SearchBox, SectionShell, SimpleTable } from './components';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { formatDate, formatMoney, parseMoneyMinor } from './format';
|
||||
import { PAGE_SIZE } from './shared';
|
||||
import { useListFilters } from './useInvoicesSection';
|
||||
import { useCurrency, useLookups, useResourceList, useResourceMutations } from './useInvoiceShelfData';
|
||||
|
||||
// Items — the reusable line-item catalogue. The shortest screen here, and the only list with no detail pane:
|
||||
// an item is four fields, so clicking one opens the editor directly rather than a preview of itself.
|
||||
|
||||
type Props = { onEdit: (id: number | 'new') => void };
|
||||
|
||||
export const ItemsListView = ({ onEdit }: Props) => {
|
||||
const { filters, patch, setPage, clearFilters } = useListFilters();
|
||||
const currency = useCurrency();
|
||||
const { units } = useLookups();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [priceInput, setPriceInput] = useState('');
|
||||
const [selection, setSelection] = useState<Set<number>>(new Set());
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
|
||||
const { rows, meta, isLoading, error } = useResourceList<Item>('items', {
|
||||
page: filters.page,
|
||||
limit: PAGE_SIZE,
|
||||
search: filters.search || undefined,
|
||||
unit_id: filters.customerId || undefined,
|
||||
// Upstream matches price EXACTLY, in minor units — it is a lookup, not a range. `1000` finds items
|
||||
// priced at 10.00 and nothing else.
|
||||
price: filters.status || undefined,
|
||||
orderByField: 'created_at',
|
||||
orderBy: 'desc',
|
||||
});
|
||||
|
||||
const { remove, removeMany } = useResourceMutations('items');
|
||||
|
||||
const toggleRow = (id: number) =>
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
|
||||
|
||||
const columns: Column<Item>[] = [
|
||||
{
|
||||
key: 'check',
|
||||
label: '',
|
||||
width: '36px',
|
||||
render: (row) => (
|
||||
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
|
||||
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
width: 'minmax(150px, 2fr)',
|
||||
render: (row) => (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{row.name}</div>
|
||||
{row.description && <div className="truncate text-[10px] text-muted-foreground">{row.description}</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'unit',
|
||||
label: 'Unit',
|
||||
width: 'minmax(80px, 0.8fr)',
|
||||
render: (row) => row.unit?.name ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'price',
|
||||
label: 'Price',
|
||||
width: 'minmax(100px, 1fr)',
|
||||
align: 'right',
|
||||
render: (row) => formatMoney(row.price, currency),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Added',
|
||||
width: 'minmax(90px, 0.9fr)',
|
||||
render: (row) => formatDate(null, row.formatted_created_at),
|
||||
},
|
||||
];
|
||||
|
||||
const rowMenu = (row: Item) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onSelect={() =>
|
||||
setConfirm({
|
||||
title: `Delete ${row.name}?`,
|
||||
description:
|
||||
'Lines already written from this item stay as they are — an invoice keeps its own copy of the name and price.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => remove.mutate(row.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} />;
|
||||
|
||||
const applyPrice = () => {
|
||||
const trimmed = priceInput.trim();
|
||||
patch({ status: trimmed ? String(parseMoneyMinor(trimmed, currency)) : null });
|
||||
};
|
||||
|
||||
const toolbar = (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
|
||||
/>
|
||||
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Item name…" />
|
||||
<Button
|
||||
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
>
|
||||
<Filter className="mr-1.5 h-3.5 w-3.5" />
|
||||
Filter
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{selection.size > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: `Delete ${selection.size} items?`,
|
||||
description: 'Lines already written from them are unaffected.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
removeMany.mutate([...selection]);
|
||||
setSelection(new Set());
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete {selection.size}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New item
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
Unit
|
||||
<select
|
||||
value={filters.customerId ?? ''}
|
||||
onChange={(e) => patch({ customer: e.target.value || null })}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{units.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
Exact price
|
||||
<Input
|
||||
value={priceInput}
|
||||
onChange={(e) => setPriceInput(e.target.value)}
|
||||
onBlur={applyPrice}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyPrice()}
|
||||
placeholder="10.00"
|
||||
className="h-8 w-28 text-xs"
|
||||
/>
|
||||
</label>
|
||||
{filters.isFiltered && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
setPriceInput('');
|
||||
clearFilters();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SimpleTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={(r) => onEdit(r.id)}
|
||||
rowMenu={rowMenu}
|
||||
empty={
|
||||
isLoading ? (
|
||||
<EmptyState icon={Package} title="Loading…" />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title={filters.isFiltered ? 'No items match' : 'No items yet'}
|
||||
hint={
|
||||
filters.isFiltered
|
||||
? 'Try clearing the filters.'
|
||||
: 'Items are optional — they just save retyping the same line.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SectionShell>
|
||||
|
||||
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,302 @@
|
||||
import type { Column } from './components';
|
||||
import type { ConfirmState } from './ConfirmDialog';
|
||||
import type { SendTarget } from './SendDocumentDialog';
|
||||
import type { Payment } from './shared';
|
||||
import { useState } from 'react';
|
||||
import { Filter, MoreHorizontal, Plus, Send, Trash2, Wallet } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { EmptyState, ErrorState, Pagination, SearchBox, SectionShell, SimpleTable } from './components';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { SendDocumentDialog } from './SendDocumentDialog';
|
||||
import { PdfPane } from './PdfPane';
|
||||
import { formatDate, formatMoney } from './format';
|
||||
import { PAGE_SIZE } from './shared';
|
||||
import { useListFilters } from './useInvoicesSection';
|
||||
import { useCurrency, useLookups, useResourceList, useResourceMutations } from './useInvoiceShelfData';
|
||||
|
||||
// Payments — upstream's PaymentIndexView. A payment is a receipt against an invoice, so the interesting
|
||||
// columns are which invoice and by what method; the PDF is the receipt itself.
|
||||
//
|
||||
// There is no status here and so no tabs: a payment either exists or it does not.
|
||||
|
||||
type Props = { onEdit: (id: number | 'new') => void };
|
||||
|
||||
export const PaymentsListView = ({ onEdit }: Props) => {
|
||||
const { filters, patch, select, setPage, clearFilters } = useListFilters();
|
||||
const currency = useCurrency();
|
||||
const { customers, paymentMethods } = useLookups();
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selection, setSelection] = useState<Set<number>>(new Set());
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
const [sendTarget, setSendTarget] = useState<SendTarget | null>(null);
|
||||
|
||||
const { rows, meta, isLoading, error } = useResourceList<Payment>('payments', {
|
||||
page: filters.page,
|
||||
limit: PAGE_SIZE,
|
||||
payment_number: filters.search || undefined,
|
||||
customer_id: filters.customerId || undefined,
|
||||
payment_method_id: filters.status || undefined,
|
||||
orderByField: 'created_at',
|
||||
orderBy: 'desc',
|
||||
});
|
||||
|
||||
const { remove, removeMany } = useResourceMutations('payments');
|
||||
const selected = rows.find((r) => r.id === filters.selected) ?? null;
|
||||
|
||||
const toggleRow = (id: number) =>
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
|
||||
|
||||
const columns: Column<Payment>[] = [
|
||||
{
|
||||
key: 'check',
|
||||
label: '',
|
||||
width: '36px',
|
||||
render: (row) => (
|
||||
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
|
||||
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'payment_date',
|
||||
label: 'Date',
|
||||
width: 'minmax(90px, 0.8fr)',
|
||||
render: (row) => formatDate(row.payment_date, row.formatted_payment_date),
|
||||
},
|
||||
{
|
||||
key: 'payment_number',
|
||||
label: 'Number',
|
||||
width: 'minmax(100px, 0.9fr)',
|
||||
render: (row) => <span className="font-medium">{row.payment_number}</span>,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Customer',
|
||||
width: 'minmax(120px, 1.3fr)',
|
||||
render: (row) => <span className="truncate">{row.customer?.name ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'payment_mode',
|
||||
label: 'Mode',
|
||||
width: 'minmax(90px, 0.9fr)',
|
||||
render: (row) => row.payment_method?.name ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'invoice_number',
|
||||
label: 'Invoice',
|
||||
width: 'minmax(90px, 0.9fr)',
|
||||
render: (row) => row.invoice?.invoice_number ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: 'Amount',
|
||||
width: 'minmax(100px, 1fr)',
|
||||
align: 'right',
|
||||
render: (row) => formatMoney(row.amount, currency),
|
||||
},
|
||||
];
|
||||
|
||||
const rowMenu = (row: Payment) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => select(row.id)}>View receipt</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
setSendTarget({
|
||||
resource: 'payments',
|
||||
id: row.id,
|
||||
number: row.payment_number,
|
||||
customerName: row.customer?.name ?? '',
|
||||
customerEmail: row.customer?.email ?? null,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Send className="mr-2 h-3.5 w-3.5" />
|
||||
Send receipt
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onSelect={() =>
|
||||
setConfirm({
|
||||
title: `Delete payment ${row.payment_number}?`,
|
||||
description:
|
||||
'The amount goes back onto the linked invoice as due. This does not refund anything — it only removes the record.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
remove.mutate(row.id);
|
||||
if (filters.selected === row.id) select(null);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} />;
|
||||
|
||||
const toolbar = (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
|
||||
/>
|
||||
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Payment number…" />
|
||||
<Button
|
||||
variant={showFilters || filters.isFiltered ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
>
|
||||
<Filter className="mr-1.5 h-3.5 w-3.5" />
|
||||
Filter
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{selection.size > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: `Delete ${selection.size} payments?`,
|
||||
description: 'Their amounts go back onto the linked invoices as due.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
removeMany.mutate([...selection]);
|
||||
setSelection(new Set());
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete {selection.size}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New payment
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap items-end gap-3 border-b border-border bg-muted/20 px-3 py-2">
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
Customer
|
||||
<select
|
||||
value={filters.customerId ?? ''}
|
||||
onChange={(e) => patch({ customer: e.target.value || null })}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
Payment mode
|
||||
{/* Reuses the `status` slot in the query string — one filter key per list, and payments
|
||||
have no status of their own to compete for it. */}
|
||||
<select
|
||||
value={filters.status ?? ''}
|
||||
onChange={(e) => patch({ status: e.target.value || null })}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{paymentMethods.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{filters.isFiltered && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SimpleTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(r) => r.id}
|
||||
selectedKey={filters.selected}
|
||||
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
|
||||
rowMenu={rowMenu}
|
||||
empty={
|
||||
isLoading ? (
|
||||
<EmptyState icon={Wallet} title="Loading…" />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Wallet}
|
||||
title={filters.isFiltered ? 'No payments match' : 'No payments yet'}
|
||||
hint={
|
||||
filters.isFiltered
|
||||
? 'Try clearing the filters.'
|
||||
: 'Record one against a sent invoice to mark it paid.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SectionShell>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="hidden w-[46%] min-w-[380px] shrink-0 lg:block">
|
||||
<PdfPane
|
||||
resource="payments"
|
||||
id={selected.id}
|
||||
title={`Payment ${selected.payment_number}`}
|
||||
subtitle={selected.customer?.name}
|
||||
filename={`payment-${selected.payment_number}`}
|
||||
onClose={() => select(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
|
||||
<SendDocumentDialog target={sendTarget} currency={currency} onClose={() => setSendTarget(null)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,734 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Address, Customer, Expense, Invoice, Item, Payment } from './shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { formatMoney, fromMajor, toInputDate, toMajor } from './format';
|
||||
import {
|
||||
useCurrency,
|
||||
useLookups,
|
||||
useNextNumber,
|
||||
useResourceDetail,
|
||||
useResourceList,
|
||||
useResourceMutations,
|
||||
} from './useInvoiceShelfData';
|
||||
|
||||
// The four small record forms: customer, item, payment, expense. Dialogs rather than full screens —
|
||||
// each is a handful of fields, and unlike a document none of them needs a line-item table.
|
||||
//
|
||||
// One shape for all four: `id === 'new'` creates, a number edits, and the form seeds itself once from the
|
||||
// loaded record. The seed is guarded by a `loaded` flag rather than a dependency list because react-query
|
||||
// re-delivers the record on every background refetch, and re-seeding mid-edit would silently discard what
|
||||
// the user had typed.
|
||||
//
|
||||
// Money is entered in MAJOR units and written back in minor ones (`fromMajor`), the same convention as the
|
||||
// document editor. Reading goes the other way through `toMajor`.
|
||||
|
||||
type EditorProps = { id: number | 'new'; onClose: () => void };
|
||||
|
||||
// ── shared bits ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const Row = ({ children }: { children: ReactNode }) => <div className="grid gap-3 sm:grid-cols-2">{children}</div>;
|
||||
|
||||
const Cell = ({ label, children }: { label: string; children: ReactNode }) => (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Select = ({
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
value: string | number;
|
||||
onChange: (value: string) => void;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
|
||||
const EditorShell = ({
|
||||
title,
|
||||
description,
|
||||
canSave,
|
||||
busy,
|
||||
onSave,
|
||||
onClose,
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
canSave: boolean;
|
||||
busy: boolean;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
wide?: boolean;
|
||||
}) => (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className={wide ? 'sm:max-w-2xl' : 'sm:max-w-lg'}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[65vh] space-y-3 overflow-y-auto pr-1">{children}</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={onSave} disabled={!canSave || busy}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
// ── customer ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY_ADDRESS = {
|
||||
name: '',
|
||||
address_street_1: '',
|
||||
address_street_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zip: '',
|
||||
phone: '',
|
||||
country_id: '' as number | '',
|
||||
};
|
||||
|
||||
type AddressForm = typeof EMPTY_ADDRESS;
|
||||
|
||||
const fromAddress = (a: Address | null | undefined): AddressForm =>
|
||||
a
|
||||
? {
|
||||
name: a.name ?? '',
|
||||
address_street_1: a.address_street_1 ?? '',
|
||||
address_street_2: a.address_street_2 ?? '',
|
||||
city: a.city ?? '',
|
||||
state: a.state ?? '',
|
||||
zip: a.zip ?? '',
|
||||
phone: a.phone ?? '',
|
||||
country_id: a.country_id ?? '',
|
||||
}
|
||||
: { ...EMPTY_ADDRESS };
|
||||
|
||||
export const CustomerEditor = ({ id, onClose }: EditorProps) => {
|
||||
const isNew = id === 'new';
|
||||
const { currencies } = useLookups();
|
||||
const currency = useCurrency();
|
||||
const { record } = useResourceDetail<Customer>('customers', isNew ? null : (id as number));
|
||||
const { create, update } = useResourceMutations('customers');
|
||||
// Read-only upstream; the address selects are unusable without it.
|
||||
const { rows: countries } = useResourceList<{ id: number; name: string }>('countries');
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
contact_name: '',
|
||||
company_name: '',
|
||||
website: '',
|
||||
prefix: '',
|
||||
tax_id: '',
|
||||
currency_id: '' as number | '',
|
||||
enable_portal: false,
|
||||
});
|
||||
const [billing, setBilling] = useState<AddressForm>({ ...EMPTY_ADDRESS });
|
||||
const [shipping, setShipping] = useState<AddressForm>({ ...EMPTY_ADDRESS });
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded || isNew || !record) return;
|
||||
setForm({
|
||||
name: record.name ?? '',
|
||||
email: record.email ?? '',
|
||||
phone: record.phone ?? '',
|
||||
contact_name: record.contact_name ?? '',
|
||||
company_name: record.company_name ?? '',
|
||||
website: record.website ?? '',
|
||||
prefix: record.prefix ?? '',
|
||||
tax_id: record.tax_id ?? '',
|
||||
currency_id: record.currency_id ?? '',
|
||||
enable_portal: Boolean(record.enable_portal),
|
||||
});
|
||||
setBilling(fromAddress(record.billing));
|
||||
setShipping(fromAddress(record.shipping));
|
||||
setLoaded(true);
|
||||
}, [record, isNew, loaded]);
|
||||
|
||||
const patch = (changes: Partial<typeof form>) => setForm((prev) => ({ ...prev, ...changes }));
|
||||
|
||||
const save = () => {
|
||||
const payload = {
|
||||
...form,
|
||||
email: form.email || null,
|
||||
currency_id: form.currency_id === '' ? (currency?.id ?? null) : form.currency_id,
|
||||
billing: { ...billing, country_id: billing.country_id === '' ? null : billing.country_id },
|
||||
shipping: { ...shipping, country_id: shipping.country_id === '' ? null : shipping.country_id },
|
||||
};
|
||||
if (isNew) create.mutate(payload, { onSuccess: onClose });
|
||||
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorShell
|
||||
title={isNew ? 'New customer' : `Edit ${form.name || 'customer'}`}
|
||||
canSave={form.name.trim() !== ''}
|
||||
busy={create.isPending || update.isPending}
|
||||
onSave={save}
|
||||
onClose={onClose}
|
||||
wide
|
||||
>
|
||||
<Row>
|
||||
<Cell label="Display name">
|
||||
<Input value={form.name} onChange={(ev) => patch({ name: ev.target.value })} className="h-8 text-xs" />
|
||||
</Cell>
|
||||
<Cell label="Contact name">
|
||||
<Input
|
||||
value={form.contact_name}
|
||||
onChange={(ev) => patch({ contact_name: ev.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Cell>
|
||||
<Cell label="Email">
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(ev) => patch({ email: ev.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Cell>
|
||||
<Cell label="Phone">
|
||||
<Input value={form.phone} onChange={(ev) => patch({ phone: ev.target.value })} className="h-8 text-xs" />
|
||||
</Cell>
|
||||
<Cell label="Company">
|
||||
<Input
|
||||
value={form.company_name}
|
||||
onChange={(ev) => patch({ company_name: ev.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Cell>
|
||||
<Cell label="Website">
|
||||
<Input value={form.website} onChange={(ev) => patch({ website: ev.target.value })} className="h-8 text-xs" />
|
||||
</Cell>
|
||||
<Cell label="Tax ID">
|
||||
<Input value={form.tax_id} onChange={(ev) => patch({ tax_id: ev.target.value })} className="h-8 text-xs" />
|
||||
</Cell>
|
||||
<Cell label="Currency">
|
||||
<Select value={form.currency_id} onChange={(v) => patch({ currency_id: v ? Number(v) : '' })}>
|
||||
<option value="">Company default</option>
|
||||
{currencies.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.code} — {c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Cell>
|
||||
</Row>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={form.enable_portal}
|
||||
onCheckedChange={(checked) => patch({ enable_portal: checked === true })}
|
||||
/>
|
||||
Give this customer a portal login
|
||||
</label>
|
||||
|
||||
<AddressFields label="Billing address" value={billing} onChange={setBilling} countries={countries} />
|
||||
<AddressFields
|
||||
label="Shipping address"
|
||||
value={shipping}
|
||||
onChange={setShipping}
|
||||
countries={countries}
|
||||
onCopy={() => setShipping({ ...billing })}
|
||||
/>
|
||||
</EditorShell>
|
||||
);
|
||||
};
|
||||
|
||||
const AddressFields = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
countries,
|
||||
onCopy,
|
||||
}: {
|
||||
label: string;
|
||||
value: AddressForm;
|
||||
onChange: (next: AddressForm) => void;
|
||||
countries: { id: number; name: string }[];
|
||||
onCopy?: () => void;
|
||||
}) => {
|
||||
const patch = (changes: Partial<AddressForm>) => onChange({ ...value, ...changes });
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
{onCopy && (
|
||||
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={onCopy}>
|
||||
Same as billing
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Input
|
||||
placeholder="Name"
|
||||
value={value.name}
|
||||
onChange={(ev) => patch({ name: ev.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Phone"
|
||||
value={value.phone}
|
||||
onChange={(ev) => patch({ phone: ev.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Street"
|
||||
value={value.address_street_1}
|
||||
onChange={(ev) => patch({ address_street_1: ev.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Street 2"
|
||||
value={value.address_street_2}
|
||||
onChange={(ev) => patch({ address_street_2: ev.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="City"
|
||||
value={value.city}
|
||||
onChange={(ev) => patch({ city: ev.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="State"
|
||||
value={value.state}
|
||||
onChange={(ev) => patch({ state: ev.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="ZIP"
|
||||
value={value.zip}
|
||||
onChange={(ev) => patch({ zip: ev.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Select value={value.country_id} onChange={(v) => patch({ country_id: v ? Number(v) : '' })}>
|
||||
<option value="">Country…</option>
|
||||
{countries.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── item ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ItemEditor = ({ id, onClose }: EditorProps) => {
|
||||
const isNew = id === 'new';
|
||||
const currency = useCurrency();
|
||||
const { units } = useLookups();
|
||||
const { record } = useResourceDetail<Item>('items', isNew ? null : (id as number));
|
||||
const { create, update } = useResourceMutations('items');
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [price, setPrice] = useState(0);
|
||||
const [unitId, setUnitId] = useState<number | ''>('');
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded || isNew || !record) return;
|
||||
setName(record.name ?? '');
|
||||
setDescription(record.description ?? '');
|
||||
setPrice(toMajor(record.price));
|
||||
setUnitId(record.unit_id ?? '');
|
||||
setLoaded(true);
|
||||
}, [record, isNew, loaded]);
|
||||
|
||||
const save = () => {
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description || null,
|
||||
price: fromMajor(price),
|
||||
unit_id: unitId === '' ? null : unitId,
|
||||
currency_id: record?.currency_id ?? currency?.id ?? null,
|
||||
};
|
||||
if (isNew) create.mutate(payload, { onSuccess: onClose });
|
||||
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorShell
|
||||
title={isNew ? 'New item' : `Edit ${name || 'item'}`}
|
||||
description="A saved line you can drop onto any invoice or estimate."
|
||||
canSave={name.trim() !== ''}
|
||||
busy={create.isPending || update.isPending}
|
||||
onSave={save}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Cell label="Name">
|
||||
<Input value={name} onChange={(ev) => setName(ev.target.value)} className="h-8 text-xs" />
|
||||
</Cell>
|
||||
<Row>
|
||||
<Cell label={`Price (${currency?.code ?? ''})`}>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={price}
|
||||
onChange={(ev) => setPrice(Number(ev.target.value))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Cell>
|
||||
<Cell label="Unit">
|
||||
<Select value={unitId} onChange={(v) => setUnitId(v ? Number(v) : '')}>
|
||||
<option value="">None</option>
|
||||
{units.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Cell>
|
||||
</Row>
|
||||
<Cell label="Description">
|
||||
<Textarea value={description} onChange={(ev) => setDescription(ev.target.value)} rows={3} className="text-xs" />
|
||||
</Cell>
|
||||
</EditorShell>
|
||||
);
|
||||
};
|
||||
|
||||
// ── payment ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `forInvoiceId` is the "Record payment" path from the invoices list: the invoice is known, its customer
|
||||
* and outstanding amount are not, so they are fetched and filled in rather than made the user's problem.
|
||||
*/
|
||||
export const PaymentEditor = ({ id, forInvoiceId, onClose }: EditorProps & { forInvoiceId?: number }) => {
|
||||
const isNew = id === 'new';
|
||||
const currency = useCurrency();
|
||||
const { customers, paymentMethods } = useLookups();
|
||||
const { record } = useResourceDetail<Payment>('payments', isNew ? null : (id as number));
|
||||
const { record: seedInvoice } = useResourceDetail<Invoice>('invoices', isNew ? (forInvoiceId ?? null) : null);
|
||||
const { create, update } = useResourceMutations('payments');
|
||||
const nextNumber = useNextNumber('payment', isNew);
|
||||
|
||||
const [customerId, setCustomerId] = useState<number | ''>('');
|
||||
const [invoiceId, setInvoiceId] = useState<number | ''>('');
|
||||
const [number, setNumber] = useState('');
|
||||
const [date, setDate] = useState(() => toInputDate(new Date().toISOString()));
|
||||
const [amount, setAmount] = useState(0);
|
||||
const [methodId, setMethodId] = useState<number | ''>('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// `status: 'DUE'` is upstream's own token for "paid_status in (UNPAID, PARTIALLY_PAID)" — the only
|
||||
// invoices a payment can sensibly be attached to. Fetched only once a customer is chosen.
|
||||
const { rows: openInvoices } = useResourceList<Invoice>(
|
||||
'invoices',
|
||||
{ customer_id: customerId || undefined, status: 'DUE', limit: 100 },
|
||||
customerId !== '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) return;
|
||||
if (isNew) {
|
||||
if (!nextNumber) return;
|
||||
// Wait for the seed invoice too, or the form would open blank and then jump under the user's cursor.
|
||||
if (forInvoiceId != null && !seedInvoice) return;
|
||||
setNumber(nextNumber);
|
||||
if (seedInvoice) {
|
||||
setCustomerId(seedInvoice.customer_id);
|
||||
setInvoiceId(seedInvoice.id);
|
||||
setAmount(toMajor(seedInvoice.due_amount));
|
||||
}
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
if (!record) return;
|
||||
setCustomerId(record.customer_id ?? '');
|
||||
setInvoiceId(record.invoice_id ?? '');
|
||||
setNumber(record.payment_number ?? '');
|
||||
setDate(toInputDate(record.payment_date));
|
||||
setAmount(toMajor(record.amount));
|
||||
setMethodId(record.payment_method_id ?? '');
|
||||
setNotes(record.notes ?? '');
|
||||
setLoaded(true);
|
||||
}, [record, isNew, nextNumber, seedInvoice, forInvoiceId, loaded]);
|
||||
|
||||
// The pre-selected invoice may not be in the customer's open list yet (that query runs a beat later), so
|
||||
// fall back to the seed for the over-payment warning.
|
||||
const selected =
|
||||
openInvoices.find((inv) => inv.id === invoiceId) ??
|
||||
(seedInvoice && seedInvoice.id === invoiceId ? seedInvoice : undefined);
|
||||
|
||||
const save = () => {
|
||||
const payload = {
|
||||
payment_date: date,
|
||||
payment_number: number.trim(),
|
||||
customer_id: customerId === '' ? null : customerId,
|
||||
invoice_id: invoiceId === '' ? null : invoiceId,
|
||||
payment_method_id: methodId === '' ? null : methodId,
|
||||
amount: fromMajor(amount),
|
||||
notes: notes || null,
|
||||
currency_id: record?.currency_id ?? currency?.id ?? null,
|
||||
exchange_rate: 1,
|
||||
};
|
||||
if (isNew) create.mutate(payload, { onSuccess: onClose });
|
||||
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorShell
|
||||
title={isNew ? 'Record a payment' : `Edit payment ${number}`}
|
||||
description="Recording a payment reduces the linked invoice's due amount. It does not email anyone."
|
||||
canSave={customerId !== '' && number.trim() !== '' && date !== '' && amount > 0}
|
||||
busy={create.isPending || update.isPending}
|
||||
onSave={save}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Row>
|
||||
<Cell label="Date">
|
||||
<Input type="date" value={date} onChange={(ev) => setDate(ev.target.value)} className="h-8 text-xs" />
|
||||
</Cell>
|
||||
<Cell label="Payment number">
|
||||
<Input value={number} onChange={(ev) => setNumber(ev.target.value)} className="h-8 text-xs" />
|
||||
</Cell>
|
||||
<Cell label="Customer">
|
||||
<Select
|
||||
value={customerId}
|
||||
onChange={(v) => {
|
||||
setCustomerId(v ? Number(v) : '');
|
||||
// The old invoice belongs to the old customer; keeping it would post the payment to a
|
||||
// document the new customer does not own.
|
||||
setInvoiceId('');
|
||||
}}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Cell>
|
||||
<Cell label="Invoice">
|
||||
<Select
|
||||
value={invoiceId}
|
||||
onChange={(v) => {
|
||||
const next = v ? Number(v) : '';
|
||||
setInvoiceId(next);
|
||||
// Default the amount to whatever is still outstanding — the overwhelmingly common case, and
|
||||
// still editable for a part payment.
|
||||
const inv = openInvoices.find((i) => i.id === next);
|
||||
if (inv) setAmount(toMajor(inv.due_amount));
|
||||
}}
|
||||
>
|
||||
<option value="">{customerId === '' ? 'Pick a customer first' : 'Unlinked'}</option>
|
||||
{/* The pre-selected invoice is listed explicitly in case the open-invoice query has not landed
|
||||
yet — otherwise the select would show a blank for a value it does hold. */}
|
||||
{seedInvoice && !openInvoices.some((inv) => inv.id === seedInvoice.id) && (
|
||||
<option value={seedInvoice.id}>
|
||||
{seedInvoice.invoice_number} — {formatMoney(seedInvoice.due_amount, currency)} due
|
||||
</option>
|
||||
)}
|
||||
{openInvoices.map((inv) => (
|
||||
<option key={inv.id} value={inv.id}>
|
||||
{inv.invoice_number} — {formatMoney(inv.due_amount, currency)} due
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Cell>
|
||||
<Cell label={`Amount (${currency?.code ?? ''})`}>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(ev) => setAmount(Number(ev.target.value))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Cell>
|
||||
<Cell label="Method">
|
||||
<Select value={methodId} onChange={(v) => setMethodId(v ? Number(v) : '')}>
|
||||
<option value="">None</option>
|
||||
{paymentMethods.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Cell>
|
||||
</Row>
|
||||
|
||||
{selected && fromMajor(amount) > Number(selected.due_amount ?? 0) && (
|
||||
<p className="text-[10px] text-warning">
|
||||
More than the {formatMoney(selected.due_amount, currency)} outstanding on {selected.invoice_number}.
|
||||
InvoiceShelf will refuse this.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Cell label="Notes">
|
||||
<Textarea value={notes} onChange={(ev) => setNotes(ev.target.value)} rows={3} className="text-xs" />
|
||||
</Cell>
|
||||
</EditorShell>
|
||||
);
|
||||
};
|
||||
|
||||
// ── expense ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ExpenseEditor = ({ id, onClose }: EditorProps) => {
|
||||
const isNew = id === 'new';
|
||||
const currency = useCurrency();
|
||||
const { customers, categories, paymentMethods } = useLookups();
|
||||
const { record } = useResourceDetail<Expense>('expenses', isNew ? null : (id as number));
|
||||
const { create, update } = useResourceMutations('expenses');
|
||||
|
||||
const [date, setDate] = useState(() => toInputDate(new Date().toISOString()));
|
||||
const [number, setNumber] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<number | ''>('');
|
||||
const [customerId, setCustomerId] = useState<number | ''>('');
|
||||
const [methodId, setMethodId] = useState<number | ''>('');
|
||||
const [amount, setAmount] = useState(0);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded || isNew || !record) return;
|
||||
setDate(toInputDate(record.expense_date));
|
||||
setNumber(record.expense_number ?? '');
|
||||
setCategoryId(record.expense_category_id ?? '');
|
||||
setCustomerId(record.customer_id ?? '');
|
||||
setMethodId(record.payment_method_id ?? '');
|
||||
setAmount(toMajor(record.amount));
|
||||
setNotes(record.notes ?? '');
|
||||
setLoaded(true);
|
||||
}, [record, isNew, loaded]);
|
||||
|
||||
const save = () => {
|
||||
const payload = {
|
||||
expense_date: date,
|
||||
expense_number: number || null,
|
||||
expense_category_id: categoryId === '' ? null : categoryId,
|
||||
customer_id: customerId === '' ? null : customerId,
|
||||
payment_method_id: methodId === '' ? null : methodId,
|
||||
amount: fromMajor(amount),
|
||||
notes: notes || null,
|
||||
currency_id: record?.currency_id ?? currency?.id ?? null,
|
||||
exchange_rate: 1,
|
||||
};
|
||||
if (isNew) create.mutate(payload, { onSuccess: onClose });
|
||||
else update.mutate({ id: id as number, payload }, { onSuccess: onClose });
|
||||
};
|
||||
|
||||
return (
|
||||
<EditorShell
|
||||
title={isNew ? 'New expense' : 'Edit expense'}
|
||||
canSave={categoryId !== '' && date !== '' && amount !== 0}
|
||||
busy={create.isPending || update.isPending}
|
||||
onSave={save}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Row>
|
||||
<Cell label="Date">
|
||||
<Input type="date" value={date} onChange={(ev) => setDate(ev.target.value)} className="h-8 text-xs" />
|
||||
</Cell>
|
||||
<Cell label="Number">
|
||||
<Input
|
||||
value={number}
|
||||
onChange={(ev) => setNumber(ev.target.value)}
|
||||
placeholder="Optional"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Cell>
|
||||
<Cell label="Category">
|
||||
<Select value={categoryId} onChange={(v) => setCategoryId(v ? Number(v) : '')}>
|
||||
<option value="">Select…</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Cell>
|
||||
<Cell label={`Amount (${currency?.code ?? ''})`}>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(ev) => setAmount(Number(ev.target.value))}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Cell>
|
||||
<Cell label="Customer">
|
||||
<Select value={customerId} onChange={(v) => setCustomerId(v ? Number(v) : '')}>
|
||||
<option value="">None</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Cell>
|
||||
<Cell label="Paid with">
|
||||
<Select value={methodId} onChange={(v) => setMethodId(v ? Number(v) : '')}>
|
||||
<option value="">None</option>
|
||||
{paymentMethods.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Cell>
|
||||
</Row>
|
||||
|
||||
<Cell label="Notes">
|
||||
<Textarea value={notes} onChange={(ev) => setNotes(ev.target.value)} rows={3} className="text-xs" />
|
||||
</Cell>
|
||||
|
||||
{/* Receipts are read-only here. Attaching one is a multipart upload to a separate endpoint that the
|
||||
sidecar's JSON allow-list does not carry; an existing receipt is linked from the expenses list. */}
|
||||
{record?.attachment_receipt_url && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
This expense has a receipt attached.{' '}
|
||||
<a
|
||||
href={record.attachment_receipt_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
Open it
|
||||
</a>
|
||||
. Replacing it has to be done in InvoiceShelf.
|
||||
</p>
|
||||
)}
|
||||
</EditorShell>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,322 @@
|
||||
import type { Column } from './components';
|
||||
import type { ConfirmState } from './ConfirmDialog';
|
||||
import type { Invoice, RecurringInvoice, RecurringStatus } from './shared';
|
||||
import { useState } from 'react';
|
||||
import { MoreHorizontal, Plus, Repeat, Trash2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
Pagination,
|
||||
SearchBox,
|
||||
SectionShell,
|
||||
SimpleTable,
|
||||
StatusBadge,
|
||||
} from './components';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { formatDate, formatMoney } from './format';
|
||||
import { PAGE_SIZE, RECURRING_TABS, frequencyLabel } from './shared';
|
||||
import { StatusTabs } from './components';
|
||||
import { useListFilters } from './useInvoicesSection';
|
||||
import { useCurrency, useResourceDetail, useResourceList, useResourceMutations } from './useInvoiceShelfData';
|
||||
|
||||
// Recurring invoices — a schedule, not a document.
|
||||
//
|
||||
// This is the one section whose detail pane is NOT a PDF, and it is not an omission: a recurring invoice has
|
||||
// no unique_hash and no PDF route upstream, because there is nothing to render. What it has is a schedule and
|
||||
// a list of the invoices that schedule has already generated, so that is what the pane shows.
|
||||
//
|
||||
// The schedule's `frequency` is a cron expression; frequencyLabel turns the ones upstream's own form can
|
||||
// produce back into words and leaves anything else alone.
|
||||
|
||||
type Props = { onEdit: (id: number | 'new') => void };
|
||||
|
||||
export const RecurringListView = ({ onEdit }: Props) => {
|
||||
const { filters, patch, select, setPage, clearFilters } = useListFilters();
|
||||
const currency = useCurrency();
|
||||
const [selection, setSelection] = useState<Set<number>>(new Set());
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
|
||||
const { rows, meta, isLoading, error } = useResourceList<RecurringInvoice>('recurring-invoices', {
|
||||
page: filters.page,
|
||||
limit: PAGE_SIZE,
|
||||
search: filters.search || undefined,
|
||||
status: filters.status || undefined,
|
||||
customer_id: filters.customerId || undefined,
|
||||
orderByField: 'created_at',
|
||||
orderBy: 'desc',
|
||||
});
|
||||
|
||||
const { remove, removeMany } = useResourceMutations('recurring-invoices', 'recurring invoices');
|
||||
|
||||
const toggleRow = (id: number) =>
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const allChecked = rows.length > 0 && rows.every((r) => selection.has(r.id));
|
||||
|
||||
const columns: Column<RecurringInvoice>[] = [
|
||||
{
|
||||
key: 'check',
|
||||
label: '',
|
||||
width: '36px',
|
||||
render: (row) => (
|
||||
<span onClick={(e) => e.stopPropagation()} className="flex items-center">
|
||||
<Checkbox checked={selection.has(row.id)} onCheckedChange={() => toggleRow(row.id)} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'starts_at',
|
||||
label: 'Starts at',
|
||||
width: 'minmax(90px, 0.9fr)',
|
||||
render: (row) => formatDate(row.starts_at, row.formatted_starts_at),
|
||||
},
|
||||
{
|
||||
key: 'customer',
|
||||
label: 'Customer',
|
||||
width: 'minmax(140px, 1.6fr)',
|
||||
render: (row) => (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate">{row.customer?.name ?? '—'}</div>
|
||||
{row.customer?.contact_name && (
|
||||
<div className="truncate text-[10px] text-muted-foreground">{row.customer.contact_name}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'frequency',
|
||||
label: 'Frequency',
|
||||
width: 'minmax(100px, 1.1fr)',
|
||||
render: (row) => <span className="truncate">{frequencyLabel(row.frequency)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
width: 'minmax(90px, 0.9fr)',
|
||||
render: (row) => <StatusBadge value={row.status} />,
|
||||
},
|
||||
{
|
||||
key: 'total',
|
||||
label: 'Total',
|
||||
width: 'minmax(100px, 1fr)',
|
||||
align: 'right',
|
||||
render: (row) => formatMoney(row.total, currency),
|
||||
},
|
||||
];
|
||||
|
||||
const rowMenu = (row: RecurringInvoice) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onSelect={() => onEdit(row.id)}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => select(row.id)}>View schedule</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onSelect={() =>
|
||||
setConfirm({
|
||||
title: 'Delete this schedule?',
|
||||
description:
|
||||
'Invoices it has already generated are kept — only the schedule stops. Nothing further will be issued.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
remove.mutate(row.id);
|
||||
if (filters.selected === row.id) select(null);
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
if (error) return <ErrorState error={error} />;
|
||||
|
||||
const toolbar = (
|
||||
<>
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
onCheckedChange={() => setSelection(allChecked ? new Set() : new Set(rows.map((r) => r.id)))}
|
||||
/>
|
||||
<StatusTabs
|
||||
tabs={RECURRING_TABS}
|
||||
value={(filters.status as RecurringStatus | null) ?? null}
|
||||
onChange={(v) => patch({ status: v })}
|
||||
/>
|
||||
<SearchBox value={filters.search} onChange={(v) => patch({ q: v })} placeholder="Customer…" />
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{filters.isFiltered && (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={clearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
{selection.size > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: `Delete ${selection.size} schedules?`,
|
||||
description: 'Invoices they have already generated are kept.',
|
||||
confirmLabel: 'Delete',
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
removeMany.mutate([...selection]);
|
||||
setSelection(new Set());
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete {selection.size}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" className="h-8" onClick={() => onEdit('new')}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New schedule
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SectionShell toolbar={toolbar} footer={<Pagination meta={meta} onPage={setPage} />}>
|
||||
<SimpleTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
rowKey={(r) => r.id}
|
||||
selectedKey={filters.selected}
|
||||
onRowClick={(r) => select(r.id === filters.selected ? null : r.id)}
|
||||
rowMenu={rowMenu}
|
||||
empty={
|
||||
isLoading ? (
|
||||
<EmptyState icon={Repeat} title="Loading…" />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
title={filters.isFiltered ? 'No schedules match' : 'No recurring invoices'}
|
||||
hint={
|
||||
filters.isFiltered
|
||||
? 'Try clearing the filters.'
|
||||
: 'A schedule issues the same invoice to a customer on a fixed cadence.'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SectionShell>
|
||||
</div>
|
||||
|
||||
{filters.selected != null && (
|
||||
<div className="hidden w-[42%] min-w-[340px] shrink-0 lg:block">
|
||||
<RecurringDetail id={filters.selected} onClose={() => select(null)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog state={confirm} onClose={() => setConfirm(null)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The schedule pane. Fetched by id rather than taken from the list row because `invoices` is only present on
|
||||
* the singular read — the index resource omits the relation.
|
||||
*/
|
||||
const RecurringDetail = ({ id, onClose }: { id: number; onClose: () => void }) => {
|
||||
const { record, isLoading } = useResourceDetail<RecurringInvoice>('recurring-invoices', id);
|
||||
const currency = useCurrency();
|
||||
const generated: Invoice[] = record?.invoices ?? [];
|
||||
|
||||
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">{record?.customer?.name ?? 'Schedule'}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">{frequencyLabel(record?.frequency)}</div>
|
||||
</div>
|
||||
<StatusBadge value={record?.status} />
|
||||
<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 className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{isLoading && <div className="text-xs text-muted-foreground">Loading…</div>}
|
||||
|
||||
{record && (
|
||||
<>
|
||||
<div className="rounded-lg border border-border px-3 py-1">
|
||||
<Field label="Starts at">{formatDate(record.starts_at, record.formatted_starts_at)}</Field>
|
||||
<Field label="Next invoice">{formatDate(record.next_invoice_at, record.formatted_next_invoice_at)}</Field>
|
||||
<Field label="Frequency">{frequencyLabel(record.frequency)}</Field>
|
||||
<Field label="Limit">{limitLabel(record)}</Field>
|
||||
<Field label="Sends automatically">{record.send_automatically ? 'Yes' : 'No'}</Field>
|
||||
<Field label="Total">{formatMoney(record.total, currency)}</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Generated invoices
|
||||
</div>
|
||||
{generated.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
Nothing issued yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50 rounded-lg border border-border">
|
||||
{generated.map((inv) => (
|
||||
<div key={inv.id} className="flex items-center gap-2 px-3 py-2 text-xs">
|
||||
<span className="font-medium">{inv.invoice_number}</span>
|
||||
<StatusBadge value={inv.status} />
|
||||
<span className="ml-auto tabular-nums">{formatMoney(inv.total, currency)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{record.notes && (
|
||||
<div className="mt-3 rounded-lg border border-border p-3 text-xs text-muted-foreground">
|
||||
{record.notes}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const limitLabel = (row: RecurringInvoice): string => {
|
||||
if (row.limit_by === 'COUNT') return `${row.limit_count ?? 0} invoices`;
|
||||
if (row.limit_by === 'DATE') return formatDate(row.limit_date, row.formatted_limit_date);
|
||||
return 'None';
|
||||
};
|
||||
@@ -0,0 +1,211 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { Currency } from './shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, Send } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useDocumentAction, useSummary } from './useInvoiceShelfData';
|
||||
|
||||
// The one dialog in /invoices that causes something irreversible to leave the building: `send` hands the
|
||||
// document to InvoiceShelf's mailer, which posts it to the customer. There is no unsend.
|
||||
//
|
||||
// So it is deliberately not a menu item that fires on click. The recipient is shown, editable, and
|
||||
// pre-filled from the customer record rather than assumed; the button says who it is emailing; and nothing
|
||||
// else in this app calls the send action. Everything that *looks* like sending but isn't — "mark as sent",
|
||||
// which only moves the status — goes through a plain confirm instead, and says so.
|
||||
|
||||
export type SendTarget = {
|
||||
/** Sidecar resource slug: invoices | estimates | payments. */
|
||||
resource: string;
|
||||
id: number;
|
||||
/** Shown in the title, e.g. "000145". */
|
||||
number: string;
|
||||
customerName: string;
|
||||
customerEmail: string | null;
|
||||
/** Whether this is a first send or a resend, purely for wording. */
|
||||
resend?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_BODY = [
|
||||
'<p>Hi {CUSTOMER_NAME},</p>',
|
||||
'<p>Please find the attached document.</p>',
|
||||
'<p>Thanks,<br/>{COMPANY_NAME}</p>',
|
||||
].join('\n');
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
invoices: 'invoice',
|
||||
estimates: 'estimate',
|
||||
payments: 'payment receipt',
|
||||
};
|
||||
|
||||
export const SendDocumentDialog = ({
|
||||
target,
|
||||
currency: _currency,
|
||||
onClose,
|
||||
}: {
|
||||
target: SendTarget | null;
|
||||
currency?: Currency | null;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const { company, summary } = useSummary();
|
||||
const action = useDocumentAction(target?.resource ?? 'invoices');
|
||||
|
||||
const kind = KIND_LABEL[target?.resource ?? ''] ?? 'document';
|
||||
|
||||
const [to, setTo] = useState('');
|
||||
const [from, setFrom] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
|
||||
// Re-seed whenever a different document is opened. Keyed on id so reopening the same one keeps nothing
|
||||
// stale from a previous edit of another document's mail.
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
setTo(target.customerEmail ?? '');
|
||||
setFrom(summary?.me?.email ?? '');
|
||||
setSubject(`${company?.name ?? 'Invoice'} — ${kind} ${target.number}`);
|
||||
setBody(
|
||||
DEFAULT_BODY.replace('{CUSTOMER_NAME}', target.customerName || 'there').replace(
|
||||
'{COMPANY_NAME}',
|
||||
company?.name ?? '',
|
||||
),
|
||||
);
|
||||
}, [target?.id, target?.resource, company?.name, summary?.me?.email, kind, target]);
|
||||
|
||||
if (!target) return null;
|
||||
|
||||
const missingRecipient = !to.trim();
|
||||
|
||||
const submit = () => {
|
||||
action.mutate({ id: target.id, action: 'send', payload: { subject, body, from, to } }, { onSuccess: onClose });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{target.resend ? 'Resend' : 'Send'} {kind} {target.number}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This emails {target.customerName} through InvoiceShelf. It cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">To</Label>
|
||||
<Input value={to} onChange={(e) => setTo(e.target.value)} className="h-8 text-xs" type="email" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">From</Label>
|
||||
<Input value={from} onChange={(e) => setFrom(e.target.value)} className="h-8 text-xs" type="email" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Subject</Label>
|
||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="h-8 text-xs" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Message</Label>
|
||||
<Textarea value={body} onChange={(e) => setBody(e.target.value)} rows={7} className="text-xs" />
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
HTML. InvoiceShelf attaches the PDF itself — do not link it here.
|
||||
</p>
|
||||
</div>
|
||||
{missingRecipient && (
|
||||
<p className="text-[10px] text-warning">This customer has no email address on file. Enter one to send.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} disabled={action.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={submit} disabled={action.isPending || missingRecipient}>
|
||||
{action.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Send to {to || '—'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,282 @@
|
||||
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>
|
||||
);
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import type { Currency, Money } from './shared';
|
||||
|
||||
// Presentation helpers. The money one is the load-bearing piece: InvoiceShelf stores minor units as
|
||||
// integers and describes how to render them in the company's `current_company_currency` record, so the
|
||||
// separators, the precision and the symbol side all come from data rather than from the browser locale.
|
||||
// Intl.NumberFormat is deliberately NOT used — it would apply the *viewer's* locale to the *company's*
|
||||
// currency and quietly render "1,234.56 €" for an install configured the other way round.
|
||||
|
||||
/**
|
||||
* Coerce an API money field to a number of minor units.
|
||||
*
|
||||
* Upstream is genuinely inconsistent about the JSON type here — `invoice.total` arrives as the number
|
||||
* 450000 while `customer.due_amount` arrives as the string "1830000" — so every read goes through this
|
||||
* rather than trusting either. Non-finite input collapses to 0 so a missing total renders as "0,00 €"
|
||||
* instead of "NaN".
|
||||
*/
|
||||
export const toMinor = (value: Money): number => {
|
||||
if (value == null) return 0;
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
const groupDigits = (digits: string, separator: string): string => digits.replace(/\B(?=(\d{3})+(?!\d))/g, separator);
|
||||
|
||||
/**
|
||||
* The minor-unit divisor.
|
||||
*
|
||||
* Deliberately the constant 100 and NOT 10**precision, because that is what upstream does — its
|
||||
* `utils/format-money.ts` opens with `amount = amountInCents / 100` and then uses `precision` only to
|
||||
* decide how many decimal digits to *print*. On a 3-decimal currency the two rules disagree, and matching
|
||||
* InvoiceShelf's own arithmetic matters more here than being independently right: a figure that differs
|
||||
* from the one its web UI shows for the same invoice would read as a bug in this screen.
|
||||
*/
|
||||
const MINOR_PER_MAJOR = 100;
|
||||
|
||||
/**
|
||||
* Render minor units the way InvoiceShelf renders them.
|
||||
*
|
||||
* Ported from `resources/scripts/utils/format-money.ts` and matched on the details that are visible:
|
||||
* there is always a single space between symbol and number, the minus sign sits inside the number rather
|
||||
* than before the symbol (so "€ -1.234,56", not "-€ 1.234,56"), and `precision: 0` prints no decimal part
|
||||
* at all rather than a bare separator.
|
||||
*/
|
||||
export const formatMoney = (value: Money, currency?: Currency | null): string => {
|
||||
let precision = Math.abs(Number(currency?.precision ?? 2));
|
||||
if (!Number.isFinite(precision)) precision = 2;
|
||||
|
||||
const amount = toMinor(value) / MINOR_PER_MAJOR;
|
||||
const negativeSign = amount < 0 ? '-' : '';
|
||||
const abs = Math.abs(amount);
|
||||
|
||||
const thousands = currency?.thousand_separator ?? ',';
|
||||
const decimal = currency?.decimal_separator ?? '.';
|
||||
const symbol = currency?.symbol ?? '';
|
||||
|
||||
const fixed = abs.toFixed(precision);
|
||||
const [whole = '0', frac = ''] = fixed.split('.');
|
||||
|
||||
const combined = negativeSign + groupDigits(whole, thousands) + (precision ? decimal + frac : '');
|
||||
|
||||
if (!symbol) return combined;
|
||||
// `swap_currency_symbol` arrives as a boolean from the API and as 0/1 from a settings write.
|
||||
return currency?.swap_currency_symbol ? `${combined} ${symbol}` : `${symbol} ${combined}`;
|
||||
};
|
||||
|
||||
/** Bare number, no symbol — for chart axes and form inputs where the symbol lives elsewhere. */
|
||||
export const formatAmount = (value: Money, currency?: Currency | null): string =>
|
||||
formatMoney(value, currency ? { ...currency, symbol: '' } : null);
|
||||
|
||||
/** Minor units → the major-unit number an editable input holds (450000 → 4500). */
|
||||
export const toMajor = (value: Money): number => toMinor(value) / MINOR_PER_MAJOR;
|
||||
|
||||
/** The inverse, for writes. Rounds because floats reaching here have been through an <input>. */
|
||||
export const fromMajor = (value: number | string): number => {
|
||||
const n = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
|
||||
return Number.isFinite(n) ? Math.round(n * MINOR_PER_MAJOR) : 0;
|
||||
};
|
||||
|
||||
/** Strips symbol and separators back to minor units — the paste-into-a-money-field path. */
|
||||
export const parseMoneyMinor = (text: string, currency?: Currency | null): number => {
|
||||
const thousands = currency?.thousand_separator ?? ',';
|
||||
const decimal = currency?.decimal_separator ?? '.';
|
||||
const cleaned = text
|
||||
.replace(currency?.symbol ?? '', '')
|
||||
.split(thousands)
|
||||
.join('')
|
||||
.replace(decimal, '.')
|
||||
.replace(/[^\d.-]/g, '');
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) ? Math.round(n * MINOR_PER_MAJOR) : 0;
|
||||
};
|
||||
|
||||
// ── dates ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the several date shapes the API mixes: "2026-06-21", "2026-06-21 00:00:00" and full ISO.
|
||||
* The space form is not valid ISO-8601, so it is normalised before Date sees it.
|
||||
*/
|
||||
const parseDate = (value: string | null | undefined): Date | null => {
|
||||
if (!value) return null;
|
||||
const normalised = value.includes('T') ? value : value.replace(' ', 'T');
|
||||
const d = new Date(normalised);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prefer upstream's own `formatted_*` field when the caller has one: it already reflects the company's
|
||||
* configured date format (dd/mm/yyyy here), which we cannot reconstruct from the raw value alone.
|
||||
*/
|
||||
export const formatDate = (raw: string | null | undefined, preformatted?: string | null): string => {
|
||||
if (preformatted) return preformatted;
|
||||
const d = parseDate(raw);
|
||||
if (!d) return '—';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
|
||||
};
|
||||
|
||||
/** ISO yyyy-mm-dd, the form every write endpoint expects and `<input type="date">` speaks. */
|
||||
export const toInputDate = (raw: string | null | undefined): string => {
|
||||
const d = parseDate(raw);
|
||||
if (!d) return '';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
};
|
||||
|
||||
/** Whole days until `raw`; negative once past. Used for the overdue hint next to a due date. */
|
||||
export const daysUntil = (raw: string | null | undefined): number | null => {
|
||||
const d = parseDate(raw);
|
||||
if (!d) return null;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return Math.round((d.getTime() - today.getTime()) / 86_400_000);
|
||||
};
|
||||
|
||||
export const relativeDue = (raw: string | null | undefined): string => {
|
||||
const days = daysUntil(raw);
|
||||
if (days == null) return '';
|
||||
if (days === 0) return 'due today';
|
||||
if (days > 0) return days === 1 ? 'due tomorrow' : `due in ${days} days`;
|
||||
const overdue = Math.abs(days);
|
||||
return overdue === 1 ? '1 day overdue' : `${overdue} days overdue`;
|
||||
};
|
||||
|
||||
/** Strip the HTML upstream stores in note fields, for one-line previews. */
|
||||
export const stripHtml = (html: string | null | undefined): string =>
|
||||
(html ?? '')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'invoices-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'invoices-nav', appType: 'invoices-nav' }, size: 20 },
|
||||
{ node: { type: 'panel', id: 'invoices-view', appType: 'invoices-view' }, size: 80 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// `panels.ts`, not `.tsx` — discovery checks for that exact name.
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { LayoutGrid, PanelLeft } from 'lucide-react';
|
||||
import { InvoicesNav } from './InvoicesNav';
|
||||
import { InvoicesView } from './InvoicesView';
|
||||
import { InvoicesViewHeader } from './InvoicesViewHeader';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'invoices-nav',
|
||||
name: 'Invoices',
|
||||
icon: PanelLeft,
|
||||
component: InvoicesNav,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'invoices-view',
|
||||
name: 'Invoices',
|
||||
icon: LayoutGrid,
|
||||
component: InvoicesView,
|
||||
header: InvoicesViewHeader,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
// Domain vocabulary for /invoices — the Officer front end over the officer-invoiceshelf sidecar.
|
||||
//
|
||||
// Types are written against the LIVE 2.4.2 instance's actual responses, not the 3.0.0-alpha.1 checkout in
|
||||
// _references. Two things about those responses drive most of the odd choices here:
|
||||
//
|
||||
// 1. Money is an integer count of minor units (cents), and the API is inconsistent about whether it hands
|
||||
// it over as a number or a decimal string — `invoice.total` is 450000 but `customer.due_amount` is
|
||||
// "1830000". Hence the `Money` alias and `toMinor()` in format.ts. Never do arithmetic on these
|
||||
// without going through it.
|
||||
// 2. Every record embeds its whole company, including the role/ability list — a single invoice row carries
|
||||
// ~40KB of super-admin abilities nobody asked for. That is upstream's shape and we cannot change it,
|
||||
// so the types below deliberately declare `company?: unknown`: naming those fields would invite code
|
||||
// that reads them, and the interesting data is never in there.
|
||||
|
||||
import type { Tone } from '@/components/Data/tone';
|
||||
|
||||
export const INVOICES_SECTIONS = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'invoices', label: 'Invoices' },
|
||||
{ id: 'estimates', label: 'Estimates' },
|
||||
{ id: 'recurring', label: 'Recurring' },
|
||||
{ id: 'payments', label: 'Payments' },
|
||||
{ id: 'expenses', label: 'Expenses' },
|
||||
{ id: 'customers', label: 'Customers' },
|
||||
{ id: 'items', label: 'Items' },
|
||||
{ id: 'reports', label: 'Reports' },
|
||||
{ id: 'settings', label: 'Connection' },
|
||||
] as const;
|
||||
|
||||
export type InvoicesSectionId = (typeof INVOICES_SECTIONS)[number]['id'];
|
||||
|
||||
export const DEFAULT_INVOICES_SECTION: InvoicesSectionId = 'dashboard';
|
||||
|
||||
export const isInvoicesSection = (value: string | undefined): value is InvoicesSectionId =>
|
||||
INVOICES_SECTIONS.some((s) => s.id === value);
|
||||
|
||||
export const invoicesSectionPath = (id: InvoicesSectionId) => `/invoices/${id}`;
|
||||
|
||||
/**
|
||||
* A record open inside a list section — `/invoices/invoices?selected=12`. `useInvoicesSection` reads
|
||||
* `selected` out of the query string, so this is the addressable form of "this row is open" and the
|
||||
* thing a row anchor points at.
|
||||
*/
|
||||
export const invoicesRecordPath = (section: InvoicesSectionId, id: number) =>
|
||||
`${invoicesSectionPath(section)}?selected=${id}`;
|
||||
|
||||
/** A list section filtered to one customer — `useListFilters` reads `customer`. */
|
||||
export const invoicesCustomerPath = (section: InvoicesSectionId, customerId: number) =>
|
||||
`${invoicesSectionPath(section)}?customer=${customerId}`;
|
||||
|
||||
/**
|
||||
* The payment editor, opened blank against an invoice — `/invoices/payments?edit=new&pay=12`.
|
||||
* `InvoicesView` reads both params (`edit` → which editor, `pay` → which invoice it prefills from), so
|
||||
* "record a payment for this invoice" is a whole address and not a callback.
|
||||
*/
|
||||
export const invoicesNewPaymentPath = (invoiceId: number) =>
|
||||
`${invoicesSectionPath('payments')}?edit=new&pay=${invoiceId}`;
|
||||
|
||||
/** The sidecar resource slug behind each list section. `dashboard` and `reports` have none. */
|
||||
export const SECTION_RESOURCE = {
|
||||
invoices: 'invoices',
|
||||
estimates: 'estimates',
|
||||
recurring: 'recurring-invoices',
|
||||
payments: 'payments',
|
||||
expenses: 'expenses',
|
||||
customers: 'customers',
|
||||
items: 'items',
|
||||
} as const satisfies Partial<Record<InvoicesSectionId, string>>;
|
||||
|
||||
export type ListSectionId = keyof typeof SECTION_RESOURCE;
|
||||
|
||||
export const isListSection = (id: InvoicesSectionId): id is ListSectionId => id in SECTION_RESOURCE;
|
||||
|
||||
// ── money ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** An integer count of minor units, which upstream may hand over as either a number or a decimal string. */
|
||||
export type Money = number | string | null | undefined;
|
||||
|
||||
export type Currency = {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
symbol: string;
|
||||
precision: number | string;
|
||||
thousand_separator: string;
|
||||
decimal_separator: string;
|
||||
/** true → "1.234,56 €"; false → "€1.234,56". */
|
||||
swap_currency_symbol: boolean | number;
|
||||
};
|
||||
|
||||
// ── status vocabulary ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// The real persisted statuses. DUE and OVERDUE are NOT among them — upstream computes those for filters
|
||||
// and badges (`overdue` is a derived boolean on the invoice, `DUE` is a filter-only token), so they appear
|
||||
// in the tone map and the filter vocabulary but never as a value you can write back.
|
||||
export type InvoiceStatus = 'DRAFT' | 'SENT' | 'VIEWED' | 'COMPLETED';
|
||||
export type PaidStatus = 'UNPAID' | 'PARTIALLY_PAID' | 'PAID';
|
||||
export type EstimateStatus = 'DRAFT' | 'SENT' | 'VIEWED' | 'EXPIRED' | 'ACCEPTED' | 'REJECTED';
|
||||
export type RecurringStatus = 'ACTIVE' | 'ON_HOLD' | 'COMPLETED';
|
||||
|
||||
/**
|
||||
* The one place a status token becomes a colour. Every badge, tab and detail header reads it, so a status
|
||||
* cannot mean success in the list and warning in the preview.
|
||||
*
|
||||
* Mapped by MEANING onto the platform's closed six-tone set (`components/Data/tone.ts`), not hue-for-hue
|
||||
* onto upstream's palette. Two consequences worth knowing:
|
||||
*
|
||||
* - SENT and VIEWED both resolve to `info`. Upstream separates them (blue vs indigo); the tone set has no
|
||||
* sixth shade of "in flight, not yet actioned" and inventing one would put the plugin back on palette
|
||||
* classes. The words still differ in the badge — only the colour is shared.
|
||||
* - PARTIALLY_PAID is `busy` rather than `warning`, which keeps it distinct from UNPAID in the same
|
||||
* column and is what it actually means: payment underway. Likewise EXPIRED is `warning` (an offer that
|
||||
* lapsed and can be re-issued) where REJECTED is `danger` (declined outright).
|
||||
*
|
||||
* Unknown tokens are the caller's problem — `StatusBadge` falls back to `neutral`.
|
||||
*/
|
||||
export const STATUS_TONE: Record<string, Tone> = {
|
||||
DRAFT: 'neutral',
|
||||
SENT: 'info',
|
||||
VIEWED: 'info',
|
||||
COMPLETED: 'success',
|
||||
DUE: 'warning',
|
||||
OVERDUE: 'danger',
|
||||
UNPAID: 'warning',
|
||||
PARTIALLY_PAID: 'busy',
|
||||
PAID: 'success',
|
||||
EXPIRED: 'warning',
|
||||
ACCEPTED: 'success',
|
||||
REJECTED: 'danger',
|
||||
ACTIVE: 'info',
|
||||
ON_HOLD: 'warning',
|
||||
};
|
||||
|
||||
/** Status filter vocabulary per list, in upstream's own order. */
|
||||
export const INVOICE_STATUS_FILTERS = ['DRAFT', 'DUE', 'SENT', 'VIEWED', 'COMPLETED'] as const;
|
||||
export const INVOICE_PAID_FILTERS = ['UNPAID', 'PAID', 'PARTIALLY_PAID'] as const;
|
||||
export const ESTIMATE_STATUS_FILTERS = ['DRAFT', 'SENT', 'VIEWED', 'EXPIRED', 'ACCEPTED', 'REJECTED'] as const;
|
||||
export const RECURRING_STATUS_FILTERS = ['ACTIVE', 'ON_HOLD', 'COMPLETED'] as const;
|
||||
|
||||
/** Tabs above each list — `null` is "All". Mirrors upstream's tab sets exactly. */
|
||||
export const INVOICE_TABS = [
|
||||
{ id: null, label: 'All' },
|
||||
{ id: 'DRAFT', label: 'Draft' },
|
||||
{ id: 'SENT', label: 'Sent' },
|
||||
{ id: 'DUE', label: 'Due' },
|
||||
] as const;
|
||||
|
||||
export const ESTIMATE_TABS = [
|
||||
{ id: null, label: 'All' },
|
||||
{ id: 'DRAFT', label: 'Draft' },
|
||||
{ id: 'SENT', label: 'Sent' },
|
||||
] as const;
|
||||
|
||||
export const RECURRING_TABS = [
|
||||
{ id: null, label: 'All' },
|
||||
{ id: 'ACTIVE', label: 'Active' },
|
||||
{ id: 'ON_HOLD', label: 'On hold' },
|
||||
] as const;
|
||||
|
||||
/** Upstream paginates every list at 10. Matched so page numbers line up between the two UIs. */
|
||||
export const PAGE_SIZE = 10;
|
||||
|
||||
export const humanStatus = (value: string | null | undefined): string =>
|
||||
(value ?? '')
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/^./, (c) => c.toUpperCase());
|
||||
|
||||
// ── records ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Address = {
|
||||
id: number;
|
||||
name: string | null;
|
||||
address_street_1: string | null;
|
||||
address_street_2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
zip: string | null;
|
||||
phone: string | null;
|
||||
country_id: number | null;
|
||||
country?: { id: number; code: string; name: string } | null;
|
||||
};
|
||||
|
||||
export type Customer = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
contact_name: string | null;
|
||||
company_name: string | null;
|
||||
website: string | null;
|
||||
prefix: string | null;
|
||||
tax_id: string | null;
|
||||
currency_id: number | null;
|
||||
enable_portal: boolean;
|
||||
due_amount: Money;
|
||||
formatted_created_at: string | null;
|
||||
created_at: string | null;
|
||||
billing?: Address | null;
|
||||
shipping?: Address | null;
|
||||
// Deliberately unnamed — see the header note about embedded company payloads.
|
||||
company?: unknown;
|
||||
};
|
||||
|
||||
export type Unit = { id: number; name: string };
|
||||
export type TaxType = { id: number; name: string; percent: number | string; compound_tax: boolean | number };
|
||||
export type Category = { id: number; name: string };
|
||||
export type PaymentMethod = { id: number; name: string };
|
||||
|
||||
export type Item = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price: Money;
|
||||
unit_id: number | null;
|
||||
currency_id: number | null;
|
||||
tax_per_item: boolean;
|
||||
formatted_created_at: string | null;
|
||||
unit?: Unit | null;
|
||||
};
|
||||
|
||||
/** A line on an invoice or estimate. Same shape either way. */
|
||||
export type DocumentItem = {
|
||||
id: number;
|
||||
item_id: number | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
quantity: number | string;
|
||||
price: Money;
|
||||
discount_type: 'fixed' | 'percentage';
|
||||
discount: number | string;
|
||||
discount_val: Money;
|
||||
tax: Money;
|
||||
total: Money;
|
||||
unit_name: string | null;
|
||||
taxes?: DocumentTax[];
|
||||
};
|
||||
|
||||
export type DocumentTax = {
|
||||
id?: number;
|
||||
tax_type_id: number | null;
|
||||
name?: string;
|
||||
percent: number | string;
|
||||
amount: Money;
|
||||
compound_tax?: boolean | number;
|
||||
};
|
||||
|
||||
/** Shared spine of invoices, estimates and recurring invoices — they differ only at the edges. */
|
||||
type DocumentBase = {
|
||||
id: number;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
reference_number: string | null;
|
||||
customer_id: number;
|
||||
currency_id: number;
|
||||
unique_hash: string;
|
||||
template_name: string | null;
|
||||
sub_total: Money;
|
||||
total: Money;
|
||||
tax: Money;
|
||||
discount: number | string;
|
||||
discount_type: 'fixed' | 'percentage';
|
||||
discount_val: Money;
|
||||
tax_per_item: 'YES' | 'NO';
|
||||
discount_per_item: 'YES' | 'NO';
|
||||
sent: boolean;
|
||||
viewed: boolean;
|
||||
items: DocumentItem[];
|
||||
taxes?: DocumentTax[];
|
||||
customer?: Customer | null;
|
||||
formatted_created_at: string | null;
|
||||
};
|
||||
|
||||
export type Invoice = DocumentBase & {
|
||||
invoice_number: string;
|
||||
invoice_date: string;
|
||||
due_date: string | null;
|
||||
paid_status: PaidStatus;
|
||||
due_amount: Money;
|
||||
overdue: boolean;
|
||||
allow_edit: boolean;
|
||||
invoice_pdf_url: string | null;
|
||||
formatted_invoice_date: string | null;
|
||||
formatted_due_date: string | null;
|
||||
recurring_invoice_id: number | null;
|
||||
};
|
||||
|
||||
export type Estimate = DocumentBase & {
|
||||
estimate_number: string;
|
||||
estimate_date: string;
|
||||
expiry_date: string | null;
|
||||
estimate_pdf_url: string | null;
|
||||
formatted_estimate_date: string | null;
|
||||
formatted_expiry_date: string | null;
|
||||
/** Set once converted; the convert action is refused a second time. */
|
||||
invoice_id?: number | null;
|
||||
};
|
||||
|
||||
export type RecurringInvoice = DocumentBase & {
|
||||
starts_at: string | null;
|
||||
next_invoice_at: string | null;
|
||||
/** A five-field cron expression, not a keyword — see FREQUENCY_PRESETS. */
|
||||
frequency: string | null;
|
||||
limit_by: 'NONE' | 'COUNT' | 'DATE' | string | null;
|
||||
limit_count: number | null;
|
||||
limit_date: string | null;
|
||||
send_automatically: boolean;
|
||||
due_amount: Money;
|
||||
formatted_starts_at: string | null;
|
||||
formatted_next_invoice_at: string | null;
|
||||
formatted_limit_date: string | null;
|
||||
/** Present only once the schedule has generated something. */
|
||||
invoices?: Invoice[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A recurring invoice's `frequency` is a raw cron expression, which is unreadable in a table column.
|
||||
*
|
||||
* These are upstream's own presets, cron string for cron string (`recurring-invoices/store.ts` →
|
||||
* `initFrequencies`), in its order — common business intervals first, then the short ones. A schedule
|
||||
* created outside these presets keeps a cron expression we do not recognise, and `frequencyLabel` shows
|
||||
* it verbatim rather than guessing.
|
||||
*/
|
||||
export const FREQUENCY_PRESETS = [
|
||||
{ cron: '0 0 * * 0', label: 'Every week' },
|
||||
{ cron: '0 0 */14 * *', label: 'Every 2 weeks' },
|
||||
{ cron: '0 0 1 * *', label: 'Every month' },
|
||||
{ cron: '0 0 1 */2 *', label: 'Every 2 months' },
|
||||
{ cron: '0 0 1 */3 *', label: 'Every quarter' },
|
||||
{ cron: '0 0 1 */6 *', label: 'Every 6 months' },
|
||||
{ cron: '0 0 1 1 *', label: 'Every year' },
|
||||
{ cron: '0 0 * * *', label: 'Every day' },
|
||||
{ cron: '0 5 */15 * *', label: 'Every 15 days' },
|
||||
{ cron: '0 * * * *', label: 'Every hour' },
|
||||
{ cron: '* * * * *', label: 'Every minute' },
|
||||
] as const;
|
||||
|
||||
export const frequencyLabel = (cron: string | null | undefined): string =>
|
||||
FREQUENCY_PRESETS.find((f) => f.cron === cron)?.label ?? cron ?? '—';
|
||||
|
||||
export type Payment = {
|
||||
id: number;
|
||||
payment_number: string;
|
||||
payment_date: string;
|
||||
amount: Money;
|
||||
notes: string | null;
|
||||
unique_hash: string;
|
||||
invoice_id: number | null;
|
||||
customer_id: number;
|
||||
payment_method_id: number | null;
|
||||
currency_id: number;
|
||||
transaction_id: string | null;
|
||||
payment_pdf_url: string | null;
|
||||
formatted_payment_date: string | null;
|
||||
formatted_created_at: string | null;
|
||||
customer?: Customer | null;
|
||||
invoice?: Invoice | null;
|
||||
payment_method?: PaymentMethod | null;
|
||||
};
|
||||
|
||||
export type Expense = {
|
||||
id: number;
|
||||
expense_date: string;
|
||||
expense_number: string | null;
|
||||
amount: Money;
|
||||
notes: string | null;
|
||||
expense_category_id: number | null;
|
||||
payment_method_id: number | null;
|
||||
customer_id: number | null;
|
||||
currency_id: number;
|
||||
/** Absolute, rendered from the instance's APP_URL — usable in a browser, not from this process. */
|
||||
attachment_receipt_url: string | null;
|
||||
formatted_expense_date: string | null;
|
||||
formatted_created_at: string | null;
|
||||
/** `expense_category`, not `category` — the column is `expense_category_id` but the relation keeps the prefix. */
|
||||
expense_category?: Category | null;
|
||||
customer?: Customer | null;
|
||||
payment_method?: PaymentMethod | null;
|
||||
};
|
||||
|
||||
// ── envelopes ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type PageMeta = {
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
from: number | null;
|
||||
to: number | null;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type Paginated<T> = { data: T[]; meta?: PageMeta };
|
||||
|
||||
export type Summary = {
|
||||
me: { id: number; name: string; email: string } | null;
|
||||
company: { id: number; name: string; unique_hash: string; address?: Address | null; logo?: string | null } | null;
|
||||
currency: Currency | null;
|
||||
settings: Record<string, string> | null;
|
||||
dashboard: Dashboard | null;
|
||||
};
|
||||
|
||||
export type Dashboard = {
|
||||
total_amount_due: Money;
|
||||
total_customer_count: number;
|
||||
total_invoice_count: number;
|
||||
total_estimate_count: number;
|
||||
total_sales: Money;
|
||||
total_receipts: Money;
|
||||
total_expenses: Money;
|
||||
total_net_income: Money;
|
||||
recent_due_invoices: Invoice[];
|
||||
recent_estimates: Estimate[];
|
||||
chart_data: {
|
||||
months: string[];
|
||||
invoice_totals: Money[];
|
||||
expense_totals: Money[];
|
||||
receipt_totals: Money[];
|
||||
net_income_totals: Money[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* `GET /customers/{id}/stats` — the customer plus a twelve-month chart.
|
||||
*
|
||||
* The chart keys are camelCase here (`invoiceTotals`) where the company dashboard's are snake_case
|
||||
* (`invoice_totals`). That is upstream's inconsistency, not a transcription slip; the two shapes cannot share
|
||||
* a type.
|
||||
*/
|
||||
export type CustomerStats = {
|
||||
data?: Customer;
|
||||
meta?: {
|
||||
chartData?: {
|
||||
months: string[];
|
||||
invoiceTotals: Money[];
|
||||
expenseTotals: Money[];
|
||||
receiptTotals: Money[];
|
||||
netProfits: Money[];
|
||||
netProfit: Money;
|
||||
salesTotal: Money;
|
||||
totalReceipts: Money;
|
||||
totalExpenses: Money;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type Lookups = {
|
||||
customers: Customer[];
|
||||
items: Item[];
|
||||
units: Unit[];
|
||||
taxTypes: TaxType[];
|
||||
categories: Category[];
|
||||
paymentMethods: PaymentMethod[];
|
||||
currencies: Currency[];
|
||||
};
|
||||
|
||||
export const REPORT_KINDS = [
|
||||
{ id: 'sales-customers', label: 'Sales by customer' },
|
||||
{ id: 'sales-items', label: 'Sales by item' },
|
||||
{ id: 'tax-summary', label: 'Tax summary' },
|
||||
{ id: 'profit-loss', label: 'Profit & loss' },
|
||||
{ id: 'expenses', label: 'Expenses' },
|
||||
] as const;
|
||||
|
||||
export type ReportKind = (typeof REPORT_KINDS)[number]['id'];
|
||||
@@ -0,0 +1,574 @@
|
||||
import type { Currency, CustomerStats, Dashboard, Lookups, Paginated, ReportKind, Summary } from './shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
// Data layer for the /invoices panels. Everything goes through the /api/invoiceshelf auth proxy to the
|
||||
// officer-invoiceshelf sidecar, which holds the InvoiceShelf URL, the Sanctum token and the company header.
|
||||
//
|
||||
// Unlike the Transmission panels this does NOT poll. InvoiceShelf is a bookkeeping app whose records only
|
||||
// change when someone in this UI changes them — a background refetch would spend requests to re-render
|
||||
// identical rows. Freshness comes from invalidating after writes instead, plus a refetch when the window
|
||||
// regains focus in case the change was made in InvoiceShelf's own UI.
|
||||
//
|
||||
// Mutations invalidate the whole ['invoiceshelf'] prefix rather than patching caches. The totals are deeply
|
||||
// entangled — recording a payment moves the invoice's due_amount AND its paid_status AND the customer's
|
||||
// due_amount AND four dashboard figures — so a targeted patch would be subtly wrong far more often than it
|
||||
// would be cheap.
|
||||
|
||||
const ROOT = 'invoiceshelf';
|
||||
const BASE = '/invoices/_officer';
|
||||
|
||||
/** Records are stable between writes; this only bounds how long a remount reuses the cache. */
|
||||
const STALE_MS = 30_000;
|
||||
|
||||
const EMPTY: never[] = [];
|
||||
|
||||
const qs = (params?: Record<string, string | number | undefined | null>): string => {
|
||||
if (!params) return '';
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
search.set(key, String(value));
|
||||
}
|
||||
const out = search.toString();
|
||||
return out ? `?${out}` : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Pull a human message out of a thrown client error.
|
||||
*
|
||||
* useClient throws `{status, message}` where message is the raw response body, so the useful text is
|
||||
* usually one JSON level down. Laravel reports validation failures as `{message, errors: {field: [msg]}}`
|
||||
* and the field-level message is the specific one — "The due date must be a date after invoice date" beats
|
||||
* "The given data was invalid."
|
||||
*/
|
||||
function errorMessage(err: unknown, fallback: string): string {
|
||||
const raw = typeof err === 'object' && err !== null && 'message' in err ? String((err as Error).message) : '';
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: string; message?: string; errors?: Record<string, string[]> };
|
||||
const firstField = parsed.errors && Object.values(parsed.errors)[0]?.[0];
|
||||
return firstField || parsed.error || parsed.message || fallback;
|
||||
} catch {
|
||||
return raw || fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ── company-wide context ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The one call every panel needs: who we are, the company, its currency and the dashboard totals.
|
||||
*
|
||||
* The currency matters beyond the dashboard — it carries precision, separators and symbol placement, and
|
||||
* every money value in the UI is unrenderable without it. Panels take it from here rather than each
|
||||
* fetching it, so there is a single cache entry keeping them consistent.
|
||||
*/
|
||||
export function useSummary() {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ROOT, 'summary'] as const,
|
||||
queryFn: () => get<Summary>(`${BASE}/summary`),
|
||||
staleTime: STALE_MS,
|
||||
});
|
||||
|
||||
return {
|
||||
summary: query.data ?? null,
|
||||
currency: query.data?.currency ?? null,
|
||||
company: query.data?.company ?? null,
|
||||
dashboard: query.data?.dashboard ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience for the many components that need only the currency to render money. */
|
||||
export function useCurrency(): Currency | null {
|
||||
return useSummary().currency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every select-box option in one call: customers, items, units, tax types, categories, payment methods,
|
||||
* currencies. Held long because these are small, near-static reference lists that half the forms need.
|
||||
*/
|
||||
export function useLookups() {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ROOT, 'lookups'] as const,
|
||||
queryFn: () => get<Lookups>(`${BASE}/lookups`),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const data = query.data;
|
||||
return {
|
||||
customers: data?.customers ?? EMPTY,
|
||||
items: data?.items ?? EMPTY,
|
||||
units: data?.units ?? EMPTY,
|
||||
taxTypes: data?.taxTypes ?? EMPTY,
|
||||
categories: data?.categories ?? EMPTY,
|
||||
paymentMethods: data?.paymentMethods ?? EMPTY,
|
||||
currencies: data?.currencies ?? EMPTY,
|
||||
isLoading: query.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDashboard(): { dashboard: Dashboard | null; isLoading: boolean } {
|
||||
const { dashboard, isLoading } = useSummary();
|
||||
return { dashboard, isLoading };
|
||||
}
|
||||
|
||||
// ── generic resource access ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ListParams = Record<string, string | number | undefined | null>;
|
||||
|
||||
/**
|
||||
* A page of any allow-listed resource. `params` is passed to upstream verbatim, which is what makes one
|
||||
* hook enough for seven list screens — InvoiceShelf's index endpoints share a filter vocabulary
|
||||
* (`page`, `limit`, `search`, `customer_id`, `status`, `from_date`, `to_date`, `orderByField`, `orderBy`).
|
||||
*
|
||||
* `params` is spread into the query key, so changing a filter is a new cache entry rather than a refetch
|
||||
* of the old one — paging back and forth is then free.
|
||||
*/
|
||||
export function useResourceList<T>(resource: string, params?: ListParams, enabled = true) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ROOT, resource, 'list', params ?? {}] as const,
|
||||
queryFn: () => get<Paginated<T>>(`${BASE}/${resource}${qs(params)}`),
|
||||
enabled,
|
||||
staleTime: STALE_MS,
|
||||
// Keeps the previous page on screen while the next one loads, instead of flashing an empty table.
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
return {
|
||||
rows: query.data?.data ?? (EMPTY as T[]),
|
||||
meta: query.data?.meta ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One record by id. Upstream wraps singular reads in a `data` key for most resources but returns some bare,
|
||||
* so the response is unwrapped defensively rather than assuming either.
|
||||
*/
|
||||
export function useResourceDetail<T>(resource: string, id: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ROOT, resource, 'detail', id] as const,
|
||||
queryFn: async () => {
|
||||
const res = await get<{ data?: T } | T>(`${BASE}/${resource}/${id}`);
|
||||
return (res && typeof res === 'object' && 'data' in res ? (res as { data: T }).data : (res as T)) ?? null;
|
||||
},
|
||||
enabled: id != null,
|
||||
staleTime: STALE_MS,
|
||||
});
|
||||
|
||||
return { record: query.data ?? null, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create / update / delete / bulk-delete for one resource.
|
||||
*
|
||||
* All four invalidate the entire ['invoiceshelf'] prefix on success — see the header note on why targeted
|
||||
* cache patching is the wrong trade here.
|
||||
*/
|
||||
export function useResourceMutations(resource: string, label = resource) {
|
||||
const { post, put, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: [ROOT] });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (payload: unknown) => post<{ data?: unknown }>(`${BASE}/${resource}`, payload),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(`${singular(label)} created`);
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, `Could not create ${singular(label).toLowerCase()}`)),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: number; payload: unknown }) =>
|
||||
put<{ data?: unknown }>(`${BASE}/${resource}/${id}`, payload),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(`${singular(label)} saved`);
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, `Could not save ${singular(label).toLowerCase()}`)),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => del<unknown>(`${BASE}/${resource}/${id}`),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success(`${singular(label)} deleted`);
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, `Could not delete ${singular(label).toLowerCase()}`)),
|
||||
});
|
||||
|
||||
const removeMany = useMutation({
|
||||
mutationFn: (ids: number[]) => post<unknown>(`${BASE}/${resource}/delete`, { ids }),
|
||||
onSuccess: (_data, ids) => {
|
||||
invalidate();
|
||||
toast.success(`${ids.length} ${ids.length === 1 ? singular(label).toLowerCase() : label} deleted`);
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, `Could not delete ${label}`)),
|
||||
});
|
||||
|
||||
return { create, update, remove, removeMany };
|
||||
}
|
||||
|
||||
const singular = (label: string): string => {
|
||||
const base = label.endsWith('ies') ? `${label.slice(0, -3)}y` : label.endsWith('s') ? label.slice(0, -1) : label;
|
||||
return base.charAt(0).toUpperCase() + base.slice(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* The per-document verbs: status, clone, send, convert-to-invoice, duplicate.
|
||||
*
|
||||
* `send` genuinely emails the customer. It is reachable from here because the UI needs it, but nothing in
|
||||
* this module calls it on its own — it must always be behind an explicit confirm step, never a side effect
|
||||
* of saving or of any other action.
|
||||
*/
|
||||
export function useDocumentAction(resource: string) {
|
||||
const { post } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, action, payload }: { id: number; action: string; payload?: unknown }) =>
|
||||
post<unknown>(`${BASE}/${resource}/${id}/${action}`, payload ?? {}),
|
||||
onSuccess: (_data, vars) => {
|
||||
qc.invalidateQueries({ queryKey: [ROOT] });
|
||||
toast.success(ACTION_TOAST[vars.action] ?? 'Done');
|
||||
},
|
||||
onError: (err) => toast.error(errorMessage(err, 'Action failed')),
|
||||
});
|
||||
}
|
||||
|
||||
const ACTION_TOAST: Record<string, string> = {
|
||||
status: 'Status updated',
|
||||
clone: 'Copy created',
|
||||
send: 'Sent',
|
||||
'convert-to-invoice': 'Converted to invoice',
|
||||
duplicate: 'Duplicated',
|
||||
};
|
||||
|
||||
/** The number the next document of this kind would take, for the create form's placeholder. */
|
||||
export function useNextNumber(key: 'invoice' | 'estimate' | 'payment', enabled = true) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ROOT, 'next-number', key] as const,
|
||||
queryFn: () => get<{ nextNumber?: string; nextSequenceNumber?: number }>(`${BASE}/next-number?key=${key}`),
|
||||
enabled,
|
||||
// Never cached: two create forms opened in sequence must not both claim the same number.
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
|
||||
return query.data?.nextNumber ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The PDF templates a document can be rendered with. `template_name` is a *required* field on every
|
||||
* invoice/estimate write, so the create form needs this before it can save anything — it is not decoration.
|
||||
*/
|
||||
export function useTemplates(resource: 'invoices' | 'estimates') {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ROOT, resource, 'templates'] as const,
|
||||
queryFn: () => get<{ [key: string]: unknown }>(`${BASE}/${resource}/templates`),
|
||||
staleTime: 60 * 60_000,
|
||||
});
|
||||
|
||||
// Upstream keys the payload by resource — `{invoiceTemplates: […]}` / `{estimateTemplates: […]}` — so it
|
||||
// is read positionally rather than by a name that differs per endpoint.
|
||||
const list = Object.values(query.data ?? {}).find(Array.isArray) as { name: string; path?: string }[] | undefined;
|
||||
return list ?? (EMPTY as { name: string; path?: string }[]);
|
||||
}
|
||||
|
||||
export function useCustomerStats(id: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [ROOT, 'customers', 'stats', id] as const,
|
||||
queryFn: () => get<CustomerStats>(`${BASE}/customers/${id}/stats`),
|
||||
enabled: id != null,
|
||||
staleTime: STALE_MS,
|
||||
});
|
||||
|
||||
return { stats: query.data ?? null, isLoading: query.isLoading };
|
||||
}
|
||||
|
||||
// ── PDFs ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type PdfState = { url: string | null; isLoading: boolean; error: string | null };
|
||||
|
||||
/**
|
||||
* Fetch a PDF and expose it as an object URL an <iframe> can render.
|
||||
*
|
||||
* It has to be done this way round: the sidecar sits behind Officer's bearer auth, and a naked
|
||||
* `<iframe src="/api/...">` cannot carry an Authorization header — it would fetch unauthenticated and
|
||||
* render a 401 body. So the bytes are pulled with the authenticated client and handed to the iframe as a
|
||||
* blob: URL instead.
|
||||
*
|
||||
* The previous URL is revoked whenever the target changes and on unmount; without that, every document a
|
||||
* user clicks through leaks its PDF into memory until the tab closes.
|
||||
*/
|
||||
export function usePdfUrl(resource: string, id: number | null, enabled = true) {
|
||||
const { getBlob } = useClient();
|
||||
const [state, setState] = useState<PdfState>({ url: null, isLoading: false, error: null });
|
||||
|
||||
useEffect(() => {
|
||||
if (id == null || !enabled) {
|
||||
setState({ url: null, isLoading: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
let objectUrl: string | null = null;
|
||||
// Guards against an out-of-order response overwriting a newer one when the user clicks quickly.
|
||||
let cancelled = false;
|
||||
|
||||
setState({ url: null, isLoading: true, error: null });
|
||||
|
||||
getBlob(`${BASE}/${resource}/${id}/pdf`)
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setState({ url: objectUrl, isLoading: false, error: null });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState({ url: null, isLoading: false, error: errorMessage(err, 'Could not load PDF') });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
// getBlob is recreated on every useClient() call, so depending on it would refetch forever.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resource, id, enabled]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Same trick for the report PDFs, which are parameterised by date range rather than by id. */
|
||||
export function useReportUrl(kind: ReportKind | null, params: ListParams, enabled = true) {
|
||||
const { getBlob } = useClient();
|
||||
const [state, setState] = useState<PdfState>({ url: null, isLoading: false, error: null });
|
||||
const key = `${kind ?? ''}${qs(params)}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!kind || !enabled) {
|
||||
setState({ url: null, isLoading: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
let objectUrl: string | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
setState({ url: null, isLoading: true, error: null });
|
||||
|
||||
getBlob(`${BASE}/reports/${kind}${qs(params)}`)
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setState({ url: objectUrl, isLoading: false, error: null });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState({ url: null, isLoading: false, error: errorMessage(err, 'Could not build report') });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
// `key` collapses kind+params into one comparable string; see the note in usePdfUrl about getBlob.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, enabled]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Save a fetched PDF to disk under a readable name. */
|
||||
export function downloadPdf(url: string, filename: string) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename.endsWith('.pdf') ? filename : `${filename}.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
export { errorMessage };
|
||||
|
||||
// ── accounts ─────────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The InvoiceShelf URL, its Sanctum token and the company it acts for are the owner's to set from
|
||||
// /invoices/settings; nothing reads them from the environment any more. It is a REGISTRY — any number of
|
||||
// labelled accounts, one of them selected — and two accounts differing only by company is the normal case,
|
||||
// because the `company` header silently returns another company's books rather than erroring.
|
||||
//
|
||||
// The token is WRITE-ONLY across this boundary: an account carries its label, URL and pinned company, and
|
||||
// has no field that could carry the token back to the browser.
|
||||
|
||||
const CONFIG = '/invoices/_config';
|
||||
|
||||
export type InvoiceShelfHealth = {
|
||||
ok: boolean;
|
||||
/** False only when no account is stored. It separates "set this up" from "this used to work". */
|
||||
configured?: boolean;
|
||||
/** Label of the selected account, so a failure names which books did not answer. */
|
||||
account?: string;
|
||||
version?: string | null;
|
||||
company?: number | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type InvoiceShelfAccount = {
|
||||
id: number;
|
||||
label: string;
|
||||
url: string;
|
||||
companyId: number | null;
|
||||
companyName: string | null;
|
||||
version: string | null;
|
||||
isActive: boolean;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type InvoiceShelfAccounts = { configured: boolean; activeId: number | null; accounts: InvoiceShelfAccount[] };
|
||||
|
||||
export type CompanyOption = { id: number; name: string | null };
|
||||
|
||||
/**
|
||||
* Unwrap a thrown client error into the shape the connection form needs.
|
||||
*
|
||||
* `needsChoice` is the interesting one: adding an account whose token can see several companies is a 409
|
||||
* carrying the list, because nothing is wrong with what was sent — it just is not enough to decide. The form
|
||||
* turns that into a picker rather than an error.
|
||||
*/
|
||||
export function parseAccountError(err: unknown): { message: string; needsChoice: boolean; companies: CompanyOption[] } {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
const fallback = { message: 'Something went wrong', needsChoice: false, companies: [] as CompanyOption[] };
|
||||
if (typeof raw !== 'string' || !raw) return fallback;
|
||||
try {
|
||||
const body = JSON.parse(raw) as { error?: string; needsChoice?: boolean; companies?: CompanyOption[] };
|
||||
return {
|
||||
message: body.error || raw.slice(0, 300),
|
||||
needsChoice: !!body.needsChoice,
|
||||
companies: body.companies ?? [],
|
||||
};
|
||||
} catch {
|
||||
return { ...fallback, message: raw.slice(0, 300) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health, including its failure bodies.
|
||||
*
|
||||
* `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two cases
|
||||
* the UI most needs to tell apart — 503 not connected and 502 connected-but-broken. Both carry a JSON body,
|
||||
* so the throw is turned back into the answer rather than an error state.
|
||||
*/
|
||||
export function useInvoiceShelfHealth() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [ROOT, 'health'] as const,
|
||||
queryFn: async (): Promise<InvoiceShelfHealth> => {
|
||||
try {
|
||||
return await get<InvoiceShelfHealth>('/invoices/_health');
|
||||
} catch (err) {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const body = JSON.parse(raw) as InvoiceShelfHealth;
|
||||
if (body && body.ok === false) return body;
|
||||
} catch {
|
||||
/* not the sidecar's body */
|
||||
}
|
||||
}
|
||||
// Anything else — the platform proxy, auth, the sidecar being down — is a configured instance that is
|
||||
// failing, not an unconfigured one. Never offer the setup form on a guess.
|
||||
return { ok: false, configured: true, error: parseAccountError(err).message };
|
||||
}
|
||||
},
|
||||
staleTime: STALE_MS,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvoiceShelfAccounts() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [ROOT, 'accounts'] as const,
|
||||
queryFn: () => get<InvoiceShelfAccounts>(CONFIG),
|
||||
staleTime: STALE_MS,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** The companies a stored account's token can act for — what the company picker is populated from. */
|
||||
export function useAccountCompanies(id: number | null) {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [ROOT, 'accounts', id, 'companies'] as const,
|
||||
queryFn: () => get<{ companies: CompanyOption[]; companyId: number | null }>(`${CONFIG}/${id}/companies`),
|
||||
enabled: id != null,
|
||||
staleTime: STALE_MS,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Either a pasted Sanctum token or a sign-in the sidecar trades for one.
|
||||
*
|
||||
* The password goes to the sidecar, which uses it for the single `/auth/login` call and stores only the token
|
||||
* it gets back. It exists because InvoiceShelf 2.4.2 has no screen that issues tokens at all.
|
||||
*/
|
||||
export type Credential = { token: string } | { email: string; password: string };
|
||||
|
||||
export type AddAccountInput = { label: string; url: string; companyId?: number | null } & Credential;
|
||||
export type EditAccountInput = { id: number; label?: string; url?: string; companyId?: number | null } & Partial<
|
||||
Record<'token' | 'email' | 'password', string>
|
||||
>;
|
||||
|
||||
export function useInvoiceShelfAccountActions() {
|
||||
const { post, patch, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
// Adding, editing, switching and removing all change what every other query here can even answer — a
|
||||
// switch in particular changes the answer to all of them without changing any of their inputs.
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: [ROOT] });
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (input: AddAccountInput) => post<{ account: InvoiceShelfAccount }>(CONFIG, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const edit = useMutation({
|
||||
mutationFn: ({ id, ...rest }: EditAccountInput) => patch<{ account: InvoiceShelfAccount }>(`${CONFIG}/${id}`, rest),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (id: number) => post<InvoiceShelfAccounts>(`${CONFIG}/${id}/activate`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => del<InvoiceShelfAccounts>(`${CONFIG}/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { add, edit, activate, remove };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
import { DEFAULT_INVOICES_SECTION, isInvoicesSection, type InvoicesSectionId } from './shared';
|
||||
|
||||
// The URL names the open section and the selected record — not a panel channel. See docs/navigation-audit.md.
|
||||
// InvoicesScreen redirects anything unrecognised, so the fallback here only covers the instant before that
|
||||
// lands.
|
||||
|
||||
export function useInvoicesSection(): InvoicesSectionId {
|
||||
const { section } = useParams();
|
||||
return isInvoicesSection(section) ? section : DEFAULT_INVOICES_SECTION;
|
||||
}
|
||||
|
||||
export type ListFilters = {
|
||||
/** The record whose detail pane is open. `?selected=` because the list stays on screen beside it. */
|
||||
selected: number | null;
|
||||
search: string;
|
||||
status: string | null;
|
||||
customerId: number | null;
|
||||
fromDate: string | null;
|
||||
toDate: string | null;
|
||||
page: number;
|
||||
isFiltered: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read/write the list query string.
|
||||
*
|
||||
* Every setter drops `page` back to 1 — changing a filter while on page 4 of the old result set otherwise
|
||||
* lands you on a page that may not exist, and upstream answers that with an empty array rather than an
|
||||
* error, which reads as "no results" for a filter that has plenty.
|
||||
*
|
||||
* Selection is `replace: true` so clicking through a list does not bury the previous screen under a dozen
|
||||
* history entries; filter changes push normally, because backing out of a filter is a thing people want.
|
||||
*/
|
||||
export function useListFilters() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const filters = useMemo<ListFilters>(() => {
|
||||
const num = (key: string) => {
|
||||
const raw = searchParams.get(key);
|
||||
const n = raw == null ? NaN : Number(raw);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
};
|
||||
const status = searchParams.get('status');
|
||||
const search = searchParams.get('q') ?? '';
|
||||
const customerId = num('customer');
|
||||
const fromDate = searchParams.get('from');
|
||||
const toDate = searchParams.get('to');
|
||||
|
||||
return {
|
||||
selected: num('selected'),
|
||||
search,
|
||||
status,
|
||||
customerId,
|
||||
fromDate,
|
||||
toDate,
|
||||
page: num('page') ?? 1,
|
||||
isFiltered: Boolean(search || status || customerId || fromDate || toDate),
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
const patch = useCallback(
|
||||
(changes: Record<string, string | number | null>, opts?: { replace?: boolean; keepPage?: boolean }) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value === null || value === '') next.delete(key);
|
||||
else next.set(key, String(value));
|
||||
}
|
||||
if (!opts?.keepPage && !('page' in changes)) next.delete('page');
|
||||
return next;
|
||||
},
|
||||
{ replace: opts?.replace ?? false },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const select = useCallback(
|
||||
(id: number | null) => patch({ selected: id }, { replace: true, keepPage: true }),
|
||||
[patch],
|
||||
);
|
||||
|
||||
const setPage = useCallback((page: number) => patch({ page: page <= 1 ? null : page }, { keepPage: true }), [patch]);
|
||||
|
||||
const clearFilters = useCallback(
|
||||
() => patch({ q: null, status: null, customer: null, from: null, to: null }),
|
||||
[patch],
|
||||
);
|
||||
|
||||
return { filters, patch, select, setPage, clearFilters };
|
||||
}
|
||||
Reference in New Issue
Block a user