Auftragsbestaetigung: Dialog-Schritte 1 bis 3 (Projekt, Auftrag, Zahlung und Termine)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3650573445
commit
5060d150ea
2 files changed
+364
No files matched your search
@@ -0,0 +1,321 @@
|
||||
import { Badge } from '../ui'
|
||||
import FormField from '../finance/FormField'
|
||||
import { useWorkorders } from '../../hooks/useWorkorders'
|
||||
import { euro } from '../../lib/financeFormat'
|
||||
import { customerName, lineTotalCents } from '../../lib/financeView'
|
||||
import { KUNDENART_OPTIONEN, kundenartLabel } from '../../lib/kundenFelder'
|
||||
import {
|
||||
AUFTRAG_LIMITS,
|
||||
AUFTRAG_WEGE,
|
||||
formularSummeCents,
|
||||
kundenartWirksam,
|
||||
neuePosition,
|
||||
parseProzent,
|
||||
parseTage,
|
||||
schalteZeitquelle,
|
||||
vorOrtHinweis,
|
||||
waehleProjekt,
|
||||
wechselArt,
|
||||
zahlungsplanOptionen,
|
||||
zahlungsplanVorschau,
|
||||
zeitquelleOptionen,
|
||||
} from '../../lib/auftragView'
|
||||
import './auftrag.css'
|
||||
|
||||
/** Meldung eines Feldes ohne FormField (Art, Positionen, Zahlungsplan, Zeitquelle). */
|
||||
function FeldFehler({ text }) {
|
||||
return text ? <div className="msg err" role="alert">{text}</div> : null
|
||||
}
|
||||
|
||||
/** Schritt 1: Projekt, Titel und (falls am Kunden noch leer) die Kundenart. */
|
||||
export function SchrittProjekt({ form, onChange, fehler, kunde, konten, neueFassung = false }) {
|
||||
const name = customerName(kunde) || 'Kunde'
|
||||
return (
|
||||
<div className="auftrag-schritt">
|
||||
<p className="muted">
|
||||
Kunde: <strong>{name}</strong>
|
||||
{kunde?.email ? ` · ${kunde.email}` : ''}
|
||||
</p>
|
||||
{neueFassung && <p className="msg muted">Neue Fassung: Beim Senden ersetzt sie die bisherige Auftragsbestätigung.</p>}
|
||||
<FormField id="ab-projekt" label="Projekt">
|
||||
<select className="select" value={form.projektId} onChange={(e) => onChange(waehleProjekt(form, e.target.value, konten))}>
|
||||
<option value="">Kein Projekt (freier Titel)</option>
|
||||
{(Array.isArray(konten) ? konten : []).map((k) => (
|
||||
<option key={k.projektId} value={k.projektId}>{k.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="ab-titel"
|
||||
label="Titel"
|
||||
hint="Steht als Titel auf der Auftragsbestätigung, z. B. „Website für Praxis Müller“."
|
||||
error={fehler.titel}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
maxLength={AUFTRAG_LIMITS.maxTitel}
|
||||
value={form.titel}
|
||||
onChange={(e) => onChange({ ...form, titel: e.target.value, titelGeaendert: true })}
|
||||
/>
|
||||
</FormField>
|
||||
{!kunde?.kundenart ? (
|
||||
<FormField
|
||||
id="ab-kundenart"
|
||||
label="Kundenart"
|
||||
hint="Wird am Kunden gespeichert. Privatpersonen bekommen die Widerrufsbelehrung in der Auftragsbestätigung."
|
||||
error={fehler.kundenart}
|
||||
>
|
||||
<select className="select" value={form.kundenart} onChange={(e) => onChange({ ...form, kundenart: e.target.value })}>
|
||||
<option value="">Bitte wählen</option>
|
||||
{KUNDENART_OPTIONEN.slice(1).map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
) : (
|
||||
<p className="muted">Kundenart: {kundenartLabel(kunde.kundenart)}. Änderbar in der Übersicht des Kunden.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Schritt 2: Absprache (Datum, Weg), Art des Auftrags und Positionen bzw. Stundensatz. */
|
||||
export function SchrittAuftrag({ form, onChange, fehler, kunde }) {
|
||||
const vorOrt = vorOrtHinweis(form.auftragWeg, kundenartWirksam(kunde, form.kundenart))
|
||||
const positionen = form.positionen
|
||||
const setPosition = (uid, feld, wert) =>
|
||||
onChange({ ...form, positionen: positionen.map((p) => (p.uid === uid ? { ...p, [feld]: wert } : p)) })
|
||||
const entferne = (uid) => onChange({ ...form, positionen: positionen.filter((p) => p.uid !== uid) })
|
||||
const summe = formularSummeCents(form)
|
||||
|
||||
return (
|
||||
<div className="auftrag-schritt">
|
||||
<div className="field-row">
|
||||
<FormField id="ab-auftragVom" label="Datum der Absprache" error={fehler.auftragVom}>
|
||||
<input className="input" type="date" value={form.auftragVom} onChange={(e) => onChange({ ...form, auftragVom: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField id="ab-auftragWeg" label="Weg der Absprache" error={fehler.auftragWeg}>
|
||||
<select className="select" value={form.auftragWeg} onChange={(e) => onChange({ ...form, auftragWeg: e.target.value })}>
|
||||
<option value="">Bitte wählen</option>
|
||||
{AUFTRAG_WEGE.map((w) => (
|
||||
<option key={w.value} value={w.value}>{w.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
{vorOrt && <p className="msg warn auftrag-hinweis">{vorOrt}</p>}
|
||||
|
||||
<fieldset className="auftrag-art">
|
||||
<legend>Art des Auftrags</legend>
|
||||
<label className="check">
|
||||
<input type="radio" name="ab-art" id="ab-art-festpreis" checked={form.art === 'festpreis'} onChange={() => onChange(wechselArt(form, 'festpreis'))} />
|
||||
Festpreis
|
||||
<span className="hint">Feste Positionen mit Preisen.</span>
|
||||
</label>
|
||||
<label className="check">
|
||||
<input type="radio" name="ab-art" id="ab-art-aufwand" checked={form.art === 'aufwand'} onChange={() => onChange(wechselArt(form, 'aufwand'))} />
|
||||
Nach Aufwand
|
||||
<span className="hint">Stundensatz, abgerechnet nach erfasster Arbeitszeit.</span>
|
||||
</label>
|
||||
<FeldFehler text={fehler.art} />
|
||||
</fieldset>
|
||||
|
||||
{form.art === 'festpreis' && (
|
||||
<fieldset id="ab-positionen" tabIndex={-1} className="auftrag-items">
|
||||
<legend>Positionen</legend>
|
||||
{positionen.map((p, i) => {
|
||||
const n = i + 1
|
||||
const zeile = lineTotalCents(p)
|
||||
return (
|
||||
<div key={p.uid} className="auftrag-item">
|
||||
<div className="auftrag-item-desc">
|
||||
<span className="auftrag-item-label">Beschreibung</span>
|
||||
<input
|
||||
className="input"
|
||||
maxLength={AUFTRAG_LIMITS.maxBeschreibung}
|
||||
aria-label={`Position ${n}: Beschreibung`}
|
||||
placeholder="z. B. Startseite"
|
||||
value={p.description}
|
||||
onChange={(e) => setPosition(p.uid, 'description', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="auftrag-item-qty">
|
||||
<span className="auftrag-item-label">Menge</span>
|
||||
<input
|
||||
className="input"
|
||||
inputMode="numeric"
|
||||
aria-label={`Position ${n}: Menge`}
|
||||
value={p.quantity}
|
||||
onChange={(e) => setPosition(p.uid, 'quantity', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="auftrag-item-price">
|
||||
<span className="auftrag-item-label">Einzelpreis in €</span>
|
||||
<input
|
||||
className="input"
|
||||
inputMode="decimal"
|
||||
aria-label={`Position ${n}: Einzelpreis in Euro`}
|
||||
placeholder="0,00"
|
||||
value={p.price}
|
||||
onChange={(e) => setPosition(p.uid, 'price', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="auftrag-item-sum">
|
||||
<span className="auftrag-item-label">Summe</span>
|
||||
<span className="auftrag-item-wert">{zeile === null ? '–' : euro(zeile)}</span>
|
||||
</div>
|
||||
<div className="auftrag-item-remove">
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
aria-label={`Position ${n} entfernen`}
|
||||
disabled={positionen.length <= 1}
|
||||
onClick={() => entferne(p.uid)}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={positionen.length >= AUFTRAG_LIMITS.maxPositionen}
|
||||
onClick={() => onChange({ ...form, positionen: [...positionen, neuePosition()] })}
|
||||
>
|
||||
+ Position
|
||||
</button>
|
||||
</div>
|
||||
<div className="auftrag-total" aria-live="polite">
|
||||
<span>Summe</span>
|
||||
<span>{euro(summe)}</span>
|
||||
</div>
|
||||
<p className="hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
|
||||
<FeldFehler text={fehler.positionen} />
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{form.art === 'aufwand' && (
|
||||
<div className="field-row">
|
||||
<FormField id="ab-stundensatz" label="Stundensatz in €" hint="Steht mit der Aufwandsregel in der Auftragsbestätigung." error={fehler.stundensatz}>
|
||||
<input className="input" inputMode="decimal" value={form.stundensatz} onChange={(e) => onChange({ ...form, stundensatz: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField
|
||||
id="ab-schaetzung"
|
||||
label="Geschätzter Umfang in Stunden (optional)"
|
||||
hint="In Schritten von 15 Minuten, z. B. 10 oder 12,5. Unverbindlich."
|
||||
error={fehler.schaetzung}
|
||||
>
|
||||
<input
|
||||
className="input"
|
||||
inputMode="decimal"
|
||||
placeholder="z. B. 10"
|
||||
value={form.schaetzung}
|
||||
onChange={(e) => onChange({ ...form, schaetzung: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Zeitquelle: Tickets des Kunden, deren Arbeitsblätter zum Auftrag zählen (höchstens 10). */
|
||||
function ZeitquelleAuswahl({ kundeId, form, onChange, projektTicketIds, fehler }) {
|
||||
const { workorders, loading, error } = useWorkorders({ customerId: kundeId, limit: 100 })
|
||||
const optionen = zeitquelleOptionen(workorders, form.zeitquelle, projektTicketIds)
|
||||
const voll = form.zeitquelle.length >= AUFTRAG_LIMITS.maxZeitquellen
|
||||
return (
|
||||
<fieldset id="ab-zeitquelle" tabIndex={-1} className="auftrag-zeitquelle">
|
||||
<legend>Zeiterfassung aus diesen Tickets</legend>
|
||||
<p className="hint">Arbeitsblätter dieser Tickets zählen zum Auftrag (Projektkonto, Aufwandsrechnung). Höchstens 10.</p>
|
||||
{loading && optionen.length === 0 && <div className="loading" role="status">Tickets werden geladen …</div>}
|
||||
{error && <p className="msg warn" role="alert">Die Tickets konnten nicht geladen werden: {error}. Die bisherige Auswahl bleibt.</p>}
|
||||
{!loading && !error && optionen.length === 0 && <p className="muted">Dieser Kunde hat noch keine Tickets.</p>}
|
||||
{optionen.length > 0 && (
|
||||
<div className="auftrag-zeitquelle-liste">
|
||||
{optionen.map((o) => (
|
||||
<label key={o.id} className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={o.gewaehlt}
|
||||
disabled={!o.gewaehlt && voll}
|
||||
onChange={(e) => onChange({ ...form, zeitquelle: schalteZeitquelle(form.zeitquelle, o.id, e.target.checked), zeitquelleGeaendert: true })}
|
||||
/>
|
||||
{o.label}
|
||||
{o.projekt && <Badge tone="muted">Ticket des Projekts</Badge>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FeldFehler text={fehler.zeitquelle} />
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
/** Schritt 3: Zahlungsplan, Zahlungsziel, Termine und Zeitquelle. */
|
||||
export function SchrittZahlung({ form, onChange, fehler, kunde, konten }) {
|
||||
const konto = (Array.isArray(konten) ? konten : []).find((k) => k.projektId && k.projektId === form.projektId)
|
||||
const projektTicketIds = konto && Array.isArray(konto.tickets) ? konto.tickets.map((t) => t.id) : []
|
||||
const vorschau = zahlungsplanVorschau({
|
||||
art: form.zahlungsart,
|
||||
prozent: parseProzent(form.prozent),
|
||||
text: form.zahlungstext,
|
||||
summeCents: formularSummeCents(form),
|
||||
zielTage: parseTage(form.zahlungsziel),
|
||||
})
|
||||
return (
|
||||
<div className="auftrag-schritt">
|
||||
<fieldset className="auftrag-art">
|
||||
<legend>Zahlungsplan</legend>
|
||||
{zahlungsplanOptionen(form.art).map((o) => (
|
||||
<label key={o.value} className="check">
|
||||
<input
|
||||
type="radio"
|
||||
name="ab-zahlungsart"
|
||||
id={`ab-zahlungsart-${o.value}`}
|
||||
checked={form.zahlungsart === o.value}
|
||||
onChange={() => onChange({ ...form, zahlungsart: o.value })}
|
||||
/>
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
<FeldFehler text={fehler.zahlungsart} />
|
||||
</fieldset>
|
||||
{form.zahlungsart === 'anzahlung' && (
|
||||
<FormField id="ab-prozent" label="Anzahlung in %" error={fehler.prozent}>
|
||||
<input className="input auftrag-narrow" inputMode="numeric" value={form.prozent} onChange={(e) => onChange({ ...form, prozent: e.target.value })} />
|
||||
</FormField>
|
||||
)}
|
||||
{form.zahlungsart === 'frei' && (
|
||||
<div>
|
||||
<FormField id="ab-zahlungstext" label="Zahlungsplan als Text" error={fehler.zahlungstext}>
|
||||
<textarea
|
||||
className="textarea"
|
||||
rows={3}
|
||||
maxLength={AUFTRAG_LIMITS.maxZahlungsplanText}
|
||||
value={form.zahlungstext}
|
||||
onChange={(e) => onChange({ ...form, zahlungstext: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="hint">{form.zahlungstext.length} / {AUFTRAG_LIMITS.maxZahlungsplanText} Zeichen</div>
|
||||
</div>
|
||||
)}
|
||||
{vorschau && <p className="msg muted auftrag-plan-vorschau">Sinngemäß: {vorschau}</p>}
|
||||
<FormField id="ab-zahlungsziel" label="Zahlungsziel in Tagen" hint="0 bis 60 Tage nach Rechnung." error={fehler.zahlungsziel}>
|
||||
<input className="input auftrag-narrow" inputMode="numeric" value={form.zahlungsziel} onChange={(e) => onChange({ ...form, zahlungsziel: e.target.value })} />
|
||||
</FormField>
|
||||
<div className="field-row">
|
||||
<FormField id="ab-startAm" label="Start (optional)" error={fehler.startAm}>
|
||||
<input className="input" type="date" value={form.startAm} onChange={(e) => onChange({ ...form, startAm: e.target.value })} />
|
||||
</FormField>
|
||||
<FormField id="ab-fertigBis" label="Voraussichtliche Fertigstellung (optional)" error={fehler.fertigBis}>
|
||||
<input className="input" type="date" value={form.fertigBis} onChange={(e) => onChange({ ...form, fertigBis: e.target.value })} />
|
||||
</FormField>
|
||||
</div>
|
||||
<ZeitquelleAuswahl kundeId={kunde?.$id} form={form} onChange={onChange} projektTicketIds={projektTicketIds} fehler={fehler} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,3 +33,46 @@
|
||||
.auftrag-aktionen { grid-area: aktionen; padding-top: 4px; }
|
||||
.auftrag-table td[data-label]::before { content: attr(data-label) " "; color: var(--text-2); font-weight: 500; }
|
||||
}
|
||||
|
||||
/* Dialog: Schritte 1 bis 3 */
|
||||
.auftrag-schritt { display: grid; gap: 16px; min-width: 0; }
|
||||
.auftrag-schritt .field { min-width: 0; }
|
||||
.auftrag-art { border: 0; padding: 0; margin: 0; display: grid; gap: 8px; min-width: 0; }
|
||||
.auftrag-art legend { font-weight: 500; padding: 0; margin-bottom: 4px; }
|
||||
.auftrag-art .check { flex-wrap: wrap; }
|
||||
.auftrag-art .check .hint { flex-basis: 100%; padding-left: 23px; font-size: 13px; color: var(--text-2); }
|
||||
.auftrag-items { border: 0; padding: 0; margin: 0; display: grid; gap: 12px; min-width: 0; }
|
||||
.auftrag-items legend { font-weight: 500; padding: 0; margin-bottom: 4px; }
|
||||
.auftrag-item {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) 80px 120px 110px auto; gap: 8px 12px; align-items: end;
|
||||
grid-template-areas: "desc qty price sum remove";
|
||||
}
|
||||
.auftrag-item .input { width: 100%; }
|
||||
.auftrag-item > div { display: grid; gap: 4px; min-width: 0; }
|
||||
.auftrag-item-desc { grid-area: desc; }
|
||||
.auftrag-item-qty { grid-area: qty; }
|
||||
.auftrag-item-price { grid-area: price; }
|
||||
.auftrag-item-sum { grid-area: sum; }
|
||||
.auftrag-item-remove { grid-area: remove; }
|
||||
.auftrag-item-qty .input, .auftrag-item-price .input { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.auftrag-item-wert { min-height: 36px; display: flex; align-items: center; justify-content: flex-end; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.auftrag-item-remove { min-height: 36px; align-content: center; }
|
||||
.auftrag-item-label { font-size: 12px; color: var(--text-2); }
|
||||
.auftrag-item + .auftrag-item .auftrag-item-label { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
.auftrag-total { display: flex; justify-content: space-between; gap: 16px; font-weight: 600; }
|
||||
.auftrag-narrow { max-width: 120px; }
|
||||
.auftrag-plan-vorschau { margin: 0; overflow-wrap: anywhere; }
|
||||
.auftrag-hinweis { margin: 0; overflow-wrap: anywhere; }
|
||||
.auftrag-zeitquelle { border: 0; padding: 0; margin: 0; display: grid; gap: 6px; min-width: 0; }
|
||||
.auftrag-zeitquelle legend { font-weight: 500; padding: 0; margin-bottom: 4px; }
|
||||
.auftrag-zeitquelle-liste { display: grid; gap: 6px; max-height: 220px; overflow-y: auto; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 6px 0; }
|
||||
.auftrag-zeitquelle-liste .check { flex-wrap: wrap; overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.auftrag-item {
|
||||
grid-template-columns: minmax(0, .7fr) minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
grid-template-areas: "desc desc desc desc" "qty price sum remove";
|
||||
}
|
||||
.auftrag-item + .auftrag-item .auftrag-item-label, .auftrag-item-label { position: static; width: auto; height: auto; margin: 0; overflow: visible; clip: auto; white-space: normal; }
|
||||
.auftrag-narrow { max-width: none; }
|
||||
}
|
||||
Reference in new issue
Block a user