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
|
|
@ -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>
|
||||
|
|
|
|||
60
apps/web/src/app/(app)/einstellungen/firma/page.tsx
Normal file
60
apps/web/src/app/(app)/einstellungen/firma/page.tsx
Normal 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>
|
||||
</>);
|
||||
}
|
||||
|
|
@ -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 (<>
|
||||
|
|
|
|||
|
|
@ -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' && <>
|
||||
|
|
|
|||
72
apps/web/src/app/(app)/rechnungen/[id]/page.tsx
Normal file
72
apps/web/src/app/(app)/rechnungen/[id]/page.tsx
Normal 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>}
|
||||
</>);
|
||||
}
|
||||
3
apps/web/src/app/(app)/rechnungen/page.tsx
Normal file
3
apps/web/src/app/(app)/rechnungen/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
'use client';
|
||||
import { InvoiceList } from '@/components/InvoiceList';
|
||||
export default function Rechnungen() { return (<><h1>Rechnungen</h1><InvoiceList /></>); }
|
||||
Loading…
Add table
Add a link
Reference in a new issue