From 8ab78b7b2c62bb3dfd5786bb733515a676adede7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:35:04 +0000 Subject: [PATCH] =?UTF-8?q?Rechnungsdialog:=20Bausteine=20f=C3=BCr=20Proje?= =?UTF-8?q?ktzusammenfassung,=20Positionen,=20Vorschauzeilen=20und=20Arbei?= =?UTF-8?q?tsblattauswahl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/components/auftraege/AufwandAuswahl.jsx | 103 ++++++++++++++++++ .../auftraege/KontoZusammenfassung.jsx | 36 ++++++ .../auftraege/RechnungPositionen.jsx | 100 +++++++++++++++++ src/components/auftraege/RechnungVorschau.jsx | 69 ++++++++++++ src/components/auftraege/rechnung-dialog.css | 48 ++++++++ 5 files changed, 356 insertions(+) create mode 100644 src/components/auftraege/AufwandAuswahl.jsx create mode 100644 src/components/auftraege/KontoZusammenfassung.jsx create mode 100644 src/components/auftraege/RechnungPositionen.jsx create mode 100644 src/components/auftraege/RechnungVorschau.jsx create mode 100644 src/components/auftraege/rechnung-dialog.css diff --git a/src/components/auftraege/AufwandAuswahl.jsx b/src/components/auftraege/AufwandAuswahl.jsx new file mode 100644 index 0000000..9a144bc --- /dev/null +++ b/src/components/auftraege/AufwandAuswahl.jsx @@ -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 ( +
+ Arbeitsblätter +
+ {satzInfo &&

{satzInfo}

} + {liste.length === 0 ? ( +

Keine offenen Arbeitsblätter.

+ ) : ( +
+ {liste.map((b) => ( + + ))} +
+ )} +

+ {summe.anzahl} von {liste.length} ausgewählt · {stundenDe(summe.minuten)} +

+ + + {ticketsFehler && ( +

+ {ticketsFehler} +

+ )} +
+ {weitere.map((t) => { + const z = zustand[t.ticketId] + return ( +
+ + #{t.woid} · {t.titel} + + {z?.status === 'laedt' && Arbeitsblätter werden geladen …} + {z?.status === 'fehler' && ( + + {z.message} + + )} + +
+ ) + })} +
+
+ ) +} diff --git a/src/components/auftraege/KontoZusammenfassung.jsx b/src/components/auftraege/KontoZusammenfassung.jsx new file mode 100644 index 0000000..782eec9 --- /dev/null +++ b/src/components/auftraege/KontoZusammenfassung.jsx @@ -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 ( + <> +
+
+
Vereinbart
+
{zeile.vereinbart}
+
+
+
Berechnet
+
{zeile.berechnet}
+ {zeile.bezahlt &&
{zeile.bezahlt}
} +
+
+
Noch offen
+
{zeile.offen}
+
+
+
Zeit erfasst
+
{zeile.zeit.stunden}
+
{zeile.zeit.wert}
+ {zeile.zeit.abo &&
{zeile.zeit.abo}
} +
+
+ {hinweis && ( +

+ {hinweis} +

+ )} + + ) +} diff --git a/src/components/auftraege/RechnungPositionen.jsx b/src/components/auftraege/RechnungPositionen.jsx new file mode 100644 index 0000000..44cf029 --- /dev/null +++ b/src/components/auftraege/RechnungPositionen.jsx @@ -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 ( +
+ {legende} +
+ {zeilen.map((zeile, index) => { + const n = index + 1 + const summe = lineTotalCents(zeile) + return ( +
+
+ + onChange(zeile.uid, 'description', e.target.value)} + /> +
+
+ + onChange(zeile.uid, 'quantity', e.target.value)} + /> +
+
+ + onChange(zeile.uid, 'price', e.target.value)} + /> +
+
+ Summe + {summe === null ? '–' : euro(summe)} +
+
+ +
+
+ ) + })} +
+ + {zeigeSumme && ( +
+ Summe + {euro(invoiceTotalCents(zeilen))} +
+ )} +
+ {zeigeSumme &&

Gemäß § 19 UStG ohne Umsatzsteuer.

} +
+
+ ) +} diff --git a/src/components/auftraege/RechnungVorschau.jsx b/src/components/auftraege/RechnungVorschau.jsx new file mode 100644 index 0000000..2491fd9 --- /dev/null +++ b/src/components/auftraege/RechnungVorschau.jsx @@ -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 ( +
+ + + + + + + + + + {liste.map((p, index) => { + const betrag = euro(p.betragCents) + const bearbeitbarEs = bearbeitbar && zeileBearbeitbar(p) + const { vorn, hinten } = zeileTeilen(p.beschreibung) + return ( + + + + + ) + })} + +
Rechnungspositionen
PositionBetrag
+ {bearbeitbarEs ? ( + <> + onZeilentext(p.schluessel, e.target.value, vorn)} + /> + {hinten} + + ) : ( + <> + {p.beschreibung} + {p.menge > 1 && ( +
+ {p.menge} × {euro(p.einzelpreisCents)} +
+ )} + + )} +
{betrag}
+
+ Summe + {euro(summeCents)} +
+

Gemäß § 19 UStG ohne Umsatzsteuer.

+ {mitEingabe &&

Die Beschreibung der Zeilen lässt sich ändern, Stunden und Beträge stehen fest.

} +
+ ) +} diff --git a/src/components/auftraege/rechnung-dialog.css b/src/components/auftraege/rechnung-dialog.css new file mode 100644 index 0000000..40118bf --- /dev/null +++ b/src/components/auftraege/rechnung-dialog.css @@ -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; } }