Rechnungsdialog: Bausteine für Projektzusammenfassung, Positionen, Vorschauzeilen und Arbeitsblattauswahl
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6a5f943034
commit
8ab78b7b2c
5 files changed
+356
No files matched your search
@@ -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; } }
|
||||||
Reference in new issue
Block a user