Rechnungsmodul: Entwurf/Ausstellen/Bezahlt/Storno, PDF, Firmenstammdaten

This commit is contained in:
Kundencenter 2026-09-27 09:05:13 +02:00
parent 012f0aa0db
commit 43dc5bc827
18 changed files with 783 additions and 3 deletions

View file

@ -6,6 +6,7 @@ import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { SecretField } from '@/components/SecretField';
import { DomainRecords } from '@/components/DomainRecords';
import { InvoiceList } from '@/components/InvoiceList';
import { Alert, ContractStatusBadge, Empty, Field, fmt, OrderStatusBadge, ResState, ROLE_LABEL, Status } from '@/components/ui';
interface Org { id: string; name: string; customerNumber: string; customerType: 'private' | 'business'; status: string; billing: Record<string, string | null>; members: { id: string; email: string; name: string; status: string; role: string }[] }
@ -60,6 +61,7 @@ export default function Kunde() {
<div className="tablewrap"><table><thead><tr><th>Name</th><th>Status</th><th>Gültig bis</th><th>Lizenzschlüssel</th></tr></thead><tbody>
{ress.map((r) => <tr key={r.id}><td><Link href={`/ressourcen/${r.id}`}>{r.name}</Link>{r.stale && <> <span className="badge warn"><span aria-hidden="true">▲</span>Veraltet</span></>}</td><td><ResState value={r.state} /></td><td>{r.validUntil ? fmt(r.validUntil) : 'unbefristet'}</td><td>{r.canReveal ? <SecretField resourceId={r.id} compact /> : <span className="muted small">–</span>}</td></tr>)}</tbody></table></div>}</div>
{can('domains.read') && <section aria-labelledby="h-dom"><h2 id="h-dom">Domains</h2><DomainRecords orgId={String(id)} /></section>}
{can('invoices.read') && <section aria-labelledby="h-inv"><InvoiceList orgId={String(id)} /></section>}
<div className="card"><h2>Bestellungen</h2>
{orders === null ? <p className="muted" role="status">Wird geladen …</p> : orders.length === 0 ? <Empty title="Noch keine Bestellungen" /> :
<div className="tablewrap"><table><thead><tr><th>Nr.</th><th>Positionen</th><th>Bestellt am</th><th>Status</th></tr></thead><tbody>

View file

@ -0,0 +1,60 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field } from '@/components/ui';
interface Settings { name: string | null; street: string | null; zip: string | null; city: string | null; country: string; taxNumber: string | null; vatId: string | null; bankName: string | null; iban: string | null; bic: string | null; invoicePrefix: string; defaultDueDays: number; paymentMethods: string[]; footerText: string | null; complete: boolean }
export default function Firma() {
const { can } = useSession(); const w = can('settings.write');
const [s, setS] = useState<Settings | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState('');
const load = useCallback(() => api<Settings>('GET', '/admin/company-settings').then(setS).catch((e) => setErr(errMsg(e))), []);
useEffect(() => { void load(); }, [load]);
async function save(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); setErr(''); setOk(''); const f = new FormData(e.currentTarget); const v = (k: string) => (String(f.get(k) ?? '').trim() || undefined);
try {
await api('PUT', '/admin/company-settings', {
name: v('name'), street: v('street'), zip: v('zip'), city: v('city'), country: v('country') ?? 'DE',
taxNumber: v('taxNumber'), vatId: v('vatId'), bankName: v('bankName'), iban: v('iban'), bic: v('bic'),
invoicePrefix: v('invoicePrefix'), defaultDueDays: Number(f.get('defaultDueDays') ?? 14),
paymentMethods: String(f.get('paymentMethods') ?? '').split(',').map((x) => x.trim()).filter(Boolean),
footerText: v('footerText') ?? null,
});
setOk('Gespeichert.'); void load();
} catch (x) { setErr(errMsg(x)); }
}
if (!s) return err ? <Alert kind="err">{err}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
return (<>
<h2>Firmenstammdaten</h2>
<p className="muted">Erscheinen auf jeder Rechnung. Ohne Name, Anschrift und Steuernummer/USt-IdNr. können keine Rechnungen ausgestellt werden.</p>
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
{!s.complete && <Alert kind="warn">Die Angaben sind noch unvollständig. Rechnungen lassen sich erst ausstellen, wenn Name, Anschrift und Steuernummer oder USt-IdNr. eingetragen sind.</Alert>}
<form className="card" onSubmit={save}>
<div className="cols">
<Field id="name" label="Firmenname"><input id="name" name="name" defaultValue={s.name ?? ''} disabled={!w} /></Field>
<Field id="street" label="Straße, Nr."><input id="street" name="street" defaultValue={s.street ?? ''} disabled={!w} /></Field>
<Field id="zip" label="PLZ"><input id="zip" name="zip" defaultValue={s.zip ?? ''} disabled={!w} /></Field>
<Field id="city" label="Ort"><input id="city" name="city" defaultValue={s.city ?? ''} disabled={!w} /></Field>
<Field id="country" label="Land (ISO)"><input id="country" name="country" maxLength={2} defaultValue={s.country} disabled={!w} /></Field>
<Field id="taxNumber" label="Steuernummer"><input id="taxNumber" name="taxNumber" defaultValue={s.taxNumber ?? ''} disabled={!w} /></Field>
<Field id="vatId" label="USt-IdNr." hint="Optional, ersetzt bei Bedarf die Steuernummer"><input id="vatId" name="vatId" defaultValue={s.vatId ?? ''} disabled={!w} /></Field>
</div>
<h3>Bankverbindung</h3>
<div className="cols">
<Field id="bankName" label="Bank"><input id="bankName" name="bankName" defaultValue={s.bankName ?? ''} disabled={!w} /></Field>
<Field id="iban" label="IBAN"><input id="iban" name="iban" defaultValue={s.iban ?? ''} disabled={!w} /></Field>
<Field id="bic" label="BIC"><input id="bic" name="bic" defaultValue={s.bic ?? ''} disabled={!w} /></Field>
</div>
<h3>Rechnungseinstellungen</h3>
<div className="cols">
<Field id="invoicePrefix" label="Rechnungsnummern-Präfix" hint="z. B. RE → RE-1000, RE-1001, …"><input id="invoicePrefix" name="invoicePrefix" defaultValue={s.invoicePrefix} disabled={!w} /></Field>
<Field id="defaultDueDays" label="Zahlungsziel (Tage)"><input id="defaultDueDays" name="defaultDueDays" type="number" min={0} max={180} defaultValue={s.defaultDueDays} disabled={!w} /></Field>
<Field id="paymentMethods" label="Zahlungsarten" hint="kommagetrennt"><input id="paymentMethods" name="paymentMethods" defaultValue={s.paymentMethods.join(', ')} disabled={!w} /></Field>
</div>
<Field id="footerText" label="Fußzeile auf der Rechnung (optional)" hint="z. B. Geschäftsführer, Registergericht"><input id="footerText" name="footerText" defaultValue={s.footerText ?? ''} disabled={!w} /></Field>
{w ? <button className="btn primary" type="submit">Speichern</button> : <p className="muted small">Nur Superadministratoren können diese Angaben ändern.</p>}
</form>
</>);
}

View file

@ -11,6 +11,7 @@ export default function SettingsLayout({ children }: { children: ReactNode }) {
const tabs = [
{ href: '/einstellungen/verbindungen', label: 'Verbindungen', show: can('connectors.read') },
{ href: '/einstellungen/backup', label: 'Backup', show: can('backup.read') },
{ href: '/einstellungen/firma', label: 'Firma', show: can('invoices.read') },
].filter((t) => t.show);
if (tabs.length === 0) return <Alert kind="err">Keine Berechtigung.</Alert>;
return (<>

View file

@ -23,6 +23,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{!enrollNeeded && (me.kind === 'customer' || can('contracts.read')) && link('/vertraege', 'Verträge')}
{!enrollNeeded && (me.kind === 'customer' || can('orders.read')) && link('/bestellungen', 'Bestellungen')}
{!enrollNeeded && (me.kind === 'customer' || can('tickets.read')) && link('/tickets', 'Tickets')}
{!enrollNeeded && (me.kind === 'customer' || can('invoices.read')) && link('/rechnungen', 'Rechnungen')}
{!enrollNeeded && me.organizations.length > 0 && link('/organisation', 'Organisation')}
{!enrollNeeded && link('/domains', 'Domain prüfen')}
{!enrollNeeded && me.kind === 'staff' && <>

View file

@ -0,0 +1,72 @@
'use client';
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { api, errMsg } from '@/lib/api';
import { useSession } from '@/lib/session';
import { Alert, Field, InvoiceStatusBadge, eur, fmt } from '@/components/ui';
interface Item { id: string; description: string; quantity: number; unitPriceNetCents: number; taxBp: number; netCents: number; taxCents: number; grossCents: number }
interface Inv { id: string; number: string | null; orgId: string; orgName: string; status: string; overdue: boolean; issueDate: string | null; dueDate: string | null; paymentMethod: string | null; note: string | null; totalNetCents: number; totalTaxCents: number; totalGrossCents: number; cancelsInvoiceId: string | null; cancelledByInvoiceId: string | null; items: Item[]; canWrite: boolean }
type Row = { description: string; quantity: string; unitPrice: string; taxBp: string };
const emptyRow = (): Row => ({ description: '', quantity: '1', unitPrice: '', taxBp: '19' });
export default function RechnungDetail() {
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('invoices.read');
const [inv, setInv] = useState<Inv | null>(null); const [err, setErr] = useState(''); const [ok, setOk] = useState('');
const [rows, setRows] = useState<Row[]>([emptyRow()]); const [busy, setBusy] = useState(false);
const load = useCallback(() => api<Inv>('GET', `/invoices/${id}`).then((v) => { setInv(v); if (v.status === 'draft') setRows(v.items.length ? v.items.map((it) => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100) })) : [emptyRow()]); }).catch((e) => setErr(errMsg(e))), [id]);
useEffect(() => { void load(); }, [load]);
const run = async (fn: () => Promise<unknown>, msg?: string) => { setErr(''); setOk(''); setBusy(true); try { await fn(); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } };
async function saveItems(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const items = rows.filter((r) => r.description.trim() && r.unitPrice.trim()).map((r) => ({ description: r.description.trim(), quantity: Number(r.quantity.replace(',', '.')), unitPriceNetCents: Math.round(Number(r.unitPrice.replace(',', '.')) * 100), taxBp: Math.round(Number(r.taxBp.replace(',', '.')) * 100) }));
if (items.length === 0) { setErr('Bitte mindestens eine Position angeben.'); return; }
await run(() => api('PUT', `/invoices/${id}/items`, { items }), 'Gespeichert.');
}
const setRow = (i: number, k: keyof Row, v: string) => setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, [k]: v } : r)));
if (!inv) return err ? <Alert kind="err">{err}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
const isDraft = inv.status === 'draft';
return (<>
<div className="row between"><h1>{inv.number ?? 'Entwurf'} {inv.orgName && <span className="muted">· {inv.orgName}</span>}</h1><Link className="btn" href="/rechnungen">← Rechnungen</Link></div>
<p className="muted small"><InvoiceStatusBadge value={inv.status} overdue={inv.overdue} />{inv.issueDate && <> · ausgestellt {fmt(inv.issueDate)}</>}{inv.dueDate && <> · fällig {fmt(inv.dueDate)}</>}{inv.paymentMethod && <> · {inv.paymentMethod}</>}</p>
{inv.cancelsInvoiceId && <Alert kind="info">Dies ist eine Stornorechnung zu <Link href={`/rechnungen/${inv.cancelsInvoiceId}`}>einer anderen Rechnung</Link>.</Alert>}
{inv.cancelledByInvoiceId && <Alert kind="warn">Diese Rechnung wurde storniert. Storno: <Link href={`/rechnungen/${inv.cancelledByInvoiceId}`}>ansehen</Link></Alert>}
{err && <Alert kind="err">{err}</Alert>}{ok && <Alert kind="ok">{ok}</Alert>}
{isDraft && inv.canWrite ? (<form className="card" onSubmit={saveItems}><h2>Positionen</h2>
{rows.map((r, i) => (<div className="cols" key={i} style={{ gridTemplateColumns: '3fr 1fr 1fr 1fr auto', alignItems: 'end' }}>
<Field id={`d${i}`} label="Beschreibung"><input id={`d${i}`} value={r.description} onChange={(e) => setRow(i, 'description', e.target.value)} /></Field>
<Field id={`q${i}`} label="Menge"><input id={`q${i}`} value={r.quantity} onChange={(e) => setRow(i, 'quantity', e.target.value)} /></Field>
<Field id={`u${i}`} label="Einzelpreis netto (€)"><input id={`u${i}`} value={r.unitPrice} onChange={(e) => setRow(i, 'unitPrice', e.target.value)} placeholder="0,00" /></Field>
<Field id={`t${i}`} label="USt. %"><input id={`t${i}`} value={r.taxBp} onChange={(e) => setRow(i, 'taxBp', e.target.value)} /></Field>
<button className="btn small" type="button" onClick={() => setRows((rs) => rs.filter((_, idx) => idx !== i))} disabled={rows.length === 1}>Entfernen</button>
</div>))}
<div className="row" style={{ margin: '8px 0 16px' }}><button className="btn small" type="button" onClick={() => setRows((rs) => [...rs, emptyRow()])}>+ Position</button></div>
<div className="row"><button className="btn primary" type="submit" disabled={busy}>Positionen speichern</button>
<button className="btn" type="button" disabled={busy} onClick={() => run(() => api('POST', `/invoices/${id}/issue`, {}), 'Ausgestellt.')}>Ausstellen</button>
<button className="btn" type="button" disabled={busy} onClick={() => confirm('Entwurf wirklich löschen?') && run(async () => { await api('DELETE', `/invoices/${id}`); location.href = '/rechnungen'; })}>Entwurf löschen</button>
</div>
<p className="muted small">Solange die Rechnung ein Entwurf ist, lassen sich Positionen beliebig ändern. Nach dem Ausstellen ist sie unveränderlich und nur noch per Storno korrigierbar.</p>
</form>) : (
<div className="card"><table style={{ width: '100%' }}><thead><tr><th style={{ textAlign: 'left' }}>Beschreibung</th><th>Menge</th><th>Einzelpreis</th><th>USt.</th><th style={{ textAlign: 'right' }}>Netto</th></tr></thead><tbody>
{inv.items.map((it) => (<tr key={it.id}><td>{it.description}</td><td style={{ textAlign: 'center' }}>{it.quantity}</td><td style={{ textAlign: 'center' }}>{eur(it.unitPriceNetCents)}</td><td style={{ textAlign: 'center' }}>{it.taxBp / 100} %</td><td style={{ textAlign: 'right' }}>{eur(it.netCents)}</td></tr>))}
</tbody></table></div>
)}
<div className="card" style={{ maxWidth: 320, marginLeft: 'auto' }}>
<div className="row between"><span>Netto</span><span>{eur(inv.totalNetCents)}</span></div>
<div className="row between"><span>USt.</span><span>{eur(inv.totalTaxCents)}</span></div>
<div className="row between" style={{ fontWeight: 600 }}><span>Gesamt</span><span>{eur(inv.totalGrossCents)}</span></div>
</div>
{!isDraft && <div className="row" style={{ marginTop: 16 }}>
<a className="btn" href={`/api/invoices/${id}/pdf`} target="_blank" rel="noreferrer">PDF ansehen</a>
{inv.canWrite && inv.status === 'open' && <button className="btn" disabled={busy} onClick={() => run(() => api('POST', `/invoices/${id}/mark-paid`), 'Als bezahlt markiert.')}>Als bezahlt markieren</button>}
{inv.canWrite && inv.status === 'open' && <button className="btn" disabled={busy} onClick={() => { const reason = prompt('Grund für den Storno (optional):') ?? undefined; run(() => api('POST', `/invoices/${id}/cancel`, reason ? { reason } : {}), 'Storniert.'); }}>Stornieren</button>}
</div>}
{inv.note && <p className="muted small" style={{ marginTop: 16 }}>Notiz: {inv.note}</p>}
</>);
}

View file

@ -0,0 +1,3 @@
'use client';
import { InvoiceList } from '@/components/InvoiceList';
export default function Rechnungen() { return (<><h1>Rechnungen</h1><InvoiceList /></>); }

View 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>
</>);
}

View file

@ -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'],