Rechnungsdialog: Bausteine für Projektzusammenfassung, Positionen, Vorschauzeilen und Arbeitsblattauswahl

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:35:04 +00:00
1 parent 6a5f943034
commit 8ab78b7b2c
5 files changed
+356

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
import { Field } from '../ui'
import { dateDe } from '../../lib/financeFormat'
import { stundenDe } from '../../lib/projektkontoView'
import { ausgewaehlteIds, blaetterSumme } from '../../lib/rechnungDialogView'
/**
* Auswahl der offenen Arbeitsblätter für eine Rechnung nach Aufwand, dazu „Weiteres Ticket des Kunden hinzunehmen“.
* Rein darstellend. Props: blaetter (alle angebotenen Blätter), abgewaehlt (Kennungen), onToggle(id), satzInfo,
* ticketOptionen ([{ value, ticketId, woid, label }]), ticketsLaden, ticketsFehler, weitereTickets ([{ ticketId, woid, titel }]),
* weitereZustand ({ [ticketId]: { status, message } }), onAddTicket(option), onRemoveTicket(ticketId), disabled.
*/
export default function AufwandAuswahl({
blaetter,
abgewaehlt,
onToggle,
satzInfo,
ticketOptionen,
ticketsLaden,
ticketsFehler,
weitereTickets,
weitereZustand,
onAddTicket,
onRemoveTicket,
disabled = false,
}) {
const liste = Array.isArray(blaetter) ? blaetter : []
const summe = blaetterSumme(liste, ausgewaehlteIds(liste, abgewaehlt))
const mehrereTickets = new Set(liste.map((b) => b.ticketId)).size > 1
const optionen = Array.isArray(ticketOptionen) ? ticketOptionen : []
const weitere = Array.isArray(weitereTickets) ? weitereTickets : []
const zustand = weitereZustand || {}
return (
<fieldset className="rd-group">
<legend>Arbeitsblätter</legend>
<div className="rd-stack">
{satzInfo && <p className="rd-hint">{satzInfo}</p>}
{liste.length === 0 ? (
<p className="msg muted">Keine offenen Arbeitsblätter.</p>
) : (
<div className="rd-blaetter">
{liste.map((b) => (
<label className="rd-blatt" key={b.id}>
<input type="checkbox" checked={!(abgewaehlt || []).includes(b.id)} onChange={() => onToggle(b.id)} disabled={disabled} />
<span className="rd-blatt-text">
<span className="num">{dateDe(b.datum)}</span> {b.text || 'Arbeitszeit'}
{mehrereTickets && <span className="faint xs"> #{b.woid}</span>}
</span>
<span className="rd-blatt-zeit">{stundenDe(b.minuten)}</span>
</label>
))}
</div>
)}
<p className="rd-hint" aria-live="polite">
{summe.anzahl} von {liste.length} ausgewählt · {stundenDe(summe.minuten)}
</p>
<Field label="Weiteres Ticket des Kunden hinzunehmen" htmlFor="rd-ticket">
<select
id="rd-ticket"
className="select"
value=""
disabled={ticketsLaden || disabled}
onChange={(e) => {
const option = optionen.find((o) => o.value === e.target.value)
if (option) onAddTicket(option)
}}
>
<option value="">{ticketsLaden ? 'Tickets werden geladen …' : 'Ticket wählen …'}</option>
{optionen.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{ticketsFehler && (
<p className="msg warn" role="alert">
{ticketsFehler}
</p>
)}
</Field>
{weitere.map((t) => {
const z = zustand[t.ticketId]
return (
<div className="rd-weiter" key={t.ticketId}>
<span>
#{t.woid} · {t.titel}
</span>
{z?.status === 'laedt' && <span className="msg muted">Arbeitsblätter werden geladen …</span>}
{z?.status === 'fehler' && (
<span className="msg warn" role="alert">
{z.message}
</span>
)}
<button type="button" className="link" aria-label={`Ticket #${t.woid} entfernen`} onClick={() => onRemoveTicket(t.ticketId)}>
Entfernen
</button>
</div>
)
})}
</div>
</fieldset>
)
}
@@ -0,0 +1,36 @@
/**
* Zusammenfassung des Projektkontos im Rechnungsdialog: Vereinbart, Berechnet (darunter bezahlt), Noch offen, Zeit erfasst.
* Rein darstellend. Props: zeile (aus kontoZeile) und hinweis (aus stripeHinweis, leer = kein Hinweis).
*/
export default function KontoZusammenfassung({ zeile, hinweis }) {
return (
<>
<div className="facts rd-facts">
<div className="fact">
<div className="k">Vereinbart</div>
<div className="v">{zeile.vereinbart}</div>
</div>
<div className="fact">
<div className="k">Berechnet</div>
<div className="v">{zeile.berechnet}</div>
{zeile.bezahlt && <div className="xs muted">{zeile.bezahlt}</div>}
</div>
<div className="fact">
<div className="k">Noch offen</div>
<div className="v">{zeile.offen}</div>
</div>
<div className="fact">
<div className="k">Zeit erfasst</div>
<div className="v">{zeile.zeit.stunden}</div>
<div className="xs muted">{zeile.zeit.wert}</div>
{zeile.zeit.abo && <div className="xs muted">{zeile.zeit.abo}</div>}
</div>
</div>
{hinweis && (
<p className="msg warn" role="status">
{hinweis}
</p>
)}
</>
)
}
@@ -0,0 +1,100 @@
import { Button } from '../ui'
import { euro } from '../../lib/financeFormat'
import { INVOICE_LIMITS, lineTotalCents, invoiceTotalCents } from '../../lib/financeView'
/**
* Positionszeilen des Rechnungsdialogs (Beschreibung, Menge, Einzelpreis, Summe, Entfernen) mit „+ Position“-Knopf.
* Rein darstellend, hält keinen Zustand. Props: legende, labelPrefix (z. B. „Position“ oder „Zusatzposition“), idPrefix,
* zeilen ([{ uid, description, quantity, price }]), minZeilen, onChange(uid, feld, wert), onAdd(), onRemove(uid), addLabel,
* zeigeSumme (Gesamtsumme und Hinweis zu § 19 UStG).
*/
export default function RechnungPositionen({
legende,
labelPrefix,
idPrefix,
zeilen,
minZeilen = 0,
onChange,
onAdd,
onRemove,
addLabel,
zeigeSumme = false,
}) {
return (
<fieldset className="rd-group">
<legend>{legende}</legend>
<div className="rd-stack">
{zeilen.map((zeile, index) => {
const n = index + 1
const summe = lineTotalCents(zeile)
return (
<div className="rd-item" key={zeile.uid}>
<div className="rd-item-field rd-item-desc">
<label className="rd-item-label" htmlFor={`${idPrefix}-desc-${zeile.uid}`}>Beschreibung</label>
<input
id={`${idPrefix}-desc-${zeile.uid}`}
className="input"
value={zeile.description}
maxLength={INVOICE_LIMITS.maxDescription}
placeholder="z. B. Webdesign Startseite"
aria-label={`${labelPrefix} ${n}: Beschreibung`}
onChange={(e) => onChange(zeile.uid, 'description', e.target.value)}
/>
</div>
<div className="rd-item-field rd-item-qty">
<label className="rd-item-label" htmlFor={`${idPrefix}-qty-${zeile.uid}`}>Menge</label>
<input
id={`${idPrefix}-qty-${zeile.uid}`}
className="input fin-amount"
value={zeile.quantity}
inputMode="numeric"
aria-label={`${labelPrefix} ${n}: Menge`}
onChange={(e) => onChange(zeile.uid, 'quantity', e.target.value)}
/>
</div>
<div className="rd-item-field rd-item-price">
<label className="rd-item-label" htmlFor={`${idPrefix}-price-${zeile.uid}`}>Einzelpreis in €</label>
<input
id={`${idPrefix}-price-${zeile.uid}`}
className="input fin-amount"
value={zeile.price}
inputMode="decimal"
placeholder="0,00"
aria-label={`${labelPrefix} ${n}: Einzelpreis in Euro`}
onChange={(e) => onChange(zeile.uid, 'price', e.target.value)}
/>
</div>
<div className="rd-item-field rd-item-sumfeld">
<span className="rd-item-label">Summe</span>
<span className="fin-amount rd-item-sum">{summe === null ? '–' : euro(summe)}</span>
</div>
<div className="rd-item-remove">
<button
type="button"
className="link"
disabled={zeilen.length <= minZeilen}
aria-label={`${labelPrefix} ${n} entfernen`}
onClick={() => onRemove(zeile.uid)}
>
Entfernen
</button>
</div>
</div>
)
})}
<div className="rd-items-foot">
<Button type="button" onClick={onAdd} disabled={zeilen.length >= INVOICE_LIMITS.maxItems}>
{addLabel}
</Button>
{zeigeSumme && (
<div className="rd-total" aria-live="polite">
<span>Summe</span>
<span className="fin-amount">{euro(invoiceTotalCents(zeilen))}</span>
</div>
)}
</div>
{zeigeSumme && <p className="rd-hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>}
</div>
</fieldset>
)
}
@@ -0,0 +1,69 @@
import { euro } from '../../lib/financeFormat'
import { isNegText } from '../../lib/financeUi'
import { zeileTeilen, zeileBearbeitbar, maxVorn } from '../../lib/rechnungAnfrage'
/**
* Vorschau der Rechnungszeilen, wie der Server sie baut. Zeigt immer alle Positionen der Antwort (eine Schlussrechnung hat
* bis zu 40). Rein darstellend. Props: positionen ([{ schluessel, beschreibung, menge, einzelpreisCents, betragCents }]),
* summeCents, bearbeitbar (Tages- und Wochenzeilen der Aufwandsrechnung lassen den Text vor dem letzten „ – “ ändern),
* zeilentexte ({ [schluessel]: Text }), onZeilentext(schluessel, text, original), abgeblendet (Vorschau wird gerade aktualisiert).
*/
export default function RechnungVorschau({ positionen, summeCents, bearbeitbar = false, zeilentexte = {}, onZeilentext, abgeblendet = false }) {
const liste = Array.isArray(positionen) ? positionen : []
const mitEingabe = bearbeitbar && liste.some((p) => zeileBearbeitbar(p))
return (
<div className={abgeblendet ? 'rd-dim' : undefined} aria-busy={abgeblendet}>
<table className="table rd-lines" role="table">
<caption className="rd-sr">Rechnungspositionen</caption>
<thead>
<tr>
<th scope="col">Position</th>
<th scope="col" className="fin-amount">Betrag</th>
</tr>
</thead>
<tbody>
{liste.map((p, index) => {
const betrag = euro(p.betragCents)
const bearbeitbarEs = bearbeitbar && zeileBearbeitbar(p)
const { vorn, hinten } = zeileTeilen(p.beschreibung)
return (
<tr key={`${p.schluessel}-${index}`}>
<td className="rd-line-text">
{bearbeitbarEs ? (
<>
<input
className="input rd-line-input"
aria-label={`Text zu Zeile ${index + 1}`}
value={zeilentexte[p.schluessel] ?? vorn}
maxLength={maxVorn(hinten)}
disabled={abgeblendet}
onChange={(e) => onZeilentext(p.schluessel, e.target.value, vorn)}
/>
<span className="rd-line-tail">{hinten}</span>
</>
) : (
<>
{p.beschreibung}
{p.menge > 1 && (
<div className="cell-sub">
{p.menge} × {euro(p.einzelpreisCents)}
</div>
)}
</>
)}
</td>
<td className={`fin-amount${isNegText(betrag) ? ' fin-neg' : ''}`}>{betrag}</td>
</tr>
)
})}
</tbody>
</table>
<div className="rd-total">
<span>Summe</span>
<span className="fin-amount">{euro(summeCents)}</span>
</div>
<p className="rd-hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
{mitEingabe && <p className="rd-hint">Die Beschreibung der Zeilen lässt sich ändern, Stunden und Beträge stehen fest.</p>}
</div>
)
}
@@ -0,0 +1,48 @@
/* Rechnung schreiben (Dialog). Dieses Stylesheet steht im Bundle vor global.css: Regeln, die globale Klassen überstimmen
müssen, tragen zwei Klassen. Task 9 hängt am Ende die Spalten der Rechnungstabelle an. */
.rd-group { border: 0; margin: 0; padding: 0; min-width: 0; }
.rd-group > legend { padding: 0; margin: 0 0 6px; font-weight: 500; }
.rd-stack { display: grid; gap: 8px; }
.rd-facts.facts { margin: 0; }
.seg.rd-seg { flex-wrap: wrap; max-width: 100%; justify-self: start; }
.rd-hint { margin: 0; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
/* Positionen */
.rd-item { display: grid; grid-template-columns: minmax(0, 1fr) 80px 120px 110px auto; gap: 8px 12px; align-items: end; }
.rd-item .input { width: 100%; }
.rd-item-field { min-width: 0; display: grid; gap: 4px; }
.rd-item-label { font-size: 12px; color: var(--text-2); }
.rd-item-sum { text-align: right; white-space: nowrap; padding-bottom: 6px; }
.rd-items-foot { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.rd-total { display: flex; gap: 16px; font-weight: 600; }
@media (max-width: 860px) {
.rd-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) auto; grid-template-areas: "desc desc desc desc" "qty price sum remove"; }
.rd-item-desc { grid-area: desc; }
.rd-item-qty { grid-area: qty; }
.rd-item-price { grid-area: price; }
.rd-item-sumfeld { grid-area: sum; }
.rd-item-remove { grid-area: remove; }
}
/* Arbeitsblätter */
.rd-blaetter { border-top: 1px solid var(--line); }
.rd-blatt { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 4px 12px; align-items: start; padding: 8px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
.rd-blatt input { accent-color: var(--accent); width: 15px; height: 15px; margin-top: 3px; }
.rd-blatt-text { min-width: 0; overflow-wrap: anywhere; }
.rd-blatt-zeit { white-space: nowrap; font-variant-numeric: tabular-nums; }
.rd-weiter { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; }
/* Vorschau der Rechnungszeilen */
.rd-lines { table-layout: fixed; }
.rd-lines .rd-line-text { overflow-wrap: anywhere; }
.rd-lines .fin-amount { width: 7.5rem; }
.rd-line-input { width: 100%; min-width: 0; }
.rd-line-tail { display: block; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
.rd-dim { opacity: 0.5; }
/* nur für Screenreader sichtbar */
.rd-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* Fuß des Dialogs */
.rd-foot { display: flex; flex: 1; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
@media (max-width: 860px) { .rd-foot .btn { flex: 1 1 auto; } }