Rechnungsmodul: Entwurf/Ausstellen/Bezahlt/Storno, PDF, Firmenstammdaten
This commit is contained in:
parent
012f0aa0db
commit
43dc5bc827
18 changed files with 783 additions and 3 deletions
42
apps/web/src/components/InvoiceList.tsx
Normal file
42
apps/web/src/components/InvoiceList.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
'use client';
|
||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { Alert, Empty, Field, InvoiceStatusBadge, eur, fmt } from '@/components/ui';
|
||||
|
||||
interface Inv { id: string; number: string | null; orgId: string; orgName: string; customerNumber: string; status: string; overdue: boolean; issueDate: string | null; dueDate: string | null; totalGrossCents: number; createdAt: string }
|
||||
interface Cust { id: string; name: string; customerNumber: string }
|
||||
|
||||
export function InvoiceList({ orgId }: { orgId?: string }) {
|
||||
const { can } = useSession(); const w = can('invoices.write');
|
||||
const [list, setList] = useState<Inv[] | null>(null); const [status, setStatus] = useState(''); const [err, setErr] = useState('');
|
||||
const [open, setOpen] = useState(false); const [custs, setCusts] = useState<Cust[]>([]); const [newOrg, setNewOrg] = useState(orgId ?? '');
|
||||
const load = useCallback(() => api<Inv[]>('GET', `/invoices?${new URLSearchParams({ ...(orgId ? { org: orgId } : {}), ...(status ? { status } : {}) })}`).then(setList).catch((e) => setErr(errMsg(e))), [orgId, status]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
useEffect(() => { if (w && !orgId) api<Cust[]>('GET', '/admin/customers').then((l) => { setCusts(l); setNewOrg(l[0]?.id ?? ''); }).catch(() => undefined); }, [w, orgId]);
|
||||
|
||||
async function create(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setErr(''); const f = new FormData(e.currentTarget);
|
||||
try { const inv = await api<{ id: string }>('POST', '/invoices', { orgId: newOrg, paymentMethod: f.get('paymentMethod') || undefined, note: f.get('note') || undefined }); location.href = `/rechnungen/${inv.id}`; }
|
||||
catch (x) { setErr(errMsg(x)); }
|
||||
}
|
||||
return (<>
|
||||
{err && <Alert kind="err">{err}</Alert>}
|
||||
{w && <div className="row between" style={{ marginBottom: 16 }}><h2 style={{ margin: 0 }}>Rechnungen</h2><button className="btn primary" onClick={() => setOpen(!open)} aria-expanded={open}>Neue Rechnung (Entwurf)</button></div>}
|
||||
{open && <form className="card" onSubmit={create}>
|
||||
{!orgId && <Field id="org" label="Kunde"><select id="org" value={newOrg} onChange={(e) => setNewOrg(e.target.value)}>{custs.map((c) => <option key={c.id} value={c.id}>{c.customerNumber} · {c.name}</option>)}</select></Field>}
|
||||
<Field id="paymentMethod" label="Zahlungsart (optional)"><input id="paymentMethod" name="paymentMethod" /></Field>
|
||||
<Field id="note" label="Notiz (optional)"><input id="note" name="note" maxLength={500} /></Field>
|
||||
<div className="row"><button className="btn primary" type="submit" disabled={!newOrg}>Anlegen</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div>
|
||||
</form>}
|
||||
<div className="card">
|
||||
{w && <Field id="ifs" label="Status"><select id="ifs" value={status} onChange={(e) => setStatus(e.target.value)}><option value="">Alle</option><option value="draft">Entwurf</option><option value="open">Offen</option><option value="paid">Bezahlt</option><option value="cancelled">Storniert</option></select></Field>}
|
||||
{list === null ? <p className="muted" role="status">Wird geladen …</p> : list.length === 0 ? <Empty title="Keine Rechnungen" /> :
|
||||
<div className="tablewrap"><table><thead><tr><th>Nr.</th>{!orgId && <th>Kunde</th>}<th>Status</th><th>Datum</th><th>Fällig</th><th>Betrag</th></tr></thead><tbody>
|
||||
{list.map((i) => (<tr key={i.id}><td className="mono"><Link href={`/rechnungen/${i.id}`}>{i.number ?? 'Entwurf'}</Link></td>{!orgId && <td>{i.orgName}</td>}
|
||||
<td><InvoiceStatusBadge value={i.status} overdue={i.overdue} /></td><td className="small">{i.issueDate ? fmt(i.issueDate) : '–'}</td><td className="small">{i.dueDate ? fmt(i.dueDate) : '–'}</td><td>{eur(i.totalGrossCents)}</td></tr>))}
|
||||
</tbody></table></div>}
|
||||
</div>
|
||||
</>);
|
||||
}
|
||||
|
|
@ -45,6 +45,8 @@ const CONTRACT: Record<string, [string, string, string]> = {
|
|||
const badge = (m: Record<string, [string, string, string]>, v: string) => { const [c, i, l] = m[v] ?? ['info', '○', v]; return <span className={`badge ${c}`}><span aria-hidden="true">{i}</span>{l}</span>; };
|
||||
export const OrderStatusBadge = ({ value }: { value: string }) => badge(ORDER, value);
|
||||
export const ContractStatusBadge = ({ value }: { value: string }) => badge(CONTRACT, value);
|
||||
const INVOICE: Record<string, [string, string, string]> = { draft: ['info', '○', 'Entwurf'], open: ['warn', '◐', 'Offen'], paid: ['ok', '✓', 'Bezahlt'], cancelled: ['lock', '■', 'Storniert'] };
|
||||
export const InvoiceStatusBadge = ({ value, overdue }: { value: string; overdue?: boolean }) => overdue ? <span className="badge err"><span aria-hidden="true">▲</span>Überfällig</span> : badge(INVOICE, value);
|
||||
const TICKET: Record<string, [string, string, string]> = {
|
||||
open: ['info', '○', 'Offen'], pending_staff: ['warn', '▲', 'Wartet auf Personal'], pending_customer: ['info', '◐', 'Wartet auf Sie'],
|
||||
resolved: ['ok', '✓', 'Gelöst'], closed: ['lock', '■', 'Geschlossen'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue