Rechnungen: Rabatt je Position

This commit is contained in:
Kundencenter 2026-09-28 11:01:19 +02:00
parent e7e5f31ebe
commit 96df0c334a
5 changed files with 51 additions and 22 deletions

View file

@ -6,12 +6,12 @@ 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 Item { id: string; description: string; quantity: number; unitPriceNetCents: number; taxBp: number; discountBp: 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' });
interface Suggested { description: string; quantity: number; unitPriceNetCents: number; taxBp: number }
const toRow = (it: Suggested): Row => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100) });
type Row = { description: string; quantity: string; unitPrice: string; taxBp: string; discount: string };
const emptyRow = (): Row => ({ description: '', quantity: '1', unitPrice: '', taxBp: '19', discount: '0' });
interface Suggested { description: string; quantity: number; unitPriceNetCents: number; taxBp: number; discountBp?: number }
const toRow = (it: Suggested): Row => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100), discount: it.discountBp ? String(it.discountBp / 100) : '0' });
interface Prod { id: string; status: string; current: { name: string } | null; termPrices: { termMonths: number }[] }
interface Contract { id: string; number: string; productName: string | null }
interface Dom { id: string; domain: string; sellNetCents: number | null }
@ -41,7 +41,7 @@ export default function RechnungDetail() {
useEffect(() => { if (fromContract && inv?.status === 'draft' && inv.items.length === 0 && !appliedRef.current) { appliedRef.current = true; void takeFrom('contract', fromContract); } }, [fromContract, inv]); // eslint-disable-line react-hooks/exhaustive-deps
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) }));
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), discountBp: Math.round(Number((r.discount || '0').replace(',', '.')) * 100) }));
if (items.length === 0) { setErr('Bitte mindestens eine Position angeben.'); return; }
await run(() => api('PUT', `/invoices/${id}/items`, { items }), 'Gespeichert.');
}
@ -66,11 +66,12 @@ export default function RechnungDetail() {
{contracts.length > 0 && <Field id="fromContract" label="Position aus laufendem Vertrag übernehmen" hint="Übernimmt den wiederkehrenden Preis der aktuellen Periode."><select id="fromContract" value="" onChange={(e) => void takeFrom('contract', e.target.value)}><option value="">Vertrag wählen …</option>{contracts.map((c) => <option key={c.id} value={c.id}>{c.number} · {c.productName ?? '–'}</option>)}</select></Field>}
{domains.length > 0 && <Field id="fromDomain" label="Position aus Domain übernehmen" hint="Verkaufspreis aus der Domain-Aufstellung, nie der Einkauf."><select id="fromDomain" value="" onChange={(e) => void takeFrom('domain', e.target.value)}><option value="">Domain wählen …</option>{domains.map((d) => <option key={d.id} value={d.id} disabled={d.sellNetCents === null}>{d.domain}{d.sellNetCents === null ? ' (kein Verkaufspreis)' : ''}</option>)}</select></Field>}
</div>}
{rows.map((r, i) => (<div className="cols" key={i} style={{ gridTemplateColumns: '3fr 1fr 1fr 1fr auto', alignItems: 'end' }}>
{rows.map((r, i) => (<div className="cols" key={i} style={{ gridTemplateColumns: '3fr 1fr 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>
<Field id={`r${i}`} label="Rabatt %"><input id={`r${i}`} value={r.discount} onChange={(e) => setRow(i, 'discount', e.target.value)} placeholder="0" /></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>
@ -80,8 +81,8 @@ export default function RechnungDetail() {
</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>))}
<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>Rabatt</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: 'center' }}>{it.discountBp > 0 ? `${it.discountBp / 100} %` : '–'}</td><td style={{ textAlign: 'right' }}>{eur(it.netCents)}</td></tr>))}
</tbody></table></div>
)}