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