Merge auftraege/w2-auftraege: Auftrags-UI (Strang W2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b7b3e834f8
13 files changed
+2596
-21
No files matched your search
@@ -1,12 +1,275 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button, Dialog } from '../ui'
|
||||
import { createAuftrag, getAuftragPdf, getSettings, patchAuftrag, sendAuftrag } from '../../lib/financeApi'
|
||||
import { alsPdfBlob } from '../../lib/auftragPdf'
|
||||
import {
|
||||
SCHRITTE,
|
||||
auftragBestaetigung,
|
||||
auftragFehler,
|
||||
auftragHinweise,
|
||||
auftragZuFormular,
|
||||
formularZuNutzlast,
|
||||
heuteBerlin,
|
||||
kundenartWirksam,
|
||||
neueSperre,
|
||||
neuesFormular,
|
||||
pruefeSchritt,
|
||||
sendeGate,
|
||||
zahlungsplanOptionen,
|
||||
} from '../../lib/auftragView'
|
||||
import { SchrittAuftrag, SchrittProjekt, SchrittZahlung } from './AuftragSchritte'
|
||||
import AuftragVorschau from './AuftragVorschau'
|
||||
import './auftrag.css'
|
||||
|
||||
/**
|
||||
* Platzhalter von W0: Strang W2 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben).
|
||||
*
|
||||
* Props (V8): open, onClose, onSaved(auftrag) (AuftragTeam mit mailStatus; nach Speichern oder Senden, danach onClose()),
|
||||
* kunde (kommt mit aktueller kundenart: der Container gleicht sie über kundeMitKundenart ab), projektId (vorausgewähltes
|
||||
* Projekt oder null = freier Titel), auftrag (Entwurf zum Bearbeiten oder null), konten (Projektkonten für die Projektwahl),
|
||||
* onShowOverview (Weg „Zur Übersicht“).
|
||||
* Die PDF-Vorschau öffnet openInNewTab(async () => URL.createObjectURL(await getAuftragPdf(id))).
|
||||
* Dialog „Auftragsbestätigung“ in vier Schritten (Projekt, Auftrag, Zahlung und Termine, Vorschau). Beim Wechsel nach
|
||||
* Schritt 4 wird der Entwurf angelegt bzw. aktualisiert und das PDF geholt (die Vorschau gibt es nur zu einem gespeicherten
|
||||
* Entwurf; archiviert wird sie nicht). „Senden“ fragt vorher nach und ist gesperrt, solange Kundendaten fehlen; Hinweise
|
||||
* (Steuernummer, „Vor Ort“ bei Privatperson) blockieren nie. Nach Speichern oder Senden ruft der Dialog onSaved(auftrag) und
|
||||
* danach immer onClose(): der Container schließt, lädt neu und meldet. Nur Entwürfe lassen sich bearbeiten.
|
||||
*/
|
||||
export default function AuftragDialog({ open, onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview }) {
|
||||
return null
|
||||
if (!open) return null
|
||||
if (auftrag && auftrag.status !== 'entwurf') {
|
||||
return (
|
||||
<Dialog title="Auftragsbestätigung" onClose={onClose} footer={<Button type="button" onClick={onClose}>Schließen</Button>}>
|
||||
<p className="msg warn" role="alert">
|
||||
Nur Entwürfe lassen sich bearbeiten. Eine gesendete Auftragsbestätigung lässt sich nur über „Neue Fassung“ in der Liste ändern.
|
||||
</p>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<AuftragForm
|
||||
key={auftrag?.id || projektId || 'neu'}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
kunde={kunde}
|
||||
projektId={projektId}
|
||||
auftrag={auftrag}
|
||||
konten={konten}
|
||||
onShowOverview={onShowOverview}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Ids der Felder (Task 6): wenige Felder haben eine abweichende id
|
||||
function feldId(feld, art) {
|
||||
if (feld === 'art') return 'ab-art-festpreis'
|
||||
if (feld === 'zahlungsart') return `ab-zahlungsart-${zahlungsplanOptionen(art)[0].value}`
|
||||
return `ab-${feld}`
|
||||
}
|
||||
|
||||
function AuftragForm({ onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview }) {
|
||||
const [schritt, setSchritt] = useState(1)
|
||||
const [form, setForm] = useState(() => (auftrag ? auftragZuFormular(auftrag) : neuesFormular({ projektId: projektId || '', konten, heute: heuteBerlin() })))
|
||||
const [fehler, setFehler] = useState({})
|
||||
const [meldung, setMeldung] = useState(null) // { text, zurUebersicht }
|
||||
const [entwurf, setEntwurf] = useState(auftrag || null)
|
||||
const [signatur, setSignatur] = useState('')
|
||||
const [pdfUrl, setPdfUrl] = useState('')
|
||||
const [phase, setPhase] = useState('') // '' | 'speichern' | 'pdf' | 'senden'
|
||||
const [einstellungen, setEinstellungen] = useState(undefined) // undefined = lädt, null = nicht ladbar
|
||||
const [fokus, setFokus] = useState(null) // { id }
|
||||
const sperre = useRef(neueSperre()).current
|
||||
const aliveRef = useRef(true)
|
||||
const schmutzigRef = useRef(false) // es wurde gespeichert und noch nicht gemeldet
|
||||
const urlRef = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
getSettings()
|
||||
.then((d) => {
|
||||
if (aliveRef.current) setEinstellungen(d?.einstellungen ?? null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (aliveRef.current) setEinstellungen(null)
|
||||
})
|
||||
return () => {
|
||||
aliveRef.current = false
|
||||
if (urlRef.current) URL.revokeObjectURL(urlRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (fokus) document.getElementById(fokus.id)?.focus()
|
||||
}, [fokus])
|
||||
|
||||
const hinweise = auftragHinweise({ kunde, form, einstellungen })
|
||||
const gate = sendeGate(kunde, form.kundenart)
|
||||
const privat = kundenartWirksam(kunde, form.kundenart) === 'privat'
|
||||
const beschaeftigt = phase !== ''
|
||||
|
||||
function zeigeFehler(schrittNr, r, art) {
|
||||
setSchritt(schrittNr)
|
||||
setFehler(r.fehler)
|
||||
setFokus({ id: feldId(r.erstes, art) })
|
||||
}
|
||||
|
||||
function weiter() {
|
||||
const r = pruefeSchritt(schritt, form, { kunde })
|
||||
if (!r.ok) {
|
||||
setFehler(r.fehler)
|
||||
setFokus({ id: feldId(r.erstes, form.art) })
|
||||
return
|
||||
}
|
||||
setFehler({})
|
||||
if (schritt === 3) zurVorschau()
|
||||
else setSchritt(schritt + 1)
|
||||
}
|
||||
|
||||
function zurueck() {
|
||||
setMeldung(null)
|
||||
setSchritt(Math.max(1, schritt - 1))
|
||||
}
|
||||
|
||||
function zurVorschau() {
|
||||
const r = formularZuNutzlast(form, { kunde })
|
||||
if (!r.ok) {
|
||||
zeigeFehler(r.schritt, r, form.art)
|
||||
return
|
||||
}
|
||||
setSchritt(4)
|
||||
vorschauLaden(r.payload)
|
||||
}
|
||||
|
||||
async function vorschauLaden(payload) {
|
||||
const neu = JSON.stringify(payload)
|
||||
if (neu === signatur && pdfUrl) return
|
||||
if (sperre.belegt) return
|
||||
await sperre.run(async () => {
|
||||
if (urlRef.current) URL.revokeObjectURL(urlRef.current)
|
||||
urlRef.current = ''
|
||||
setPdfUrl('')
|
||||
setPhase('speichern')
|
||||
setMeldung(null)
|
||||
try {
|
||||
const data = entwurf ? await patchAuftrag(entwurf.id, payload) : await createAuftrag({ kundenId: kunde.$id, ...payload })
|
||||
if (!aliveRef.current) return
|
||||
setEntwurf(data.auftrag)
|
||||
schmutzigRef.current = true
|
||||
setSignatur(neu)
|
||||
setPhase('pdf')
|
||||
const blob = await getAuftragPdf(data.auftrag.id)
|
||||
const url = URL.createObjectURL(alsPdfBlob(blob))
|
||||
if (!aliveRef.current) {
|
||||
URL.revokeObjectURL(url)
|
||||
return
|
||||
}
|
||||
urlRef.current = url
|
||||
setPdfUrl(url)
|
||||
} catch (err) {
|
||||
if (aliveRef.current) setMeldung(auftragFehler(err)) // ein schon gespeicherter Entwurf bleibt
|
||||
} finally {
|
||||
if (aliveRef.current) setPhase('')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function speichernUndSchliessen() {
|
||||
schmutzigRef.current = false
|
||||
if (entwurf) onSaved(entwurf)
|
||||
onClose()
|
||||
}
|
||||
|
||||
function schliessen() {
|
||||
if (beschaeftigt) return
|
||||
if (schmutzigRef.current && entwurf) onSaved(entwurf)
|
||||
schmutzigRef.current = false
|
||||
onClose()
|
||||
}
|
||||
|
||||
async function senden() {
|
||||
if (sperre.belegt || !entwurf) return
|
||||
const g = sendeGate(kunde, form.kundenart)
|
||||
if (!g.erlaubt) {
|
||||
setMeldung({ text: g.grund, zurUebersicht: g.zurUebersicht })
|
||||
return
|
||||
}
|
||||
if (!window.confirm(auftragBestaetigung('senden', entwurf, { email: kunde?.email }))) return
|
||||
await sperre.run(async () => {
|
||||
setPhase('senden')
|
||||
setMeldung(null)
|
||||
try {
|
||||
const data = await sendAuftrag(entwurf.id)
|
||||
schmutzigRef.current = false
|
||||
onSaved(data.auftrag)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
if (aliveRef.current) setMeldung(auftragFehler(err))
|
||||
} finally {
|
||||
if (aliveRef.current) setPhase('')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fuss = (
|
||||
<div className="auftrag-foot">
|
||||
{schritt > 1 && (
|
||||
<Button type="button" onClick={zurueck} disabled={beschaeftigt}>Zurück</Button>
|
||||
)}
|
||||
<span className="grow" />
|
||||
{schritt < 4 ? (
|
||||
<>
|
||||
<Button type="button" onClick={schliessen} disabled={beschaeftigt}>Abbrechen</Button>
|
||||
<Button type="submit" variant="primary" disabled={beschaeftigt}>{schritt === 3 ? 'Zur Vorschau' : 'Weiter'}</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button type="button" onClick={speichernUndSchliessen} disabled={beschaeftigt || !entwurf}>Als Entwurf speichern</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={senden}
|
||||
disabled={beschaeftigt || !entwurf || !pdfUrl || !gate.erlaubt}
|
||||
title={!gate.erlaubt ? gate.grund : undefined}
|
||||
>
|
||||
{phase === 'senden' ? 'Sende …' : 'Senden'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
as="form"
|
||||
wide
|
||||
title={auftrag ? 'Auftragsbestätigung bearbeiten' : 'Auftragsbestätigung'}
|
||||
busy={beschaeftigt}
|
||||
onClose={schliessen}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (schritt < 4) weiter()
|
||||
}}
|
||||
footer={fuss}
|
||||
>
|
||||
<ol className="auftrag-schritte" aria-label="Schritte">
|
||||
{SCHRITTE.map((s) => (
|
||||
<li key={s.id} aria-current={s.id === schritt ? 'step' : undefined}>
|
||||
<span className="auftrag-schritt-nr">{s.id}</span>
|
||||
<span className="auftrag-schritt-text">{s.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{schritt === 1 && <SchrittProjekt form={form} onChange={setForm} fehler={fehler} kunde={kunde} konten={konten} neueFassung={Boolean(auftrag?.ersetztId)} />}
|
||||
{schritt === 2 && <SchrittAuftrag form={form} onChange={setForm} fehler={fehler} kunde={kunde} konten={konten} />}
|
||||
{schritt === 3 && <SchrittZahlung form={form} onChange={setForm} fehler={fehler} kunde={kunde} konten={konten} />}
|
||||
{schritt === 4 && (
|
||||
<AuftragVorschau
|
||||
phase={phase}
|
||||
pdfUrl={pdfUrl}
|
||||
meldung={meldung}
|
||||
gate={gate}
|
||||
privat={privat}
|
||||
hinweise={hinweise}
|
||||
onRetry={zurVorschau}
|
||||
onShowOverview={() => {
|
||||
schliessen()
|
||||
onShowOverview()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,244 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Badge, Button, Card, EmptyState } from '../ui'
|
||||
import { noticeProps } from '../../lib/financeUi'
|
||||
import {
|
||||
deleteAuftrag,
|
||||
erinnereAuftrag,
|
||||
getAuftragPdf,
|
||||
neueFassungAuftrag,
|
||||
ordneAuftragProjektZu,
|
||||
sendAuftrag,
|
||||
storniereAuftrag,
|
||||
} from '../../lib/financeApi'
|
||||
import { oeffnePdfImTab } from '../../lib/auftragPdf'
|
||||
import {
|
||||
auftragBestaetigung,
|
||||
auftragErfolg,
|
||||
auftragFehler,
|
||||
auftragZeilen,
|
||||
neueSperre,
|
||||
zuordnenOptionen,
|
||||
} from '../../lib/auftragView'
|
||||
import './auftrag.css'
|
||||
|
||||
const LAUFEND = { senden: 'Sende …', erinnern: 'Sende …', stornieren: 'Storniere …', loeschen: 'Lösche …', neueFassung: 'Lege an …', pdf: 'Öffne …' }
|
||||
const AUFRUFE = {
|
||||
senden: sendAuftrag,
|
||||
erinnern: erinnereAuftrag,
|
||||
stornieren: storniereAuftrag,
|
||||
loeschen: deleteAuftrag,
|
||||
neueFassung: neueFassungAuftrag,
|
||||
}
|
||||
|
||||
/**
|
||||
* Platzhalter von W0: Strang W2 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben).
|
||||
* Die fertige Komponente rendert <Card title="Aufträge"> selbst und ruft nach jeder Aktion onChanged().
|
||||
*
|
||||
* Props (V8): auftraege (AuftragTeam[], V3), konten (Projektname der Zeile und Auswahl für „Projekt zuordnen“, einer Aktion für
|
||||
* ABs ohne Projekt über ordneAuftragProjektZu(id, projektId) aus financeApi.js), onChanged(), onBearbeiten(auftrag)
|
||||
* („Entwurf bearbeiten“; nach „Neue Fassung“ mit dem neuen Entwurf) und onShowOverview (Weg „Zur Übersicht“ aus Meldungen).
|
||||
* Karte „Aufträge“ im Kundenreiter: Auftragsbestätigungen (AB) mit Status, Zusatzzeilen und den Aktionen je Status.
|
||||
* Die Regeln stecken in src/lib/auftragView.js. Nach jeder Aktion ruft die Liste onChanged() (auch nach einem Fehler:
|
||||
* der Stand kann ein anderer sein). „Bearbeiten“ und „Neue Fassung“ geben den Entwurf über onBearbeiten an den Container,
|
||||
* „Zur Übersicht“ in Meldungen läuft über onShowOverview. Eine Sperre fängt den Doppelklick ab (eine Anfrage, eine Rückfrage).
|
||||
* Schmal (Wrapper .auftrag-body bis 860 px) wird jede Zeile ein Raster.
|
||||
*/
|
||||
export default function AuftragListe({ auftraege, konten, onChanged, onBearbeiten, onShowOverview }) {
|
||||
return null
|
||||
const zeilen = useMemo(() => auftragZeilen(auftraege, { konten }), [auftraege, konten])
|
||||
const optionen = useMemo(() => zuordnenOptionen(konten), [konten])
|
||||
const [busy, setBusy] = useState(null) // `${id}:${aktion}` der laufenden Anfrage
|
||||
const [message, setMessage] = useState(null) // { tone, text, zurUebersicht? }
|
||||
const [zuordnen, setZuordnen] = useState(null) // { id, projektId } der Zeile mit offener Auswahl
|
||||
const sperre = useRef(neueSperre()).current
|
||||
const aliveRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
return () => {
|
||||
aliveRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const zeigeFehler = (err) => {
|
||||
const f = auftragFehler(err)
|
||||
if (aliveRef.current) setMessage({ tone: 'warn', text: f.text, zurUebersicht: f.zurUebersicht })
|
||||
}
|
||||
|
||||
async function run(zeile, aktion) {
|
||||
if (aktion.key === 'bearbeiten') {
|
||||
onBearbeiten(zeile.auftrag)
|
||||
return
|
||||
}
|
||||
if (aktion.key === 'projekt') {
|
||||
setZuordnen({ id: zeile.id, projektId: '' })
|
||||
setMessage(null)
|
||||
return
|
||||
}
|
||||
if (sperre.belegt) return
|
||||
const frage = auftragBestaetigung(aktion.key, zeile.auftrag)
|
||||
if (frage && !window.confirm(frage)) return
|
||||
await sperre.run(async () => {
|
||||
setBusy(`${zeile.id}:${aktion.key}`)
|
||||
setMessage(null)
|
||||
try {
|
||||
if (aktion.key === 'pdf') {
|
||||
// kein await davor: der neue Tab muss im Klick aufgehen
|
||||
await oeffnePdfImTab(() => getAuftragPdf(zeile.id))
|
||||
return
|
||||
}
|
||||
const data = await AUFRUFE[aktion.key](zeile.id)
|
||||
if (aliveRef.current) setMessage(auftragErfolg(aktion.key, data?.auftrag || zeile.auftrag, { mailFehler: data?.mailFehler }))
|
||||
onChanged()
|
||||
if (aktion.key === 'neueFassung' && data?.auftrag) onBearbeiten(data.auftrag)
|
||||
} catch (err) {
|
||||
if (aktion.key === 'pdf') {
|
||||
if (aliveRef.current) setMessage({ tone: 'warn', text: auftragFehler(err).text })
|
||||
return
|
||||
}
|
||||
zeigeFehler(err)
|
||||
onChanged() // der Stand kann ein anderer sein
|
||||
} finally {
|
||||
if (aliveRef.current) setBusy(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function ordneZu(zeile) {
|
||||
const gewaehlt = optionen.find((o) => o.value === zuordnen?.projektId)
|
||||
if (!gewaehlt || sperre.belegt) return
|
||||
const frage = auftragBestaetigung('projekt', zeile.auftrag, { projektName: gewaehlt.label })
|
||||
if (frage && !window.confirm(frage)) return
|
||||
await sperre.run(async () => {
|
||||
setBusy(`${zeile.id}:projekt`)
|
||||
setMessage(null)
|
||||
try {
|
||||
const data = await ordneAuftragProjektZu(zeile.id, gewaehlt.value)
|
||||
if (aliveRef.current) {
|
||||
setMessage(auftragErfolg('projekt', data?.auftrag || zeile.auftrag, { projektName: gewaehlt.label }))
|
||||
setZuordnen(null)
|
||||
}
|
||||
onChanged()
|
||||
} catch (err) {
|
||||
zeigeFehler(err)
|
||||
if (aliveRef.current) setZuordnen(null)
|
||||
onChanged()
|
||||
} finally {
|
||||
if (aliveRef.current) setBusy(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Aufträge" className="auftrag-liste">
|
||||
<div className="auftrag-body">
|
||||
{message && (
|
||||
<p {...noticeProps(message.tone, 'auftrag-notice')}>
|
||||
{message.text}
|
||||
{message.zurUebersicht && (
|
||||
<>
|
||||
{' '}
|
||||
<button type="button" className="link" onClick={onShowOverview}>
|
||||
Zur Übersicht
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{zeilen.length === 0 ? (
|
||||
<EmptyState title="Noch keine Aufträge." hint="Eine Auftragsbestätigung entsteht über „Auftragsbestätigung“ in der Projektliste." />
|
||||
) : (
|
||||
<div className="auftrag-table-wrap">
|
||||
<table className="table auftrag-table" role="table">
|
||||
<caption className="auftrag-sr">Auftragsbestätigungen, neueste zuerst</caption>
|
||||
<thead role="rowgroup">
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col">Nummer</th>
|
||||
<th role="columnheader" scope="col">Projekt / Titel</th>
|
||||
<th role="columnheader" scope="col">Datum</th>
|
||||
<th role="columnheader" scope="col" className="auftrag-amount">Summe</th>
|
||||
<th role="columnheader" scope="col">Status</th>
|
||||
<th role="columnheader" scope="col">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody role="rowgroup">
|
||||
{zeilen.map((zeile) => {
|
||||
const offen = zuordnen?.id === zeile.id
|
||||
const wahl = offen && optionen.some((o) => o.value === zuordnen.projektId) ? zuordnen.projektId : ''
|
||||
const ordnet = busy === `${zeile.id}:projekt`
|
||||
return (
|
||||
<tr key={zeile.key} role="row">
|
||||
<td role="cell" className="auftrag-nummer">
|
||||
{zeile.istEntwurf ? <span className="muted">{zeile.nummerText}</span> : zeile.nummerText}
|
||||
</td>
|
||||
<td role="cell" className="auftrag-titel">
|
||||
<strong>{zeile.titel}</strong>
|
||||
{zeile.projektText && <div className="cell-sub">{zeile.projektText}</div>}
|
||||
</td>
|
||||
<td role="cell" className="auftrag-datum" data-label="Datum">{zeile.datumText}</td>
|
||||
<td role="cell" className="auftrag-amount" data-label="Summe">
|
||||
{zeile.summeText}
|
||||
{zeile.summeSub && <div className="cell-sub">{zeile.summeSub}</div>}
|
||||
</td>
|
||||
<td role="cell" className="auftrag-status">
|
||||
<Badge tone={zeile.statusTone}>{zeile.statusLabel}</Badge>
|
||||
{zeile.notizen.map((note) => (
|
||||
<div key={note.key} className={note.tone === 'warn' ? 'msg warn auftrag-note' : 'muted auftrag-note'}>
|
||||
{note.text}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
<td role="cell" className="auftrag-aktionen">
|
||||
<div className="auftrag-actions">
|
||||
{zeile.aktionen.map((aktion) => {
|
||||
const label = `${aktion.label}: ${zeile.nummerText}, ${zeile.titel}`
|
||||
const text = busy === `${zeile.id}:${aktion.key}` && LAUFEND[aktion.key] ? LAUFEND[aktion.key] : aktion.label
|
||||
if (aktion.danger) {
|
||||
return (
|
||||
<Button key={aktion.key} type="button" variant="danger" onClick={() => run(zeile, aktion)} disabled={busy !== null} aria-label={label}>
|
||||
{text}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={aktion.key}
|
||||
type="button"
|
||||
className="link"
|
||||
onClick={() => run(zeile, aktion)}
|
||||
disabled={busy !== null}
|
||||
aria-label={label}
|
||||
aria-expanded={aktion.key === 'projekt' ? offen : undefined}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{offen && (
|
||||
<div className="auftrag-zuordnen" role="group" aria-label={`Projekt zuordnen: ${zeile.nummerText}, ${zeile.titel}`}>
|
||||
<select
|
||||
className="select"
|
||||
aria-label="Projekt wählen"
|
||||
value={wahl}
|
||||
onChange={(e) => setZuordnen({ id: zeile.id, projektId: e.target.value })}
|
||||
>
|
||||
<option value="">Projekt wählen …</option>
|
||||
{optionen.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="button" onClick={() => ordneZu(zeile)} disabled={wahl === '' || busy !== null}>
|
||||
{ordnet ? 'Ordne zu …' : 'Zuordnen'}
|
||||
</Button>
|
||||
<button type="button" className="link" disabled={busy !== null} onClick={() => setZuordnen(null)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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'))} />
|
||||
<span className="auftrag-check-text">Festpreis</span>
|
||||
<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'))} />
|
||||
<span className="auftrag-check-text">Nach Aufwand</span>
|
||||
<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 })}
|
||||
/>
|
||||
<span className="auftrag-check-text">{o.label}</span>
|
||||
{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 })}
|
||||
/>
|
||||
<span className="auftrag-check-text">{o.label}</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useRef } from 'react'
|
||||
import { Button } from '../ui'
|
||||
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||
import './auftrag.css'
|
||||
|
||||
/**
|
||||
* Schritt 4 des Dialogs als reine Darstellung (kein eigener Zustand): Lade- und Fehlerzustand, Hinweise und die PDF-Vorschau.
|
||||
* Alles, was vor dem Senden zu lesen ist, steht über dem hohen PDF-Rahmen. Hinweise sind reine Texte und sperren nichts;
|
||||
* nur `gate` (Kundendaten) hält das Senden auf, der Entwurf lässt sich trotzdem speichern.
|
||||
*
|
||||
* phase: '' | 'speichern' | 'pdf' | 'senden'; meldung: { text, zurUebersicht? } | null; gate: { erlaubt, grund, zurUebersicht };
|
||||
* hinweise: [{ key, text }]; privat: Kundenart Privatperson.
|
||||
*/
|
||||
export default function AuftragVorschau({ phase, pdfUrl, meldung, gate, privat, hinweise = [], onRetry, onShowOverview }) {
|
||||
const errorRef = useRef(null)
|
||||
useScrollIntoView(errorRef, meldung)
|
||||
const laedt = phase === 'speichern' || phase === 'pdf'
|
||||
|
||||
return (
|
||||
<div className="auftrag-schritt">
|
||||
{phase === 'speichern' && <div className="loading" role="status">Entwurf wird gespeichert …</div>}
|
||||
{phase === 'pdf' && <div className="loading" role="status">Vorschau wird erstellt …</div>}
|
||||
|
||||
{meldung && (
|
||||
<div className="msg err" role="alert" ref={errorRef}>
|
||||
{meldung.text}
|
||||
{meldung.zurUebersicht && (
|
||||
<>
|
||||
{' '}
|
||||
<button type="button" className="link" onClick={onShowOverview}>
|
||||
Zur Übersicht
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{meldung && !pdfUrl && !laedt && phase !== 'senden' && (
|
||||
<div>
|
||||
<Button type="button" onClick={onRetry}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pdfUrl && <p className="msg ok">Entwurf gespeichert. Er bleibt in der Liste, bis er gesendet oder gelöscht wird.</p>}
|
||||
|
||||
{gate && !gate.erlaubt && (
|
||||
<div className="msg warn" role="alert">
|
||||
<span>{gate.grund}</span>
|
||||
{gate.zurUebersicht && (
|
||||
<>
|
||||
{' '}
|
||||
<button type="button" className="link" onClick={onShowOverview}>
|
||||
Zur Übersicht
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hinweise.map((h) => (
|
||||
<p key={h.key} className="msg warn auftrag-hinweis">{h.text}</p>
|
||||
))}
|
||||
|
||||
{privat && (
|
||||
<p className="hint">
|
||||
Privatperson: Die Auftragsbestätigung enthält die Widerrufsbelehrung. Mit der Arbeit darf erst nach Ablauf der Widerrufsfrist begonnen
|
||||
werden, es sei denn, der Kunde verlangt es ausdrücklich (Häkchen auf der Bestätigungsseite).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pdfUrl && (
|
||||
<>
|
||||
<iframe className="auftrag-pdf" title="Vorschau der Auftragsbestätigung" src={pdfUrl} />
|
||||
<a className="link" href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
PDF in neuem Tab öffnen
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/* Auftragsbestaetigungen (W2): Liste, Dialog-Schritte, Vorschau. Nur Tokens, alle Klassen beginnen mit auftrag-.
|
||||
Die Datei wird von der Komponente geladen, global.css kommt danach: darum nie eine globale Klasse ueberschreiben. */
|
||||
|
||||
/* Liste */
|
||||
.auftrag-body { container-type: inline-size; }
|
||||
.auftrag-notice { margin: 0 0 8px; }
|
||||
.auftrag-table-wrap { overflow-x: auto; }
|
||||
.auftrag-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
.auftrag-nummer { white-space: nowrap; }
|
||||
.auftrag-titel { overflow-wrap: anywhere; }
|
||||
.auftrag-table th.auftrag-amount { text-align: right; }
|
||||
.auftrag-amount { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.auftrag-note { margin-top: 2px; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.auftrag-actions { display: flex; flex-wrap: wrap; gap: 4px 14px; }
|
||||
.auftrag-zuordnen { padding: 2px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 8px; }
|
||||
.auftrag-zuordnen .select { flex: 1 1 180px; min-width: 0; max-width: 100%; }
|
||||
.auftrag-liste button.link:disabled, .auftrag-liste button.link-danger:disabled { cursor: not-allowed; }
|
||||
|
||||
@container (max-width: 860px) {
|
||||
.auftrag-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.auftrag-table, .auftrag-table tbody { display: block; }
|
||||
.auftrag-liste .auftrag-table tr {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; align-items: baseline;
|
||||
grid-template-areas: "nummer status" "titel titel" "datum summe" "aktionen aktionen";
|
||||
padding: 10px 0; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.auftrag-liste .auftrag-table td, .auftrag-liste .auftrag-table td:first-child, .auftrag-liste .auftrag-table td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
|
||||
.auftrag-nummer { grid-area: nummer; }
|
||||
.auftrag-status { grid-area: status; text-align: right; }
|
||||
.auftrag-titel { grid-area: titel; }
|
||||
.auftrag-datum { grid-area: datum; }
|
||||
.auftrag-liste .auftrag-table td.auftrag-amount { grid-area: summe; }
|
||||
.auftrag-liste .auftrag-table td.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-check-text { flex: 1 1 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.auftrag-zeitquelle-liste .auftrag-check-text { flex: 0 1 auto; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
/* Dialog: Schrittleiste, Vorschau, Fuss */
|
||||
.auftrag-schritte { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 13px; color: var(--text-2); }
|
||||
.auftrag-schritte li[aria-current="step"] { color: var(--text); font-weight: 600; }
|
||||
.auftrag-schritt-nr { margin-right: 6px; }
|
||||
.auftrag-pdf { width: 100%; height: min(70vh, 640px); border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-subtle); }
|
||||
.auftrag-foot { display: flex; flex: 1; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.auftrag-foot .grow { flex: 1; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.auftrag-schritt-text { display: none; }
|
||||
.auftrag-schritte li[aria-current="step"] .auftrag-schritt-text { display: inline; }
|
||||
.auftrag-pdf { height: 50vh; }
|
||||
.auftrag-foot .btn { flex: 1 1 auto; }
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
settingsToForm,
|
||||
validateSettingsForm,
|
||||
} from '../../lib/financeSettings'
|
||||
import { STEUERNUMMER_HINWEIS, steuernummerAenderung } from '../../lib/kundenFelder'
|
||||
|
||||
const LOAD_ERROR = 'Die Einstellungen konnten nicht geladen werden.'
|
||||
const fieldId = (name) => `fin-set-${name}`
|
||||
@@ -44,6 +45,8 @@ export default function SettingsCard() {
|
||||
|
||||
function SettingsForm({ initial }) {
|
||||
const [form, setForm] = useState(() => settingsToForm(initial))
|
||||
const [steuernummer, setSteuernummer] = useState(() => (typeof initial?.steuernummer === 'string' ? initial.steuernummer : ''))
|
||||
const gespeichertRef = useRef(typeof initial?.steuernummer === 'string' ? initial.steuernummer : '')
|
||||
const [errors, setErrors] = useState({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState(null) // { tone: 'ok' | 'warn', text }
|
||||
@@ -68,10 +71,12 @@ function SettingsForm({ initial }) {
|
||||
e.preventDefault()
|
||||
if (lockRef.current) return
|
||||
const check = validateSettingsForm(form)
|
||||
if (!check.ok) {
|
||||
setErrors(check.errors)
|
||||
const stn = steuernummerAenderung(steuernummer, gespeichertRef.current)
|
||||
if (!check.ok || !stn.ok) {
|
||||
const fehler = { ...(check.errors || {}), ...(stn.ok ? {} : { steuernummer: stn.fehler }) }
|
||||
setErrors(fehler)
|
||||
setNotice({ tone: 'warn', text: 'Bitte die markierten Felder prüfen. Es wurde nichts gespeichert.' })
|
||||
const first = SETTINGS_FIELD_ORDER.find((name) => check.errors[name])
|
||||
const first = [...SETTINGS_FIELD_ORDER, 'steuernummer'].find((name) => fehler[name])
|
||||
if (first) document.getElementById(fieldId(first))?.focus()
|
||||
return
|
||||
}
|
||||
@@ -80,9 +85,12 @@ function SettingsForm({ initial }) {
|
||||
setErrors({})
|
||||
setNotice(null)
|
||||
try {
|
||||
const data = await patchSettings(check.payload)
|
||||
const data = await patchSettings({ ...check.payload, ...stn.payload })
|
||||
if (!aliveRef.current) return
|
||||
if (data?.einstellungen) setForm(settingsToForm(data.einstellungen))
|
||||
const gespeichert = typeof data?.einstellungen?.steuernummer === 'string' ? data.einstellungen.steuernummer : steuernummer.trim()
|
||||
setSteuernummer(gespeichert)
|
||||
gespeichertRef.current = gespeichert
|
||||
setNotice({ tone: 'ok', text: 'Gespeichert.' })
|
||||
} catch (err) {
|
||||
if (aliveRef.current) setNotice({ tone: 'warn', text: apiErrorMessage(err) })
|
||||
@@ -148,6 +156,19 @@ function SettingsForm({ initial }) {
|
||||
<div className={`fin-counter${tooLong ? ' fin-counter-over' : ''}`}>
|
||||
{length} / {SETTINGS_LIMITS.maxBankDetails} Zeichen
|
||||
</div>
|
||||
<FormField id={fieldId('steuernummer')} label="Steuernummer" hint={STEUERNUMMER_HINWEIS} error={errors.steuernummer}>
|
||||
<input
|
||||
className="input"
|
||||
autoComplete="off"
|
||||
value={steuernummer}
|
||||
onChange={(e) => {
|
||||
setSteuernummer(e.target.value)
|
||||
setErrors((er) => (er.steuernummer ? { ...er, steuernummer: undefined } : er))
|
||||
if (notice) setNotice(null)
|
||||
}}
|
||||
placeholder="z. B. 12/345/67890 oder DE123456789"
|
||||
/>
|
||||
</FormField>
|
||||
</fieldset>
|
||||
|
||||
{notice && <p {...noticeProps(notice.tone, 'fin-notice')}>{notice.text}</p>}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// PDF der Auftragsbestaetigung in einem neuen Tab oeffnen. Rein (ohne React), damit unter Node testbar. Der Tab geht
|
||||
// synchron im Klick auf (sonst blockiert der Browser das Pop-up), das PDF kommt erst nach der Anfrage ans Portal.
|
||||
import { openInNewTab } from './openInNewTab'
|
||||
|
||||
/** Objekt-URLs bleiben fuenf Minuten gueltig, dann werden sie freigegeben. */
|
||||
export const PDF_FREIGABE_NACH_MS = 300000
|
||||
|
||||
/** Blob mit dem Typ application/pdf (damit der Browser es anzeigt); ein passender Blob bleibt unveraendert. */
|
||||
export function alsPdfBlob(blob) {
|
||||
return blob.type === 'application/pdf' ? blob : new Blob([blob], { type: 'application/pdf' })
|
||||
}
|
||||
|
||||
/** holeBlob: () => Promise<Blob>. Fehler und ein blockiertes Pop-up behandelt openInNewTab (Tab zu, Fehler weitergereicht). */
|
||||
export async function oeffnePdfImTab(holeBlob, { win = globalThis.window, urlApi = globalThis.URL, timer = globalThis.setTimeout } = {}) {
|
||||
await openInNewTab(
|
||||
async () => {
|
||||
const url = urlApi.createObjectURL(alsPdfBlob(await holeBlob()))
|
||||
timer(() => urlApi.revokeObjectURL(url), PDF_FREIGABE_NACH_MS)
|
||||
return url
|
||||
},
|
||||
{ win }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
// Reine Ansichtslogik der Auftragsbestaetigungen (AB) in WOMS: Eingaben pruefen, Cent und Minuten, Formular <-> Anfrage,
|
||||
// Zahlungsplan-Vorschau, Hinweise, Status und Aktionen der Liste, Meldungen und Fehlerklassen. Kein React, kein Appwrite,
|
||||
// unter Node testbar. Betraege sind ganze Cent, in Formularen Euro-Texte (de-DE); Texte deutsch und sachlich (keine Anrede).
|
||||
// Die Grenzen entsprechen dem Vertrag V2 (LIMITS) der Uebersicht, der Server entscheidet.
|
||||
import { canInvoice, dateDe, euro, ymdBerlin } from './financeFormat'
|
||||
import { ADDRESS_INCOMPLETE_TEXT, addressComplete, invoiceTotalCents, parseQuantity, parseUnitPrice } from './financeView'
|
||||
import { euroField } from './financeSettings'
|
||||
import { ticketNumber } from './ticketView'
|
||||
import { KUNDENARTEN, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt } from './kundenFelder'
|
||||
|
||||
// --- Konstanten --------------------------------------------------------------------------------------------------------
|
||||
|
||||
export const AUFTRAG_LIMITS = {
|
||||
maxPositionen: 20,
|
||||
maxBeschreibung: 200,
|
||||
maxMenge: 999,
|
||||
maxEinzelpreisCents: 10_000_000,
|
||||
maxSummeCents: 99_999_999,
|
||||
maxTitel: 200,
|
||||
maxZahlungsplanText: 500,
|
||||
maxZahlungszielTage: 60,
|
||||
standardZahlungszielTage: 14,
|
||||
standardAnzahlungProzent: 50,
|
||||
maxZeitquellen: 10,
|
||||
maxSchaetzungMinuten: 99_960,
|
||||
maxLeistungszeitraum: 140,
|
||||
}
|
||||
|
||||
export const STUNDENSATZ_STANDARD_CENTS = 10000
|
||||
|
||||
export const AUFTRAG_WEGE = [
|
||||
{ value: 'muendlich', label: 'Mündlich' },
|
||||
{ value: 'telefon', label: 'Telefonisch' },
|
||||
{ value: 'email', label: 'Per E-Mail' },
|
||||
{ value: 'vor_ort', label: 'Vor Ort' },
|
||||
{ value: 'chat', label: 'Im Chat' },
|
||||
]
|
||||
|
||||
export const ZAHLUNGSPLAENE = {
|
||||
nach_abnahme: 'Alles nach Abnahme',
|
||||
anzahlung: 'Anzahlung bei Auftrag, Rest nach Abnahme',
|
||||
monatlich: 'Monatlich nach Aufwand',
|
||||
frei: 'Frei (eigener Text)',
|
||||
}
|
||||
|
||||
export const SCHRITTE = [
|
||||
{ id: 1, label: 'Projekt' },
|
||||
{ id: 2, label: 'Auftrag' },
|
||||
{ id: 3, label: 'Zahlung und Termine' },
|
||||
{ id: 4, label: 'Vorschau' },
|
||||
]
|
||||
|
||||
const ARTEN = ['festpreis', 'aufwand']
|
||||
|
||||
/** Auswahl des Zahlungsplans je Art: „Anzahlung“ nur beim Festpreis, „Monatlich“ nur nach Aufwand. */
|
||||
export function zahlungsplanOptionen(art) {
|
||||
const werte = art === 'aufwand' ? ['nach_abnahme', 'monatlich', 'frei'] : ['nach_abnahme', 'anzahlung', 'frei']
|
||||
return werte.map((value) => ({ value, label: ZAHLUNGSPLAENE[value] }))
|
||||
}
|
||||
|
||||
// --- Hilfen ------------------------------------------------------------------------------------------------------------
|
||||
|
||||
const text = (v) => (typeof v === 'string' ? v : '')
|
||||
const trimmed = (v) => text(v).trim()
|
||||
const istObjekt = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
|
||||
const objekte = (v) => (Array.isArray(v) ? v.filter(istObjekt) : [])
|
||||
|
||||
/** 'YYYY-MM-DD' und ein wirklich existierender Tag. */
|
||||
function istTag(wert) {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text(wert)) && dateDe(wert) !== '–'
|
||||
}
|
||||
|
||||
// --- Eingaben ----------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Stunden mit Komma in 15-Minuten-Schritten („10“, „12,5“, „2 Std.“) -> Minuten; ungueltig, leer oder 0 -> null. */
|
||||
export function parseStunden(eingabe) {
|
||||
const s = trimmed(eingabe).replace(/\s*(std\.?|h)$/i, '')
|
||||
const m = /^(\d{1,4})(?:,(\d{1,2}))?$/.exec(s)
|
||||
if (!m) return null
|
||||
const hundertstel = Number(m[1]) * 60 * 100 + Number((m[2] || '').padEnd(2, '0') || 0) * 60
|
||||
if (hundertstel % 100 !== 0) return null
|
||||
const minuten = hundertstel / 100
|
||||
if (!(minuten > 0) || minuten % 15 !== 0 || minuten > AUFTRAG_LIMITS.maxSchaetzungMinuten) return null
|
||||
return minuten
|
||||
}
|
||||
|
||||
/** Minuten -> Stunden mit Komma ohne ueberfluessige Nullen (150 -> „2,5“); keine Zahl -> „–“. */
|
||||
export function stundenText(minuten) {
|
||||
if (typeof minuten !== 'number' || !Number.isFinite(minuten)) return '–'
|
||||
return String(Number((minuten / 60).toFixed(2))).replace('.', ',')
|
||||
}
|
||||
|
||||
/** „50“ oder „30 %“ -> 1..99, sonst null. */
|
||||
export function parseProzent(eingabe) {
|
||||
const s = trimmed(eingabe).replace(/\s*%$/, '')
|
||||
if (!/^\d{1,2}$/.test(s)) return null
|
||||
const n = Number(s)
|
||||
return n >= 1 && n <= 99 ? n : null
|
||||
}
|
||||
|
||||
/** Zahlungsziel in Tagen 0..60, sonst null. */
|
||||
export function parseTage(eingabe) {
|
||||
const s = trimmed(eingabe)
|
||||
if (!/^\d{1,3}$/.test(s)) return null
|
||||
const n = Number(s)
|
||||
return n <= AUFTRAG_LIMITS.maxZahlungszielTage ? n : null
|
||||
}
|
||||
|
||||
/** Berliner Kalendertag („YYYY-MM-DD“), nicht der UTC-Tag. */
|
||||
export function heuteBerlin(now = Date.now()) {
|
||||
return ymdBerlin(now)
|
||||
}
|
||||
|
||||
// --- Formular ----------------------------------------------------------------------------------------------------------
|
||||
|
||||
let naechsteUid = 1
|
||||
|
||||
/** Leere Position des Formulars; `uid` ist nur der React-Schluessel. */
|
||||
export function neuePosition() {
|
||||
const uid = naechsteUid
|
||||
naechsteUid += 1
|
||||
return { uid, description: '', quantity: '1', price: '' }
|
||||
}
|
||||
|
||||
const kontoMit = (konten, projektId) =>
|
||||
projektId && Array.isArray(konten) ? objekte(konten).find((k) => k.projektId === projektId) || null : null
|
||||
|
||||
const ticketIds = (konto) => (konto && Array.isArray(konto.tickets) ? konto.tickets.map((t) => t?.id).filter((id) => typeof id === 'string' && id) : [])
|
||||
|
||||
/** Neues Formular; ein vorgewaehltes Projekt fuellt Titel und Zeitquelle. */
|
||||
export function neuesFormular({ projektId = '', konten = [], heute = heuteBerlin() } = {}) {
|
||||
const konto = kontoMit(konten, projektId)
|
||||
return {
|
||||
projektId: konto ? konto.projektId : '',
|
||||
titel: konto ? text(konto.name) : '',
|
||||
titelGeaendert: false,
|
||||
kundenart: '',
|
||||
art: 'festpreis',
|
||||
auftragVom: heute,
|
||||
auftragWeg: '',
|
||||
positionen: [neuePosition()],
|
||||
stundensatz: euroField(STUNDENSATZ_STANDARD_CENTS),
|
||||
schaetzung: '',
|
||||
zahlungsart: 'nach_abnahme',
|
||||
prozent: String(AUFTRAG_LIMITS.standardAnzahlungProzent),
|
||||
zahlungstext: '',
|
||||
zahlungsziel: String(AUFTRAG_LIMITS.standardZahlungszielTage),
|
||||
startAm: '',
|
||||
fertigBis: '',
|
||||
zeitquelle: ticketIds(konto),
|
||||
zeitquelleGeaendert: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Entwurf vom Server -> Formular (Euro- und Stundentexte). Titel und Zeitquelle gelten als von Hand gesetzt. */
|
||||
export function auftragZuFormular(auftrag) {
|
||||
const a = istObjekt(auftrag) ? auftrag : {}
|
||||
const positionen = objekte(a.positionen).map((p) => ({
|
||||
uid: neuePosition().uid,
|
||||
description: text(p.beschreibung),
|
||||
quantity: String(p.menge ?? ''),
|
||||
price: euroField(p.einzelpreisCents),
|
||||
}))
|
||||
const plan = istObjekt(a.zahlungsplan) ? a.zahlungsplan : {}
|
||||
return {
|
||||
projektId: text(a.projektId),
|
||||
titel: text(a.titel),
|
||||
titelGeaendert: true,
|
||||
kundenart: '',
|
||||
art: ARTEN.includes(a.art) ? a.art : 'festpreis',
|
||||
auftragVom: text(a.auftragVom),
|
||||
auftragWeg: text(a.auftragWeg),
|
||||
positionen: positionen.length ? positionen : [neuePosition()],
|
||||
stundensatz: euroField(typeof a.stundensatzCents === 'number' ? a.stundensatzCents : STUNDENSATZ_STANDARD_CENTS),
|
||||
schaetzung: typeof a.schaetzungMinuten === 'number' ? stundenText(a.schaetzungMinuten) : '',
|
||||
zahlungsart: text(plan.art) || 'nach_abnahme',
|
||||
prozent: typeof plan.prozent === 'number' ? String(plan.prozent) : String(AUFTRAG_LIMITS.standardAnzahlungProzent),
|
||||
zahlungstext: text(plan.text),
|
||||
zahlungsziel: String(typeof a.zahlungszielTage === 'number' ? a.zahlungszielTage : AUFTRAG_LIMITS.standardZahlungszielTage),
|
||||
startAm: text(a.startAm),
|
||||
fertigBis: text(a.fertigBis),
|
||||
zeitquelle: Array.isArray(a.zeitquelle) ? a.zeitquelle.filter((id) => typeof id === 'string') : [],
|
||||
zeitquelleGeaendert: true,
|
||||
}
|
||||
}
|
||||
|
||||
/** Projekt waehlen: Titel und Zeitquelle folgen dem Projekt, solange sie nicht von Hand geaendert wurden. */
|
||||
export function waehleProjekt(form, projektId, konten) {
|
||||
const konto = kontoMit(konten, projektId)
|
||||
const neu = { ...form, projektId: konto ? konto.projektId : '' }
|
||||
if (!form.titelGeaendert) neu.titel = konto ? text(konto.name) : ''
|
||||
if (!form.zeitquelleGeaendert) neu.zeitquelle = ticketIds(konto)
|
||||
return neu
|
||||
}
|
||||
|
||||
/** Art wechseln; ein unpassender Zahlungsplan wird zu „Alles nach Abnahme“. */
|
||||
export function wechselArt(form, art) {
|
||||
if (!ARTEN.includes(art)) return form
|
||||
const passt = zahlungsplanOptionen(art).some((o) => o.value === form.zahlungsart)
|
||||
return { ...form, art, zahlungsart: passt ? form.zahlungsart : 'nach_abnahme' }
|
||||
}
|
||||
|
||||
/** Summe der gueltigen Positionen in Cent (Festpreis), bei Aufwand null. */
|
||||
export function formularSummeCents(form) {
|
||||
return form?.art === 'festpreis' ? invoiceTotalCents(form.positionen) : null
|
||||
}
|
||||
|
||||
// --- Pruefen -----------------------------------------------------------------------------------------------------------
|
||||
|
||||
const MELDUNG = {
|
||||
titel: `Bitte einen Titel eingeben (höchstens ${AUFTRAG_LIMITS.maxTitel} Zeichen).`,
|
||||
kundenart: 'Bitte die Kundenart wählen.',
|
||||
auftragVom: 'Bitte das Datum der Absprache angeben.',
|
||||
auftragWeg: 'Bitte den Weg der Absprache wählen.',
|
||||
art: 'Bitte die Art des Auftrags wählen.',
|
||||
anzahl: `Eine Auftragsbestätigung braucht 1 bis ${AUFTRAG_LIMITS.maxPositionen} Positionen.`,
|
||||
menge: `Die Menge muss eine ganze Zahl von 1 bis ${AUFTRAG_LIMITS.maxMenge} sein.`,
|
||||
preis: 'Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).',
|
||||
summe: `Die Summe ist zu hoch (höchstens ${euro(AUFTRAG_LIMITS.maxSummeCents)}).`,
|
||||
stundensatz: 'Der Stundensatz muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 100,00).',
|
||||
schaetzung: `Die Schätzung geht in Schritten von 15 Minuten (z. B. 10 oder 12,5 Stunden, höchstens ${AUFTRAG_LIMITS.maxSchaetzungMinuten / 60}).`,
|
||||
zahlungsart: 'Dieser Zahlungsplan passt nicht zur Art des Auftrags.',
|
||||
prozent: 'Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.',
|
||||
zahlungstext: `Bitte den Zahlungsplan als Text eingeben (höchstens ${AUFTRAG_LIMITS.maxZahlungsplanText} Zeichen).`,
|
||||
zahlungsziel: `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${AUFTRAG_LIMITS.maxZahlungszielTage} Tagen sein.`,
|
||||
datum: 'Bitte ein gültiges Datum wählen.',
|
||||
fertigVorStart: 'Die Fertigstellung liegt vor dem Start.',
|
||||
zeitquelle: `Höchstens ${AUFTRAG_LIMITS.maxZeitquellen} Tickets als Zeitquelle.`,
|
||||
}
|
||||
|
||||
function positionenFehler(positionen) {
|
||||
const liste = objekte(positionen)
|
||||
if (liste.length < 1 || liste.length > AUFTRAG_LIMITS.maxPositionen) return MELDUNG.anzahl
|
||||
for (let i = 0; i < liste.length; i += 1) {
|
||||
const p = liste[i]
|
||||
const vor = `Position ${i + 1}: `
|
||||
const beschreibung = trimmed(p.description)
|
||||
if (!beschreibung) return `${vor}Bitte eine Beschreibung eingeben.`
|
||||
if (beschreibung.length > AUFTRAG_LIMITS.maxBeschreibung) return `${vor}Die Beschreibung darf höchstens ${AUFTRAG_LIMITS.maxBeschreibung} Zeichen lang sein.`
|
||||
if (parseQuantity(p.quantity) === null) return `${vor}${MELDUNG.menge}`
|
||||
if (parseUnitPrice(p.price) === null) return `${vor}${MELDUNG.preis}`
|
||||
}
|
||||
if (invoiceTotalCents(liste) > AUFTRAG_LIMITS.maxSummeCents) return MELDUNG.summe
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Prueft einen Schritt (1 bis 3). Ergebnis { ok, fehler: { feld: Meldung }, erstes } (erstes = erstes fehlerhaftes Feld). */
|
||||
export function pruefeSchritt(schritt, form, { kunde } = {}) {
|
||||
const f = istObjekt(form) ? form : {}
|
||||
const fehler = {}
|
||||
if (schritt === 1) {
|
||||
const titel = trimmed(f.titel)
|
||||
if (titel.length < 1 || titel.length > AUFTRAG_LIMITS.maxTitel) fehler.titel = MELDUNG.titel
|
||||
if (!trimmed(kunde?.kundenart) && !KUNDENARTEN.includes(f.kundenart)) fehler.kundenart = MELDUNG.kundenart
|
||||
} else if (schritt === 2) {
|
||||
if (!istTag(f.auftragVom)) fehler.auftragVom = MELDUNG.auftragVom
|
||||
if (!AUFTRAG_WEGE.some((w) => w.value === f.auftragWeg)) fehler.auftragWeg = MELDUNG.auftragWeg
|
||||
if (!ARTEN.includes(f.art)) {
|
||||
fehler.art = MELDUNG.art
|
||||
} else if (f.art === 'festpreis') {
|
||||
const meldung = positionenFehler(f.positionen)
|
||||
if (meldung) fehler.positionen = meldung
|
||||
} else {
|
||||
if (parseUnitPrice(f.stundensatz) === null) fehler.stundensatz = MELDUNG.stundensatz
|
||||
if (trimmed(f.schaetzung) !== '' && parseStunden(f.schaetzung) === null) fehler.schaetzung = MELDUNG.schaetzung
|
||||
}
|
||||
} else if (schritt === 3) {
|
||||
if (!zahlungsplanOptionen(f.art).some((o) => o.value === f.zahlungsart)) fehler.zahlungsart = MELDUNG.zahlungsart
|
||||
if (f.zahlungsart === 'anzahlung' && parseProzent(f.prozent) === null) fehler.prozent = MELDUNG.prozent
|
||||
if (f.zahlungsart === 'frei') {
|
||||
const t = trimmed(f.zahlungstext)
|
||||
if (t.length < 1 || t.length > AUFTRAG_LIMITS.maxZahlungsplanText) fehler.zahlungstext = MELDUNG.zahlungstext
|
||||
}
|
||||
if (parseTage(f.zahlungsziel) === null) fehler.zahlungsziel = MELDUNG.zahlungsziel
|
||||
const start = trimmed(f.startAm)
|
||||
const fertig = trimmed(f.fertigBis)
|
||||
if (start !== '' && !istTag(start)) fehler.startAm = MELDUNG.datum
|
||||
if (fertig !== '' && !istTag(fertig)) fehler.fertigBis = MELDUNG.datum
|
||||
if (start !== '' && fertig !== '' && istTag(start) && istTag(fertig) && fertig < start) fehler.fertigBis = MELDUNG.fertigVorStart
|
||||
if (Array.isArray(f.zeitquelle) && f.zeitquelle.length > AUFTRAG_LIMITS.maxZeitquellen) fehler.zeitquelle = MELDUNG.zeitquelle
|
||||
}
|
||||
const erstes = Object.keys(fehler)[0] || ''
|
||||
return { ok: erstes === '', fehler, erstes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular -> Anfrage fuer POST/PATCH …/auftraege. Prueft die Schritte 1 bis 3 der Reihe nach.
|
||||
* { ok: true, payload, summeCents } oder { ok: false, schritt, erstes, fehler }. `kundenId` setzt der Aufrufer.
|
||||
*/
|
||||
export function formularZuNutzlast(form, { kunde } = {}) {
|
||||
for (const schritt of [1, 2, 3]) {
|
||||
const r = pruefeSchritt(schritt, form, { kunde })
|
||||
if (!r.ok) return { ok: false, schritt, erstes: r.erstes, fehler: r.fehler }
|
||||
}
|
||||
const festpreis = form.art === 'festpreis'
|
||||
const plan = form.zahlungsart
|
||||
const schaetzung = trimmed(form.schaetzung)
|
||||
const payload = {
|
||||
projektId: text(form.projektId),
|
||||
titel: trimmed(form.titel),
|
||||
art: form.art,
|
||||
positionen: festpreis
|
||||
? form.positionen.map((p) => ({ beschreibung: trimmed(p.description), menge: parseQuantity(p.quantity), einzelpreisCents: parseUnitPrice(p.price) }))
|
||||
: [],
|
||||
stundensatzCents: festpreis ? null : parseUnitPrice(form.stundensatz),
|
||||
schaetzungMinuten: festpreis || schaetzung === '' ? null : parseStunden(schaetzung),
|
||||
zahlungsplan: {
|
||||
art: plan,
|
||||
prozent: plan === 'anzahlung' ? parseProzent(form.prozent) : null,
|
||||
text: plan === 'frei' ? trimmed(form.zahlungstext) : '',
|
||||
},
|
||||
zahlungszielTage: parseTage(form.zahlungsziel),
|
||||
auftragVom: trimmed(form.auftragVom),
|
||||
auftragWeg: form.auftragWeg,
|
||||
startAm: trimmed(form.startAm),
|
||||
fertigBis: trimmed(form.fertigBis),
|
||||
zeitquelle: [...form.zeitquelle],
|
||||
}
|
||||
if (!trimmed(kunde?.kundenart)) payload.kundenart = form.kundenart
|
||||
return { ok: true, payload, summeCents: festpreis ? invoiceTotalCents(form.positionen) : null }
|
||||
}
|
||||
|
||||
// --- Texte -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Sinngemaesser Zahlungsplan wie in der AB; massgeblich bleibt der Wortlaut im PDF. Unbekannt oder unvollstaendig: ''. */
|
||||
export function zahlungsplanVorschau({ art, prozent, text: freitext, summeCents, zielTage } = {}) {
|
||||
const tage = Number.isInteger(zielTage) ? zielTage : AUFTRAG_LIMITS.standardZahlungszielTage
|
||||
const ziel = tage === 0 ? 'sofort nach Rechnung' : tage === 1 ? 'innerhalb von 1 Tag nach Rechnung' : `innerhalb von ${tage} Tagen nach Rechnung`
|
||||
const summe = Number.isInteger(summeCents) ? summeCents : null
|
||||
if (art === 'nach_abnahme') {
|
||||
return `${summe === null ? 'Alles' : `Gesamtbetrag (${euro(summe)})`} nach Abnahme; zahlbar ${ziel}.`
|
||||
}
|
||||
if (art === 'anzahlung') {
|
||||
if (!Number.isInteger(prozent)) return ''
|
||||
if (summe === null) return `Anzahlung ${prozent} % nach Auftragsbestätigung, Rest nach Abnahme; zahlbar jeweils ${ziel}.`
|
||||
const anzahlung = Math.round((summe * prozent) / 100)
|
||||
return `Anzahlung ${prozent} % (${euro(anzahlung)}) nach Auftragsbestätigung, Rest (${euro(summe - anzahlung)}) nach Abnahme; zahlbar jeweils ${ziel}.`
|
||||
}
|
||||
if (art === 'monatlich') return `Monatlich nach Aufwand; zahlbar ${ziel}.`
|
||||
if (art === 'frei') {
|
||||
const t = trimmed(freitext)
|
||||
return t ? `${t} (zahlbar ${ziel}).` : ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf gesendet werden? { erlaubt, grund, zurUebersicht }. Der Entwurf laesst sich immer speichern; `zurUebersicht`
|
||||
* heisst: der Grund liegt in den Kundendaten (Reiter Uebersicht).
|
||||
*/
|
||||
export function sendeGate(kunde, kundenart = '') {
|
||||
if (!kunde) return { erlaubt: false, grund: 'Kein Kunde ausgewählt', zurUebersicht: false }
|
||||
const check = canInvoice(kunde)
|
||||
if (!check.ok) return { erlaubt: false, grund: check.reason, zurUebersicht: true }
|
||||
if (!addressComplete(kunde)) return { erlaubt: false, grund: ADDRESS_INCOMPLETE_TEXT, zurUebersicht: true }
|
||||
if (!trimmed(kunde.kundenart) && !trimmed(kundenart)) {
|
||||
return { erlaubt: false, grund: 'Die Kundenart fehlt. Bitte im ersten Schritt wählen.', zurUebersicht: false }
|
||||
}
|
||||
return { erlaubt: true, grund: '', zurUebersicht: false }
|
||||
}
|
||||
|
||||
// --- Zeitquelle --------------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tickets der Zeitquelle: erst die Tickets des Projekts, dann die uebrigen gewaehlten, dann alle weiteren. Eine gewaehlte
|
||||
* Kennung, die nicht (mehr) in `tickets` steht, bleibt sichtbar („Ticket <Ende der Kennung>“).
|
||||
*/
|
||||
export function zeitquelleOptionen(tickets, auswahl, projektTicketIds) {
|
||||
const liste = objekte(tickets)
|
||||
const gewaehlt = Array.isArray(auswahl) ? auswahl : []
|
||||
const projekt = Array.isArray(projektTicketIds) ? projektTicketIds : []
|
||||
const nachId = new Map(liste.map((t) => [t.$id, t]))
|
||||
const reihenfolge = []
|
||||
const gesehen = new Set()
|
||||
for (const id of [...projekt, ...gewaehlt, ...liste.map((t) => t.$id)]) {
|
||||
if (typeof id !== 'string' || !id || gesehen.has(id)) continue
|
||||
gesehen.add(id)
|
||||
reihenfolge.push(id)
|
||||
}
|
||||
return reihenfolge.map((id) => {
|
||||
const ticket = nachId.get(id)
|
||||
const titel = ticket ? trimmed(ticket.topic) || trimmed(ticket.title) : ''
|
||||
const label = ticket ? `#${ticketNumber(ticket)}${titel ? ` ${titel}` : ''}` : `Ticket ${id.slice(-5)}`
|
||||
return { id, label, gewaehlt: gewaehlt.includes(id), projekt: projekt.includes(id) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Ticket zur Auswahl hinzufuegen (hoechstens 10, keine Doppelten) oder entfernen; liefert eine neue Liste. */
|
||||
export function schalteZeitquelle(auswahl, id, an) {
|
||||
const liste = Array.isArray(auswahl) ? [...auswahl] : []
|
||||
if (!an) return liste.filter((x) => x !== id)
|
||||
if (liste.includes(id) || liste.length >= AUFTRAG_LIMITS.maxZeitquellen) return liste
|
||||
return [...liste, id]
|
||||
}
|
||||
|
||||
// --- Hinweise (nie blockierend) ----------------------------------------------------------------------------------------
|
||||
|
||||
/** Wirksame Kundenart: die am Kunden gespeicherte hat Vorrang (der Server schreibt sie nur, wenn sie dort leer ist). */
|
||||
export function kundenartWirksam(kunde, kundenart) {
|
||||
if (KUNDENARTEN.includes(kunde?.kundenart)) return kunde.kundenart
|
||||
if (KUNDENARTEN.includes(kundenart)) return kundenart
|
||||
return ''
|
||||
}
|
||||
|
||||
/** § 312f Abs. 1 BGB: bei ausserhalb von Geschaeftsraeumen geschlossenen Vertraegen mit Verbrauchern eine Papierfassung geben. */
|
||||
export const VOR_ORT_HINWEIS = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
|
||||
|
||||
export function vorOrtHinweis(weg, kundenart) {
|
||||
return weg === 'vor_ort' && kundenart === 'privat' ? VOR_ORT_HINWEIS : ''
|
||||
}
|
||||
|
||||
/** Hinweise fuer Schritt 4 (Steuernummer fehlt; „Vor Ort“ bei Privatperson). Reine Texte, sie sperren nichts. */
|
||||
export function auftragHinweise({ kunde, form, einstellungen } = {}) {
|
||||
const liste = []
|
||||
if (steuernummerFehlt(einstellungen)) liste.push({ key: 'steuernummer', text: STEUERNUMMER_FEHLT_HINWEIS })
|
||||
const vorOrt = vorOrtHinweis(form?.auftragWeg, kundenartWirksam(kunde, form?.kundenart))
|
||||
if (vorOrt) liste.push({ key: 'vorOrt', text: vorOrt })
|
||||
return liste
|
||||
}
|
||||
|
||||
// --- Liste: Status, Aktionen, Zusatzzeilen -----------------------------------------------------------------------------
|
||||
|
||||
/** Status der AB (Vertrag V2) mit Beschriftung und Ton des Abzeichens. */
|
||||
export const AUFTRAG_STATUS = {
|
||||
entwurf: { label: 'Entwurf', tone: 'muted' },
|
||||
versendet: { label: 'Versendet', tone: 'info' },
|
||||
bestaetigt: { label: 'Bestätigt', tone: 'ok' },
|
||||
abgerechnet: { label: 'Abgerechnet', tone: 'violet' },
|
||||
storniert: { label: 'Storniert', tone: 'plain' },
|
||||
ersetzt: { label: 'Ersetzt', tone: 'plain' },
|
||||
widerrufen: { label: 'Widerrufen', tone: 'danger' }, // rot: die Erstattung folgt von Hand
|
||||
}
|
||||
|
||||
/** Status, in denen „Projekt zuordnen“ gilt (der Server lehnt die uebrigen mit 409 ab). */
|
||||
export const PROJEKT_ZUORDNEN_STATUS = ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet']
|
||||
|
||||
export function istEntwurf(auftrag) {
|
||||
return auftrag?.status === 'entwurf'
|
||||
}
|
||||
|
||||
/** Projekte zur Auswahl aus den Projektkonten: [{ value: projektId, label: Name }]. */
|
||||
export function zuordnenOptionen(konten) {
|
||||
if (!Array.isArray(konten)) return []
|
||||
return konten
|
||||
.filter((k) => istObjekt(k) && typeof k.projektId === 'string' && k.projektId !== '')
|
||||
.map((k) => ({ value: k.projektId, label: trimmed(k.name) || k.projektId }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt es „Projekt zuordnen“? Nur fuer eine AB ohne Projekt in einem der Status aus PROJEKT_ZUORDNEN_STATUS und nur, wenn
|
||||
* es mindestens ein Projekt zur Auswahl gibt. Hat die AB schon eine Kennung (auch eine unbekannte), lehnt der Server mit 409 ab.
|
||||
*/
|
||||
export function projektZuordnenMoeglich(auftrag, konten) {
|
||||
if (!istObjekt(auftrag)) return false
|
||||
if (!PROJEKT_ZUORDNEN_STATUS.includes(auftrag.status)) return false
|
||||
if (typeof auftrag.projektId === 'string' ? auftrag.projektId !== '' : Boolean(auftrag.projektId)) return false
|
||||
return zuordnenOptionen(konten).length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktionen einer Zeile [{ key, label, danger? }]. Bearbeiten, Senden und Loeschen gibt es nur beim Entwurf; danach nur
|
||||
* „Neue Fassung“. „Projekt zuordnen“ steht unmittelbar vor der ersten gefaehrlichen Aktion (sonst am Ende).
|
||||
*/
|
||||
export function auftragAktionen(auftrag, { konten } = {}) {
|
||||
if (!istObjekt(auftrag)) return []
|
||||
const status = auftrag.status
|
||||
const pdf = (label) => ({ key: 'pdf', label })
|
||||
let liste
|
||||
if (status === 'entwurf') {
|
||||
liste = [{ key: 'bearbeiten', label: 'Bearbeiten' }, { key: 'senden', label: 'Senden' }, pdf('Vorschau'), { key: 'loeschen', label: 'Löschen', danger: true }]
|
||||
} else {
|
||||
liste = []
|
||||
if (auftrag.pdfVorhanden === true) liste.push(pdf('PDF'))
|
||||
const erinnern = { key: 'erinnern', label: auftrag.mailStatus === 'fehler' ? 'Erneut senden' : 'Erinnerung senden' }
|
||||
if (status === 'versendet') liste.push(erinnern, { key: 'neueFassung', label: 'Neue Fassung' }, { key: 'stornieren', label: 'Stornieren', danger: true })
|
||||
else if (status === 'bestaetigt') liste.push({ key: 'neueFassung', label: 'Neue Fassung' }, { key: 'stornieren', label: 'Stornieren', danger: true })
|
||||
else if (status === 'abgerechnet') liste.push({ key: 'neueFassung', label: 'Neue Fassung' })
|
||||
}
|
||||
if (projektZuordnenMoeglich(auftrag, konten)) {
|
||||
const aktion = { key: 'projekt', label: 'Projekt zuordnen' }
|
||||
const gefahr = liste.findIndex((a) => a.danger)
|
||||
if (gefahr === -1) liste.push(aktion)
|
||||
else liste.splice(gefahr, 0, aktion)
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
/** Zusatzzeilen unter dem Status [{ key, tone, text }]; `alle` sind alle ABs der Liste (fuer Nummern der Fassungen). */
|
||||
export function auftragNotizen(auftrag, alle = []) {
|
||||
if (!istObjekt(auftrag)) return []
|
||||
const a = auftrag
|
||||
const status = a.status
|
||||
const liste = []
|
||||
const nummerVon = (id) => {
|
||||
const treffer = objekte(alle).find((x) => x.id === id)
|
||||
return treffer && trimmed(treffer.nummer) ? treffer.nummer : ''
|
||||
}
|
||||
if (status === 'versendet' && a.mailStatus === 'fehler') liste.push({ key: 'mail', tone: 'warn', text: 'Mail nicht zugestellt' })
|
||||
if (a.bestaetigtAm && (status === 'bestaetigt' || status === 'abgerechnet')) {
|
||||
liste.push({ key: 'bestaetigt', tone: 'muted', text: `Bestätigt am ${dateDe(a.bestaetigtAm)}` })
|
||||
}
|
||||
if (a.fruehbeginn === true && status !== 'entwurf') liste.push({ key: 'fruehbeginn', tone: 'muted', text: 'Frühbeginn verlangt' })
|
||||
if (a.fruehesterBeginn && ['versendet', 'bestaetigt', 'abgerechnet'].includes(status)) {
|
||||
liste.push({ key: 'beginn', tone: 'muted', text: `Arbeitsbeginn frühestens am ${dateDe(a.fruehesterBeginn)}` })
|
||||
}
|
||||
if (a.ersetztId) {
|
||||
const nr = nummerVon(a.ersetztId)
|
||||
liste.push({ key: 'fassung', tone: 'muted', text: nr ? `Neue Fassung zu ${nr}` : 'Neue Fassung' })
|
||||
}
|
||||
if (a.ersetztDurchId) {
|
||||
const nr = nummerVon(a.ersetztDurchId)
|
||||
liste.push({ key: 'ersetzt', tone: 'muted', text: nr ? `Ersetzt durch ${nr}` : 'Ersetzt durch eine neue Fassung' })
|
||||
}
|
||||
if (status === 'widerrufen' && a.widerrufenAm) liste.push({ key: 'widerruf', tone: 'muted', text: `Widerrufen am ${dateDe(a.widerrufenAm)}` })
|
||||
return liste
|
||||
}
|
||||
|
||||
/** Zeilen der Tabelle in der Reihenfolge des Servers (neueste zuerst). */
|
||||
export function auftragZeilen(auftraege, { konten = [] } = {}) {
|
||||
const liste = objekte(auftraege)
|
||||
return liste.map((a) => {
|
||||
const konto = objekte(konten).find((k) => k.projektId && k.projektId === a.projektId)
|
||||
const projektName = konto ? trimmed(konto.name) : ''
|
||||
const aufwand = a.art === 'aufwand'
|
||||
let summeSub = ''
|
||||
if (aufwand && typeof a.stundensatzCents === 'number') {
|
||||
summeSub = `${euro(a.stundensatzCents)} pro Std.`
|
||||
if (typeof a.schaetzungMinuten === 'number') summeSub += `, Schätzung etwa ${stundenText(a.schaetzungMinuten)} Std.`
|
||||
}
|
||||
const status = AUFTRAG_STATUS[a.status] || { label: text(a.status), tone: 'muted' }
|
||||
return {
|
||||
key: a.id,
|
||||
id: a.id,
|
||||
auftrag: a,
|
||||
nummerText: trimmed(a.nummer) || 'Entwurf',
|
||||
istEntwurf: istEntwurf(a),
|
||||
titel: text(a.titel),
|
||||
projektText: projektName && projektName !== trimmed(a.titel) ? projektName : '',
|
||||
datumText: dateDe(a.gesendetAm || a.erstelltAm),
|
||||
summeText: aufwand ? 'nach Aufwand' : euro(a.summeCents),
|
||||
summeSub,
|
||||
statusLabel: status.label,
|
||||
statusTone: status.tone,
|
||||
notizen: auftragNotizen(a, liste),
|
||||
aktionen: auftragAktionen(a, { konten }),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Rueckfragen, Meldungen, Fehler ------------------------------------------------------------------------------------
|
||||
|
||||
/** Rueckfrage vor einer Aktion; '' = keine Rueckfrage. */
|
||||
export function auftragBestaetigung(key, auftrag, { email, projektName } = {}) {
|
||||
const a = istObjekt(auftrag) ? auftrag : {}
|
||||
const titel = `„${trimmed(a.titel) || 'ohne Titel'}“`
|
||||
const nummer = trimmed(a.nummer)
|
||||
const ab = `Die Auftragsbestätigung${nummer ? ` ${nummer}` : ''}`
|
||||
if (key === 'senden') {
|
||||
return `Auftragsbestätigung ${titel} jetzt festschreiben und an ${trimmed(email) || 'den Kunden'} senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.`
|
||||
}
|
||||
if (key === 'loeschen') return `Den Entwurf ${titel} wirklich löschen? Das lässt sich nicht rückgängig machen.`
|
||||
if (key === 'stornieren') {
|
||||
return `${ab} ${titel} wirklich stornieren? Der Kunde bekommt keine Mail, der Bestätigungslink wird ungültig. Das lässt sich nicht rückgängig machen.`
|
||||
}
|
||||
if (key === 'erinnern') return `${ab} jetzt noch einmal an den Kunden senden? Der bisherige Bestätigungslink gilt danach nicht mehr.`
|
||||
if (key === 'projekt') {
|
||||
if (istEntwurf(a)) return '' // ein Entwurf bleibt ueber „Bearbeiten“ aenderbar
|
||||
const ziel = trimmed(projektName) ? `dem Projekt „${trimmed(projektName)}“` : 'einem Projekt'
|
||||
return `${ab} ${titel} ${ziel} zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern.`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Meldung nach einer Aktion { tone, text }; „senden“ im selben Wortlaut wie der Container (auftragMeldung in W0). */
|
||||
export function auftragErfolg(key, auftrag, { mailFehler = false, projektName = '' } = {}) {
|
||||
const a = istObjekt(auftrag) ? auftrag : {}
|
||||
const nummer = trimmed(a.nummer)
|
||||
const name = nummer ? `Auftragsbestätigung ${nummer}` : 'Die Auftragsbestätigung'
|
||||
const an = trimmed(a.gesendetAn)
|
||||
if (key === 'senden') {
|
||||
if (mailFehler) return { tone: 'warn', text: `${name} ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.` }
|
||||
return { tone: 'ok', text: an ? `${name} wurde an ${an} gesendet.` : `${name} wurde gesendet.` }
|
||||
}
|
||||
if (key === 'erinnern') {
|
||||
const wen = nummer || 'der Auftragsbestätigung'
|
||||
if (mailFehler) return { tone: 'warn', text: `Die Erinnerung zu ${wen} konnte nicht zugestellt werden. „Erneut senden“ versucht es noch einmal.` }
|
||||
return { tone: 'ok', text: `Die Erinnerung zu ${wen} wurde an ${an || 'den Kunden'} gesendet.` }
|
||||
}
|
||||
if (key === 'stornieren') return { tone: 'ok', text: `${name} wurde storniert.` }
|
||||
if (key === 'loeschen') return { tone: 'ok', text: 'Der Entwurf wurde gelöscht.' }
|
||||
if (key === 'neueFassung') return { tone: 'ok', text: 'Neue Fassung als Entwurf angelegt.' }
|
||||
if (key === 'projekt') {
|
||||
const ziel = trimmed(projektName) ? `dem Projekt „${trimmed(projektName)}“` : 'einem Projekt'
|
||||
return { tone: 'ok', text: `${name} wurde ${ziel} zugeordnet.` }
|
||||
}
|
||||
return { tone: 'ok', text: '' }
|
||||
}
|
||||
|
||||
const KUNDENDATEN_FEHLER = {
|
||||
kundenart: 'Die Kundenart des Kunden fehlt. Bitte in der Übersicht des Kunden festlegen (Feld „Kundenart“) oder über „Bearbeiten“ und dann erneut senden.',
|
||||
email: 'Dem Kunden fehlt die E-Mail-Adresse. Bitte in der Übersicht eintragen und dann erneut senden.',
|
||||
kunde: 'Das ist noch kein fester Kunde. Bitte in der Übersicht zum festen Kunden machen und dann erneut senden.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehler des Servers -> { art, text, zurUebersicht }. Fehler in den Kundendaten fuehren zur Uebersicht, die uebrigen zeigen
|
||||
* die Meldung des Servers (Texte: Plan L3 „Meldungstexte“, Plan L4 „Entwurf unvollstaendig“).
|
||||
*/
|
||||
export function auftragFehler(err) {
|
||||
const status = err?.status
|
||||
const meldung = trimmed(err?.message)
|
||||
const ergebnis = (art, t, zurUebersicht = false) => ({ art, text: t, zurUebersicht })
|
||||
if (status === 409 && /^Entwurf unvollständig/i.test(meldung)) return ergebnis('entwurf', meldung)
|
||||
if (status === 409 && /adresse/i.test(meldung)) return ergebnis('adresse', ADDRESS_INCOMPLETE_TEXT, true)
|
||||
if (status === 400 && /kundenart/i.test(meldung)) return ergebnis('kundenart', KUNDENDATEN_FEHLER.kundenart, true)
|
||||
if (status === 400 && /e-?mail/i.test(meldung)) return ergebnis('email', KUNDENDATEN_FEHLER.email, true)
|
||||
if (status === 400 && /feste[rn]? kunde|interessent|\blead\b/i.test(meldung)) return ergebnis('kunde', KUNDENDATEN_FEHLER.kunde, true)
|
||||
if (status === 409) return ergebnis('stand', `${meldung} Die Liste zeigt den aktuellen Stand.`.trim())
|
||||
if (status === 400) return ergebnis('eingabe', meldung || 'Die Eingabe ist ungültig.')
|
||||
if (status === 500 && /pdf/i.test(meldung)) {
|
||||
return ergebnis('pdf', `${meldung} Die Auftragsbestätigung bleibt ein Entwurf, es wurde keine Nummer vergeben.`)
|
||||
}
|
||||
if (typeof status === 'number' && status >= 500) {
|
||||
return ergebnis('server', `${meldung || 'Interner Fehler.'} Bitte in der Liste prüfen, ob die Auftragsbestätigung gesendet wurde, und sonst erneut versuchen.`)
|
||||
}
|
||||
if (status === 0) return ergebnis('netz', meldung || 'Das Kundenportal ist gerade nicht erreichbar.')
|
||||
return ergebnis('sonst', meldung || 'Die Aktion ist fehlgeschlagen.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sperre gegen Doppelklick: `run(fn)` fuehrt fn nur aus, wenn gerade nichts laeuft (`belegt` gilt sofort beim Aufruf);
|
||||
* sonst { uebersprungen: true }. Ein Fehler von fn gibt die Sperre frei und wird weitergereicht.
|
||||
*/
|
||||
export function neueSperre() {
|
||||
let belegt = false
|
||||
return {
|
||||
get belegt() {
|
||||
return belegt
|
||||
},
|
||||
async run(fn) {
|
||||
if (belegt) return { uebersprungen: true }
|
||||
belegt = true
|
||||
try {
|
||||
return { uebersprungen: false, wert: await fn() }
|
||||
} finally {
|
||||
belegt = false
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Reine Helfer fuer zwei Felder der Auftragsbestaetigung: die Kundenart am Kunden und die Steuernummer in den
|
||||
// Finanz-Einstellungen; kein React, kein Appwrite, unter Node testbar. Texte deutsch, sachlich.
|
||||
|
||||
/** Werte der Kundenart (Vertrag V2); '' = nicht festgelegt. */
|
||||
export const KUNDENARTEN = ['unternehmen', 'privat']
|
||||
|
||||
export const KUNDENART_OPTIONEN = [
|
||||
{ value: '', label: 'Nicht festgelegt' },
|
||||
{ value: 'unternehmen', label: 'Unternehmen' },
|
||||
{ value: 'privat', label: 'Privatperson' },
|
||||
]
|
||||
|
||||
export const KUNDENART_HINWEIS = 'Für Auftragsbestätigungen: Privatpersonen bekommen die Widerrufsbelehrung.'
|
||||
|
||||
const text = (v) => (typeof v === 'string' ? v : '')
|
||||
|
||||
/** Beschriftung der Kundenart; alles Unbekannte heisst „Nicht festgelegt“. */
|
||||
export function kundenartLabel(value) {
|
||||
const treffer = KUNDENART_OPTIONEN.find((o) => o.value !== '' && o.value === value)
|
||||
return treffer ? treffer.label : KUNDENART_OPTIONEN[0].label
|
||||
}
|
||||
|
||||
/**
|
||||
* Was beim Speichern der Kundendaten zusaetzlich ins Dokument soll: nur eine geaenderte, bekannte Kundenart
|
||||
* (so scheitert das Speichern der uebrigen Stammdaten nie an einem noch nicht angelegten Attribut).
|
||||
*/
|
||||
export function kundenartAenderung(neu, gespeichert) {
|
||||
const n = text(neu)
|
||||
const alt = text(gespeichert)
|
||||
if (n !== '' && !KUNDENARTEN.includes(n)) return {}
|
||||
if (n === alt) return {}
|
||||
return { kundenart: n }
|
||||
}
|
||||
|
||||
export const STEUERNUMMER_MAX = 40
|
||||
|
||||
export const STEUERNUMMER_HINWEIS = 'Steht auf jeder Auftragsbestätigung. Für Rechnungen bitte zusätzlich im Stripe-Dashboard hinterlegen.'
|
||||
|
||||
export const STEUERNUMMER_FEHLT_HINWEIS =
|
||||
'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.'
|
||||
|
||||
const STEUERNUMMER_ZEICHEN = /^[A-Za-z0-9/ .-]*$/
|
||||
|
||||
/**
|
||||
* Steuernummer fuer PATCH /einstellungen. Der Client prueft wie der Server (Vertrag V4): getrimmt hoechstens 40 Zeichen aus
|
||||
* ASCII-Buchstaben, Ziffern, Leerzeichen, „/“, „-“ und „.“; leer loescht den Wert. Unveraendert wird nichts gesendet.
|
||||
*/
|
||||
export function steuernummerAenderung(eingabe, gespeichert) {
|
||||
const neu = text(eingabe).trim()
|
||||
const alt = text(gespeichert).trim()
|
||||
if (neu === alt) return { ok: true, payload: {} }
|
||||
if (neu.length > STEUERNUMMER_MAX) return { ok: false, fehler: `Die Steuernummer darf höchstens ${STEUERNUMMER_MAX} Zeichen lang sein.` }
|
||||
if (!STEUERNUMMER_ZEICHEN.test(neu)) {
|
||||
return { ok: false, fehler: 'Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.' }
|
||||
}
|
||||
return { ok: true, payload: { steuernummer: neu } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlt die Steuernummer? Nur wahr, wenn die Einstellungen da sind (Objekt) und der Wert leer ist; „noch nicht geladen“
|
||||
* (undefined) und „nicht ladbar“ (null) behaupten nichts.
|
||||
*/
|
||||
export function steuernummerFehlt(einstellungen) {
|
||||
if (!einstellungen || typeof einstellungen !== 'object' || Array.isArray(einstellungen)) return false
|
||||
const wert = einstellungen.steuernummer
|
||||
return typeof wert !== 'string' || wert.trim() === ''
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
|
||||
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer, markLost, reopenAsLead } from '../lib/leads'
|
||||
import { lastRejection } from '../lib/leadDuplicates'
|
||||
import { KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartAenderung } from '../lib/kundenFelder'
|
||||
import { customerStage, isArchived, customerBadges, customerEyebrow, customerTitle } from '../lib/customerList'
|
||||
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
|
||||
import { ticketNumber } from '../lib/ticketView'
|
||||
@@ -154,11 +155,15 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
notes: customer.notes || '',
|
||||
importantCustomer: !!customer.importantCustomer,
|
||||
})
|
||||
const [kundenart, setKundenart] = useState(customer.kundenart || '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
|
||||
const [showLost, setShowLost] = useState(false)
|
||||
const { user, employee } = useAuth()
|
||||
|
||||
// Die Kundenart geht nur ins Dokument, wenn sie geaendert wurde (kein Fehler bei noch nicht angelegtem Attribut)
|
||||
const felder = () => ({ ...form, ...kundenartAenderung(kundenart, customer.kundenart) })
|
||||
|
||||
const ready = isInvoiceReady({ ...customer, ...form })
|
||||
|
||||
// Absage: ungespeicherte Notizen gehen mit, damit der Vermerk nichts ueberschreibt
|
||||
@@ -182,7 +187,7 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
const save = async () => {
|
||||
setBusy(true); setMsg(null)
|
||||
try {
|
||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
|
||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, felder())
|
||||
setMsg({ tone: 'ok', text: 'Gespeichert' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
@@ -194,7 +199,7 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
if (!ready) { setMsg({ tone: 'warn', text: 'Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.' }); return }
|
||||
setBusy(true); setMsg(null)
|
||||
try {
|
||||
await upgradeToCustomer(customer, form)
|
||||
await upgradeToCustomer(customer, felder())
|
||||
setMsg({ tone: 'ok', text: 'Zum festen Kunden gemacht.' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
@@ -249,6 +254,13 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
<Card title="Rechnungsdaten">
|
||||
{!ready && <div className="kunde-hinweis"><Badge tone="warn">Adresse unvollständig</Badge></div>}
|
||||
<dl className="kv kunde-kv">
|
||||
<dt><label htmlFor="kunde-kundenart">Kundenart</label></dt>
|
||||
<dd>
|
||||
<select id="kunde-kundenart" className="select" value={kundenart} onChange={(e) => setKundenart(e.target.value)}>
|
||||
{KUNDENART_OPTIONEN.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<div className="hint">{KUNDENART_HINWEIS}</div>
|
||||
</dd>
|
||||
<dt><label htmlFor="kunde-street">Strasse + Nr.</label></dt>
|
||||
<dd><input id="kunde-street" className="input" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></dd>
|
||||
<dt><label htmlFor="kunde-postal">PLZ</label></dt>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { PDF_FREIGABE_NACH_MS, alsPdfBlob, oeffnePdfImTab } = await loadModule('src/lib/auftragPdf.js')
|
||||
|
||||
// Fester Ablauf: Tab zuerst (der Browser blockiert sonst das Pop-up), dann Blob holen, Objekt-URL erzeugen, später freigeben
|
||||
function umgebung() {
|
||||
const ablauf = []
|
||||
const tab = { location: {}, opener: 'woms', close: () => ablauf.push('schliessen') }
|
||||
const win = { open: (...args) => { ablauf.push('oeffnen'); win.args = args; return tab } }
|
||||
const urlApi = {
|
||||
createObjectURL: () => { ablauf.push('url'); return 'blob:https://woms.test/abc' },
|
||||
revokeObjectURL: (url) => ablauf.push(`frei:${url}`),
|
||||
}
|
||||
const timer = (fn, ms) => { ablauf.push(`timer:${ms}`); timer.fn = fn }
|
||||
return { ablauf, tab, win, urlApi, timer }
|
||||
}
|
||||
|
||||
test('alsPdfBlob: setzt den Typ application/pdf, ein passender Blob bleibt unverändert', async () => {
|
||||
const roh = new Blob(['%PDF-1.4'], { type: 'application/octet-stream' })
|
||||
const pdf = alsPdfBlob(roh)
|
||||
assert.equal(pdf.type, 'application/pdf')
|
||||
assert.equal(await pdf.text(), '%PDF-1.4')
|
||||
const schon = new Blob(['x'], { type: 'application/pdf' })
|
||||
assert.equal(alsPdfBlob(schon), schon)
|
||||
})
|
||||
|
||||
test('oeffnePdfImTab: Tab zuerst, dann Blob, Objekt-URL im Tab, später freigeben', async () => {
|
||||
const u = umgebung()
|
||||
await oeffnePdfImTab(async () => { u.ablauf.push('holen'); return new Blob(['%PDF'], { type: 'application/pdf' }) }, u)
|
||||
assert.equal(PDF_FREIGABE_NACH_MS, 300000)
|
||||
assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'url', 'timer:300000'])
|
||||
assert.equal(u.tab.location.href, 'blob:https://woms.test/abc')
|
||||
assert.equal(u.tab.opener, null)
|
||||
u.timer.fn()
|
||||
assert.equal(u.ablauf.at(-1), 'frei:blob:https://woms.test/abc')
|
||||
})
|
||||
|
||||
test('oeffnePdfImTab: Fehler beim Holen schließt den Tab, es entsteht keine URL', async () => {
|
||||
const u = umgebung()
|
||||
await assert.rejects(() => oeffnePdfImTab(async () => { u.ablauf.push('holen'); throw new Error('PDF nicht gefunden.') }, u), /PDF nicht gefunden/)
|
||||
assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'schliessen'])
|
||||
})
|
||||
|
||||
test('oeffnePdfImTab: blockiertes Pop-up, der Blob wird gar nicht geholt', async () => {
|
||||
let geholt = false
|
||||
const win = { open: () => null }
|
||||
await assert.rejects(() => oeffnePdfImTab(async () => { geholt = true; return new Blob() }, { win }), { message: 'Neuer Tab wurde vom Browser blockiert.' })
|
||||
assert.equal(geholt, false)
|
||||
})
|
||||
@@ -0,0 +1,680 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const {
|
||||
AUFTRAG_LIMITS, STUNDENSATZ_STANDARD_CENTS, AUFTRAG_WEGE, SCHRITTE, zahlungsplanOptionen,
|
||||
parseStunden, stundenText, parseProzent, parseTage, heuteBerlin,
|
||||
neuePosition, neuesFormular, auftragZuFormular, waehleProjekt, wechselArt, formularSummeCents,
|
||||
pruefeSchritt, formularZuNutzlast, zahlungsplanVorschau, sendeGate, zeitquelleOptionen, schalteZeitquelle,
|
||||
kundenartWirksam, VOR_ORT_HINWEIS, vorOrtHinweis, auftragHinweise,
|
||||
AUFTRAG_STATUS, PROJEKT_ZUORDNEN_STATUS, istEntwurf, zuordnenOptionen, projektZuordnenMoeglich, auftragAktionen,
|
||||
auftragNotizen, auftragZeilen, auftragBestaetigung, auftragErfolg, auftragFehler, neueSperre,
|
||||
} = await loadModule('src/lib/auftragView.js')
|
||||
|
||||
const KONTEN = [
|
||||
{ projektId: 'p1', name: 'Website Praxis Müller', tickets: [{ id: 't1', woid: '100131' }] },
|
||||
{ projektId: 'p2', name: 'Shop Relaunch', tickets: [{ id: 't2', woid: '100140' }, { id: 't3', woid: '100141' }] },
|
||||
]
|
||||
const KUNDE = { $id: 'c1', name: 'Praxis Müller', email: 'info@praxis.test', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', kundenart: '', customerStatus: 'customer' }
|
||||
const festpreisForm = (over = {}) => ({
|
||||
...neuesFormular({ heute: '2026-10-06' }),
|
||||
titel: 'Website', kundenart: 'privat', auftragWeg: 'telefon',
|
||||
positionen: [{ uid: 1, description: 'Startseite', quantity: '1', price: '800,00' }],
|
||||
...over,
|
||||
})
|
||||
const aufwandForm = (over = {}) => festpreisForm({ art: 'aufwand', positionen: [], ...over })
|
||||
const OK = { ok: true, fehler: {}, erstes: '' }
|
||||
|
||||
test('Konstanten gleichen dem Vertrag V2 und der Spec (Limits, Wege, Zahlungspläne, Schritte)', () => {
|
||||
assert.deepEqual(AUFTRAG_LIMITS, {
|
||||
maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000,
|
||||
maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60,
|
||||
standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10,
|
||||
maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140,
|
||||
})
|
||||
assert.equal(STUNDENSATZ_STANDARD_CENTS, 10000)
|
||||
assert.deepEqual(AUFTRAG_WEGE, [
|
||||
{ value: 'muendlich', label: 'Mündlich' }, { value: 'telefon', label: 'Telefonisch' },
|
||||
{ value: 'email', label: 'Per E-Mail' }, { value: 'vor_ort', label: 'Vor Ort' }, { value: 'chat', label: 'Im Chat' },
|
||||
])
|
||||
assert.deepEqual(zahlungsplanOptionen('festpreis'), [
|
||||
{ value: 'nach_abnahme', label: 'Alles nach Abnahme' },
|
||||
{ value: 'anzahlung', label: 'Anzahlung bei Auftrag, Rest nach Abnahme' },
|
||||
{ value: 'frei', label: 'Frei (eigener Text)' },
|
||||
])
|
||||
assert.deepEqual(zahlungsplanOptionen('aufwand'), [
|
||||
{ value: 'nach_abnahme', label: 'Alles nach Abnahme' },
|
||||
{ value: 'monatlich', label: 'Monatlich nach Aufwand' },
|
||||
{ value: 'frei', label: 'Frei (eigener Text)' },
|
||||
])
|
||||
assert.deepEqual(SCHRITTE, [
|
||||
{ id: 1, label: 'Projekt' }, { id: 2, label: 'Auftrag' }, { id: 3, label: 'Zahlung und Termine' }, { id: 4, label: 'Vorschau' },
|
||||
])
|
||||
})
|
||||
|
||||
test('parseStunden: Stunden mit Komma in 15-Minuten-Schritten werden Minuten', () => {
|
||||
const gut = { '10': 600, '10,25': 615, '10,5': 630, '10,50': 630, '10,75': 645, '0,25': 15, '1': 60, '12,5 h': 750, '2 Std.': 120, ' 8 ': 480, '1666': 99960 }
|
||||
for (const [text, minuten] of Object.entries(gut)) assert.equal(parseStunden(text), minuten, text)
|
||||
for (const schlecht of ['', '0', '0,00', '10,3', '10,30', '10,1', '10.5', '-1', 'abc', '1666,25', '1667', '10,255', '1e3', ',5', '10 Minuten', null, undefined]) {
|
||||
assert.equal(parseStunden(schlecht), null, String(schlecht))
|
||||
}
|
||||
})
|
||||
|
||||
test('stundenText: Minuten als Stunden mit Komma, ohne überflüssige Nullen', () => {
|
||||
assert.deepEqual([15, 30, 45, 60, 75, 120, 135, 150, 600, 99960].map(stundenText), ['0,25', '0,5', '0,75', '1', '1,25', '2', '2,25', '2,5', '10', '1666'])
|
||||
assert.equal(stundenText(null), '–')
|
||||
assert.equal(stundenText('x'), '–')
|
||||
})
|
||||
|
||||
test('parseProzent und parseTage', () => {
|
||||
assert.deepEqual(['50', ' 30 % ', '1', '99'].map(parseProzent), [50, 30, 1, 99])
|
||||
for (const schlecht of ['', '0', '100', '-5', '33,3', '5a', 'x', null]) assert.equal(parseProzent(schlecht), null, String(schlecht))
|
||||
assert.deepEqual(['14', '0', '60', ' 7 '].map(parseTage), [14, 0, 60, 7])
|
||||
for (const schlecht of ['', '61', '-1', '1,5', 'x', '1000', null]) assert.equal(parseTage(schlecht), null, String(schlecht))
|
||||
})
|
||||
|
||||
test('heuteBerlin: Berliner Kalendertag, nicht der UTC-Tag', () => {
|
||||
assert.equal(heuteBerlin(Date.parse('2026-10-05T22:30:00.000Z')), '2026-10-06')
|
||||
assert.equal(heuteBerlin(Date.parse('2026-10-05T21:59:00.000Z')), '2026-10-05')
|
||||
assert.match(heuteBerlin(), /^\d{4}-\d{2}-\d{2}$/)
|
||||
})
|
||||
|
||||
test('neuesFormular: Vorgaben; ein Projekt füllt Titel und Zeitquelle', () => {
|
||||
const f = neuesFormular({ projektId: 'p2', konten: KONTEN, heute: '2026-10-06' })
|
||||
const { positionen, ...rest } = f
|
||||
assert.equal(positionen.length, 1)
|
||||
const { uid, ...zeile } = positionen[0]
|
||||
assert.equal(typeof uid, 'number')
|
||||
assert.deepEqual(zeile, { description: '', quantity: '1', price: '' })
|
||||
assert.deepEqual(rest, {
|
||||
projektId: 'p2', titel: 'Shop Relaunch', titelGeaendert: false, kundenart: '', art: 'festpreis',
|
||||
auftragVom: '2026-10-06', auftragWeg: '',
|
||||
stundensatz: '100,00', schaetzung: '',
|
||||
zahlungsart: 'nach_abnahme', prozent: '50', zahlungstext: '', zahlungsziel: '14',
|
||||
startAm: '', fertigBis: '', zeitquelle: ['t2', 't3'], zeitquelleGeaendert: false,
|
||||
})
|
||||
assert.deepEqual(neuesFormular({ konten: KONTEN, heute: '2026-10-06' }).zeitquelle, [])
|
||||
assert.equal(neuesFormular({ projektId: 'gibtsnicht', konten: KONTEN, heute: '2026-10-06' }).projektId, '')
|
||||
assert.equal(neuesFormular({ projektId: 'p1', heute: '2026-10-06' }).projektId, '')
|
||||
assert.notEqual(neuePosition().uid, neuePosition().uid)
|
||||
})
|
||||
|
||||
test('auftragZuFormular: Entwurf vom Server wird zum Formular (Euro- und Stundentexte)', () => {
|
||||
const entwurf = {
|
||||
id: 'a1', status: 'entwurf', projektId: 'p1', titel: 'Website', art: 'festpreis',
|
||||
positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 }],
|
||||
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
|
||||
zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
|
||||
auftragVom: '2026-10-05', auftragWeg: 'email', startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'],
|
||||
}
|
||||
const f = auftragZuFormular(entwurf)
|
||||
assert.deepEqual(f.positionen.map(({ uid, ...p }) => p), [{ description: 'Startseite', quantity: '2', price: '400,00' }])
|
||||
const { positionen, ...rest } = f
|
||||
assert.deepEqual(rest, {
|
||||
projektId: 'p1', titel: 'Website', titelGeaendert: true, kundenart: '', art: 'festpreis',
|
||||
auftragVom: '2026-10-05', auftragWeg: 'email',
|
||||
stundensatz: '100,00', schaetzung: '',
|
||||
zahlungsart: 'anzahlung', prozent: '30', zahlungstext: '', zahlungsziel: '21',
|
||||
startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'], zeitquelleGeaendert: true,
|
||||
})
|
||||
const aufwand = auftragZuFormular({
|
||||
...entwurf, art: 'aufwand', positionen: [], stundensatzCents: 9550, schaetzungMinuten: 750, summeCents: null,
|
||||
zahlungsplan: { art: 'frei', prozent: null, text: 'Nach Absprache' },
|
||||
})
|
||||
assert.equal(aufwand.stundensatz, '95,50')
|
||||
assert.equal(aufwand.schaetzung, '12,5')
|
||||
assert.equal(aufwand.positionen.length, 1) // eine leere Zeile für den Wechsel auf Festpreis
|
||||
assert.deepEqual([aufwand.zahlungsart, aufwand.prozent, aufwand.zahlungstext], ['frei', '50', 'Nach Absprache'])
|
||||
})
|
||||
|
||||
test('waehleProjekt: Titel und Zeitquelle folgen dem Projekt, bis sie von Hand geändert wurden', () => {
|
||||
const f0 = neuesFormular({ konten: KONTEN, heute: '2026-10-06' })
|
||||
const f1 = waehleProjekt(f0, 'p1', KONTEN)
|
||||
assert.deepEqual([f1.projektId, f1.titel, f1.zeitquelle], ['p1', 'Website Praxis Müller', ['t1']])
|
||||
const f2 = waehleProjekt(f1, 'p2', KONTEN)
|
||||
assert.deepEqual([f2.titel, f2.zeitquelle], ['Shop Relaunch', ['t2', 't3']])
|
||||
const f3 = waehleProjekt(f2, '', KONTEN)
|
||||
assert.deepEqual([f3.projektId, f3.titel, f3.zeitquelle], ['', '', []])
|
||||
assert.equal(waehleProjekt(f1, 'gibtsnicht', KONTEN).projektId, '')
|
||||
const hand = { ...f1, titel: 'Eigener Titel', titelGeaendert: true, zeitquelle: ['t7'], zeitquelleGeaendert: true }
|
||||
const f4 = waehleProjekt(hand, 'p2', KONTEN)
|
||||
assert.deepEqual([f4.projektId, f4.titel, f4.zeitquelle], ['p2', 'Eigener Titel', ['t7']])
|
||||
assert.equal(f0.projektId, '') // die Eingabe bleibt unverändert
|
||||
})
|
||||
|
||||
test('wechselArt: ein unpassender Zahlungsplan wird zurückgesetzt', () => {
|
||||
const f = { ...neuesFormular({ heute: '2026-10-06' }), zahlungsart: 'anzahlung' }
|
||||
assert.equal(wechselArt(f, 'aufwand').art, 'aufwand')
|
||||
assert.equal(wechselArt(f, 'aufwand').zahlungsart, 'nach_abnahme')
|
||||
assert.equal(wechselArt(f, 'festpreis').zahlungsart, 'anzahlung')
|
||||
assert.equal(wechselArt({ ...f, art: 'aufwand', zahlungsart: 'monatlich' }, 'festpreis').zahlungsart, 'nach_abnahme')
|
||||
assert.equal(wechselArt({ ...f, zahlungsart: 'frei' }, 'aufwand').zahlungsart, 'frei')
|
||||
assert.equal(wechselArt(f, 'quatsch'), f)
|
||||
})
|
||||
|
||||
test('formularSummeCents: Festpreis summiert gültige Positionen, Aufwand hat keine Summe', () => {
|
||||
const f = festpreisForm({
|
||||
positionen: [
|
||||
{ uid: 1, description: 'a', quantity: '2', price: '100,00' },
|
||||
{ uid: 2, description: 'b', quantity: '1', price: '50' },
|
||||
{ uid: 3, description: 'c', quantity: 'x', price: '9,99' },
|
||||
],
|
||||
})
|
||||
assert.equal(formularSummeCents(f), 25000)
|
||||
assert.equal(formularSummeCents(festpreisForm({ positionen: [] })), 0)
|
||||
assert.equal(formularSummeCents(aufwandForm()), null)
|
||||
})
|
||||
|
||||
test('pruefeSchritt 1: Titel ist Pflicht, die Kundenart nur, wenn sie am Kunden fehlt', () => {
|
||||
const f = neuesFormular({ heute: '2026-10-06' })
|
||||
const r = pruefeSchritt(1, f, { kunde: KUNDE })
|
||||
assert.equal(r.ok, false)
|
||||
assert.deepEqual(r.fehler, { titel: 'Bitte einen Titel eingeben (höchstens 200 Zeichen).', kundenart: 'Bitte die Kundenart wählen.' })
|
||||
assert.equal(r.erstes, 'titel')
|
||||
assert.deepEqual(pruefeSchritt(1, { ...f, titel: ' Website ', kundenart: 'privat' }, { kunde: KUNDE }), OK)
|
||||
assert.deepEqual(pruefeSchritt(1, { ...f, titel: 'x' }, { kunde: { ...KUNDE, kundenart: 'privat' } }), OK)
|
||||
assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(201), kundenart: 'privat' }, { kunde: KUNDE }).ok, false)
|
||||
assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(200), kundenart: 'privat' }, { kunde: KUNDE }).ok, true)
|
||||
assert.equal(pruefeSchritt(1, { ...f, titel: 'x', kundenart: 'firma' }, { kunde: KUNDE }).fehler.kundenart, 'Bitte die Kundenart wählen.')
|
||||
})
|
||||
|
||||
test('pruefeSchritt 2 (Festpreis): Datum, Weg, Positionen und Summe', () => {
|
||||
const pruefe = (over) => pruefeSchritt(2, festpreisForm(over), { kunde: KUNDE })
|
||||
assert.deepEqual(pruefe(), OK)
|
||||
const r = pruefe({ auftragVom: '', auftragWeg: '' })
|
||||
assert.deepEqual(r.fehler, { auftragVom: 'Bitte das Datum der Absprache angeben.', auftragWeg: 'Bitte den Weg der Absprache wählen.' })
|
||||
assert.equal(r.erstes, 'auftragVom')
|
||||
for (const schlecht of ['2026-02-30', '06.10.2026', '2026-1-1', 'morgen']) {
|
||||
assert.equal(pruefe({ auftragVom: schlecht }).fehler.auftragVom, 'Bitte das Datum der Absprache angeben.', schlecht)
|
||||
}
|
||||
assert.equal(pruefe({ auftragWeg: 'brieftaube' }).fehler.auftragWeg, 'Bitte den Weg der Absprache wählen.')
|
||||
assert.equal(pruefe({ art: 'xyz' }).fehler.art, 'Bitte die Art des Auftrags wählen.')
|
||||
|
||||
const pos = (o = {}) => ({ uid: 1, description: 'x', quantity: '1', price: '10,00', ...o })
|
||||
const meldung = (positionen) => pruefe({ positionen }).fehler.positionen
|
||||
const PREIS = 'Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).'
|
||||
assert.equal(meldung([pos({ description: ' ' })]), 'Position 1: Bitte eine Beschreibung eingeben.')
|
||||
assert.equal(meldung([pos(), pos({ description: 'y'.repeat(201) })]), 'Position 2: Die Beschreibung darf höchstens 200 Zeichen lang sein.')
|
||||
assert.equal(meldung([pos({ quantity: '0' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
|
||||
assert.equal(meldung([pos({ quantity: '1000' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
|
||||
assert.equal(meldung([pos({ price: '' })]), `Position 1: ${PREIS}`)
|
||||
assert.equal(meldung([pos({ price: '100.000,01' })]), `Position 1: ${PREIS}`)
|
||||
assert.equal(meldung([]), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
|
||||
assert.equal(meldung(Array.from({ length: 21 }, () => pos())), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
|
||||
assert.equal(meldung(Array.from({ length: 20 }, () => pos())), undefined)
|
||||
// Summe: genau 999.999,99 € gehen, 1.000.000,00 € nicht
|
||||
const neun = Array.from({ length: 9 }, () => pos({ price: '100.000,00' }))
|
||||
assert.equal(meldung([...neun, pos({ price: '99.999,99' })]), undefined)
|
||||
assert.equal(meldung([...neun, pos({ price: '100.000,00' })]), 'Die Summe ist zu hoch (höchstens 999.999,99 €).')
|
||||
})
|
||||
|
||||
test('pruefeSchritt 2 (Aufwand): Stundensatz und Schätzung, Positionen zählen nicht', () => {
|
||||
const pruefe = (over) => pruefeSchritt(2, aufwandForm(over), { kunde: KUNDE })
|
||||
const SATZ = 'Der Stundensatz muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 100,00).'
|
||||
assert.deepEqual(pruefe(), OK)
|
||||
assert.equal(pruefe({ schaetzung: '12,5' }).ok, true)
|
||||
assert.equal(pruefe({ stundensatz: '' }).fehler.stundensatz, SATZ)
|
||||
assert.equal(pruefe({ stundensatz: '12.50' }).fehler.stundensatz, SATZ)
|
||||
assert.equal(pruefe({ schaetzung: '10,3' }).fehler.schaetzung, 'Die Schätzung geht in Schritten von 15 Minuten (z. B. 10 oder 12,5 Stunden, höchstens 1666).')
|
||||
assert.equal(pruefe({ positionen: [{ uid: 1, description: '', quantity: 'x', price: '' }] }).ok, true)
|
||||
})
|
||||
|
||||
test('pruefeSchritt 3: Zahlungsplan, Zahlungsziel, Termine, Zeitquelle', () => {
|
||||
const pruefe = (f) => pruefeSchritt(3, f, { kunde: KUNDE })
|
||||
assert.deepEqual(pruefe(festpreisForm()), OK)
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: '50' })).ok, true)
|
||||
for (const schlecht of ['', '0', '100', '33,3']) {
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: schlecht })).fehler.prozent, 'Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.', schlecht)
|
||||
}
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'nach_abnahme', prozent: 'x' })).ok, true) // Prozent zählt nur bei „Anzahlung“
|
||||
const TEXT = 'Bitte den Zahlungsplan als Text eingeben (höchstens 500 Zeichen).'
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' ' })).fehler.zahlungstext, TEXT)
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(501) })).fehler.zahlungstext, TEXT)
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(500) })).ok, true)
|
||||
for (const schlecht of ['', '61', '-1', '1,5']) {
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsziel: schlecht })).fehler.zahlungsziel, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.', schlecht)
|
||||
}
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsziel: '0' })).ok, true)
|
||||
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-30' })).ok, true)
|
||||
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-01' })).ok, true)
|
||||
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-02', fertigBis: '2026-11-01' })).fehler.fertigBis, 'Die Fertigstellung liegt vor dem Start.')
|
||||
assert.equal(pruefe(festpreisForm({ startAm: '2026-13-01' })).fehler.startAm, 'Bitte ein gültiges Datum wählen.')
|
||||
assert.equal(pruefe(festpreisForm({ fertigBis: 'bald' })).fehler.fertigBis, 'Bitte ein gültiges Datum wählen.')
|
||||
const ids = (n) => Array.from({ length: n }, (_, i) => `t${i}`)
|
||||
assert.equal(pruefe(festpreisForm({ zeitquelle: ids(10) })).ok, true)
|
||||
assert.equal(pruefe(festpreisForm({ zeitquelle: ids(11) })).fehler.zeitquelle, 'Höchstens 10 Tickets als Zeitquelle.')
|
||||
const PASST = 'Dieser Zahlungsplan passt nicht zur Art des Auftrags.'
|
||||
assert.equal(pruefe(festpreisForm({ zahlungsart: 'monatlich' })).fehler.zahlungsart, PASST)
|
||||
assert.equal(pruefe(aufwandForm({ zahlungsart: 'anzahlung' })).fehler.zahlungsart, PASST)
|
||||
})
|
||||
|
||||
test('formularZuNutzlast: Festpreis in Cent, getrimmte Texte, Kundenart nur wenn sie am Kunden fehlt', () => {
|
||||
const f = festpreisForm({
|
||||
titel: ' Website Praxis ', projektId: 'p1',
|
||||
positionen: [
|
||||
{ uid: 1, description: ' Startseite ', quantity: '2', price: '400,00' },
|
||||
{ uid: 2, description: 'Kontaktformular', quantity: '1', price: '1.234,50' },
|
||||
],
|
||||
zahlungsart: 'anzahlung', prozent: '30', zahlungsziel: '21',
|
||||
startAm: '2026-11-01', fertigBis: '2026-11-30', zeitquelle: ['t1', 't2'],
|
||||
})
|
||||
const r = formularZuNutzlast(f, { kunde: KUNDE })
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.summeCents, 203450)
|
||||
assert.deepEqual(r.payload, {
|
||||
projektId: 'p1', titel: 'Website Praxis', kundenart: 'privat', art: 'festpreis',
|
||||
positionen: [
|
||||
{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 },
|
||||
{ beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 123450 },
|
||||
],
|
||||
stundensatzCents: null, schaetzungMinuten: null,
|
||||
zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
|
||||
auftragVom: '2026-10-06', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '2026-11-30',
|
||||
zeitquelle: ['t1', 't2'],
|
||||
})
|
||||
assert.equal('kundenart' in formularZuNutzlast(f, { kunde: { ...KUNDE, kundenart: 'unternehmen' } }).payload, false)
|
||||
const frei = formularZuNutzlast(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' Zahlung in zwei Raten ' }), { kunde: KUNDE })
|
||||
assert.deepEqual(frei.payload.zahlungsplan, { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten' })
|
||||
assert.equal(frei.payload.projektId, '')
|
||||
assert.deepEqual([frei.payload.startAm, frei.payload.fertigBis], ['', ''])
|
||||
})
|
||||
|
||||
test('formularZuNutzlast: Aufwand mit Satz in Cent und Schätzung in Minuten, ohne Positionen und Summe', () => {
|
||||
const r = formularZuNutzlast(aufwandForm({ stundensatz: '95,50', schaetzung: '12,5', zahlungsart: 'monatlich' }), { kunde: { ...KUNDE, kundenart: 'privat' } })
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.summeCents, null)
|
||||
assert.deepEqual(r.payload.positionen, [])
|
||||
assert.equal(r.payload.art, 'aufwand')
|
||||
assert.equal(r.payload.stundensatzCents, 9550)
|
||||
assert.equal(r.payload.schaetzungMinuten, 750)
|
||||
assert.deepEqual(r.payload.zahlungsplan, { art: 'monatlich', prozent: null, text: '' })
|
||||
assert.equal(formularZuNutzlast(aufwandForm({ schaetzung: '' }), { kunde: KUNDE }).payload.schaetzungMinuten, null)
|
||||
})
|
||||
|
||||
test('formularZuNutzlast: Fehler nennen den Schritt und das erste Feld', () => {
|
||||
const r1 = formularZuNutzlast(festpreisForm({ titel: '', zahlungsziel: '99' }), { kunde: KUNDE })
|
||||
assert.deepEqual([r1.ok, r1.schritt, r1.erstes], [false, 1, 'titel'])
|
||||
assert.equal(r1.fehler.titel, 'Bitte einen Titel eingeben (höchstens 200 Zeichen).')
|
||||
const r2 = formularZuNutzlast(festpreisForm({ auftragWeg: '' }), { kunde: KUNDE })
|
||||
assert.deepEqual([r2.ok, r2.schritt, r2.erstes], [false, 2, 'auftragWeg'])
|
||||
const r3 = formularZuNutzlast(festpreisForm({ zahlungsziel: '99' }), { kunde: KUNDE })
|
||||
assert.deepEqual([r3.ok, r3.schritt, r3.erstes], [false, 3, 'zahlungsziel'])
|
||||
})
|
||||
|
||||
test('zahlungsplanVorschau: Wortlaut sinngemäß wie in der Auftragsbestätigung', () => {
|
||||
const v = (o) => zahlungsplanVorschau({ art: 'nach_abnahme', prozent: null, text: '', summeCents: 80000, zielTage: 14, ...o })
|
||||
assert.equal(v({}), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ summeCents: null }), 'Alles nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'anzahlung', prozent: 50 }), 'Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
|
||||
// Rundung wie im Vertrag V3: Anzahlung = Math.round(Summe × Prozent / 100), Rest = Summe − Anzahlung
|
||||
assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: 33333 }), 'Anzahlung 50 % (166,67 €) nach Auftragsbestätigung, Rest (166,66 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'anzahlung', prozent: 33, summeCents: 33333 }), 'Anzahlung 33 % (110,00 €) nach Auftragsbestätigung, Rest (223,33 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: null }), 'Anzahlung 50 % nach Auftragsbestätigung, Rest nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'anzahlung', prozent: null }), '')
|
||||
assert.equal(v({ art: 'monatlich', summeCents: null }), 'Monatlich nach Aufwand; zahlbar innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'frei', text: ' Zwei Raten, je zur Hälfte ' }), 'Zwei Raten, je zur Hälfte (zahlbar innerhalb von 14 Tagen nach Rechnung).')
|
||||
assert.equal(v({ art: 'frei', text: '' }), '')
|
||||
assert.equal(v({ zielTage: 0 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar sofort nach Rechnung.')
|
||||
assert.equal(v({ zielTage: 1 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 1 Tag nach Rechnung.')
|
||||
assert.equal(v({ zielTage: null }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
|
||||
assert.equal(v({ art: 'unbekannt' }), '')
|
||||
})
|
||||
|
||||
test('sendeGate: Lead, E-Mail, Anschrift und Kundenart halten das Senden auf, der Entwurf bleibt möglich', () => {
|
||||
assert.deepEqual(sendeGate(KUNDE, 'privat'), { erlaubt: true, grund: '', zurUebersicht: false })
|
||||
assert.deepEqual(sendeGate({ ...KUNDE, kundenart: 'privat' }, ''), { erlaubt: true, grund: '', zurUebersicht: false })
|
||||
assert.deepEqual(sendeGate(KUNDE, ''), { erlaubt: false, grund: 'Die Kundenart fehlt. Bitte im ersten Schritt wählen.', zurUebersicht: false })
|
||||
assert.deepEqual(sendeGate({ ...KUNDE, street: '' }, 'privat'), {
|
||||
erlaubt: false, grund: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true,
|
||||
})
|
||||
assert.deepEqual(sendeGate({ ...KUNDE, email: ' ' }, 'privat'), { erlaubt: false, grund: 'E-Mail fehlt', zurUebersicht: true })
|
||||
assert.deepEqual(sendeGate({ ...KUNDE, customerStatus: 'lead' }, 'privat'), { erlaubt: false, grund: 'Noch kein fester Kunde', zurUebersicht: true })
|
||||
assert.deepEqual(sendeGate(null, 'privat'), { erlaubt: false, grund: 'Kein Kunde ausgewählt', zurUebersicht: false })
|
||||
})
|
||||
|
||||
const TICKETS = [
|
||||
{ $id: 't9', woid: '100149', topic: 'Neueste Anfrage' },
|
||||
{ $id: 't2', woid: '100140', topic: 'Shop Relaunch' },
|
||||
{ $id: 't1', woid: '100131', topic: 'Website Praxis' },
|
||||
{ $id: 'xyz12345', woid: '', title: 'Ohne Nummer' },
|
||||
]
|
||||
|
||||
test('zeitquelleOptionen: Projekt-Tickets zuerst, dann gewählte, dann der Rest; unbekannte Auswahl bleibt sichtbar', () => {
|
||||
const o = zeitquelleOptionen(TICKETS, ['t9', 'alt77'], ['t1'])
|
||||
assert.deepEqual(o.map((x) => x.id), ['t1', 't9', 'alt77', 't2', 'xyz12345'])
|
||||
assert.deepEqual(o.map((x) => x.gewaehlt), [false, true, true, false, false])
|
||||
assert.deepEqual(o.map((x) => x.projekt), [true, false, false, false, false])
|
||||
assert.equal(o[0].label, '#100131 Website Praxis')
|
||||
assert.equal(o[1].label, '#100149 Neueste Anfrage')
|
||||
assert.equal(o[2].label, 'Ticket alt77')
|
||||
assert.equal(o[4].label, '#12345 Ohne Nummer')
|
||||
assert.deepEqual(zeitquelleOptionen(null, [], []), [])
|
||||
})
|
||||
|
||||
test('schalteZeitquelle: höchstens 10, keine Doppelten, Reihenfolge der Auswahl', () => {
|
||||
assert.deepEqual(schalteZeitquelle(['a'], 'b', true), ['a', 'b'])
|
||||
assert.deepEqual(schalteZeitquelle(['a', 'b'], 'a', false), ['b'])
|
||||
assert.deepEqual(schalteZeitquelle(['a'], 'a', true), ['a'])
|
||||
assert.deepEqual(schalteZeitquelle(undefined, 'a', true), ['a'])
|
||||
const zehn = Array.from({ length: 10 }, (_, i) => `t${i}`)
|
||||
assert.deepEqual(schalteZeitquelle(zehn, 'neu', true), zehn)
|
||||
assert.deepEqual(schalteZeitquelle(zehn, 't3', false), zehn.filter((id) => id !== 't3'))
|
||||
})
|
||||
|
||||
test('kundenartWirksam: die Kundenart am Kunden gilt, sonst die im Formular gewählte', () => {
|
||||
assert.equal(kundenartWirksam({ kundenart: 'unternehmen' }, 'privat'), 'unternehmen')
|
||||
assert.equal(kundenartWirksam({ kundenart: '' }, 'privat'), 'privat')
|
||||
assert.equal(kundenartWirksam({}, 'unternehmen'), 'unternehmen')
|
||||
assert.equal(kundenartWirksam(null, 'privat'), 'privat')
|
||||
assert.equal(kundenartWirksam({ kundenart: 'privat' }, ''), 'privat')
|
||||
assert.equal(kundenartWirksam({ kundenart: 'firma' }, ''), '')
|
||||
assert.equal(kundenartWirksam({ kundenart: '' }, 'firma'), '')
|
||||
assert.equal(kundenartWirksam(null, undefined), '')
|
||||
})
|
||||
|
||||
test('vorOrtHinweis: nur bei „Vor Ort“ und Privatperson (§ 312f Abs. 1 BGB), nie als Sperre', () => {
|
||||
const TEXT = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
|
||||
assert.equal(VOR_ORT_HINWEIS, TEXT)
|
||||
assert.equal(vorOrtHinweis('vor_ort', 'privat'), TEXT)
|
||||
for (const [weg, art] of [['vor_ort', 'unternehmen'], ['vor_ort', ''], ['telefon', 'privat'], ['muendlich', 'privat'], ['email', 'privat'], ['chat', 'privat'], ['', 'privat'], [undefined, 'privat']]) {
|
||||
assert.equal(vorOrtHinweis(weg, art), '', `${weg}/${art}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('auftragHinweise: fehlende Steuernummer, dann „vor Ort“ bei Privatperson; sonst nichts', () => {
|
||||
const STN = 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.'
|
||||
const VOR = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
|
||||
const hinweise = (o) => auftragHinweise({ kunde: KUNDE, form: festpreisForm(), einstellungen: { steuernummer: '12/345/67890' }, ...o })
|
||||
assert.deepEqual(hinweise(), []) // festpreisForm: Privatperson, Weg „telefon“, Steuernummer vorhanden
|
||||
assert.deepEqual(hinweise({ einstellungen: { steuernummer: '' } }), [{ key: 'steuernummer', text: STN }])
|
||||
assert.deepEqual(hinweise({ einstellungen: {} }).map((h) => h.key), ['steuernummer'])
|
||||
// Einstellungen noch nicht geladen (undefined) oder nicht ladbar (null): keine Behauptung
|
||||
assert.deepEqual(hinweise({ einstellungen: undefined }), [])
|
||||
assert.deepEqual(hinweise({ einstellungen: null }), [])
|
||||
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort' }) }), [{ key: 'vorOrt', text: VOR }])
|
||||
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: 'unternehmen' }) }), [])
|
||||
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }), [])
|
||||
// die Kundenart des Kunden hat Vorrang vor dem Formular
|
||||
const privatAmKunden = { kunde: { ...KUNDE, kundenart: 'privat' }, form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }
|
||||
assert.deepEqual(hinweise(privatAmKunden).map((h) => h.key), ['vorOrt'])
|
||||
assert.deepEqual(hinweise({ ...privatAmKunden, einstellungen: { steuernummer: '' } }).map((h) => h.key), ['steuernummer', 'vorOrt'])
|
||||
})
|
||||
|
||||
// --- Liste -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
const A = (o = {}) => ({
|
||||
id: 'a1', nummer: 'AB-2026-0002', status: 'versendet', kundenId: 'c1', projektId: 'p1', titel: 'Website Praxis Müller',
|
||||
art: 'festpreis', stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
|
||||
gesendetAm: '2026-10-06T08:15:00.000+00:00', gesendetAn: 'info@praxis.test', mailStatus: 'gesendet', mailFehler: '',
|
||||
bestaetigtAm: null, bestaetigtUeber: '', fruehbeginn: false, ersetztId: '', ersetztDurchId: '', widerrufenAm: null,
|
||||
erstelltAm: '2026-10-05T16:00:00.000+00:00', geaendertAm: '2026-10-06T08:15:00.000+00:00',
|
||||
pdfVorhanden: true, fruehesterBeginn: null, zahlungsplanText: '', aufwandText: null, ...o,
|
||||
})
|
||||
|
||||
test('Status: Beschriftung und Ton für alle sieben Status (Vertrag V2)', () => {
|
||||
assert.deepEqual(Object.entries(AUFTRAG_STATUS).map(([k, v]) => [k, v.label]), [
|
||||
['entwurf', 'Entwurf'], ['versendet', 'Versendet'], ['bestaetigt', 'Bestätigt'], ['abgerechnet', 'Abgerechnet'],
|
||||
['storniert', 'Storniert'], ['ersetzt', 'Ersetzt'], ['widerrufen', 'Widerrufen'],
|
||||
])
|
||||
assert.deepEqual(Object.values(AUFTRAG_STATUS).map((v) => v.tone), ['muted', 'info', 'ok', 'violet', 'plain', 'plain', 'danger'])
|
||||
// „Projekt zuordnen“ gilt für diese Status (Übersicht, Nachträge; V5 `POST …/projekt` lehnt die übrigen mit 409 ab)
|
||||
assert.deepEqual(PROJEKT_ZUORDNEN_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet'])
|
||||
})
|
||||
|
||||
test('auftragZeilen: Nummer, Titel, Datum, Summe und Status', () => {
|
||||
const [z] = auftragZeilen([A()])
|
||||
assert.deepEqual([z.id, z.nummerText, z.titel, z.datumText], ['a1', 'AB-2026-0002', 'Website Praxis Müller', '06.10.2026'])
|
||||
assert.deepEqual([z.summeText, z.summeSub, z.istEntwurf], ['800,00 €', '', false])
|
||||
assert.deepEqual([z.statusLabel, z.statusTone], ['Versendet', 'info'])
|
||||
assert.equal(z.auftrag.id, 'a1')
|
||||
})
|
||||
|
||||
test('auftragZeilen: Entwurf ohne Nummer, nach Aufwand, Projektname nur wenn er vom Titel abweicht', () => {
|
||||
const konten = [{ projektId: 'p1', name: 'Praxis Müller – Relaunch' }]
|
||||
const entwurf = A({
|
||||
id: 'a2', nummer: '', status: 'entwurf', gesendetAm: null, pdfVorhanden: false, art: 'aufwand', summeCents: null,
|
||||
stundensatzCents: 10000, schaetzungMinuten: 600, erstelltAm: '2026-10-05T22:30:00.000+00:00',
|
||||
})
|
||||
const [z] = auftragZeilen([entwurf], { konten })
|
||||
assert.deepEqual([z.nummerText, z.istEntwurf, z.datumText], ['Entwurf', true, '06.10.2026'])
|
||||
assert.deepEqual([z.summeText, z.summeSub], ['nach Aufwand', '100,00 € pro Std., Schätzung etwa 10 Std.'])
|
||||
assert.equal(z.projektText, 'Praxis Müller – Relaunch')
|
||||
assert.equal(auftragZeilen([A()], { konten: [{ projektId: 'p1', name: 'Website Praxis Müller' }] })[0].projektText, '')
|
||||
assert.equal(auftragZeilen([A({ projektId: '' })], { konten })[0].projektText, '')
|
||||
assert.equal(auftragZeilen([A({ projektId: 'p9' })], { konten })[0].projektText, '')
|
||||
assert.equal(auftragZeilen([A()])[0].projektText, '')
|
||||
assert.equal(auftragZeilen([A({ art: 'aufwand', summeCents: null, stundensatzCents: 9550, schaetzungMinuten: null })])[0].summeSub, '95,50 € pro Std.')
|
||||
assert.equal(auftragZeilen([A({ summeCents: null })])[0].summeText, '–')
|
||||
})
|
||||
|
||||
test('auftragZeilen: Notizen und Aktionen hängen an der Zeile, die Reihenfolge des Servers bleibt', () => {
|
||||
const zeilen = auftragZeilen([A({ id: 'x2', mailStatus: 'fehler' }), A({ id: 'x1', status: 'entwurf', nummer: '' })])
|
||||
assert.deepEqual(zeilen.map((z) => z.id), ['x2', 'x1'])
|
||||
assert.deepEqual(zeilen[0].notizen.map((n) => n.text), ['Mail nicht zugestellt'])
|
||||
assert.equal(zeilen[0].aktionen[1].label, 'Erneut senden')
|
||||
assert.equal(zeilen[1].istEntwurf, true)
|
||||
assert.deepEqual(auftragZeilen(null), [])
|
||||
assert.deepEqual(auftragZeilen([null, 'x', A()]).map((z) => z.id), ['a1'])
|
||||
})
|
||||
|
||||
test('Zusatzzeilen: Mail, bestätigt am, Frühbeginn, Arbeitsbeginn, Fassungen, Widerruf', () => {
|
||||
const texte = (o, alle) => auftragNotizen(A(o), alle).map((n) => n.text)
|
||||
const BESTAETIGT = '2026-10-07T09:00:00.000+00:00'
|
||||
assert.deepEqual(texte({}), [])
|
||||
assert.deepEqual(texte({ mailStatus: 'fehler', mailFehler: 'SMTP' }), ['Mail nicht zugestellt'])
|
||||
assert.deepEqual(auftragNotizen(A({ mailStatus: 'fehler' })).map((n) => n.tone), ['warn'])
|
||||
// nach der Bestätigung zählt ein früherer Mailfehler nicht mehr
|
||||
assert.deepEqual(texte({ status: 'bestaetigt', mailStatus: 'fehler', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026'])
|
||||
assert.deepEqual(texte({ fruehesterBeginn: '2026-10-21' }), ['Arbeitsbeginn frühestens am 21.10.2026'])
|
||||
assert.deepEqual(texte({ status: 'bestaetigt', fruehbeginn: true, bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Frühbeginn verlangt'])
|
||||
assert.deepEqual(texte({ status: 'bestaetigt', fruehesterBeginn: '2026-10-21', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Arbeitsbeginn frühestens am 21.10.2026'])
|
||||
// ohne Wirkung bei Entwurf und bei beendeten ABs
|
||||
assert.deepEqual(texte({ status: 'entwurf', fruehesterBeginn: '2026-10-21', fruehbeginn: true }), [])
|
||||
assert.deepEqual(texte({ status: 'storniert', fruehesterBeginn: '2026-10-21' }), [])
|
||||
assert.deepEqual(texte({ status: 'widerrufen', widerrufenAm: '2026-10-09T10:00:00.000+00:00' }), ['Widerrufen am 09.10.2026'])
|
||||
const alle = [
|
||||
A({ id: 'a1', nummer: 'AB-2026-0002', status: 'ersetzt', ersetztDurchId: 'a3' }),
|
||||
A({ id: 'a3', nummer: 'AB-2026-0004', ersetztId: 'a1' }),
|
||||
A({ id: 'a4', nummer: '', status: 'entwurf', ersetztId: 'a3' }),
|
||||
]
|
||||
assert.deepEqual(auftragNotizen(alle[0], alle).map((n) => n.text), ['Ersetzt durch AB-2026-0004'])
|
||||
assert.deepEqual(auftragNotizen(alle[1], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0002'])
|
||||
assert.deepEqual(auftragNotizen(alle[2], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0004'])
|
||||
assert.deepEqual(texte({ ersetztId: 'fremd' }, []), ['Neue Fassung'])
|
||||
assert.deepEqual(texte({ status: 'ersetzt', ersetztDurchId: 'fremd' }, []), ['Ersetzt durch eine neue Fassung'])
|
||||
})
|
||||
|
||||
test('auftragAktionen: je Status genau die erlaubten, nach dem Senden nur noch „Neue Fassung“ statt Bearbeiten', () => {
|
||||
const keys = (o) => auftragAktionen(A(o)).map((a) => a.key)
|
||||
assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'loeschen'])
|
||||
assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
|
||||
assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'stornieren'])
|
||||
assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung'])
|
||||
for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status)
|
||||
for (const status of ['versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) {
|
||||
assert.equal(keys({ status }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false, status)
|
||||
}
|
||||
// ohne gespeichertes PDF keine PDF-Aktion (beim Entwurf gibt es die Vorschau immer)
|
||||
assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'stornieren'])
|
||||
assert.deepEqual(keys({ status: 'storniert', pdfVorhanden: false }), [])
|
||||
assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).map((a) => a.label), ['Bearbeiten', 'Senden', 'Vorschau', 'Löschen'])
|
||||
assert.deepEqual(auftragAktionen(A()).map((a) => a.label), ['PDF', 'Erinnerung senden', 'Neue Fassung', 'Stornieren'])
|
||||
assert.equal(auftragAktionen(A({ mailStatus: 'fehler' }))[1].label, 'Erneut senden')
|
||||
assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).filter((a) => a.danger).map((a) => a.key), ['loeschen'])
|
||||
assert.deepEqual(auftragAktionen(A()).filter((a) => a.danger).map((a) => a.key), ['stornieren'])
|
||||
assert.deepEqual(auftragAktionen(A({ status: 'xyz', pdfVorhanden: false })), [])
|
||||
assert.deepEqual(auftragAktionen(null), [])
|
||||
assert.equal(istEntwurf(A({ status: 'entwurf' })), true)
|
||||
assert.equal(istEntwurf(A()), false)
|
||||
assert.equal(istEntwurf(null), false)
|
||||
})
|
||||
|
||||
test('Projekt zuordnen: Auswahl aus den Konten, Verfügbarkeit nach Status und Projekt', () => {
|
||||
const konten = [{ projektId: 'p1', name: 'Website Praxis Müller' }, { projektId: 'p2', name: ' Shop Relaunch ' }, { projektId: 'p3', name: '' }, { name: 'ohne Kennung' }, null]
|
||||
assert.deepEqual(zuordnenOptionen(konten), [
|
||||
{ value: 'p1', label: 'Website Praxis Müller' }, { value: 'p2', label: 'Shop Relaunch' }, { value: 'p3', label: 'p3' },
|
||||
])
|
||||
assert.deepEqual(zuordnenOptionen(undefined), [])
|
||||
assert.deepEqual(zuordnenOptionen('x'), [])
|
||||
const ohne = (o) => A({ projektId: '', ...o })
|
||||
for (const status of ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet']) {
|
||||
assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), true, status)
|
||||
}
|
||||
for (const status of ['storniert', 'ersetzt', 'widerrufen', 'xyz']) {
|
||||
assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), false, status)
|
||||
}
|
||||
assert.equal(projektZuordnenMoeglich(ohne({ projektId: null }), konten), true)
|
||||
assert.equal(projektZuordnenMoeglich(ohne({ projektId: undefined }), konten), true)
|
||||
// hat die AB schon ein Projekt (auch ein unbekanntes), gibt es die Aktion nicht: der Server lehnt sie mit 409 ab
|
||||
assert.equal(projektZuordnenMoeglich(A({ projektId: 'p1' }), konten), false)
|
||||
assert.equal(projektZuordnenMoeglich(A({ projektId: 'p9' }), konten), false)
|
||||
// ohne Projekt zur Auswahl ist nichts zu tun
|
||||
assert.equal(projektZuordnenMoeglich(ohne({}), []), false)
|
||||
assert.equal(projektZuordnenMoeglich(ohne({}), undefined), false)
|
||||
assert.equal(projektZuordnenMoeglich(null, konten), false)
|
||||
})
|
||||
|
||||
test('auftragAktionen mit Konten: „Projekt zuordnen“ nur ohne Projekt, vor den gefährlichen Aktionen', () => {
|
||||
const konten = [{ projektId: 'p1', name: 'Website' }]
|
||||
const keys = (o, k = konten) => auftragAktionen(A({ projektId: '', ...o }), { konten: k }).map((a) => a.key)
|
||||
assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'projekt', 'loeschen'])
|
||||
assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren'])
|
||||
assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'projekt', 'stornieren'])
|
||||
assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung', 'projekt'])
|
||||
assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'projekt', 'stornieren'])
|
||||
for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status)
|
||||
// mit Projekt, ohne Konten und ohne Angabe der Konten gibt es die Aktion nicht
|
||||
assert.deepEqual(auftragAktionen(A({ status: 'versendet' }), { konten }).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
|
||||
assert.deepEqual(keys({ status: 'versendet' }, []), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
|
||||
assert.deepEqual(auftragAktionen(A({ projektId: '' })).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
|
||||
// Beschriftung, nie gefährlich; Bearbeiten, Senden und Löschen bleiben dem Entwurf vorbehalten
|
||||
const aktion = auftragAktionen(A({ projektId: '' }), { konten }).find((a) => a.key === 'projekt')
|
||||
assert.equal(aktion.label, 'Projekt zuordnen')
|
||||
assert.notEqual(aktion.danger, true)
|
||||
assert.equal(keys({ status: 'versendet' }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false)
|
||||
// die Zeilen geben die Konten weiter
|
||||
assert.deepEqual(auftragZeilen([A({ projektId: '' })], { konten })[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren'])
|
||||
assert.deepEqual(auftragZeilen([A({ projektId: '' })])[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
|
||||
})
|
||||
|
||||
test('Rückfragen: Senden, Löschen, Stornieren, Erinnerung, Projekt zuordnen', () => {
|
||||
assert.equal(
|
||||
auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' }), { email: 'info@praxis.test' }),
|
||||
'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an info@praxis.test senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.'
|
||||
)
|
||||
assert.equal(
|
||||
auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' })),
|
||||
'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an den Kunden senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.'
|
||||
)
|
||||
assert.equal(auftragBestaetigung('loeschen', A({ status: 'entwurf' })), 'Den Entwurf „Website Praxis Müller“ wirklich löschen? Das lässt sich nicht rückgängig machen.')
|
||||
assert.equal(
|
||||
auftragBestaetigung('stornieren', A()),
|
||||
'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ wirklich stornieren? Der Kunde bekommt keine Mail, der Bestätigungslink wird ungültig. Das lässt sich nicht rückgängig machen.'
|
||||
)
|
||||
assert.equal(
|
||||
auftragBestaetigung('erinnern', A()),
|
||||
'Die Auftragsbestätigung AB-2026-0002 jetzt noch einmal an den Kunden senden? Der bisherige Bestätigungslink gilt danach nicht mehr.'
|
||||
)
|
||||
assert.equal(
|
||||
auftragBestaetigung('projekt', A({ projektId: '' }), { projektName: 'Physio-Website' }),
|
||||
'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ dem Projekt „Physio-Website“ zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern.'
|
||||
)
|
||||
assert.match(auftragBestaetigung('projekt', A({ projektId: '' })), /„Website Praxis Müller“ einem Projekt zuordnen\? /)
|
||||
// ein Entwurf bleibt über „Bearbeiten“ änderbar: keine Rückfrage
|
||||
assert.equal(auftragBestaetigung('projekt', A({ status: 'entwurf', nummer: '', projektId: '' }), { projektName: 'Physio-Website' }), '')
|
||||
for (const key of ['bearbeiten', 'pdf', 'neueFassung', 'gibtsnicht']) assert.equal(auftragBestaetigung(key, A()), '', key)
|
||||
assert.match(auftragBestaetigung('senden', A({ titel: '' })), /„ohne Titel“/)
|
||||
})
|
||||
|
||||
test('Erfolgsmeldungen (Wortlaut des Containers von W0 für „senden“)', () => {
|
||||
const gesendet = A({ nummer: 'AB-2026-0003' }) // gesendetAn: info@praxis.test
|
||||
assert.deepEqual(auftragErfolg('senden', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde an info@praxis.test gesendet.' })
|
||||
assert.deepEqual(auftragErfolg('senden', A({ nummer: 'AB-2026-0003', gesendetAn: '' })), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde gesendet.' })
|
||||
assert.deepEqual(auftragErfolg('senden', gesendet, { mailFehler: true }), {
|
||||
tone: 'warn',
|
||||
text: 'Auftragsbestätigung AB-2026-0003 ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.',
|
||||
})
|
||||
assert.deepEqual(auftragErfolg('erinnern', gesendet), { tone: 'ok', text: 'Die Erinnerung zu AB-2026-0003 wurde an info@praxis.test gesendet.' })
|
||||
assert.deepEqual(auftragErfolg('erinnern', gesendet, { mailFehler: true }), {
|
||||
tone: 'warn', text: 'Die Erinnerung zu AB-2026-0003 konnte nicht zugestellt werden. „Erneut senden“ versucht es noch einmal.',
|
||||
})
|
||||
assert.deepEqual(auftragErfolg('stornieren', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde storniert.' })
|
||||
assert.deepEqual(auftragErfolg('loeschen', A({ nummer: '' })), { tone: 'ok', text: 'Der Entwurf wurde gelöscht.' })
|
||||
assert.deepEqual(auftragErfolg('neueFassung', A({ nummer: '' })), { tone: 'ok', text: 'Neue Fassung als Entwurf angelegt.' })
|
||||
assert.deepEqual(auftragErfolg('projekt', gesendet, { projektName: 'Physio-Website' }), {
|
||||
tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde dem Projekt „Physio-Website“ zugeordnet.',
|
||||
})
|
||||
assert.deepEqual(auftragErfolg('projekt', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde einem Projekt zugeordnet.' })
|
||||
assert.deepEqual(auftragErfolg('projekt', A({ nummer: '' }), { projektName: 'Physio-Website' }), {
|
||||
tone: 'ok', text: 'Die Auftragsbestätigung wurde dem Projekt „Physio-Website“ zugeordnet.',
|
||||
})
|
||||
assert.deepEqual(auftragErfolg('senden', null), { tone: 'ok', text: 'Die Auftragsbestätigung wurde gesendet.' })
|
||||
assert.deepEqual(auftragErfolg('senden', null, { mailFehler: true }), {
|
||||
tone: 'warn', text: 'Die Auftragsbestätigung ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.',
|
||||
})
|
||||
assert.deepEqual(auftragErfolg('gibtsnicht', gesendet), { tone: 'ok', text: '' })
|
||||
})
|
||||
|
||||
test('auftragFehler: Kundendaten-Fehler führen zur Übersicht, andere zeigen die Meldung des Servers (Texte aus Plan L3)', () => {
|
||||
const fehler = (status, message) => auftragFehler(Object.assign(new Error(message), { status }))
|
||||
assert.deepEqual(fehler(409, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.'), {
|
||||
art: 'adresse', text: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true,
|
||||
})
|
||||
assert.deepEqual(fehler(400, 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).'), {
|
||||
art: 'kundenart',
|
||||
text: 'Die Kundenart des Kunden fehlt. Bitte in der Übersicht des Kunden festlegen (Feld „Kundenart“) oder über „Bearbeiten“ und dann erneut senden.',
|
||||
zurUebersicht: true,
|
||||
})
|
||||
assert.deepEqual(fehler(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.'), {
|
||||
art: 'email', text: 'Dem Kunden fehlt die E-Mail-Adresse. Bitte in der Übersicht eintragen und dann erneut senden.', zurUebersicht: true,
|
||||
})
|
||||
assert.deepEqual(fehler(400, 'Auftragsbestätigungen nur für feste Kunden.'), {
|
||||
art: 'kunde', text: 'Das ist noch kein fester Kunde. Bitte in der Übersicht zum festen Kunden machen und dann erneut senden.', zurUebersicht: true,
|
||||
})
|
||||
// Entwurf unvollständig (L4, wird von L3 beim Senden und bei der Vorschau durchgereicht): Meldung unverändert, kein „aktueller Stand“
|
||||
assert.deepEqual(fehler(409, 'Entwurf unvollständig: Titel fehlt.'), {
|
||||
art: 'entwurf', text: 'Entwurf unvollständig: Titel fehlt.', zurUebersicht: false,
|
||||
})
|
||||
assert.equal(fehler(409, 'Entwurf unvollständig: Kundendaten fehlen.').art, 'entwurf')
|
||||
assert.deepEqual(fehler(409, 'Nur ein Entwurf lässt sich senden.'), {
|
||||
art: 'stand', text: 'Nur ein Entwurf lässt sich senden. Die Liste zeigt den aktuellen Stand.', zurUebersicht: false,
|
||||
})
|
||||
assert.deepEqual(fehler(400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.'), {
|
||||
art: 'eingabe', text: 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.', zurUebersicht: false,
|
||||
})
|
||||
assert.deepEqual(fehler(500, 'Das PDF konnte nicht erzeugt werden.'), {
|
||||
art: 'pdf', text: 'Das PDF konnte nicht erzeugt werden. Die Auftragsbestätigung bleibt ein Entwurf, es wurde keine Nummer vergeben.', zurUebersicht: false,
|
||||
})
|
||||
assert.deepEqual(fehler(500, 'Interner Fehler.'), {
|
||||
art: 'server',
|
||||
text: 'Interner Fehler. Bitte in der Liste prüfen, ob die Auftragsbestätigung gesendet wurde, und sonst erneut versuchen.',
|
||||
zurUebersicht: false,
|
||||
})
|
||||
assert.deepEqual(fehler(0, 'Das Kundenportal ist gerade nicht erreichbar.'), { art: 'netz', text: 'Das Kundenportal ist gerade nicht erreichbar.', zurUebersicht: false })
|
||||
assert.deepEqual(fehler(404, 'Auftragsbestätigung nicht gefunden.'), { art: 'sonst', text: 'Auftragsbestätigung nicht gefunden.', zurUebersicht: false })
|
||||
assert.deepEqual(auftragFehler(new Error('')), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false })
|
||||
assert.deepEqual(auftragFehler(null), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false })
|
||||
})
|
||||
|
||||
test('neueSperre: ein zweiter Aufruf während des ersten wird ignoriert (Doppelklick)', async () => {
|
||||
const sperre = neueSperre()
|
||||
let aufrufe = 0
|
||||
let fertig
|
||||
const warten = new Promise((r) => { fertig = r })
|
||||
const erster = sperre.run(async () => { aufrufe += 1; await warten; return 'ok' })
|
||||
const zweiter = await sperre.run(async () => { aufrufe += 1; return 'doppelt' })
|
||||
assert.deepEqual(zweiter, { uebersprungen: true })
|
||||
assert.equal(sperre.belegt, true)
|
||||
fertig()
|
||||
assert.deepEqual(await erster, { uebersprungen: false, wert: 'ok' })
|
||||
assert.equal(aufrufe, 1)
|
||||
assert.equal(sperre.belegt, false)
|
||||
assert.deepEqual(await sperre.run(async () => 'wieder'), { uebersprungen: false, wert: 'wieder' })
|
||||
})
|
||||
|
||||
test('neueSperre: nach einem Fehler wieder frei, der Fehler wird weitergereicht', async () => {
|
||||
const sperre = neueSperre()
|
||||
await assert.rejects(() => sperre.run(async () => { throw new Error('kaputt') }), /kaputt/)
|
||||
assert.equal(sperre.belegt, false)
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const {
|
||||
KUNDENARTEN, KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartLabel, kundenartAenderung,
|
||||
STEUERNUMMER_MAX, STEUERNUMMER_HINWEIS, steuernummerAenderung, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt,
|
||||
} = await loadModule('src/lib/kundenFelder.js')
|
||||
|
||||
test('Kundenart: Werte (Vertrag V2), Auswahl und Beschriftung', () => {
|
||||
assert.deepEqual(KUNDENARTEN, ['unternehmen', 'privat'])
|
||||
assert.deepEqual(KUNDENART_OPTIONEN, [
|
||||
{ value: '', label: 'Nicht festgelegt' },
|
||||
{ value: 'unternehmen', label: 'Unternehmen' },
|
||||
{ value: 'privat', label: 'Privatperson' },
|
||||
])
|
||||
assert.equal(KUNDENART_HINWEIS, 'Für Auftragsbestätigungen: Privatpersonen bekommen die Widerrufsbelehrung.')
|
||||
assert.equal(kundenartLabel('unternehmen'), 'Unternehmen')
|
||||
assert.equal(kundenartLabel('privat'), 'Privatperson')
|
||||
for (const v of ['', undefined, null, 'firma']) assert.equal(kundenartLabel(v), 'Nicht festgelegt')
|
||||
})
|
||||
|
||||
test('kundenartAenderung: nur eine geänderte, bekannte Kundenart wird geschrieben', () => {
|
||||
assert.deepEqual(kundenartAenderung('', ''), {})
|
||||
assert.deepEqual(kundenartAenderung('', undefined), {})
|
||||
assert.deepEqual(kundenartAenderung('privat', 'privat'), {})
|
||||
assert.deepEqual(kundenartAenderung('privat', ''), { kundenart: 'privat' })
|
||||
assert.deepEqual(kundenartAenderung('privat', undefined), { kundenart: 'privat' })
|
||||
assert.deepEqual(kundenartAenderung('unternehmen', 'privat'), { kundenart: 'unternehmen' })
|
||||
assert.deepEqual(kundenartAenderung('', 'privat'), { kundenart: '' })
|
||||
// unbekannte Werte werden nie geschrieben
|
||||
assert.deepEqual(kundenartAenderung('firma', ''), {})
|
||||
assert.deepEqual(kundenartAenderung('firma', 'privat'), {})
|
||||
})
|
||||
|
||||
test('Steuernummer: Hinweis und Grenze', () => {
|
||||
assert.equal(STEUERNUMMER_HINWEIS, 'Steht auf jeder Auftragsbestätigung. Für Rechnungen bitte zusätzlich im Stripe-Dashboard hinterlegen.')
|
||||
assert.equal(STEUERNUMMER_MAX, 40)
|
||||
})
|
||||
|
||||
test('steuernummerAenderung: unverändert sendet nichts, geändert den getrimmten Wert, leer löscht', () => {
|
||||
assert.deepEqual(steuernummerAenderung('12/345/67890', '12/345/67890'), { ok: true, payload: {} })
|
||||
assert.deepEqual(steuernummerAenderung('', ''), { ok: true, payload: {} })
|
||||
assert.deepEqual(steuernummerAenderung('', null), { ok: true, payload: {} })
|
||||
assert.deepEqual(steuernummerAenderung(' 12/345/67890 ', '12/345/67890'), { ok: true, payload: {} })
|
||||
assert.deepEqual(steuernummerAenderung(' 12/345/67890 ', ''), { ok: true, payload: { steuernummer: '12/345/67890' } })
|
||||
assert.deepEqual(steuernummerAenderung('', '12/345/67890'), { ok: true, payload: { steuernummer: '' } })
|
||||
assert.equal(steuernummerAenderung('123 456 789-0', '').ok, true)
|
||||
assert.equal(steuernummerAenderung('1'.repeat(40), '').ok, true)
|
||||
})
|
||||
|
||||
test('steuernummerAenderung: Buchstaben, Ziffern, Leerzeichen, „/“, „-“ und „.“ sind erlaubt wie auf dem Server (V4)', () => {
|
||||
for (const gut of ['12/345/67890', '12.345.67890', 'DE123456789', 'de 123 456 789', 'DE-123.456/789', 'a', 'USt-IdNr 12/345']) {
|
||||
assert.deepEqual(steuernummerAenderung(gut, ''), { ok: true, payload: { steuernummer: gut } }, gut)
|
||||
}
|
||||
assert.deepEqual(steuernummerAenderung(' DE123456789 ', ''), { ok: true, payload: { steuernummer: 'DE123456789' } })
|
||||
assert.deepEqual(steuernummerAenderung('A'.repeat(40), ''), { ok: true, payload: { steuernummer: 'A'.repeat(40) } })
|
||||
})
|
||||
|
||||
test('steuernummerAenderung: andere Zeichen und zu lang', () => {
|
||||
const zeichen = 'Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.'
|
||||
for (const schlecht of ['12_345', '12,345', '12#34', 'Müller 12', 'ß12', '12\t34', '12\n34', '12+34', '123']) {
|
||||
assert.deepEqual(steuernummerAenderung(schlecht, ''), { ok: false, fehler: zeichen }, JSON.stringify(schlecht))
|
||||
}
|
||||
const LANG = 'Die Steuernummer darf höchstens 40 Zeichen lang sein.'
|
||||
assert.deepEqual(steuernummerAenderung('1'.repeat(41), ''), { ok: false, fehler: LANG })
|
||||
assert.deepEqual(steuernummerAenderung('A'.repeat(41), ''), { ok: false, fehler: LANG })
|
||||
assert.equal(steuernummerAenderung('_'.repeat(41), '').fehler, LANG) // die Länge wird zuerst geprüft
|
||||
})
|
||||
|
||||
test('steuernummerFehlt: Hinweis nur, wenn die Einstellungen da sind und die Steuernummer leer ist', () => {
|
||||
assert.equal(STEUERNUMMER_FEHLT_HINWEIS, 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.')
|
||||
for (const leer of [{ steuernummer: '' }, { steuernummer: ' ' }, { steuernummer: null }, { steuernummer: 12 }, {}, { bankDetailsText: 'IBAN' }]) {
|
||||
assert.equal(steuernummerFehlt(leer), true, JSON.stringify(leer))
|
||||
}
|
||||
for (const da of [{ steuernummer: '12/345/67890' }, { steuernummer: ' DE123456789 ' }]) assert.equal(steuernummerFehlt(da), false)
|
||||
// noch nicht geladen oder nicht ladbar: es wird nichts behauptet
|
||||
for (const unbekannt of [undefined, null, 'x', 5, []]) assert.equal(steuernummerFehlt(unbekannt), false, String(unbekannt))
|
||||
})
|
||||
Reference in new issue
Block a user