Buchungskalender: origin/test zusammenführen
This commit is contained in:
commit
dc4df16cab
48 files changed
+6387
-367
No files matched your search
@@ -1,6 +1,8 @@
|
|||||||
import { EmptyState } from './ui'
|
import { EmptyState } from './ui'
|
||||||
import { needsTimeEntry, isGitWorksheet } from '../lib/ticketForm'
|
import { needsTimeEntry, isGitWorksheet } from '../lib/ticketForm'
|
||||||
import { parseSupporters } from '../lib/support'
|
import { parseSupporters } from '../lib/support'
|
||||||
|
import { useAbgerechnet } from '../hooks/useAbgerechnet'
|
||||||
|
import { abgerechnetText, ticketIdsOf } from '../lib/projektkontoView'
|
||||||
import {
|
import {
|
||||||
showAboToggle,
|
showAboToggle,
|
||||||
statusAfterLabel,
|
statusAfterLabel,
|
||||||
@@ -23,6 +25,8 @@ export default function WorksheetList({
|
|||||||
onOwnSupport,
|
onOwnSupport,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
}) {
|
}) {
|
||||||
|
// Hooks laufen immer, auch bei den frühen Rückgaben unten; die Abzeichen sehen alle Mitarbeiter und zeigen nie Beträge
|
||||||
|
const abgerechnet = useAbgerechnet(ticketIdsOf(worksheets))
|
||||||
if (loading) return <div className="loading">Wird geladen …</div>
|
if (loading) return <div className="loading">Wird geladen …</div>
|
||||||
|
|
||||||
if (!worksheets || worksheets.length === 0) {
|
if (!worksheets || worksheets.length === 0) {
|
||||||
@@ -35,6 +39,7 @@ export default function WorksheetList({
|
|||||||
const when = worksheetWhen(ws)
|
const when = worksheetWhen(ws)
|
||||||
const kind = worksheetKindLabel(ws)
|
const kind = worksheetKindLabel(ws)
|
||||||
const badge = worksheetBadge(ws)
|
const badge = worksheetBadge(ws)
|
||||||
|
const billed = abgerechnetText(abgerechnet, ws.$id)
|
||||||
const lines = supportLines(ws)
|
const lines = supportLines(ws)
|
||||||
const status = statusAfterLabel(ws)
|
const status = statusAfterLabel(ws)
|
||||||
const time = timeLabel(ws)
|
const time = timeLabel(ws)
|
||||||
@@ -58,6 +63,18 @@ export default function WorksheetList({
|
|||||||
{kind && <span className="muted">{kind}</span>}
|
{kind && <span className="muted">{kind}</span>}
|
||||||
<span className="faint xs num">WSID {ws.wsid}</span>
|
<span className="faint xs num">WSID {ws.wsid}</span>
|
||||||
{badge && <span className={`badge ${badge.tone}`}>{badge.text}</span>}
|
{badge && <span className={`badge ${badge.tone}`}>{badge.text}</span>}
|
||||||
|
{billed && (
|
||||||
|
<span
|
||||||
|
className="badge blue"
|
||||||
|
title={
|
||||||
|
billed === 'abgerechnet (Entwurf)'
|
||||||
|
? 'Dieses Arbeitsblatt steht in einem Rechnungsentwurf.'
|
||||||
|
: 'Dieses Arbeitsblatt ist auf einer Rechnung abgerechnet.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{billed}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{showAbo ? (
|
{showAbo ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Button, DateTimeInput, Dialog, DurationSelect, Field } from './ui'
|
|||||||
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
|
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
|
||||||
import { parseSupporters, validateSupporters } from '../lib/support'
|
import { parseSupporters, validateSupporters } from '../lib/support'
|
||||||
import SupportersEditor from './SupportersEditor'
|
import SupportersEditor from './SupportersEditor'
|
||||||
|
import { useAbgerechnet } from '../hooks/useAbgerechnet'
|
||||||
|
import { abgerechnetWarnung, ticketIdsOf } from '../lib/projektkontoView'
|
||||||
|
|
||||||
// Gespeichert ist dd.mm.yyyy / hhmm, die Kalender-Felder brauchen yyyy-mm-dd / hh:mm
|
// Gespeichert ist dd.mm.yyyy / hhmm, die Kalender-Felder brauchen yyyy-mm-dd / hh:mm
|
||||||
const formFromWorksheet = (worksheet) => ({
|
const formFromWorksheet = (worksheet) => ({
|
||||||
@@ -23,6 +25,8 @@ const NO_EMPLOYEES_STATE = { employees: [], loading: false, error: null }
|
|||||||
*/
|
*/
|
||||||
export default function WorksheetTimesModal({ worksheet, currentUserId, onClose, onSave, employeesState = NO_EMPLOYEES_STATE }) {
|
export default function WorksheetTimesModal({ worksheet, currentUserId, onClose, onSave, employeesState = NO_EMPLOYEES_STATE }) {
|
||||||
const isGit = isGitWorksheet(worksheet)
|
const isGit = isGitWorksheet(worksheet)
|
||||||
|
const abgerechnet = useAbgerechnet(ticketIdsOf([worksheet]))
|
||||||
|
const warnung = abgerechnetWarnung(abgerechnet, worksheet.$id)
|
||||||
const [formData, setFormData] = useState(() => formFromWorksheet(worksheet))
|
const [formData, setFormData] = useState(() => formFromWorksheet(worksheet))
|
||||||
// null = die Arbeitszeit folgt Beginn und Ende (aufgerundet auf 15 Minuten). Passte sie
|
// null = die Arbeitszeit folgt Beginn und Ende (aufgerundet auf 15 Minuten). Passte sie
|
||||||
// schon vorher nicht dazu, wurde sie von Hand gewaehlt (z.B. ohne Pause) und bleibt stehen.
|
// schon vorher nicht dazu, wurde sie von Hand gewaehlt (z.B. ohne Pause) und bleibt stehen.
|
||||||
@@ -117,6 +121,12 @@ export default function WorksheetTimesModal({ worksheet, currentUserId, onClose,
|
|||||||
{isGit && <><br />Das Ende ist der Zeitpunkt des Git-Pushs. Trag ein, wann die Arbeit begonnen hat.</>}
|
{isGit && <><br />Das Ende ist der Zeitpunkt des Git-Pushs. Trag ein, wann die Arbeit begonnen hat.</>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{warnung && (
|
||||||
|
<p className="msg warn" role="alert">
|
||||||
|
{warnung}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<Field label="Beginn" htmlFor="worksheet-times-start">
|
<Field label="Beginn" htmlFor="worksheet-times-start">
|
||||||
<DateTimeInput
|
<DateTimeInput
|
||||||
id="worksheet-times-start"
|
id="worksheet-times-start"
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { Button, Card, EmptyState } from '../ui'
|
||||||
|
import AddressWarning from '../finance/AddressWarning'
|
||||||
|
import InvoiceTable from '../finance/InvoiceTable'
|
||||||
|
import ProjektkontoListe from './ProjektkontoListe'
|
||||||
|
import RechnungDialog from './RechnungDialog'
|
||||||
|
import AuftragListe from './AuftragListe'
|
||||||
|
import AuftragDialog from './AuftragDialog'
|
||||||
|
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
||||||
|
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||||
|
import { getProjektkonten, listAuftraege } from '../../lib/financeApi'
|
||||||
|
import { customerInvoicesGate } from '../../lib/financeView'
|
||||||
|
import { noticeProps } from '../../lib/financeUi'
|
||||||
|
import {
|
||||||
|
KONTEN_FEHLER,
|
||||||
|
ladeAuftraegeAnsicht,
|
||||||
|
ansichtsZustand,
|
||||||
|
kundeMitKundenart,
|
||||||
|
erstelltMeldung,
|
||||||
|
auftragMeldung,
|
||||||
|
} from '../../lib/auftraegeAnsicht'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reiter „Aufträge und Rechnungen“ am Kunden (ersetzt CustomerInvoices). Kann für den Kunden keine Rechnung geschrieben
|
||||||
|
* werden (noch Lead, abgesagt, keine E-Mail), steht der Grund da, mit „Daten erfassen“; es werden keine Abrufe gestartet.
|
||||||
|
* Sonst von oben nach unten: Adresswarnung, Rückmeldung, Hinweis zum Neuladen, Projekte (Projektkonten), Aufträge (nur
|
||||||
|
* mit eingeschaltetem Schalter AUFTRAEGE_ENABLED) und die Rechnungsliste. Die Rechnungsliste und die Adresswarnung hängen
|
||||||
|
* nie vom Laden der Projekte oder Aufträge ab: fällt eines davon aus, bleibt „Rechnung schreiben“ bedienbar.
|
||||||
|
* Die Dialoge stehen als Geschwister hinter den Abschnitten (nie in InvoiceTable oder .fin-body).
|
||||||
|
*/
|
||||||
|
export default function AuftraegeUndRechnungen({ customer, onShowOverview }) {
|
||||||
|
const gate = customerInvoicesGate(customer)
|
||||||
|
if (!gate.ok) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={gate.title}
|
||||||
|
hint={gate.hint}
|
||||||
|
action={onShowOverview ? <Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button> : null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <AuftraegeInhalt customer={customer} onShowOverview={onShowOverview} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuftraegeInhalt({ customer, onShowOverview }) {
|
||||||
|
const res = useFinanceResource(() => ladeAuftraegeAnsicht(customer.$id, { getProjektkonten, listAuftraege }), KONTEN_FEHLER)
|
||||||
|
const ansicht = ansichtsZustand(res)
|
||||||
|
const reload = res.reload
|
||||||
|
|
||||||
|
const [meldung, setMeldung] = useState(null) // { tone, text } oder null
|
||||||
|
const [rechnungsDialog, setRechnungsDialog] = useState(null) // { projektId } oder null
|
||||||
|
const [auftragsDialog, setAuftragsDialog] = useState(null) // { projektId, auftrag } oder null
|
||||||
|
const [listeToken, setListeToken] = useState(0)
|
||||||
|
const meldungRef = useRef(null)
|
||||||
|
useScrollIntoView(meldungRef, meldung)
|
||||||
|
|
||||||
|
// Die Dialoge bekommen die frische Kundenart des Portals (die Kundenseite hält ggf. noch den alten Datensatz)
|
||||||
|
const kunde = kundeMitKundenart(customer, ansicht.kunde)
|
||||||
|
|
||||||
|
function openRechnung(projektId) {
|
||||||
|
setMeldung(null)
|
||||||
|
setRechnungsDialog({ projektId })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAuftrag(projektId, auftrag = null) {
|
||||||
|
setMeldung(null)
|
||||||
|
setAuftragsDialog({ projektId, auftrag })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreated(rechnung, info) {
|
||||||
|
setRechnungsDialog(null)
|
||||||
|
setMeldung(erstelltMeldung(rechnung, info))
|
||||||
|
setListeToken((n) => n + 1)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaved(auftrag) {
|
||||||
|
setAuftragsDialog(null)
|
||||||
|
setMeldung(auftragMeldung(auftrag))
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AddressWarning customer={customer} onShowOverview={onShowOverview} variant="page" />
|
||||||
|
|
||||||
|
{meldung && (
|
||||||
|
<p ref={meldungRef} {...noticeProps(meldung.tone, 'fin-notice')}>
|
||||||
|
{meldung.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ansicht.projekteStatus === 'bereit' && ansicht.nachladeFehler && (
|
||||||
|
<p {...noticeProps('warn', 'fin-notice')}>
|
||||||
|
Die Übersicht konnte nicht neu geladen werden: {ansicht.nachladeFehler}{' '}
|
||||||
|
<button type="button" className="link" onClick={reload}>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ansicht.projekteStatus === 'laden' && (
|
||||||
|
<Card title="Projekte">
|
||||||
|
<div className="loading" role="status">Projekte werden geladen …</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{ansicht.projekteStatus === 'fehler' && (
|
||||||
|
<Card title="Projekte">
|
||||||
|
<div className="fin-load-error">
|
||||||
|
<p className="msg warn" role="alert">{ansicht.fehler}</p>
|
||||||
|
<Button onClick={reload}>Erneut versuchen</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{ansicht.projekteStatus === 'bereit' && (
|
||||||
|
<ProjektkontoListe
|
||||||
|
konten={ansicht.konten}
|
||||||
|
ohneProjekt={ansicht.ohneProjekt}
|
||||||
|
auftraegeAktiv={ansicht.auftraegeAktiv}
|
||||||
|
stripeOk={ansicht.stripeOk}
|
||||||
|
onRechnung={openRechnung}
|
||||||
|
onAuftrag={(projektId) => openAuftrag(projektId)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ansicht.auftraegeStatus === 'bereit' && (
|
||||||
|
<AuftragListe
|
||||||
|
auftraege={ansicht.auftraege}
|
||||||
|
konten={ansicht.konten}
|
||||||
|
onChanged={reload}
|
||||||
|
onBearbeiten={(auftrag) => openAuftrag(auftrag.projektId || null, auftrag)}
|
||||||
|
onShowOverview={onShowOverview}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{ansicht.auftraegeStatus === 'fehler' && (
|
||||||
|
<Card title="Aufträge">
|
||||||
|
<div className="fin-load-error">
|
||||||
|
<p className="msg warn" role="alert">{ansicht.auftraegeFehler}</p>
|
||||||
|
<Button onClick={reload}>Erneut versuchen</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InvoiceTable customer={customer} onShowOverview={onShowOverview} refreshToken={listeToken} onChanged={reload} />
|
||||||
|
|
||||||
|
{rechnungsDialog && (
|
||||||
|
<RechnungDialog
|
||||||
|
open
|
||||||
|
onClose={() => setRechnungsDialog(null)}
|
||||||
|
onCreated={handleCreated}
|
||||||
|
kunde={kunde}
|
||||||
|
projektId={rechnungsDialog.projektId}
|
||||||
|
auftraegeAktiv={ansicht.auftraegeAktiv}
|
||||||
|
onShowOverview={onShowOverview}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{auftragsDialog && (
|
||||||
|
<AuftragDialog
|
||||||
|
open
|
||||||
|
onClose={() => setAuftragsDialog(null)}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
kunde={kunde}
|
||||||
|
projektId={auftragsDialog.projektId}
|
||||||
|
auftrag={auftragsDialog.auftrag}
|
||||||
|
konten={ansicht.konten}
|
||||||
|
onShowOverview={onShowOverview}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }) {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }) {
|
||||||
|
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,103 @@
|
|||||||
|
import { Field } from '../ui'
|
||||||
|
import { dateDe } from '../../lib/financeFormat'
|
||||||
|
import { stundenDe } from '../../lib/projektkontoView'
|
||||||
|
import { ausgewaehlteIds, blaetterSumme } from '../../lib/rechnungDialogView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auswahl der offenen Arbeitsblätter für eine Rechnung nach Aufwand, dazu „Weiteres Ticket des Kunden hinzunehmen“.
|
||||||
|
* Rein darstellend. Props: blaetter (alle angebotenen Blätter), abgewaehlt (Kennungen), onToggle(id), satzInfo,
|
||||||
|
* ticketOptionen ([{ value, ticketId, woid, label }]), ticketsLaden, ticketsFehler, weitereTickets ([{ ticketId, woid, titel }]),
|
||||||
|
* weitereZustand ({ [ticketId]: { status, message } }), onAddTicket(option), onRemoveTicket(ticketId), disabled.
|
||||||
|
*/
|
||||||
|
export default function AufwandAuswahl({
|
||||||
|
blaetter,
|
||||||
|
abgewaehlt,
|
||||||
|
onToggle,
|
||||||
|
satzInfo,
|
||||||
|
ticketOptionen,
|
||||||
|
ticketsLaden,
|
||||||
|
ticketsFehler,
|
||||||
|
weitereTickets,
|
||||||
|
weitereZustand,
|
||||||
|
onAddTicket,
|
||||||
|
onRemoveTicket,
|
||||||
|
disabled = false,
|
||||||
|
}) {
|
||||||
|
const liste = Array.isArray(blaetter) ? blaetter : []
|
||||||
|
const summe = blaetterSumme(liste, ausgewaehlteIds(liste, abgewaehlt))
|
||||||
|
const mehrereTickets = new Set(liste.map((b) => b.ticketId)).size > 1
|
||||||
|
const optionen = Array.isArray(ticketOptionen) ? ticketOptionen : []
|
||||||
|
const weitere = Array.isArray(weitereTickets) ? weitereTickets : []
|
||||||
|
const zustand = weitereZustand || {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="rd-group">
|
||||||
|
<legend>Arbeitsblätter</legend>
|
||||||
|
<div className="rd-stack">
|
||||||
|
{satzInfo && <p className="rd-hint">{satzInfo}</p>}
|
||||||
|
{liste.length === 0 ? (
|
||||||
|
<p className="msg muted">Keine offenen Arbeitsblätter.</p>
|
||||||
|
) : (
|
||||||
|
<div className="rd-blaetter">
|
||||||
|
{liste.map((b) => (
|
||||||
|
<label className="rd-blatt" key={b.id}>
|
||||||
|
<input type="checkbox" checked={!(abgewaehlt || []).includes(b.id)} onChange={() => onToggle(b.id)} disabled={disabled} />
|
||||||
|
<span className="rd-blatt-text">
|
||||||
|
<span className="num">{dateDe(b.datum)}</span> {b.text || 'Arbeitszeit'}
|
||||||
|
{mehrereTickets && <span className="faint xs"> #{b.woid}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="rd-blatt-zeit">{stundenDe(b.minuten)}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="rd-hint" aria-live="polite">
|
||||||
|
{summe.anzahl} von {liste.length} ausgewählt · {stundenDe(summe.minuten)}
|
||||||
|
</p>
|
||||||
|
<Field label="Weiteres Ticket des Kunden hinzunehmen" htmlFor="rd-ticket">
|
||||||
|
<select
|
||||||
|
id="rd-ticket"
|
||||||
|
className="select"
|
||||||
|
value=""
|
||||||
|
disabled={ticketsLaden || disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const option = optionen.find((o) => o.value === e.target.value)
|
||||||
|
if (option) onAddTicket(option)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{ticketsLaden ? 'Tickets werden geladen …' : 'Ticket wählen …'}</option>
|
||||||
|
{optionen.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{ticketsFehler && (
|
||||||
|
<p className="msg warn" role="alert">
|
||||||
|
{ticketsFehler}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
{weitere.map((t) => {
|
||||||
|
const z = zustand[t.ticketId]
|
||||||
|
return (
|
||||||
|
<div className="rd-weiter" key={t.ticketId}>
|
||||||
|
<span>
|
||||||
|
#{t.woid} · {t.titel}
|
||||||
|
</span>
|
||||||
|
{z?.status === 'laedt' && <span className="msg muted">Arbeitsblätter werden geladen …</span>}
|
||||||
|
{z?.status === 'fehler' && (
|
||||||
|
<span className="msg warn" role="alert">
|
||||||
|
{z.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button type="button" className="link" aria-label={`Ticket #${t.woid} entfernen`} onClick={() => onRemoveTicket(t.ticketId)}>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Zusammenfassung des Projektkontos im Rechnungsdialog: Vereinbart, Berechnet (darunter bezahlt), Noch offen, Zeit erfasst.
|
||||||
|
* Rein darstellend. Props: zeile (aus kontoZeile) und hinweis (aus stripeHinweis, leer = kein Hinweis).
|
||||||
|
*/
|
||||||
|
export default function KontoZusammenfassung({ zeile, hinweis }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="facts rd-facts">
|
||||||
|
<div className="fact">
|
||||||
|
<div className="k">Vereinbart</div>
|
||||||
|
<div className="v">{zeile.vereinbart}</div>
|
||||||
|
</div>
|
||||||
|
<div className="fact">
|
||||||
|
<div className="k">Berechnet</div>
|
||||||
|
<div className="v">{zeile.berechnet}</div>
|
||||||
|
{zeile.bezahlt && <div className="xs muted">{zeile.bezahlt}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="fact">
|
||||||
|
<div className="k">Noch offen</div>
|
||||||
|
<div className="v">{zeile.offen}</div>
|
||||||
|
</div>
|
||||||
|
<div className="fact">
|
||||||
|
<div className="k">Zeit erfasst</div>
|
||||||
|
<div className="v">{zeile.zeit.stunden}</div>
|
||||||
|
<div className="xs muted">{zeile.zeit.wert}</div>
|
||||||
|
{zeile.zeit.abo && <div className="xs muted">{zeile.zeit.abo}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hinweis && (
|
||||||
|
<p className="msg warn" role="status">
|
||||||
|
{hinweis}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import './projektkonto.css'
|
||||||
|
import { Card } from '../ui'
|
||||||
|
import { kontoZeile, ohneProjektZeile, stripeHinweis, KEINE_PROJEKTE_HINWEIS } from '../../lib/projektkontoView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abschnitt „Projekte“ im Kundenreiter „Aufträge und Rechnungen“: je Projekt eine Zeile mit Vereinbart, Berechnet
|
||||||
|
* (darunter bezahlt), Noch offen und erfasster Zeit, am Ende die feste Zeile „Ohne Projekt“.
|
||||||
|
* Props: konten (Projektkonten, darf fehlen), ohneProjekt ({ berechnetCents, bezahltCents, anzahl }), auftraegeAktiv,
|
||||||
|
* stripeOk, onRechnung(projektId) und onAuftrag(projektId). Die Kennung ist die des Projekts, `null` die Zeile „Ohne Projekt“.
|
||||||
|
* Die Überschrift „Projekte“ rendert die Komponente selbst; die Anzeigelogik steht in lib/projektkontoView.js.
|
||||||
|
*/
|
||||||
|
export default function ProjektkontoListe({ konten, ohneProjekt, auftraegeAktiv, stripeOk, onRechnung, onAuftrag }) {
|
||||||
|
const liste = Array.isArray(konten) ? konten : []
|
||||||
|
const zeilen = [...liste.map((k) => kontoZeile(k, { stripeOk })), ohneProjektZeile(ohneProjekt, { stripeOk })]
|
||||||
|
const hinweis = stripeHinweis(stripeOk)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Projekte">
|
||||||
|
<div className="pk">
|
||||||
|
{hinweis && (
|
||||||
|
<p className="msg warn pk-hinweis" role="status">
|
||||||
|
{hinweis}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{liste.length === 0 && <p className="msg muted pk-hinweis">{KEINE_PROJEKTE_HINWEIS}</p>}
|
||||||
|
<table className="table pk-table" role="table">
|
||||||
|
<caption className="pk-sr">Projektkonten</caption>
|
||||||
|
<thead role="rowgroup">
|
||||||
|
<tr role="row">
|
||||||
|
<th role="columnheader" scope="col">Projekt</th>
|
||||||
|
<th role="columnheader" scope="col">Vereinbart</th>
|
||||||
|
<th role="columnheader" scope="col">Berechnet</th>
|
||||||
|
<th role="columnheader" scope="col">Noch offen</th>
|
||||||
|
<th role="columnheader" scope="col">Zeit erfasst</th>
|
||||||
|
<th role="columnheader" scope="col"><span className="pk-sr">Aktionen</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody role="rowgroup">
|
||||||
|
{zeilen.map((z) => {
|
||||||
|
const id = z.projektId || null
|
||||||
|
const fuer = id === null ? 'ohne Projekt' : `für ${z.name}`
|
||||||
|
return (
|
||||||
|
<tr role="row" key={z.key}>
|
||||||
|
<td role="cell" className="pk-name">
|
||||||
|
<div className="cell-title">{z.name}</div>
|
||||||
|
{z.meta && <div className="cell-sub">{z.meta}</div>}
|
||||||
|
</td>
|
||||||
|
<td role="cell" className="pk-vereinbart" data-label="Vereinbart">{z.vereinbart}</td>
|
||||||
|
<td role="cell" className="pk-berechnet" data-label="Berechnet">
|
||||||
|
<div className="num">{z.berechnet}</div>
|
||||||
|
{z.bezahlt && <div className="cell-sub">{z.bezahlt}</div>}
|
||||||
|
</td>
|
||||||
|
<td role="cell" className="pk-offen" data-label="Noch offen">
|
||||||
|
<span className="num">{z.offen}</span>
|
||||||
|
</td>
|
||||||
|
<td role="cell" className="pk-zeit" data-label="Zeit erfasst">
|
||||||
|
<div className="num">{z.zeit.stunden}</div>
|
||||||
|
{z.zeit.wert && <div className="cell-sub">{z.zeit.wert}</div>}
|
||||||
|
{z.zeit.abo && <div className="cell-sub">{z.zeit.abo}</div>}
|
||||||
|
</td>
|
||||||
|
<td role="cell" className="pk-actions-cell">
|
||||||
|
<div className="pk-actions">
|
||||||
|
{auftraegeAktiv && (
|
||||||
|
<button type="button" className="link" aria-label={`Auftragsbestätigung ${fuer}`} onClick={() => onAuftrag(id)}>
|
||||||
|
Auftragsbestätigung
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="link" aria-label={`Rechnung ${fuer}`} onClick={() => onRechnung(id)}>
|
||||||
|
Rechnung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,631 @@
|
|||||||
|
import './rechnung-dialog.css'
|
||||||
|
import { useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
||||||
|
import { AutocompleteInput, Button, Dialog, Field } from '../ui'
|
||||||
|
import AddressWarning from '../finance/AddressWarning'
|
||||||
|
import KontoZusammenfassung from './KontoZusammenfassung'
|
||||||
|
import RechnungPositionen from './RechnungPositionen'
|
||||||
|
import RechnungVorschau from './RechnungVorschau'
|
||||||
|
import AufwandAuswahl from './AufwandAuswahl'
|
||||||
|
import { useCustomers } from '../../hooks/useCustomers'
|
||||||
|
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
||||||
|
import {
|
||||||
|
useKundenTickets,
|
||||||
|
useEinstellungen,
|
||||||
|
useOhneProjektAbs,
|
||||||
|
useProjektkontoDetail,
|
||||||
|
useProjektkonten,
|
||||||
|
useRechnungVorschau,
|
||||||
|
useWeitereBlaetter,
|
||||||
|
} from '../../hooks/useRechnungDaten'
|
||||||
|
import { createInvoice } from '../../lib/financeApi'
|
||||||
|
import { canInvoice } from '../../lib/financeFormat'
|
||||||
|
import {
|
||||||
|
ADDRESS_INCOMPLETE_TEXT,
|
||||||
|
INVOICE_LIMITS,
|
||||||
|
customerName,
|
||||||
|
customerPickerOptions,
|
||||||
|
customerSuggestions,
|
||||||
|
invoiceSendGate,
|
||||||
|
invoiceTotalCents,
|
||||||
|
resolveCustomerText,
|
||||||
|
} from '../../lib/financeView'
|
||||||
|
import { kontoZeile, satzText, stripeHinweis, KEINE_PROJEKTE_HINWEIS } from '../../lib/projektkontoView'
|
||||||
|
import {
|
||||||
|
abLabel,
|
||||||
|
anzahlungStandard,
|
||||||
|
artOptionen,
|
||||||
|
abSummenText,
|
||||||
|
aufwandAuftrag,
|
||||||
|
gueltigeFestpreisAbs,
|
||||||
|
projektOptionen,
|
||||||
|
standardArt,
|
||||||
|
standardAuftrag,
|
||||||
|
startProjekt,
|
||||||
|
steuernummerHinweis,
|
||||||
|
ticketOptionen,
|
||||||
|
wirksamesProjekt,
|
||||||
|
zahlungszielVorschlag,
|
||||||
|
} from '../../lib/rechnungDialogView'
|
||||||
|
import {
|
||||||
|
BEZEICHNUNG_MAX,
|
||||||
|
VORSCHAU_ARTEN,
|
||||||
|
createSubmitter,
|
||||||
|
darfSpeichern,
|
||||||
|
rechnungAnfrage,
|
||||||
|
vorschauAnfrage,
|
||||||
|
vorschauSchluessel,
|
||||||
|
vorschauStatus,
|
||||||
|
} from '../../lib/rechnungAnfrage'
|
||||||
|
import { anfangszustand, reduce } from '../../lib/rechnungDialogState'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog „Rechnung schreiben“. Ablauf von oben nach unten: Kunde (fest oder per Suche), Projekt,
|
||||||
|
* Projektkonto (Vereinbart, Berechnet, Noch offen, Zeit), Art der Rechnung (Anzahlung, Schlussrechnung, nach Aufwand, Festbetrag,
|
||||||
|
* ohne Projekt nur freie Positionen), Inhalt je Art mit Vorschau vom Server, Leistungszeitraum, Zahlungsziel und Notiz.
|
||||||
|
* „Als Entwurf speichern“ und „Senden“ bleiben gesperrt, solange die Vorschau nicht zur aktuellen Eingabe gehört.
|
||||||
|
*
|
||||||
|
* Props: open, onClose, onCreated(rechnung, info), kunde (Kundendokument; null = Kundenwahl im Dialog), projektId (undefined =
|
||||||
|
* keine Vorauswahl, null = „Ohne Projekt“, Text = dieses Projekt), auftraegeAktiv (nur der Startwert, danach gilt
|
||||||
|
* getProjektkonten), onShowOverview (optional, „Zur Übersicht“ in der Adresswarnung).
|
||||||
|
* onCreated bekommt wie im bisherigen Dialog ({ senden }) bzw. bei festgeschriebener, nicht versendeter Rechnung
|
||||||
|
* (502 mit invoiceId) (null, { senden: true, notice, tone: 'warn', finalizedFilter }); danach ruft der Dialog onClose().
|
||||||
|
* Enter sendet nie (das Formular hat keinen Absenden-Knopf); „Senden“ fragt vorher nach.
|
||||||
|
*/
|
||||||
|
export default function RechnungDialog({ open, onClose, onCreated, kunde = null, projektId, auftraegeAktiv, onShowOverview = null }) {
|
||||||
|
if (!open) return null
|
||||||
|
const gemeinsam = { onClose, onCreated, projektId, auftraegeAktiv, onShowOverview }
|
||||||
|
// Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht
|
||||||
|
return kunde ? <RechnungFormular {...gemeinsam} festerKunde={kunde} quelle={null} /> : <KundenWahl {...gemeinsam} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function KundenWahl(props) {
|
||||||
|
const { customers, loading, error } = useCustomers()
|
||||||
|
return <RechnungFormular {...props} festerKunde={null} quelle={{ customers, loading, error }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZEITRAUM_PLATZHALTER = 'z. B. 01.10.–20.10.2026'
|
||||||
|
|
||||||
|
function RechnungFormular({ onClose, onCreated, projektId, auftraegeAktiv, onShowOverview, festerKunde, quelle }) {
|
||||||
|
const [state, dispatch] = useReducer(reduce, startProjekt(projektId), (p) => anfangszustand({ projektId: p }))
|
||||||
|
const [customerText, setCustomerText] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [busy, setBusy] = useState(null) // null | 'draft' | 'send'
|
||||||
|
const submitter = useMemo(() => createSubmitter({ create: createInvoice, confirm: (t) => window.confirm(t) }), [])
|
||||||
|
const errorRef = useRef(null)
|
||||||
|
const aliveRef = useRef(true)
|
||||||
|
const kundeIdRef = useRef('')
|
||||||
|
const neuGeladenRef = useRef(null)
|
||||||
|
useScrollIntoView(errorRef, error)
|
||||||
|
useEffect(() => {
|
||||||
|
aliveRef.current = true
|
||||||
|
return () => {
|
||||||
|
aliveRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const act = (aktion) => {
|
||||||
|
if (error) setError('')
|
||||||
|
dispatch(aktion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Abgeleitete Werte ------------------------------------------------------------------------------------
|
||||||
|
const options = useMemo(() => customerPickerOptions(quelle?.customers), [quelle?.customers])
|
||||||
|
const resolved = festerKunde
|
||||||
|
? { state: canInvoice(festerKunde).ok ? 'ok' : 'blocked', option: null }
|
||||||
|
: resolveCustomerText(options, customerText)
|
||||||
|
const kundeDoc = festerKunde || resolved.option?.customer || null
|
||||||
|
const kunde = kundeDoc && canInvoice(kundeDoc).ok ? kundeDoc : null
|
||||||
|
const blockiert = Boolean(kundeDoc) && !kunde
|
||||||
|
|
||||||
|
const einstellungen = useEinstellungen()
|
||||||
|
const konten = useProjektkonten(kunde?.$id)
|
||||||
|
const projekt = wirksamesProjekt(state.projektId, konten.data?.konten)
|
||||||
|
const aktiv = konten.data ? Boolean(konten.data.auftraegeAktiv) : Boolean(auftraegeAktiv)
|
||||||
|
|
||||||
|
const detail = useProjektkontoDetail(projekt || '')
|
||||||
|
// „Ohne Projekt“: gültige ABs ohne Projekt (der Server nimmt Anzahlung und Schlussrechnung dazu ohne Projekt an)
|
||||||
|
const ohne = useOhneProjektAbs(kunde?.$id, projekt === '' && aktiv)
|
||||||
|
const quelleDaten = projekt === '' ? ohne.data : detail.data
|
||||||
|
const auftraege = quelleDaten?.auftraege || []
|
||||||
|
const rechnungen = quelleDaten?.rechnungen || []
|
||||||
|
const datenNeuLaden = projekt === '' ? ohne.reload : detail.reload
|
||||||
|
|
||||||
|
let optionen = []
|
||||||
|
if (projekt === '') optionen = artOptionen({ projektId: '', auftraege, rechnungen, auftraegeAktiv: aktiv })
|
||||||
|
else if (projekt && detail.data) optionen = artOptionen({ projektId: projekt, auftraege, rechnungen, auftraegeAktiv: aktiv })
|
||||||
|
|
||||||
|
const stripeOk = detail.data?.stripeOk
|
||||||
|
const art = optionen.some((o) => o.key === state.art && o.enabled)
|
||||||
|
? state.art
|
||||||
|
: standardArt(optionen, { auftraege, rechnungen, offeneMinuten: detail.data?.konto?.zeit?.offeneMinuten || 0, stripeOk })
|
||||||
|
|
||||||
|
const festpreisAbs = gueltigeFestpreisAbs(auftraege)
|
||||||
|
const auftragId =
|
||||||
|
(art === 'anzahlung' || art === 'schluss') && festpreisAbs.some((a) => a.id === state.auftragId)
|
||||||
|
? state.auftragId
|
||||||
|
: standardAuftrag(art, auftraege, rechnungen)
|
||||||
|
const ab = auftraege.find((a) => a.id === auftragId)
|
||||||
|
|
||||||
|
const weitereTickets = state.weitereTickets
|
||||||
|
const weitere = useWeitereBlaetter(weitereTickets)
|
||||||
|
const blaetter = [...(detail.data?.offeneArbeitsblaetter || []), ...weitereTickets.flatMap((t) => weitere[t.ticketId]?.blaetter || [])]
|
||||||
|
const kundenTickets = useKundenTickets(kunde?.$id, art === 'aufwand')
|
||||||
|
|
||||||
|
const e = {
|
||||||
|
kunde,
|
||||||
|
art,
|
||||||
|
projektId: projekt ?? '',
|
||||||
|
auftragId,
|
||||||
|
items: state.items,
|
||||||
|
zusatz: state.zusatz,
|
||||||
|
blaetter,
|
||||||
|
abgewaehlt: state.abgewaehlt,
|
||||||
|
anzahlung: { ...anzahlungStandard(ab), ...state.anzahlung },
|
||||||
|
zeilentexte: state.zeilentexte,
|
||||||
|
zeitraum: state.zeitraum,
|
||||||
|
zeitraumGeaendert: state.zeitraumGeaendert,
|
||||||
|
days: state.days,
|
||||||
|
memo: state.memo,
|
||||||
|
}
|
||||||
|
const vorschauReq = vorschauAnfrage(e)
|
||||||
|
const schluessel = vorschauReq.ok ? vorschauSchluessel(vorschauReq.body) : ''
|
||||||
|
const vorschau = useRechnungVorschau({ anfrage: vorschauReq, schluessel, aktiv: VORSCHAU_ARTEN.includes(art) })
|
||||||
|
const status = vorschauStatus({ art, anfrage: vorschauReq, vorschau, schluessel })
|
||||||
|
const anfrage = rechnungAnfrage(e)
|
||||||
|
|
||||||
|
let totalCents = null
|
||||||
|
if (art === 'frei' || art === 'festbetrag') totalCents = invoiceTotalCents(state.items)
|
||||||
|
else if (status.state === 'bereit') totalCents = vorschau.daten.summeCents
|
||||||
|
const gate = invoiceSendGate(kunde, totalCents)
|
||||||
|
const darf = darfSpeichern(status) && busy === null && Boolean(kunde) && projekt !== null && art !== ''
|
||||||
|
|
||||||
|
// Das Zahlungsziel der gewählten AB (bzw. 14); der Reducer lässt eine Eingabe des Nutzers unberührt
|
||||||
|
const tageVorschlag = zahlungszielVorschlag({ art, ab, auftraege })
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch({ type: 'tage-vorschlag', wert: tageVorschlag })
|
||||||
|
}, [tageVorschlag])
|
||||||
|
|
||||||
|
// --- Effekte auf die Vorschau -----------------------------------------------------------------------------
|
||||||
|
const vorschauZeitraum = status.state === 'bereit' ? vorschau.daten.leistungszeitraum || '' : null
|
||||||
|
useEffect(() => {
|
||||||
|
// Der Vorschlag überschreibt nie eine Eingabe des Nutzers (der Reducer prüft das)
|
||||||
|
if (art === 'aufwand' && vorschauZeitraum !== null) dispatch({ type: 'zeitraum-vorschlag', text: vorschauZeitraum })
|
||||||
|
}, [art, vorschauZeitraum])
|
||||||
|
|
||||||
|
const detailReloadRef = useRef(datenNeuLaden)
|
||||||
|
detailReloadRef.current = datenNeuLaden
|
||||||
|
useEffect(() => {
|
||||||
|
// 409 der Vorschau: ein Arbeitsblatt ist inzwischen abgerechnet, das Projektkonto neu laden (einmal je Fehlerobjekt)
|
||||||
|
if (vorschau?.status === 'fehler' && vorschau.httpStatus === 409 && neuGeladenRef.current !== vorschau) {
|
||||||
|
neuGeladenRef.current = vorschau
|
||||||
|
detailReloadRef.current()
|
||||||
|
}
|
||||||
|
}, [vorschau])
|
||||||
|
|
||||||
|
// --- Aktionen ---------------------------------------------------------------------------------------------
|
||||||
|
async function abschicken(senden) {
|
||||||
|
setBusy(senden ? 'send' : 'draft')
|
||||||
|
setError('')
|
||||||
|
const r = await submitter({ anfrage, senden, kunde, totalCents })
|
||||||
|
if (aliveRef.current) setBusy(null)
|
||||||
|
if (r.status === 'fehler') {
|
||||||
|
if (aliveRef.current) setError(r.message)
|
||||||
|
if (r.httpStatus === 409) datenNeuLaden()
|
||||||
|
} else if (r.status === 'festgeschrieben') {
|
||||||
|
if (onCreated) onCreated(null, { senden: true, notice: r.notice, tone: 'warn', finalizedFilter: r.finalizedFilter })
|
||||||
|
onClose()
|
||||||
|
} else if (r.status === 'ok') {
|
||||||
|
if (onCreated) onCreated(r.rechnung, { senden: r.senden })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (busy !== null) return
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function kundeGeaendert(text) {
|
||||||
|
setCustomerText(text)
|
||||||
|
if (error) setError('')
|
||||||
|
const neu = resolveCustomerText(options, text).option?.customer?.$id || ''
|
||||||
|
if (neu !== kundeIdRef.current) {
|
||||||
|
kundeIdRef.current = neu
|
||||||
|
dispatch({ type: 'projekt', projektId: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gesperrt = busy !== null
|
||||||
|
const footer = (
|
||||||
|
<div className="rd-foot">
|
||||||
|
<Button type="button" onClick={close} disabled={gesperrt}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="button" disabled={!darf} onClick={() => abschicken(false)}>
|
||||||
|
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!darf || !gate.sendAllowed}
|
||||||
|
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
|
||||||
|
onClick={() => abschicken(true)}
|
||||||
|
>
|
||||||
|
{busy === 'send' ? 'Sende …' : 'Senden'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Abschnitte -------------------------------------------------------------------------------------------
|
||||||
|
const mitPositionen = (liste, legende, labelPrefix, idPrefix, minZeilen, addLabel, zeigeSumme) => (
|
||||||
|
<RechnungPositionen
|
||||||
|
legende={legende}
|
||||||
|
labelPrefix={labelPrefix}
|
||||||
|
idPrefix={idPrefix}
|
||||||
|
zeilen={state[liste]}
|
||||||
|
minZeilen={minZeilen}
|
||||||
|
onChange={(uid, feld, wert) => act({ type: 'position', liste, uid, feld, wert })}
|
||||||
|
onAdd={() => act({ type: 'position+', liste })}
|
||||||
|
onRemove={(uid) => act({ type: 'position-', liste, uid })}
|
||||||
|
addLabel={addLabel}
|
||||||
|
zeigeSumme={zeigeSumme}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const abBlock = () => {
|
||||||
|
if (!ab) return null
|
||||||
|
return (
|
||||||
|
<div className="rd-stack">
|
||||||
|
{festpreisAbs.length > 1 ? (
|
||||||
|
<Field label="Auftragsbestätigung" htmlFor="rd-ab">
|
||||||
|
<select id="rd-ab" className="select" value={ab.id} onChange={(ev) => act({ type: 'auftrag', auftragId: ev.target.value })}>
|
||||||
|
{festpreisAbs.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{abLabel(a)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<p className="rd-hint">{abLabel(ab)}</p>
|
||||||
|
)}
|
||||||
|
<p className="rd-hint">{abSummenText({ ab, rechnungen, stripeOk })}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const anzahlungFelder = () => {
|
||||||
|
const a = e.anzahlung
|
||||||
|
const prozent = a.modus !== 'betrag'
|
||||||
|
return (
|
||||||
|
<div className="rd-stack">
|
||||||
|
<div className="seg rd-seg" role="group" aria-label="Anzahlung in">
|
||||||
|
<button type="button" aria-pressed={prozent} onClick={() => act({ type: 'anzahlung', patch: { modus: 'prozent' } })}>
|
||||||
|
Prozent
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-pressed={!prozent} onClick={() => act({ type: 'anzahlung', patch: { modus: 'betrag' } })}>
|
||||||
|
Betrag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<Field label={prozent ? 'Anzahlung in %' : 'Betrag in €'} htmlFor="rd-anzahlung-wert">
|
||||||
|
<input
|
||||||
|
id="rd-anzahlung-wert"
|
||||||
|
className="input"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={a.wert}
|
||||||
|
onChange={(ev) => act({ type: 'anzahlung', patch: { wert: ev.target.value } })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Bezeichnung" htmlFor="rd-anzahlung-bez">
|
||||||
|
<input
|
||||||
|
id="rd-anzahlung-bez"
|
||||||
|
className="input"
|
||||||
|
maxLength={BEZEICHNUNG_MAX}
|
||||||
|
value={a.bezeichnung}
|
||||||
|
onChange={(ev) => act({ type: 'anzahlung', patch: { bezeichnung: ev.target.value } })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBereich = () => (
|
||||||
|
<>
|
||||||
|
{status.state === 'laedt' && (
|
||||||
|
<p className="msg muted" role="status" aria-live="polite">
|
||||||
|
Vorschau wird aktualisiert …
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status.state === 'ungueltig' && status.message && <p className="msg muted">{status.message}</p>}
|
||||||
|
{status.state === 'fehler' && (
|
||||||
|
<p className="msg err" role="alert">
|
||||||
|
{status.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const vorschauZeilen = () => {
|
||||||
|
const daten = vorschau?.daten
|
||||||
|
if ((status.state !== 'bereit' && status.state !== 'laedt') || !daten) return null
|
||||||
|
return (
|
||||||
|
<RechnungVorschau
|
||||||
|
positionen={daten.positionen}
|
||||||
|
summeCents={daten.summeCents}
|
||||||
|
bearbeitbar={art === 'aufwand'}
|
||||||
|
zeilentexte={state.zeilentexte}
|
||||||
|
onZeilentext={(schl, text, original) => act({ type: 'zeilentext', schluessel: schl, text, original })}
|
||||||
|
abgeblendet={status.state === 'laedt'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const aufwandBlock = () => {
|
||||||
|
const konto = detail.data.konto
|
||||||
|
const laut = aufwandAuftrag(auftraege)
|
||||||
|
const satzInfo = `Stundensatz ${satzText(konto.zeit?.satzCents)}${laut ? `, laut ${laut.nummer}` : ''}`
|
||||||
|
const ausgeschlossen = [...(konto.zeitquelle || []), ...(konto.tickets || []).map((t) => t.id), ...weitereTickets.map((t) => t.ticketId)]
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AufwandAuswahl
|
||||||
|
blaetter={blaetter}
|
||||||
|
abgewaehlt={state.abgewaehlt}
|
||||||
|
onToggle={(id) => act({ type: 'blatt', id })}
|
||||||
|
satzInfo={satzInfo}
|
||||||
|
ticketOptionen={ticketOptionen(kundenTickets.tickets, { ausgeschlossen })}
|
||||||
|
ticketsLaden={kundenTickets.loading}
|
||||||
|
ticketsFehler={kundenTickets.error}
|
||||||
|
weitereTickets={weitereTickets}
|
||||||
|
weitereZustand={weitere}
|
||||||
|
onAddTicket={(o) =>
|
||||||
|
act({ type: 'ticket+', ticket: { ticketId: o.ticketId, woid: o.woid, titel: o.label.replace(/^#\S+ · /, '') } })
|
||||||
|
}
|
||||||
|
onRemoveTicket={(id) => act({ type: 'ticket-', ticketId: id, blattIds: (weitere[id]?.blaetter || []).map((b) => b.id) })}
|
||||||
|
/>
|
||||||
|
{statusBereich()}
|
||||||
|
{vorschauZeilen()}
|
||||||
|
{mitPositionen('zusatz', 'Zusätzliche Positionen', 'Zusatzposition', 'rd-zus', 0, '+ Zusatzposition', false)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inhalt = () => {
|
||||||
|
if (art === 'frei' || art === 'festbetrag') return mitPositionen('items', 'Positionen', 'Position', 'rd-pos', 1, '+ Position', true)
|
||||||
|
if (art === 'aufwand') return aufwandBlock()
|
||||||
|
if (art === 'anzahlung') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{abBlock()}
|
||||||
|
{anzahlungFelder()}
|
||||||
|
{statusBereich()}
|
||||||
|
{vorschauZeilen()}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (art === 'schluss') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{abBlock()}
|
||||||
|
{mitPositionen('zusatz', 'Zusätzliche Positionen (Nachträge)', 'Zusatzposition', 'rd-zus', 0, '+ Zusatzposition', false)}
|
||||||
|
{statusBereich()}
|
||||||
|
{vorschauZeilen()}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const projektBereit = projekt !== null
|
||||||
|
const kontoBereit = projekt === '' || Boolean(detail.data)
|
||||||
|
const hinweise = [...new Set(optionen.filter((o) => !o.enabled && o.hint).map((o) => o.hint))]
|
||||||
|
|
||||||
|
// Enter sendet nie (das wäre eine Mail an den Kunden): das Formular hat keinen Absenden-Knopf, jeder Knopf darin ist type="button"
|
||||||
|
return (
|
||||||
|
<Dialog as="form" wide title="Rechnung schreiben" onClose={close} busy={gesperrt} onSubmit={(ev) => ev.preventDefault()} footer={footer}>
|
||||||
|
{error && (
|
||||||
|
<div className="msg err" role="alert" ref={errorRef}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rd-stack">
|
||||||
|
<Field label="Kunde" htmlFor={festerKunde ? undefined : 'rd-kunde'}>
|
||||||
|
{festerKunde ? (
|
||||||
|
<p>
|
||||||
|
<strong>{customerName(festerKunde) || 'Kunde'}</strong>
|
||||||
|
{festerKunde.email ? <span className="msg muted"> · {festerKunde.email}</span> : null}
|
||||||
|
</p>
|
||||||
|
) : quelle?.loading ? (
|
||||||
|
<p className="msg muted" role="status">
|
||||||
|
Kunden werden geladen …
|
||||||
|
</p>
|
||||||
|
) : quelle?.error ? (
|
||||||
|
<p className="msg warn" role="alert">
|
||||||
|
Die Kunden konnten nicht geladen werden: {quelle.error}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<AutocompleteInput
|
||||||
|
id="rd-kunde"
|
||||||
|
value={customerText}
|
||||||
|
onChange={kundeGeaendert}
|
||||||
|
getSuggestions={(t) => customerSuggestions(options, t)}
|
||||||
|
placeholder="Kunde suchen …"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
{blockiert && (
|
||||||
|
<p className="msg warn" role="alert">
|
||||||
|
{customerName(kundeDoc)}: {festerKunde ? canInvoice(festerKunde).reason : resolved.option?.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!festerKunde && resolved.state === 'unknown' && (
|
||||||
|
<p className="rd-hint">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
|
||||||
|
)}
|
||||||
|
{!festerKunde && kunde && kunde.email && <p className="rd-hint">Die Rechnung geht an {kunde.email}.</p>}
|
||||||
|
{steuernummerHinweis(einstellungen) && (
|
||||||
|
<p className="msg warn" role="status">
|
||||||
|
{steuernummerHinweis(einstellungen)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{kunde && gate.warn && (
|
||||||
|
<AddressWarning
|
||||||
|
customer={kunde}
|
||||||
|
variant="dialog"
|
||||||
|
onShowOverview={
|
||||||
|
onShowOverview
|
||||||
|
? () => {
|
||||||
|
if (busy !== null) return
|
||||||
|
onClose()
|
||||||
|
onShowOverview()
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kunde && (
|
||||||
|
<>
|
||||||
|
<Field label="Projekt" htmlFor="rd-projekt">
|
||||||
|
<select
|
||||||
|
id="rd-projekt"
|
||||||
|
className="select"
|
||||||
|
value={projekt === null ? '?' : projekt}
|
||||||
|
disabled={konten.loading}
|
||||||
|
onChange={(ev) => act({ type: 'projekt', projektId: ev.target.value })}
|
||||||
|
>
|
||||||
|
{projekt === null && (
|
||||||
|
<option value="?" disabled>
|
||||||
|
Projekt wählen …
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{projektOptionen(konten.data?.konten).map((o) => (
|
||||||
|
<option key={o.value || 'ohne'} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{konten.loading && (
|
||||||
|
<p className="msg muted" role="status">
|
||||||
|
Projekte werden geladen …
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{konten.error && (
|
||||||
|
<p className="msg warn" role="alert">
|
||||||
|
Die Projekte konnten nicht geladen werden: {konten.error}{' '}
|
||||||
|
<button type="button" className="link" onClick={konten.reload}>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{konten.data && konten.data.konten.length === 0 && <p className="rd-hint">{KEINE_PROJEKTE_HINWEIS}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kunde && projekt === '' && ohne.loading && (
|
||||||
|
<p className="msg muted" role="status">
|
||||||
|
Aufträge werden geladen …
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{kunde && projekt === '' && ohne.error && (
|
||||||
|
<p className="msg warn" role="alert">
|
||||||
|
Die Aufträge ohne Projekt konnten nicht geladen werden: {ohne.error}{' '}
|
||||||
|
<button type="button" className="link" onClick={ohne.reload}>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kunde && projektBereit && projekt !== '' && (
|
||||||
|
<>
|
||||||
|
{detail.loading && !detail.data && (
|
||||||
|
<p className="msg muted" role="status">
|
||||||
|
Projektkonto wird geladen …
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{detail.error && !detail.data && (
|
||||||
|
<p className="msg warn" role="alert">
|
||||||
|
Das Projektkonto konnte nicht geladen werden: {detail.error}{' '}
|
||||||
|
<button type="button" className="link" onClick={detail.reload}>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{detail.data && (
|
||||||
|
<KontoZusammenfassung
|
||||||
|
zeile={kontoZeile(detail.data.konto, { stripeOk: detail.data.stripeOk })}
|
||||||
|
hinweis={stripeHinweis(detail.data.stripeOk)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kunde && projektBereit && kontoBereit && optionen.length > 1 && (
|
||||||
|
<fieldset className="rd-group">
|
||||||
|
<legend>Art der Rechnung</legend>
|
||||||
|
<div className="rd-stack">
|
||||||
|
<div className="seg rd-seg" role="group" aria-label="Art der Rechnung">
|
||||||
|
{optionen.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={o.key === art}
|
||||||
|
disabled={!o.enabled}
|
||||||
|
title={o.hint || undefined}
|
||||||
|
onClick={() => act({ type: 'art', art: o.key })}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hinweise.map((h) => (
|
||||||
|
<p className="rd-hint" key={h}>
|
||||||
|
{h}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kunde && projektBereit && kontoBereit && art !== '' && inhalt()}
|
||||||
|
|
||||||
|
{kunde && projektBereit && kontoBereit && art !== '' && (
|
||||||
|
<>
|
||||||
|
<Field label="Leistungszeitraum (optional)" htmlFor="rd-zeitraum">
|
||||||
|
<input
|
||||||
|
id="rd-zeitraum"
|
||||||
|
className="input"
|
||||||
|
value={state.zeitraum}
|
||||||
|
placeholder={ZEITRAUM_PLATZHALTER}
|
||||||
|
onChange={(ev) => act({ type: 'zeitraum', text: ev.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="field-row">
|
||||||
|
<Field label="Zahlungsziel in Tagen" htmlFor="rd-tage">
|
||||||
|
<input
|
||||||
|
id="rd-tage"
|
||||||
|
className="input fin-amount"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={state.days}
|
||||||
|
onChange={(ev) => act({ type: 'tage', wert: ev.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Notiz (erscheint auf der Rechnung)" htmlFor="rd-notiz">
|
||||||
|
<textarea
|
||||||
|
id="rd-notiz"
|
||||||
|
className="textarea"
|
||||||
|
rows={2}
|
||||||
|
maxLength={INVOICE_LIMITS.maxMemo}
|
||||||
|
value={state.memo}
|
||||||
|
onChange={(ev) => act({ type: 'notiz', wert: ev.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Button } from '../ui'
|
||||||
|
import { euro } from '../../lib/financeFormat'
|
||||||
|
import { INVOICE_LIMITS, lineTotalCents, invoiceTotalCents } from '../../lib/financeView'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positionszeilen des Rechnungsdialogs (Beschreibung, Menge, Einzelpreis, Summe, Entfernen) mit „+ Position“-Knopf.
|
||||||
|
* Rein darstellend, hält keinen Zustand. Props: legende, labelPrefix (z. B. „Position“ oder „Zusatzposition“), idPrefix,
|
||||||
|
* zeilen ([{ uid, description, quantity, price }]), minZeilen, onChange(uid, feld, wert), onAdd(), onRemove(uid), addLabel,
|
||||||
|
* zeigeSumme (Gesamtsumme und Hinweis zu § 19 UStG).
|
||||||
|
*/
|
||||||
|
export default function RechnungPositionen({
|
||||||
|
legende,
|
||||||
|
labelPrefix,
|
||||||
|
idPrefix,
|
||||||
|
zeilen,
|
||||||
|
minZeilen = 0,
|
||||||
|
onChange,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
addLabel,
|
||||||
|
zeigeSumme = false,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<fieldset className="rd-group">
|
||||||
|
<legend>{legende}</legend>
|
||||||
|
<div className="rd-stack">
|
||||||
|
{zeilen.map((zeile, index) => {
|
||||||
|
const n = index + 1
|
||||||
|
const summe = lineTotalCents(zeile)
|
||||||
|
return (
|
||||||
|
<div className="rd-item" key={zeile.uid}>
|
||||||
|
<div className="rd-item-field rd-item-desc">
|
||||||
|
<label className="rd-item-label" htmlFor={`${idPrefix}-desc-${zeile.uid}`}>Beschreibung</label>
|
||||||
|
<input
|
||||||
|
id={`${idPrefix}-desc-${zeile.uid}`}
|
||||||
|
className="input"
|
||||||
|
value={zeile.description}
|
||||||
|
maxLength={INVOICE_LIMITS.maxDescription}
|
||||||
|
placeholder="z. B. Webdesign Startseite"
|
||||||
|
aria-label={`${labelPrefix} ${n}: Beschreibung`}
|
||||||
|
onChange={(e) => onChange(zeile.uid, 'description', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rd-item-field rd-item-qty">
|
||||||
|
<label className="rd-item-label" htmlFor={`${idPrefix}-qty-${zeile.uid}`}>Menge</label>
|
||||||
|
<input
|
||||||
|
id={`${idPrefix}-qty-${zeile.uid}`}
|
||||||
|
className="input fin-amount"
|
||||||
|
value={zeile.quantity}
|
||||||
|
inputMode="numeric"
|
||||||
|
aria-label={`${labelPrefix} ${n}: Menge`}
|
||||||
|
onChange={(e) => onChange(zeile.uid, 'quantity', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rd-item-field rd-item-price">
|
||||||
|
<label className="rd-item-label" htmlFor={`${idPrefix}-price-${zeile.uid}`}>Einzelpreis in €</label>
|
||||||
|
<input
|
||||||
|
id={`${idPrefix}-price-${zeile.uid}`}
|
||||||
|
className="input fin-amount"
|
||||||
|
value={zeile.price}
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="0,00"
|
||||||
|
aria-label={`${labelPrefix} ${n}: Einzelpreis in Euro`}
|
||||||
|
onChange={(e) => onChange(zeile.uid, 'price', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rd-item-field rd-item-sumfeld">
|
||||||
|
<span className="rd-item-label">Summe</span>
|
||||||
|
<span className="fin-amount rd-item-sum">{summe === null ? '–' : euro(summe)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="rd-item-remove">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link"
|
||||||
|
disabled={zeilen.length <= minZeilen}
|
||||||
|
aria-label={`${labelPrefix} ${n} entfernen`}
|
||||||
|
onClick={() => onRemove(zeile.uid)}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="rd-items-foot">
|
||||||
|
<Button type="button" onClick={onAdd} disabled={zeilen.length >= INVOICE_LIMITS.maxItems}>
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
{zeigeSumme && (
|
||||||
|
<div className="rd-total" aria-live="polite">
|
||||||
|
<span>Summe</span>
|
||||||
|
<span className="fin-amount">{euro(invoiceTotalCents(zeilen))}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{zeigeSumme && <p className="rd-hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { euro } from '../../lib/financeFormat'
|
||||||
|
import { isNegText } from '../../lib/financeUi'
|
||||||
|
import { zeileTeilen, zeileBearbeitbar, maxVorn } from '../../lib/rechnungAnfrage'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorschau der Rechnungszeilen, wie der Server sie baut. Zeigt immer alle Positionen der Antwort (eine Schlussrechnung hat
|
||||||
|
* bis zu 40). Rein darstellend. Props: positionen ([{ schluessel, beschreibung, menge, einzelpreisCents, betragCents }]),
|
||||||
|
* summeCents, bearbeitbar (Tages- und Wochenzeilen der Aufwandsrechnung lassen den Text vor dem letzten „ – “ ändern),
|
||||||
|
* zeilentexte ({ [schluessel]: Text }), onZeilentext(schluessel, text, original), abgeblendet (Vorschau wird gerade aktualisiert).
|
||||||
|
*/
|
||||||
|
export default function RechnungVorschau({ positionen, summeCents, bearbeitbar = false, zeilentexte = {}, onZeilentext, abgeblendet = false }) {
|
||||||
|
const liste = Array.isArray(positionen) ? positionen : []
|
||||||
|
const mitEingabe = bearbeitbar && liste.some((p) => zeileBearbeitbar(p))
|
||||||
|
return (
|
||||||
|
<div className={abgeblendet ? 'rd-dim' : undefined} aria-busy={abgeblendet}>
|
||||||
|
<table className="table rd-lines" role="table">
|
||||||
|
<caption className="rd-sr">Rechnungspositionen</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Position</th>
|
||||||
|
<th scope="col" className="fin-amount">Betrag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{liste.map((p, index) => {
|
||||||
|
const betrag = euro(p.betragCents)
|
||||||
|
const bearbeitbarEs = bearbeitbar && zeileBearbeitbar(p)
|
||||||
|
const { vorn, hinten } = zeileTeilen(p.beschreibung)
|
||||||
|
return (
|
||||||
|
<tr key={`${p.schluessel}-${index}`}>
|
||||||
|
<td className="rd-line-text">
|
||||||
|
{bearbeitbarEs ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="input rd-line-input"
|
||||||
|
aria-label={`Text zu Zeile ${index + 1}`}
|
||||||
|
value={zeilentexte[p.schluessel] ?? vorn}
|
||||||
|
maxLength={maxVorn(hinten)}
|
||||||
|
disabled={abgeblendet}
|
||||||
|
onChange={(e) => onZeilentext(p.schluessel, e.target.value, vorn)}
|
||||||
|
/>
|
||||||
|
<span className="rd-line-tail">{hinten}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{p.beschreibung}
|
||||||
|
{p.menge > 1 && (
|
||||||
|
<div className="cell-sub">
|
||||||
|
{p.menge} × {euro(p.einzelpreisCents)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={`fin-amount${isNegText(betrag) ? ' fin-neg' : ''}`}>{betrag}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="rd-total">
|
||||||
|
<span>Summe</span>
|
||||||
|
<span className="fin-amount">{euro(summeCents)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="rd-hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
|
||||||
|
{mitEingabe && <p className="rd-hint">Die Beschreibung der Zeilen lässt sich ändern, Stunden und Beträge stehen fest.</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/* Projektliste im Kundenreiter „Aufträge und Rechnungen“. .pk ist der Container der Abfrage unten (in W1 nur hier
|
||||||
|
container-type; der Rechnungsdialog liegt nie darin). Jede Regel trägt .pk und überstimmt so .table td aus global.css. */
|
||||||
|
.pk { container-type: inline-size; }
|
||||||
|
.pk .pk-hinweis { margin: 0 0 8px; }
|
||||||
|
.pk .pk-table th, .pk .pk-table td { vertical-align: top; }
|
||||||
|
.pk .pk-name { overflow-wrap: anywhere; } /* lange Projektnamen ohne Umbruchstelle dürfen die Spalte nie aufweiten */
|
||||||
|
.pk .pk-table td .cell-sub { overflow-wrap: break-word; }
|
||||||
|
/* Beträge und Stunden brechen nie um: das € bleibt am Betrag */
|
||||||
|
.pk .pk-table .num, .pk .pk-berechnet .cell-sub, .pk .pk-zeit .cell-sub { white-space: nowrap; }
|
||||||
|
.pk .pk-table .num { font-variant-numeric: tabular-nums; }
|
||||||
|
.pk .pk-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 14px; }
|
||||||
|
.pk .pk-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||||
|
@container (max-width: 860px) {
|
||||||
|
.pk .pk-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||||
|
.pk .pk-table, .pk .pk-table tbody { display: block; }
|
||||||
|
.pk .pk-table tr {
|
||||||
|
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; align-items: start;
|
||||||
|
grid-template-areas: "name name" "vereinbart berechnet" "offen zeit" "actions actions";
|
||||||
|
padding: 12px 0; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.pk .pk-table td, .pk .pk-table td:first-child, .pk .pk-table td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
|
||||||
|
.pk .pk-name { grid-area: name; }
|
||||||
|
.pk .pk-vereinbart { grid-area: vereinbart; }
|
||||||
|
.pk .pk-berechnet { grid-area: berechnet; }
|
||||||
|
.pk .pk-offen { grid-area: offen; }
|
||||||
|
.pk .pk-zeit { grid-area: zeit; }
|
||||||
|
.pk .pk-actions-cell { grid-area: actions; }
|
||||||
|
.pk .pk-actions { justify-content: flex-start; }
|
||||||
|
.pk .pk-table td[data-label]::before { content: attr(data-label); display: block; font-size: 12px; font-weight: 500; color: var(--text-2); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/* Rechnung schreiben (Dialog). Dieses Stylesheet steht im Bundle vor global.css: Regeln, die globale Klassen überstimmen
|
||||||
|
müssen, tragen zwei Klassen. Task 9 hängt am Ende die Spalten der Rechnungstabelle an. */
|
||||||
|
.rd-group { border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||||
|
.rd-group > legend { padding: 0; margin: 0 0 6px; font-weight: 500; }
|
||||||
|
.rd-stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; }
|
||||||
|
.rd-facts.facts { margin: 0; }
|
||||||
|
.seg.rd-seg { flex-wrap: wrap; max-width: 100%; justify-self: start; }
|
||||||
|
.rd-hint { margin: 0; font-size: 13px; color: var(--text-2); overflow-wrap: break-word; }
|
||||||
|
|
||||||
|
/* Positionen */
|
||||||
|
.rd-item { display: grid; grid-template-columns: minmax(0, 1fr) 80px 120px 110px auto; gap: 8px 12px; align-items: end; }
|
||||||
|
.rd-item .input { width: 100%; }
|
||||||
|
.rd-item-field { min-width: 0; display: grid; gap: 4px; }
|
||||||
|
.rd-item-label { font-size: 12px; color: var(--text-2); }
|
||||||
|
.rd-item-sum { text-align: right; white-space: nowrap; padding-bottom: 6px; }
|
||||||
|
.rd-items-foot { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.rd-total { display: flex; gap: 16px; font-weight: 600; }
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.rd-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) auto; grid-template-areas: "desc desc desc desc" "qty price sum remove"; }
|
||||||
|
.rd-item-desc { grid-area: desc; }
|
||||||
|
.rd-item-qty { grid-area: qty; }
|
||||||
|
.rd-item-price { grid-area: price; }
|
||||||
|
.rd-item-sumfeld { grid-area: sum; }
|
||||||
|
.rd-item-remove { grid-area: remove; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Arbeitsblätter */
|
||||||
|
.rd-blaetter { border-top: 1px solid var(--line); }
|
||||||
|
.rd-blatt { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 4px 12px; align-items: start; padding: 8px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
|
||||||
|
.rd-blatt input { accent-color: var(--accent); width: 15px; height: 15px; margin-top: 3px; }
|
||||||
|
.rd-blatt-text { min-width: 0; overflow-wrap: break-word; }
|
||||||
|
.rd-blatt-zeit { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
|
.rd-weiter { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Vorschau der Rechnungszeilen */
|
||||||
|
.rd-lines { table-layout: fixed; }
|
||||||
|
.rd-lines .rd-line-text { overflow-wrap: break-word; }
|
||||||
|
.rd-lines .fin-amount { width: 7.5rem; }
|
||||||
|
.rd-lines th.fin-amount { text-align: right; }
|
||||||
|
.rd-line-input { width: 100%; min-width: 0; }
|
||||||
|
.rd-line-tail { display: block; font-size: 13px; color: var(--text-2); overflow-wrap: break-word; }
|
||||||
|
.rd-dim { opacity: 0.5; }
|
||||||
|
|
||||||
|
/* nur für Screenreader sichtbar */
|
||||||
|
.rd-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||||
|
|
||||||
|
/* Fuß des Dialogs */
|
||||||
|
.rd-foot { display: flex; flex: 1; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||||
|
@media (max-width: 860px) { .rd-foot .btn { flex: 1 1 auto; } }
|
||||||
|
|
||||||
|
/* Rechnungstabelle (InvoiceTable): Spalten Projekt und Art. Zwei Klassen vor dem Elementnamen, damit diese Rasterflächen
|
||||||
|
die Regel .fin-invoices tr aus global.css überstimmen (dieses Stylesheet steht davor im Bundle). */
|
||||||
|
.fin-block .fin-inv-project { overflow-wrap: break-word; }
|
||||||
|
.fin-block .fin-inv-art { white-space: nowrap; }
|
||||||
|
@container (max-width: 860px) {
|
||||||
|
.fin-block .fin-invoices tr { grid-template-areas: "number status" "customer customer" "projekt art" "created due" "total remaining" "actions actions"; }
|
||||||
|
.fin-block .fin-inv-project { grid-area: projekt; }
|
||||||
|
.fin-block .fin-inv-art { grid-area: art; }
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Button, EmptyState } from '../ui'
|
|
||||||
import AddressWarning from './AddressWarning'
|
|
||||||
import InvoiceTable from './InvoiceTable'
|
|
||||||
import { customerInvoicesGate } from '../../lib/financeView'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reiter „Rechnungen“ am Kunden. Kann für den Kunden keine Rechnung geschrieben werden (noch Lead, abgesagt, keine
|
|
||||||
* E-Mail), steht der Grund da, mit „Daten erfassen“ (onShowOverview wechselt zum Reiter Übersicht). Sonst die
|
|
||||||
* Rechnungen dieses Kunden mit „Rechnung schreiben“ (der Kunde steht im Dialog fest). Fehlt dem Kunden die Anschrift
|
|
||||||
* (Straße, PLZ, Ort), steht darüber eine Warnung mit Knopf zur Übersicht; „Senden“ ist dann über 250 € gesperrt.
|
|
||||||
*/
|
|
||||||
export default function CustomerInvoices({ customer, onShowOverview }) {
|
|
||||||
const gate = customerInvoicesGate(customer)
|
|
||||||
if (!gate.ok) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
title={gate.title}
|
|
||||||
hint={gate.hint}
|
|
||||||
action={onShowOverview ? <Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button> : null}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AddressWarning customer={customer} onShowOverview={onShowOverview} variant="page" />
|
|
||||||
<InvoiceTable customer={customer} onShowOverview={onShowOverview} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { AutocompleteInput, Button, Dialog, Field } from '../ui'
|
|
||||||
import AddressWarning from './AddressWarning'
|
|
||||||
import { useCustomers } from '../../hooks/useCustomers'
|
|
||||||
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
|
|
||||||
import { createInvoice } from '../../lib/financeApi'
|
|
||||||
import { canInvoice, euro } from '../../lib/financeFormat'
|
|
||||||
import {
|
|
||||||
ADDRESS_INCOMPLETE_TEXT,
|
|
||||||
INVOICE_LIMITS,
|
|
||||||
customerName,
|
|
||||||
customerPickerOptions,
|
|
||||||
customerSuggestions,
|
|
||||||
finalizedFilter,
|
|
||||||
invoiceFailureMessage,
|
|
||||||
invoiceSendConfirmText,
|
|
||||||
invoiceSendGate,
|
|
||||||
invoiceTotalCents,
|
|
||||||
isFinalizedNotSent,
|
|
||||||
lineTotalCents,
|
|
||||||
resolveCustomerText,
|
|
||||||
validateInvoiceForm,
|
|
||||||
} from '../../lib/financeView'
|
|
||||||
|
|
||||||
let itemCounter = 0
|
|
||||||
const newItem = () => ({ uid: ++itemCounter, description: '', quantity: '1', price: '' })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dialog „Rechnung schreiben“. Kunde (nur feste Kunden mit E-Mail wählbar; mit `fixedCustomer` steht er fest),
|
|
||||||
* Positionen (Beschreibung, Menge, Einzelpreis in Euro), Zahlungsziel (Standard 14 Tage), Notiz, Summe und der
|
|
||||||
* Hinweis nach § 19 UStG. „Als Entwurf speichern“ und „Senden“ sperren sich gegenseitig, solange die Anfrage läuft;
|
|
||||||
* Fehler des Servers stehen im Dialog (und werden in den sichtbaren Bereich geholt). „Senden“ fragt vorher nach
|
|
||||||
* (die Rechnung wird festgeschrieben und per Mail an den Kunden geschickt). Fehlt dem Kunden die Anschrift (Straße,
|
|
||||||
* PLZ, Ort), steht eine Warnung mit Weg zur Übersicht da, und „Senden“ ist über 250 € gesperrt (§ 14 Abs. 4 Nr. 1
|
|
||||||
* UStG; Entwürfe bleiben möglich). `onShowOverview` (Reiter am Kunden) wechselt zur Übersicht, sonst führt ein Link
|
|
||||||
* zur Kundenseite.
|
|
||||||
*
|
|
||||||
* onCreated(rechnung, { senden }) nach Erfolg; wurde die Rechnung festgeschrieben, aber nicht versendet (502 mit
|
|
||||||
* invoiceId), kommt onCreated(null, { senden: true, notice, tone: 'warn', finalizedFilter }) und der Dialog schließt
|
|
||||||
* (die Rechnung steht dann „offen“ in der Liste und lässt sich dort erneut senden). Danach ruft der Dialog onClose().
|
|
||||||
*/
|
|
||||||
export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCustomer = null, onShowOverview = null }) {
|
|
||||||
if (!open) return null
|
|
||||||
// Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht
|
|
||||||
return fixedCustomer ? (
|
|
||||||
<InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={fixedCustomer} source={null} onShowOverview={onShowOverview} />
|
|
||||||
) : (
|
|
||||||
<PickerForm onClose={onClose} onCreated={onCreated} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PickerForm({ onClose, onCreated }) {
|
|
||||||
const { customers, loading, error } = useCustomers()
|
|
||||||
return <InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={null} source={{ customers, loading, error }} onShowOverview={null} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview }) {
|
|
||||||
const [customerText, setCustomerText] = useState('')
|
|
||||||
const [items, setItems] = useState(() => [newItem()])
|
|
||||||
const [days, setDays] = useState(String(INVOICE_LIMITS.defaultDays))
|
|
||||||
const [memo, setMemo] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [busy, setBusy] = useState(null) // 'draft' | 'send'
|
|
||||||
const lockRef = useRef(false)
|
|
||||||
const aliveRef = useRef(true)
|
|
||||||
const errorRef = useRef(null)
|
|
||||||
|
|
||||||
// Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen
|
|
||||||
useScrollIntoView(errorRef, error)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
aliveRef.current = true
|
|
||||||
return () => {
|
|
||||||
aliveRef.current = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// × und Esc kommen vom Dialog (er ignoriert Esc, wenn die Kundenliste die Taste schon behandelt hat, und solange `busy` gilt).
|
|
||||||
// Die Sperre deckt den Augenblick zwischen Klick und erstem Zeichnen von `busy` ab.
|
|
||||||
const close = () => {
|
|
||||||
if (lockRef.current) return
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers])
|
|
||||||
const resolved = fixedCustomer
|
|
||||||
? { state: canInvoice(fixedCustomer).ok ? 'ok' : 'blocked', option: null }
|
|
||||||
: resolveCustomerText(options, customerText)
|
|
||||||
const customer = fixedCustomer || resolved.option?.customer || null
|
|
||||||
const customerBlocked = fixedCustomer ? canInvoice(fixedCustomer) : resolved.option ? { ok: resolved.option.ok, reason: resolved.option.reason } : { ok: true }
|
|
||||||
const total = invoiceTotalCents(items)
|
|
||||||
// Anschrift des Empfängers (§ 14 Abs. 4 Nr. 1 UStG): ohne sie ist „Senden“ nur bis 250 € möglich
|
|
||||||
const gate = invoiceSendGate(customerBlocked.ok ? customer : null, total)
|
|
||||||
|
|
||||||
const updateItem = (uid, field, value) => {
|
|
||||||
setItems((list) => list.map((it) => (it.uid === uid ? { ...it, [field]: value } : it)))
|
|
||||||
if (error) setError('')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit(senden) {
|
|
||||||
if (lockRef.current) return
|
|
||||||
const check = validateInvoiceForm({ customer, items, days, memo })
|
|
||||||
if (!check.ok) {
|
|
||||||
setError(check.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (senden) {
|
|
||||||
if (!invoiceSendGate(customer, check.totalCents).sendAllowed) {
|
|
||||||
setError(ADDRESS_INCOMPLETE_TEXT)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Senden schreibt die Rechnung fest und schickt sie an den Kunden: erst nach Rückfrage
|
|
||||||
const sure = window.confirm(
|
|
||||||
invoiceSendConfirmText({ name: customerName(customer), email: customer.email, totalCents: check.totalCents })
|
|
||||||
)
|
|
||||||
if (!sure) return
|
|
||||||
}
|
|
||||||
lockRef.current = true
|
|
||||||
setBusy(senden ? 'send' : 'draft')
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
const data = await createInvoice({ ...check.payload, senden })
|
|
||||||
if (onCreated) onCreated(data?.rechnung || null, { senden })
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
if (isFinalizedNotSent(err)) {
|
|
||||||
// Die Rechnung steht bei Stripe: keine zweite anlegen, die Liste zeigt sie
|
|
||||||
if (onCreated) onCreated(null, { senden: true, notice: invoiceFailureMessage(err), tone: 'warn', finalizedFilter: finalizedFilter(err) })
|
|
||||||
onClose()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (aliveRef.current) setError(invoiceFailureMessage(err))
|
|
||||||
} finally {
|
|
||||||
lockRef.current = false
|
|
||||||
if (aliveRef.current) setBusy(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const disabled = busy !== null
|
|
||||||
|
|
||||||
const footer = (
|
|
||||||
<div className="fin-foot">
|
|
||||||
<Button type="button" onClick={close} disabled={disabled}>Abbrechen</Button>
|
|
||||||
<Button type="button" onClick={() => submit(false)} disabled={disabled}>
|
|
||||||
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => submit(true)}
|
|
||||||
disabled={disabled || !gate.sendAllowed}
|
|
||||||
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
|
|
||||||
>
|
|
||||||
{busy === 'send' ? 'Sende …' : 'Senden'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Enter sendet nie (das wäre eine Mail an den Kunden): das Formular hat keinen Absenden-Knopf, jeder Knopf darin ist type="button"
|
|
||||||
return (
|
|
||||||
<Dialog as="form" wide title="Rechnung schreiben" onClose={close} busy={disabled} onSubmit={(e) => e.preventDefault()} footer={footer}>
|
|
||||||
{error && (
|
|
||||||
<div className="msg err" role="alert" ref={errorRef}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Field label="Kunde" htmlFor="fin-invoice-customer">
|
|
||||||
{fixedCustomer ? (
|
|
||||||
<p className="fin-fixed-customer">
|
|
||||||
<strong className="strong">{customerName(fixedCustomer) || 'Kunde'}</strong>
|
|
||||||
{fixedCustomer.email ? <span className="msg muted"> · {fixedCustomer.email}</span> : null}
|
|
||||||
</p>
|
|
||||||
) : source?.loading ? (
|
|
||||||
<p className="msg muted" role="status">Kunden werden geladen …</p>
|
|
||||||
) : source?.error ? (
|
|
||||||
<p className="msg warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
|
|
||||||
) : (
|
|
||||||
<AutocompleteInput
|
|
||||||
id="fin-invoice-customer"
|
|
||||||
value={customerText}
|
|
||||||
onChange={(v) => {
|
|
||||||
setCustomerText(v)
|
|
||||||
if (error) setError('')
|
|
||||||
}}
|
|
||||||
getSuggestions={(t) => customerSuggestions(options, t)}
|
|
||||||
placeholder="Kunde suchen …"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!customerBlocked.ok && (
|
|
||||||
<p className="msg warn" role="alert">
|
|
||||||
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!fixedCustomer && resolved.state === 'unknown' && (
|
|
||||||
<p className="msg muted">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
|
|
||||||
)}
|
|
||||||
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
|
|
||||||
<p className="msg muted">Die Rechnung geht an {customer.email}.</p>
|
|
||||||
)}
|
|
||||||
{gate.warn && (
|
|
||||||
<AddressWarning
|
|
||||||
customer={customer}
|
|
||||||
variant="dialog"
|
|
||||||
onShowOverview={
|
|
||||||
onShowOverview
|
|
||||||
? () => {
|
|
||||||
if (lockRef.current) return
|
|
||||||
onClose()
|
|
||||||
onShowOverview()
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<fieldset className="fin-items">
|
|
||||||
<legend>Positionen</legend>
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const line = lineTotalCents(item)
|
|
||||||
const n = index + 1
|
|
||||||
return (
|
|
||||||
<div key={item.uid} className="fin-item">
|
|
||||||
<div className="fin-item-field fin-item-desc">
|
|
||||||
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
|
|
||||||
<input
|
|
||||||
id={`fin-item-desc-${item.uid}`}
|
|
||||||
className="input"
|
|
||||||
maxLength={INVOICE_LIMITS.maxDescription}
|
|
||||||
value={item.description}
|
|
||||||
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
|
|
||||||
aria-label={`Position ${n}: Beschreibung`}
|
|
||||||
placeholder="z. B. Webdesign Startseite"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="fin-item-field fin-item-qty">
|
|
||||||
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
|
|
||||||
<input
|
|
||||||
id={`fin-item-qty-${item.uid}`}
|
|
||||||
className="input fin-amount"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={item.quantity}
|
|
||||||
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
|
|
||||||
aria-label={`Position ${n}: Menge`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="fin-item-field fin-item-price">
|
|
||||||
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
|
|
||||||
<input
|
|
||||||
id={`fin-item-price-${item.uid}`}
|
|
||||||
className="input fin-amount"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={item.price}
|
|
||||||
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
|
|
||||||
aria-label={`Position ${n}: Einzelpreis in Euro`}
|
|
||||||
placeholder="0,00"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="fin-item-field fin-item-sum">
|
|
||||||
<span className="fin-item-label">Summe</span>
|
|
||||||
<span className="fin-amount fin-item-total">{line === null ? '–' : euro(line)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="fin-item-field fin-item-remove">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="link"
|
|
||||||
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
|
|
||||||
disabled={items.length <= 1}
|
|
||||||
aria-label={`Position ${n} entfernen`}
|
|
||||||
>
|
|
||||||
Entfernen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<div className="fin-items-foot">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
|
|
||||||
disabled={items.length >= INVOICE_LIMITS.maxItems}
|
|
||||||
>
|
|
||||||
+ Position
|
|
||||||
</Button>
|
|
||||||
<div className="fin-total" aria-live="polite">
|
|
||||||
<span>Summe</span>
|
|
||||||
<span className="fin-amount">{euro(total)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="msg muted">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="fin-dialog-row">
|
|
||||||
<Field label="Zahlungsziel in Tagen" htmlFor="fin-invoice-days">
|
|
||||||
<input
|
|
||||||
id="fin-invoice-days"
|
|
||||||
className="input fin-amount"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={days}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDays(e.target.value)
|
|
||||||
if (error) setError('')
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Notiz (erscheint auf der Rechnung)" htmlFor="fin-invoice-memo">
|
|
||||||
<textarea
|
|
||||||
id="fin-invoice-memo"
|
|
||||||
className="textarea"
|
|
||||||
rows={2}
|
|
||||||
maxLength={INVOICE_LIMITS.maxMemo}
|
|
||||||
value={memo}
|
|
||||||
onChange={(e) => setMemo(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Badge, Button, Card, EmptyState, Stat } from '../ui'
|
import { Badge, Button, Card, EmptyState, Stat } from '../ui'
|
||||||
import InvoiceCreateModal from './InvoiceCreateModal'
|
import RechnungDialog from '../auftraege/RechnungDialog'
|
||||||
import Pager from './Pager'
|
import Pager from './Pager'
|
||||||
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
|
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
|
||||||
import { pageOf } from '../../lib/financeFormat'
|
import { pageOf } from '../../lib/financeFormat'
|
||||||
import { noticeProps } from '../../lib/financeUi'
|
import { noticeProps } from '../../lib/financeUi'
|
||||||
|
import { artLabel, projektSpalte } from '../../lib/projektkontoView'
|
||||||
import {
|
import {
|
||||||
ADDRESS_INCOMPLETE_TEXT,
|
ADDRESS_INCOMPLETE_TEXT,
|
||||||
DEFAULT_INVOICE_FILTER,
|
DEFAULT_INVOICE_FILTER,
|
||||||
@@ -37,6 +38,8 @@ const CALLS = { send: sendInvoice, resend: resendInvoice, void: voidInvoice, del
|
|||||||
* wird jede Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. „Senden“ eines Entwurfs
|
* wird jede Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. „Senden“ eines Entwurfs
|
||||||
* fragt vorher nach. In der Kundenansicht ist „Senden“ gesperrt, solange dem Kunden die Anschrift fehlt und der
|
* fragt vorher nach. In der Kundenansicht ist „Senden“ gesperrt, solange dem Kunden die Anschrift fehlt und der
|
||||||
* Entwurf über 250 € liegt (§ 14 Abs. 4 Nr. 1 UStG); `onShowOverview` führt dann im Dialog zur Übersicht.
|
* Entwurf über 250 € liegt (§ 14 Abs. 4 Nr. 1 UStG); `onShowOverview` führt dann im Dialog zur Übersicht.
|
||||||
|
* Die Tabelle zeigt zusätzlich die Spalten Projekt und Art; „Rechnung schreiben“ öffnet den Dialog `RechnungDialog`
|
||||||
|
* (Projektwahl, Arten, Vorschau).
|
||||||
*
|
*
|
||||||
* „Offen“ zeigt offene und überfällige Rechnungen (zwei Abrufe, zusammengeführt, neueste zuerst). Ändert sich
|
* „Offen“ zeigt offene und überfällige Rechnungen (zwei Abrufe, zusammengeführt, neueste zuerst). Ändert sich
|
||||||
* `refreshToken` (die Seite lädt nach Zuordnen, Bankabruf usw. alles neu), lädt auch die Liste neu.
|
* `refreshToken` (die Seite lädt nach Zuordnen, Bankabruf usw. alles neu), lädt auch die Liste neu.
|
||||||
@@ -204,6 +207,8 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
|
|||||||
<tr role="row">
|
<tr role="row">
|
||||||
<th role="columnheader" scope="col">Nr.</th>
|
<th role="columnheader" scope="col">Nr.</th>
|
||||||
<th role="columnheader" scope="col">Kunde</th>
|
<th role="columnheader" scope="col">Kunde</th>
|
||||||
|
<th role="columnheader" scope="col">Projekt</th>
|
||||||
|
<th role="columnheader" scope="col">Art</th>
|
||||||
<th role="columnheader" scope="col">Datum</th>
|
<th role="columnheader" scope="col">Datum</th>
|
||||||
<th role="columnheader" scope="col">Fällig</th>
|
<th role="columnheader" scope="col">Fällig</th>
|
||||||
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
|
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
|
||||||
@@ -220,6 +225,8 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
|
|||||||
{row.isAbo && <span className="fin-inv-abo muted"> · Abo</span>}
|
{row.isAbo && <span className="fin-inv-abo muted"> · Abo</span>}
|
||||||
</td>
|
</td>
|
||||||
<td role="cell" className="fin-inv-customer">{row.customerText}</td>
|
<td role="cell" className="fin-inv-customer">{row.customerText}</td>
|
||||||
|
<td role="cell" className="fin-inv-project" data-label="Projekt">{projektSpalte(byId[row.id])}</td>
|
||||||
|
<td role="cell" className="fin-inv-art" data-label="Art">{artLabel(byId[row.id]?.art)}</td>
|
||||||
<td role="cell" className="fin-inv-created" data-label="Datum">{row.createdText}</td>
|
<td role="cell" className="fin-inv-created" data-label="Datum">{row.createdText}</td>
|
||||||
<td role="cell" className="fin-inv-due" data-label="Fällig">{row.dueText}</td>
|
<td role="cell" className="fin-inv-due" data-label="Fällig">{row.dueText}</td>
|
||||||
<td role="cell" className="fin-amount fin-inv-total" data-label="Betrag">{row.totalText}</td>
|
<td role="cell" className="fin-amount fin-inv-total" data-label="Betrag">{row.totalText}</td>
|
||||||
@@ -290,11 +297,11 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<InvoiceCreateModal
|
<RechnungDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
onCreated={handleCreated}
|
onCreated={handleCreated}
|
||||||
fixedCustomer={customer}
|
kunde={customer}
|
||||||
onShowOverview={onShowOverview}
|
onShowOverview={onShowOverview}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
settingsToForm,
|
settingsToForm,
|
||||||
validateSettingsForm,
|
validateSettingsForm,
|
||||||
} from '../../lib/financeSettings'
|
} from '../../lib/financeSettings'
|
||||||
|
import { STEUERNUMMER_HINWEIS, steuernummerAenderung } from '../../lib/kundenFelder'
|
||||||
|
|
||||||
const LOAD_ERROR = 'Die Einstellungen konnten nicht geladen werden.'
|
const LOAD_ERROR = 'Die Einstellungen konnten nicht geladen werden.'
|
||||||
const fieldId = (name) => `fin-set-${name}`
|
const fieldId = (name) => `fin-set-${name}`
|
||||||
@@ -44,6 +45,8 @@ export default function SettingsCard() {
|
|||||||
|
|
||||||
function SettingsForm({ initial }) {
|
function SettingsForm({ initial }) {
|
||||||
const [form, setForm] = useState(() => settingsToForm(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 [errors, setErrors] = useState({})
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [notice, setNotice] = useState(null) // { tone: 'ok' | 'warn', text }
|
const [notice, setNotice] = useState(null) // { tone: 'ok' | 'warn', text }
|
||||||
@@ -68,10 +71,12 @@ function SettingsForm({ initial }) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (lockRef.current) return
|
if (lockRef.current) return
|
||||||
const check = validateSettingsForm(form)
|
const check = validateSettingsForm(form)
|
||||||
if (!check.ok) {
|
const stn = steuernummerAenderung(steuernummer, gespeichertRef.current)
|
||||||
setErrors(check.errors)
|
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.' })
|
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()
|
if (first) document.getElementById(fieldId(first))?.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -80,9 +85,12 @@ function SettingsForm({ initial }) {
|
|||||||
setErrors({})
|
setErrors({})
|
||||||
setNotice(null)
|
setNotice(null)
|
||||||
try {
|
try {
|
||||||
const data = await patchSettings(check.payload)
|
const data = await patchSettings({ ...check.payload, ...stn.payload })
|
||||||
if (!aliveRef.current) return
|
if (!aliveRef.current) return
|
||||||
if (data?.einstellungen) setForm(settingsToForm(data.einstellungen))
|
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.' })
|
setNotice({ tone: 'ok', text: 'Gespeichert.' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (aliveRef.current) setNotice({ tone: 'warn', text: apiErrorMessage(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' : ''}`}>
|
<div className={`fin-counter${tooLong ? ' fin-counter-over' : ''}`}>
|
||||||
{length} / {SETTINGS_LIMITS.maxBankDetails} Zeichen
|
{length} / {SETTINGS_LIMITS.maxBankDetails} Zeichen
|
||||||
</div>
|
</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>
|
</fieldset>
|
||||||
|
|
||||||
{notice && <p {...noticeProps(notice.tone, 'fin-notice')}>{notice.text}</p>}
|
{notice && <p {...noticeProps(notice.tone, 'fin-notice')}>{notice.text}</p>}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getAbgerechnet } from '../lib/teamApi'
|
||||||
|
|
||||||
|
const NICHTS = Object.freeze({})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arbeitsblatt-Kennung -> Rechnungsnummer oder „Entwurf“ für die Tickets in `ticketIds` (Team-Route, alle Mitarbeiter,
|
||||||
|
* ohne Beträge). Ein Fehler verbirgt nur die Abzeichen, das Arbeitsblatt bleibt bedienbar; der Hook wirft nie.
|
||||||
|
*/
|
||||||
|
export function useAbgerechnet(ticketIds) {
|
||||||
|
const key = (ticketIds || []).join(',')
|
||||||
|
const [state, setState] = useState({ key: '', map: NICHTS })
|
||||||
|
useEffect(() => {
|
||||||
|
if (!key) return undefined
|
||||||
|
let cancelled = false
|
||||||
|
Promise.all(key.split(',').map((id) => getAbgerechnet(id).then((r) => r?.abgerechnet || NICHTS).catch(() => NICHTS)))
|
||||||
|
.then((maps) => { if (!cancelled) setState({ key, map: Object.assign({}, ...maps) }) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [key])
|
||||||
|
return state.key === key ? state.map : NICHTS
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Hooks des Rechnungsdialogs: Projektliste, Projektkonto, Vorschau, Tickets des Kunden und Arbeitsblätter weiterer Tickets.
|
||||||
|
// Überall zählt nur die jüngste Antwort; nach dem Ausbau eines Hooks zählt keine mehr.
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { getInvoices, getProjektkonten, getProjektkonto, getSettings, listAuftraege, previewInvoice } from '../lib/financeApi'
|
||||||
|
import { getAbgerechnet } from '../lib/teamApi'
|
||||||
|
import { listKundenTickets, listTicketBlaetter } from '../lib/rechnungDialogApi'
|
||||||
|
import { blattAusAppwrite, ohneProjektAbDaten } from '../lib/rechnungDialogView'
|
||||||
|
import { createRequestGuard } from '../lib/requestGuard'
|
||||||
|
|
||||||
|
const VORSCHAU_WARTEZEIT_MS = 350
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt eine Ressource zu einem Schlüssel (Kunde oder Projekt). Ohne Schlüssel wird nichts geladen. Die Daten gehören zu
|
||||||
|
* ihrem Schlüssel: nach einem Wechsel ist sofort `data: null` und `loading: true`. `reload()` behält die Daten stehen und
|
||||||
|
* liefert ein Promise, das nie wirft.
|
||||||
|
*/
|
||||||
|
function useGeladen(laden, schluessel, fallbackError) {
|
||||||
|
const [state, setState] = useState({ key: null, data: null, loading: Boolean(schluessel), error: '' })
|
||||||
|
const guardRef = useRef(null)
|
||||||
|
if (!guardRef.current) guardRef.current = createRequestGuard()
|
||||||
|
const ladenRef = useRef(laden)
|
||||||
|
ladenRef.current = laden
|
||||||
|
const lebtRef = useRef(true)
|
||||||
|
|
||||||
|
const lade = useCallback(
|
||||||
|
async (key) => {
|
||||||
|
const guard = guardRef.current
|
||||||
|
const id = guard.next()
|
||||||
|
setState((s) => (s.key === key ? { ...s, loading: true, error: '' } : { key, data: null, loading: true, error: '' }))
|
||||||
|
const gilt = () => lebtRef.current && guard.isCurrent(id)
|
||||||
|
try {
|
||||||
|
const data = await ladenRef.current()
|
||||||
|
if (gilt()) setState({ key, data: data && typeof data === 'object' ? data : {}, loading: false, error: '' })
|
||||||
|
} catch (err) {
|
||||||
|
if (gilt()) setState((s) => ({ ...s, key, loading: false, error: err?.message || fallbackError }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fallbackError]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
lebtRef.current = true
|
||||||
|
if (!schluessel) {
|
||||||
|
guardRef.current.next()
|
||||||
|
setState({ key: null, data: null, loading: false, error: '' })
|
||||||
|
} else {
|
||||||
|
lade(schluessel)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
lebtRef.current = false
|
||||||
|
}
|
||||||
|
}, [schluessel, lade])
|
||||||
|
|
||||||
|
const reload = useCallback(() => (schluessel ? lade(schluessel) : Promise.resolve()), [schluessel, lade])
|
||||||
|
const passt = Boolean(schluessel) && state.key === schluessel
|
||||||
|
return {
|
||||||
|
data: passt ? state.data : null,
|
||||||
|
loading: Boolean(schluessel) && (!passt || state.loading),
|
||||||
|
error: passt ? state.error : '',
|
||||||
|
reload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finanzeinstellungen, einmal geladen: undefined = lädt, null = nicht ladbar, sonst das Objekt `einstellungen`. */
|
||||||
|
export function useEinstellungen() {
|
||||||
|
const [einstellungen, setEinstellungen] = useState(undefined)
|
||||||
|
useEffect(() => {
|
||||||
|
let aktuell = true
|
||||||
|
getSettings().then(
|
||||||
|
(d) => aktuell && setEinstellungen(d?.einstellungen ?? null),
|
||||||
|
() => aktuell && setEinstellungen(null)
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
aktuell = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
return einstellungen
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Projektkonten eines Kunden: { auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk }. */
|
||||||
|
export function useProjektkonten(kundeId) {
|
||||||
|
return useGeladen(() => getProjektkonten(kundeId), kundeId, 'Die Projekte konnten nicht geladen werden.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detail eines Projektkontos: { konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk }. */
|
||||||
|
export function useProjektkontoDetail(projektId) {
|
||||||
|
return useGeladen(() => getProjektkonto(projektId), projektId, 'Das Projektkonto konnte nicht geladen werden.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ABs ohne Projekt und ihre Rechnungen für „Ohne Projekt“ im Rechnungsdialog: { auftraege, rechnungen }. Geladen wird nur,
|
||||||
|
* wenn `aktiv` wahr ist (Schalter an und „Ohne Projekt“ gewählt).
|
||||||
|
*/
|
||||||
|
export function useOhneProjektAbs(kundeId, aktiv) {
|
||||||
|
return useGeladen(
|
||||||
|
async () => {
|
||||||
|
const [abs, rechnungen] = await Promise.all([listAuftraege(kundeId), getInvoices({ status: 'alle', kunde: kundeId })])
|
||||||
|
return ohneProjektAbDaten(abs, rechnungen)
|
||||||
|
},
|
||||||
|
aktiv ? kundeId : '',
|
||||||
|
'Die Aufträge konnten nicht geladen werden.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorschau der Rechnung zum Körper der Anfrage. `vorschau = { key, status: 'laedt' | 'ok' | 'fehler', daten?, message?, httpStatus? }`.
|
||||||
|
* Der Körper wird über einen Ref gelesen, der Effekt hängt nur an [aktiv, schluessel]; nach 350 ms Ruhe geht die Anfrage raus.
|
||||||
|
*/
|
||||||
|
export function useRechnungVorschau({ anfrage, schluessel, aktiv }) {
|
||||||
|
const [vorschau, setVorschau] = useState(null)
|
||||||
|
const bodyRef = useRef(null)
|
||||||
|
bodyRef.current = anfrage?.ok ? anfrage.body : null
|
||||||
|
const datenRef = useRef(null)
|
||||||
|
const kontextRef = useRef('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!aktiv || !bodyRef.current || !schluessel) {
|
||||||
|
datenRef.current = null
|
||||||
|
kontextRef.current = ''
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
// Vorherige Zeilen bleiben nur innerhalb derselben Rechnung (Kunde, Art, Projekt, AB) abgeblendet stehen
|
||||||
|
const { customerId, art, projektId, auftragId } = bodyRef.current
|
||||||
|
const kontext = JSON.stringify([customerId, art, projektId, auftragId])
|
||||||
|
if (kontext !== kontextRef.current) datenRef.current = null
|
||||||
|
kontextRef.current = kontext
|
||||||
|
let aktuell = true
|
||||||
|
setVorschau({ key: schluessel, status: 'laedt', daten: datenRef.current })
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const daten = await previewInvoice(bodyRef.current)
|
||||||
|
if (!aktuell) return
|
||||||
|
datenRef.current = daten
|
||||||
|
setVorschau({ key: schluessel, status: 'ok', daten })
|
||||||
|
} catch (err) {
|
||||||
|
if (!aktuell) return
|
||||||
|
setVorschau({
|
||||||
|
key: schluessel,
|
||||||
|
status: 'fehler',
|
||||||
|
message: err?.message || 'Die Vorschau konnte nicht geladen werden.',
|
||||||
|
httpStatus: err?.status,
|
||||||
|
daten: datenRef.current,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, VORSCHAU_WARTEZEIT_MS)
|
||||||
|
return () => {
|
||||||
|
aktuell = false
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [aktiv, schluessel])
|
||||||
|
|
||||||
|
return vorschau
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tickets des Kunden (einmal je Kunde, sobald `aktiv` wahr ist). */
|
||||||
|
export function useKundenTickets(kundeId, aktiv) {
|
||||||
|
const [state, setState] = useState({ key: null, tickets: [], loading: false, error: '' })
|
||||||
|
useEffect(() => {
|
||||||
|
if (!aktiv || !kundeId) return undefined
|
||||||
|
let aktuell = true
|
||||||
|
setState({ key: kundeId, tickets: [], loading: true, error: '' })
|
||||||
|
listKundenTickets(kundeId).then(
|
||||||
|
(tickets) => aktuell && setState({ key: kundeId, tickets, loading: false, error: '' }),
|
||||||
|
() => aktuell && setState({ key: kundeId, tickets: [], loading: false, error: 'Die Tickets konnten nicht geladen werden.' })
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
aktuell = false
|
||||||
|
}
|
||||||
|
}, [kundeId, aktiv])
|
||||||
|
const passt = state.key === kundeId
|
||||||
|
return { tickets: passt ? state.tickets : [], loading: Boolean(aktiv && kundeId) && (!passt || state.loading), error: passt ? state.error : '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arbeitsblätter weiterer Tickets: je Ticket einmal Blätter und Abgerechnet-Liste laden.
|
||||||
|
* Ergebnis: { [ticketId]: { status: 'laedt' | 'ok' | 'fehler', blaetter, message } }; entfernte Tickets verschwinden.
|
||||||
|
*/
|
||||||
|
export function useWeitereBlaetter(weitereTickets) {
|
||||||
|
const [ergebnis, setErgebnis] = useState({})
|
||||||
|
const angefragtRef = useRef(new Set())
|
||||||
|
const lebtRef = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
lebtRef.current = true
|
||||||
|
return () => {
|
||||||
|
lebtRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const liste = Array.isArray(weitereTickets) ? weitereTickets : []
|
||||||
|
const ids = new Set(liste.map((t) => t.ticketId))
|
||||||
|
for (const id of [...angefragtRef.current]) if (!ids.has(id)) angefragtRef.current.delete(id)
|
||||||
|
for (const ticket of liste) {
|
||||||
|
if (angefragtRef.current.has(ticket.ticketId)) continue
|
||||||
|
angefragtRef.current.add(ticket.ticketId)
|
||||||
|
const gilt = () => lebtRef.current && angefragtRef.current.has(ticket.ticketId)
|
||||||
|
setErgebnis((r) => ({ ...r, [ticket.ticketId]: { status: 'laedt', blaetter: [], message: '' } }))
|
||||||
|
Promise.all([listTicketBlaetter(ticket.woid), getAbgerechnet(ticket.ticketId)]).then(
|
||||||
|
([dokumente, antwort]) => {
|
||||||
|
if (!gilt()) return
|
||||||
|
const blaetter = dokumente
|
||||||
|
.map((ws) => blattAusAppwrite(ws, antwort?.abgerechnet))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => String(a.datum).localeCompare(String(b.datum)) || String(a.id).localeCompare(String(b.id)))
|
||||||
|
setErgebnis((r) => ({ ...r, [ticket.ticketId]: { status: 'ok', blaetter, message: '' } }))
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!gilt()) return
|
||||||
|
setErgebnis((r) => ({
|
||||||
|
...r,
|
||||||
|
[ticket.ticketId]: { status: 'fehler', blaetter: [], message: `Die Arbeitsblätter von #${ticket.woid} konnten nicht geladen werden.` },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setErgebnis((r) => {
|
||||||
|
const keys = Object.keys(r).filter((k) => !ids.has(k))
|
||||||
|
if (keys.length === 0) return r
|
||||||
|
const rest = { ...r }
|
||||||
|
for (const k of keys) delete rest[k]
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
}, [weitereTickets])
|
||||||
|
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Ansichtslogik des Reiters „Aufträge und Rechnungen“ (rein, ohne React und ohne Appwrite; unter Node testbar).
|
||||||
|
// Die API-Funktionen kommen als Argument. Der Container (AuftraegeUndRechnungen.jsx) verdrahtet diese Funktionen mit
|
||||||
|
// useFinanceResource und den Abschnitten des Reiters.
|
||||||
|
import { invoiceDoneMessage } from './financeView'
|
||||||
|
|
||||||
|
export const KONTEN_FEHLER = 'Die Projektübersicht konnte nicht geladen werden.'
|
||||||
|
export const AUFTRAEGE_FEHLER = 'Die Aufträge konnten nicht geladen werden.'
|
||||||
|
|
||||||
|
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||||||
|
const ohneProjektLeer = () => ({ berechnetCents: 0, bezahltCents: 0, anzahl: 0 })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt Projektkonten und, nur bei eingeschaltetem Schalter (`auftraegeAktiv === true`), die Aufträge des Kunden.
|
||||||
|
* Scheitert nur listAuftraege, bleiben die Projektkonten und der Fehler steht eigens da.
|
||||||
|
* Scheitert getProjektkonten, wird die Auftragsliste trotzdem geholt (der Server prüft den Schalter ohnehin und antwortet bei
|
||||||
|
* „aus“ mit 404): kommt sie, bleibt der Abschnitt Aufträge bedienbar und der Fehler steht als `kontenFehler` daneben;
|
||||||
|
* kommt sie nicht, geht der Fehler der Projektkonten weiter.
|
||||||
|
* -> { konten, auftraege: Liste | null, auftraegeFehler: Text | '', kontenFehler?: Text (nur bei ausgefallenen Projektkonten) }
|
||||||
|
*/
|
||||||
|
export async function ladeAuftraegeAnsicht(kundeId, { getProjektkonten, listAuftraege }) {
|
||||||
|
let antwort
|
||||||
|
try {
|
||||||
|
antwort = await getProjektkonten(kundeId)
|
||||||
|
} catch (kontenErr) {
|
||||||
|
let liste
|
||||||
|
try {
|
||||||
|
liste = await listAuftraege(kundeId)
|
||||||
|
} catch {
|
||||||
|
throw kontenErr
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
konten: {},
|
||||||
|
kontenFehler: (kontenErr && kontenErr.message) || KONTEN_FEHLER,
|
||||||
|
auftraege: Array.isArray(liste?.auftraege) ? liste.auftraege : [],
|
||||||
|
auftraegeFehler: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const konten = istObjekt(antwort) ? antwort : {}
|
||||||
|
if (konten.auftraegeAktiv !== true) return { konten, auftraege: null, auftraegeFehler: '' }
|
||||||
|
try {
|
||||||
|
const liste = await listAuftraege(kundeId)
|
||||||
|
return { konten, auftraege: Array.isArray(liste?.auftraege) ? liste.auftraege : [], auftraegeFehler: '' }
|
||||||
|
} catch (err) {
|
||||||
|
return { konten, auftraege: null, auftraegeFehler: (err && err.message) || AUFTRAEGE_FEHLER }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustand der Ansicht aus dem Ergebnis von useFinanceResource ({ data, error }).
|
||||||
|
* -> { projekteStatus: 'laden' | 'fehler' | 'bereit', fehler, nachladeFehler,
|
||||||
|
* auftraegeAktiv, auftraegeStatus: 'aus' | 'bereit' | 'fehler', auftraegeFehler,
|
||||||
|
* konten, ohneProjekt, stripeOk, kunde, auftraege }
|
||||||
|
* Ohne Daten gibt es keinen Aufträge-Abschnitt (der Schalter ist noch unbekannt). Scheitert das Neuladen, nachdem Daten
|
||||||
|
* da sind, bleiben die alten Daten stehen und die Meldung kommt als `nachladeFehler` daneben. Waren die Projektkonten
|
||||||
|
* ausgefallen und die Aufträge trotzdem da (`data.kontenFehler`), ist `projekteStatus` „fehler“ und die Aufträge sind bereit.
|
||||||
|
*/
|
||||||
|
export function ansichtsZustand({ data, error } = {}) {
|
||||||
|
if (!data) {
|
||||||
|
return {
|
||||||
|
projekteStatus: error ? 'fehler' : 'laden',
|
||||||
|
fehler: error || '',
|
||||||
|
nachladeFehler: '',
|
||||||
|
auftraegeAktiv: false,
|
||||||
|
auftraegeStatus: 'aus',
|
||||||
|
auftraegeFehler: '',
|
||||||
|
konten: [],
|
||||||
|
ohneProjekt: ohneProjektLeer(),
|
||||||
|
stripeOk: true,
|
||||||
|
kunde: null,
|
||||||
|
auftraege: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const k = istObjekt(data.konten) ? data.konten : {}
|
||||||
|
const kontenFehler = typeof data.kontenFehler === 'string' ? data.kontenFehler : ''
|
||||||
|
// Mit ausgefallenen Projektkonten gilt der Schalter als an, wenn der Server die Auftragsliste geliefert hat
|
||||||
|
const auftraegeAktiv = kontenFehler ? Array.isArray(data.auftraege) : k.auftraegeAktiv === true
|
||||||
|
const auftraegeStatus = !auftraegeAktiv ? 'aus' : data.auftraegeFehler ? 'fehler' : 'bereit'
|
||||||
|
return {
|
||||||
|
projekteStatus: kontenFehler ? 'fehler' : 'bereit',
|
||||||
|
fehler: kontenFehler ? error || kontenFehler : '',
|
||||||
|
nachladeFehler: kontenFehler ? '' : error || '',
|
||||||
|
auftraegeAktiv,
|
||||||
|
auftraegeStatus,
|
||||||
|
auftraegeFehler: auftraegeStatus === 'fehler' ? data.auftraegeFehler : '',
|
||||||
|
konten: Array.isArray(k.konten) ? k.konten : [],
|
||||||
|
ohneProjekt: istObjekt(k.ohneProjekt) ? k.ohneProjekt : ohneProjektLeer(),
|
||||||
|
stripeOk: k.stripeOk !== false,
|
||||||
|
kunde: istObjekt(k.kunde) ? k.kunde : null,
|
||||||
|
auftraege: auftraegeAktiv && Array.isArray(data.auftraege) ? data.auftraege : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wertebereich von customers.kundenart (V1); bewusst lokal, auftragView.js gehört dem Strang W2
|
||||||
|
const KUNDENARTEN = ['unternehmen', 'privat']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kundendokument mit der frischen Kundenart des Portals (getProjektkonten -> kunde.kundenart). Gilt nur ein gültiger Wert
|
||||||
|
* („unternehmen“ oder „privat“), kommt eine Kopie mit diesem Wert zurück, das Dokument der Seite bleibt unverändert.
|
||||||
|
* Sonst dieselbe Referenz (null bleibt null). So fragt der AB-Dialog nicht erneut, wenn die Kundenart eben gespeichert wurde
|
||||||
|
* und die Kundenseite noch den alten Datensatz hält.
|
||||||
|
*/
|
||||||
|
export function kundeMitKundenart(customer, kundeInfo) {
|
||||||
|
const art = istObjekt(kundeInfo) ? kundeInfo.kundenart : undefined
|
||||||
|
if (!customer || !KUNDENARTEN.includes(art)) return customer
|
||||||
|
return { ...customer, kundenart: art }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rückmeldung nach dem Anlegen einer Rechnung (wie InvoiceTable.handleCreated): `info.notice` (festgeschrieben, aber nicht
|
||||||
|
* versendet) gewinnt mit seinem Ton, sonst Entwurf oder gesendet. `info` darf fehlen oder null sein. -> { tone, text }
|
||||||
|
*/
|
||||||
|
export function erstelltMeldung(rechnung, info) {
|
||||||
|
const i = istObjekt(info) ? info : {}
|
||||||
|
if (typeof i.notice === 'string' && i.notice) return { tone: i.tone || 'ok', text: i.notice }
|
||||||
|
return { tone: 'ok', text: invoiceDoneMessage(i.senden ? 'create-send' : 'create-draft', rechnung) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rückmeldung nach dem Speichern oder Senden einer Auftragsbestätigung (onSaved des AB-Dialogs). -> { tone, text } */
|
||||||
|
export function auftragMeldung(auftrag) {
|
||||||
|
const a = istObjekt(auftrag) ? auftrag : {}
|
||||||
|
const name = a.nummer ? `Auftragsbestätigung ${a.nummer}` : 'Die Auftragsbestätigung'
|
||||||
|
if (a.status === 'entwurf') return { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' }
|
||||||
|
if (a.mailStatus === 'fehler') {
|
||||||
|
return { tone: 'warn', text: `${name} ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.` }
|
||||||
|
}
|
||||||
|
if (a.nummer) return { tone: 'ok', text: `${name} wurde${a.gesendetAn ? ` an ${a.gesendetAn}` : ''} gesendet.` }
|
||||||
|
return { tone: 'ok', text: 'Die Auftragsbestätigung wurde gespeichert.' }
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Pfade und Methoden der neuen Portal-Aufrufe für Projektkonten, Auftragsbestätigungen (AB) und die Rechnungsvorschau
|
||||||
|
// (V5/V8 des Vorhabens „Aufträge und Rechnungen“). Rein (ohne Appwrite-Import): `request` kommt aus createFinanceRequest
|
||||||
|
// (financeRequest.js) und wird injiziert, damit Pfade, Methoden und Körper unter Node mit einer Fetch-Attrappe testbar sind.
|
||||||
|
// financeApi.js verdrahtet die Funktionen mit dem Appwrite-JWT. Alle Pfade gelten relativ zu /api/admin/finanzen.
|
||||||
|
|
||||||
|
const enc = encodeURIComponent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pfad der abgerechneten Arbeitsblätter eines Tickets, relativ zu /api/team (teamApi.js: getAbgerechnet).
|
||||||
|
* Antwort: { abgerechnet: { <worksheetId>: '<Rechnungsnummer>' | 'Entwurf' } }
|
||||||
|
*/
|
||||||
|
export const abgerechnetPfad = (ticketId) => `/arbeitsblaetter/abgerechnet?ticketId=${enc(ticketId)}`
|
||||||
|
|
||||||
|
/** createAuftraegeEndpoints(request) -> die 14 Funktionen von V8 (jede Kennung wird URL-kodiert). */
|
||||||
|
export function createAuftraegeEndpoints(request) {
|
||||||
|
const post = (path, body) => request(path, { method: 'POST', ...(body === undefined ? {} : { body }) })
|
||||||
|
|
||||||
|
return {
|
||||||
|
// --- Projektkonten ---
|
||||||
|
/** { auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk } */
|
||||||
|
getProjektkonten: (kundeId) => request(`/projektkonten?kunde=${enc(kundeId)}`),
|
||||||
|
/** { konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk } */
|
||||||
|
getProjektkonto: (projektId) => request(`/projektkonten/${enc(projektId)}`),
|
||||||
|
|
||||||
|
// --- Rechnungsvorschau (ohne Schreibzugriff, ohne Schlüssel) ---
|
||||||
|
/** { positionen, summeCents, leistungszeitraum, customFields } */
|
||||||
|
previewInvoice: (body) => post('/rechnungen/vorschau', body),
|
||||||
|
|
||||||
|
// --- Auftragsbestätigungen ---
|
||||||
|
/** { auftraege } (404, wenn der Schalter AUFTRAEGE_ENABLED für den Kunden aus ist) */
|
||||||
|
listAuftraege: (kundeId) => request(`/auftraege?kunde=${enc(kundeId)}`),
|
||||||
|
getAuftrag: (id) => request(`/auftraege/${enc(id)}`),
|
||||||
|
/** { auftrag } */
|
||||||
|
createAuftrag: (body) => post('/auftraege', body),
|
||||||
|
/** { auftrag } */
|
||||||
|
patchAuftrag: (id, body) => request(`/auftraege/${enc(id)}`, { method: 'PATCH', body }),
|
||||||
|
/** { ok: true } */
|
||||||
|
deleteAuftrag: (id) => request(`/auftraege/${enc(id)}`, { method: 'DELETE' }),
|
||||||
|
/** { auftrag, mailFehler } */
|
||||||
|
sendAuftrag: (id) => post(`/auftraege/${enc(id)}/senden`),
|
||||||
|
/** { auftrag, mailFehler } */
|
||||||
|
erinnereAuftrag: (id) => post(`/auftraege/${enc(id)}/erinnern`),
|
||||||
|
/** { auftrag } (neuer Entwurf) */
|
||||||
|
neueFassungAuftrag: (id) => post(`/auftraege/${enc(id)}/neue-fassung`),
|
||||||
|
/** { auftrag } */
|
||||||
|
storniereAuftrag: (id) => post(`/auftraege/${enc(id)}/stornieren`),
|
||||||
|
/** { auftrag }; 409, wenn die AB schon ein Projekt hat oder storniert, ersetzt oder widerrufen ist */
|
||||||
|
ordneAuftragProjektZu: (id, projektId) => post(`/auftraege/${enc(id)}/projekt`, { projektId }),
|
||||||
|
/** PDF der AB (gesendet: gespeicherte Datei, Entwurf: Vorschau) als Blob */
|
||||||
|
getAuftragPdf: (id) => request(`/auftraege/${enc(id)}/pdf`, { as: 'blob' }),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
// Reiter der Kundenansicht (rein, ohne React; unter Node testbar). „Rechnungen“ ruft die Finanzen-API des Portals auf,
|
// Reiter der Kundenansicht (rein, ohne React; unter Node testbar). „Aufträge und Rechnungen“ ruft die Finanzen-API des Portals auf,
|
||||||
// die nur Admins freigibt: für alle anderen gibt es den Reiter nicht.
|
// die nur Admins freigibt: für alle anderen gibt es den Reiter nicht.
|
||||||
|
|
||||||
const BASE_TABS = [
|
const BASE_TABS = [
|
||||||
{ id: 'overview', label: 'Übersicht' },
|
{ id: 'overview', label: 'Übersicht' },
|
||||||
{ id: 'tickets', label: 'Tickets' },
|
{ id: 'tickets', label: 'Tickets' },
|
||||||
{ id: 'invoices', label: 'Rechnungen', adminOnly: true },
|
{ id: 'invoices', label: 'Aufträge und Rechnungen', adminOnly: true },
|
||||||
{ id: 'projects', label: 'Projekte' },
|
{ id: 'projects', label: 'Projekte' },
|
||||||
// Chat mit dem Kunden (derselbe Gespraechsbaustein wie im Messenger unter Kunden -> Chat)
|
// Chat mit dem Kunden (derselbe Gespraechsbaustein wie im Messenger unter Kunden -> Chat)
|
||||||
{ id: 'chat', label: 'Chat' },
|
{ id: 'chat', label: 'Chat' },
|
||||||
@@ -16,7 +16,7 @@ const BASE_TABS = [
|
|||||||
{ id: 'protokoll', label: 'Protokoll' },
|
{ id: 'protokoll', label: 'Protokoll' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Reiter für Tabs: [{ id, label }]; „Rechnungen“ nur für Admins. */
|
/** Reiter für Tabs: [{ id, label }]; „Aufträge und Rechnungen“ nur für Admins. */
|
||||||
export function customerTabs(isAdmin) {
|
export function customerTabs(isAdmin) {
|
||||||
return BASE_TABS.filter((t) => !t.adminOnly || isAdmin === true).map(({ id, label }) => ({ id, label }))
|
return BASE_TABS.filter((t) => !t.adminOnly || isAdmin === true).map(({ id, label }) => ({ id, label }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { account } from './appwrite'
|
import { account } from './appwrite'
|
||||||
import { createFinanceRequest } from './financeRequest'
|
import { createFinanceRequest } from './financeRequest'
|
||||||
|
import { createAuftraegeEndpoints } from './auftraegeEndpoints'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client fuer die Finanzen-API des Kundenportals (/api/admin/finanzen).
|
* Client fuer die Finanzen-API des Kundenportals (/api/admin/finanzen).
|
||||||
@@ -26,6 +27,8 @@ export function getInvoices({ status, kunde } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Jeder Aufruf bekommt einen neuen Schluessel, damit ein Doppelklick keine zweite Rechnung erzeugt.
|
// Jeder Aufruf bekommt einen neuen Schluessel, damit ein Doppelklick keine zweite Rechnung erzeugt.
|
||||||
|
// Koerper: { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum,
|
||||||
|
// daysUntilDue, memo, senden } (wird unveraendert durchgereicht)
|
||||||
export const createInvoice = (body) =>
|
export const createInvoice = (body) =>
|
||||||
request('/rechnungen', { method: 'POST', body: { ...body, idempotencyKey: crypto.randomUUID() } })
|
request('/rechnungen', { method: 'POST', body: { ...body, idempotencyKey: crypto.randomUUID() } })
|
||||||
export const sendInvoice = (id) => request(`/rechnungen/${enc(id)}/senden`, { method: 'POST' })
|
export const sendInvoice = (id) => request(`/rechnungen/${enc(id)}/senden`, { method: 'POST' })
|
||||||
@@ -34,6 +37,24 @@ export const resendInvoice = sendInvoice
|
|||||||
export const voidInvoice = (id) => request(`/rechnungen/${enc(id)}/stornieren`, { method: 'POST' })
|
export const voidInvoice = (id) => request(`/rechnungen/${enc(id)}/stornieren`, { method: 'POST' })
|
||||||
export const deleteInvoice = (id) => request(`/rechnungen/${enc(id)}`, { method: 'DELETE' })
|
export const deleteInvoice = (id) => request(`/rechnungen/${enc(id)}`, { method: 'DELETE' })
|
||||||
|
|
||||||
|
// --- Projektkonten, Auftraege und Rechnungsvorschau (Pfade und Methoden: auftraegeEndpoints.js, getestet) ---
|
||||||
|
export const {
|
||||||
|
getProjektkonten,
|
||||||
|
getProjektkonto,
|
||||||
|
previewInvoice,
|
||||||
|
listAuftraege,
|
||||||
|
getAuftrag,
|
||||||
|
createAuftrag,
|
||||||
|
patchAuftrag,
|
||||||
|
deleteAuftrag,
|
||||||
|
sendAuftrag,
|
||||||
|
erinnereAuftrag,
|
||||||
|
neueFassungAuftrag,
|
||||||
|
storniereAuftrag,
|
||||||
|
ordneAuftragProjektZu,
|
||||||
|
getAuftragPdf,
|
||||||
|
} = createAuftraegeEndpoints(request)
|
||||||
|
|
||||||
// --- Buchungen ---
|
// --- Buchungen ---
|
||||||
export const getTransactions = () => request('/buchungen')
|
export const getTransactions = () => request('/buchungen')
|
||||||
// target: genau eins von { invoiceId } | { payoutId } | { fixedCostId } | { category }, optional note
|
// target: genau eins von { invoiceId } | { payoutId } | { fixedCostId } | { category }, optional note
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ export const SESSION_EXPIRED_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
|
|||||||
const statusError = (message, status) => Object.assign(new Error(message), { status })
|
const statusError = (message, status) => Object.assign(new Error(message), { status })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* request(path, { method, body }) -> geparstes JSON oder ein Error mit `status` (und bei 502 nach dem Festschreiben
|
* request(path, { method, body, as }) -> geparstes JSON oder ein Error mit `status` (und bei 502 nach dem Festschreiben
|
||||||
* `invoiceId` / `invoiceNumber`). Meldungen:
|
* `invoiceId` / `invoiceNumber`). Meldungen:
|
||||||
* - fetch wirft (Netz, Portal aus): „Das Kundenportal ist gerade nicht erreichbar.“ (status 0)
|
* - fetch wirft (Netz, Portal aus): „Das Kundenportal ist gerade nicht erreichbar.“ (status 0)
|
||||||
* - 502/503/504 ohne JSON-Antwort (Proxy-Fehlerseite): dieselbe Meldung (status = HTTP-Status)
|
* - 502/503/504 ohne JSON-Antwort (Proxy-Fehlerseite): dieselbe Meldung (status = HTTP-Status)
|
||||||
* - createJWT scheitert (Sitzung abgelaufen): „Anmeldung abgelaufen. Bitte neu anmelden.“ (status 401)
|
* - createJWT scheitert (Sitzung abgelaufen): „Anmeldung abgelaufen. Bitte neu anmelden.“ (status 401)
|
||||||
* - sonst die Meldung des Portals (`error`), ohne sie „API-Fehler <Status>“
|
* - sonst die Meldung des Portals (`error`), ohne sie „API-Fehler <Status>“
|
||||||
* Das JWT wird 10 Minuten gecacht; bei 401 einmal mit frischem JWT wiederholt.
|
* Das JWT wird 10 Minuten gecacht; bei 401 einmal mit frischem JWT wiederholt.
|
||||||
|
* Mit `as: 'blob'` liefert eine erfolgreiche Antwort den rohen Inhalt (Blob); Fehler kommen weiter als Meldung des Portals.
|
||||||
*/
|
*/
|
||||||
export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args) => globalThis.fetch(...args), now }) {
|
export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args) => globalThis.fetch(...args), now }) {
|
||||||
// JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer)
|
// JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer)
|
||||||
@@ -39,7 +40,7 @@ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return async function request(path, { method = 'GET', body } = {}) {
|
return async function request(path, { method = 'GET', body, as } = {}) {
|
||||||
const res = await fetchWithRetry(jwtCache, (jwt) =>
|
const res = await fetchWithRetry(jwtCache, (jwt) =>
|
||||||
portalFetch(`${baseUrl}${path}`, {
|
portalFetch(`${baseUrl}${path}`, {
|
||||||
method,
|
method,
|
||||||
@@ -47,6 +48,8 @@ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args)
|
|||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
// Dateien (z. B. das PDF einer Auftragsbestätigung) kommen roh; Fehlerantworten bleiben JSON mit Meldung
|
||||||
|
if (as === 'blob' && res.ok) return res.blob()
|
||||||
let data = null
|
let data = null
|
||||||
try {
|
try {
|
||||||
data = await res.json()
|
data = await res.json()
|
||||||
|
|||||||
@@ -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() === ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Anzeigelogik des Projektkontos (rein, ohne React und Appwrite; unter Node testbar).
|
||||||
|
// Aufgaben: Zeilen der Projektliste im Kundenreiter „Aufträge und Rechnungen“, die Spalten „Projekt“ und „Art“ der
|
||||||
|
// Rechnungstabelle und das Abzeichen „abgerechnet“ der Arbeitsblätter. Alle Beträge sind Cent; Ausgaben sind Zeichenketten.
|
||||||
|
import { euro, euroWhole } from './financeFormat'
|
||||||
|
|
||||||
|
const PLATZHALTER = '–'
|
||||||
|
|
||||||
|
export const STRIPE_HINWEIS = 'Stripe ist gerade nicht erreichbar. Berechnete und bezahlte Beträge sind deshalb nicht verfügbar.'
|
||||||
|
export const KEINE_PROJEKTE_HINWEIS = 'Dieser Kunde hat noch keine Projekte. Rechnungen entstehen ohne Projektbezug.'
|
||||||
|
|
||||||
|
const ART_LABEL = {
|
||||||
|
frei: 'Ohne Projekt',
|
||||||
|
festbetrag: 'Festbetrag',
|
||||||
|
aufwand: 'Aufwand',
|
||||||
|
anzahlung: 'Anzahlung',
|
||||||
|
schluss: 'Schlussrechnung',
|
||||||
|
abo: 'Abo',
|
||||||
|
}
|
||||||
|
|
||||||
|
function istZahl(v) {
|
||||||
|
return typeof v === 'number' && Number.isFinite(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function eigen(obj, key) {
|
||||||
|
return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 750 -> '12,5 Std.' (Dezimalstunden, höchstens zwei Nachkommastellen, ohne überflüssige Nullen). */
|
||||||
|
export function stundenDe(minuten) {
|
||||||
|
const min = istZahl(minuten) ? minuten : 0
|
||||||
|
const h = Math.round((min / 60) * 100) / 100
|
||||||
|
return `${String(h).replace('.', ',')} Std.`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 10000 -> '100 €/Std.', 8550 -> '85,50 €/Std.', keine Zahl -> '–'. */
|
||||||
|
export function satzText(cents) {
|
||||||
|
if (!istZahl(cents)) return PLATZHALTER
|
||||||
|
const ganz = Math.round(cents) % 100 === 0
|
||||||
|
return `${ganz ? euroWhole(cents) : euro(cents)}/Std.`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticketnummern mit vorangestellter Raute, durch Komma getrennt; Tickets ohne Nummer fallen weg. */
|
||||||
|
export function ticketsText(tickets) {
|
||||||
|
if (!Array.isArray(tickets)) return ''
|
||||||
|
return tickets
|
||||||
|
.map((t) => String(t?.woid ?? '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((w) => `#${w}`)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hinweis nur, wenn Stripe ausdrücklich nicht erreichbar war. */
|
||||||
|
export function stripeHinweis(stripeOk) {
|
||||||
|
return stripeOk === false ? STRIPE_HINWEIS : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text der Spalte „Vereinbart“: Festpreis, Aufwand mit Satz und Schätzung, beides oder „keine AB“. */
|
||||||
|
export function vereinbartText(vereinbart) {
|
||||||
|
const teile = []
|
||||||
|
if (istZahl(vereinbart?.festpreisCents)) teile.push(euro(vereinbart.festpreisCents))
|
||||||
|
const aufwand = vereinbart?.aufwand
|
||||||
|
if (aufwand) {
|
||||||
|
let t = `nach Aufwand, ${satzText(aufwand.stundensatzCents)}`
|
||||||
|
if (istZahl(aufwand.schaetzungMinuten) && aufwand.schaetzungMinuten > 0) t += `, Schätzung ${stundenDe(aufwand.schaetzungMinuten)}`
|
||||||
|
teile.push(t)
|
||||||
|
}
|
||||||
|
return teile.length ? teile.join(' und ') : 'keine AB'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Zeile der Projektliste. `stripeOk: false` ersetzt Berechnet, bezahlt und (Festpreis) Noch offen durch „–“. */
|
||||||
|
export function kontoZeile(konto, { stripeOk = true } = {}) {
|
||||||
|
const stripe = stripeOk !== false
|
||||||
|
const k = konto || {}
|
||||||
|
const zeit = k.zeit || {}
|
||||||
|
const vereinbart = k.vereinbart || {}
|
||||||
|
const name = String(k.name ?? '').trim() || 'Projekt'
|
||||||
|
const meta = [String(k.projektTyp ?? '').trim(), ticketsText(k.tickets)].filter(Boolean).join(' · ')
|
||||||
|
let offen = PLATZHALTER
|
||||||
|
if (istZahl(vereinbart.festpreisCents)) offen = stripe ? euro(k.offenCents) : PLATZHALTER
|
||||||
|
else if (vereinbart.aufwand) offen = euro(zeit.offenerWertCents)
|
||||||
|
return {
|
||||||
|
key: k.projektId,
|
||||||
|
projektId: k.projektId,
|
||||||
|
name,
|
||||||
|
meta,
|
||||||
|
vereinbart: vereinbartText(vereinbart),
|
||||||
|
berechnet: stripe ? euro(k.berechnetCents) : PLATZHALTER,
|
||||||
|
bezahlt: stripe ? `bezahlt ${euro(k.bezahltCents)}` : '',
|
||||||
|
offen,
|
||||||
|
zeit: {
|
||||||
|
stunden: stundenDe(zeit.minuten),
|
||||||
|
wert: `Wert ${euro(zeit.wertCents)}`,
|
||||||
|
abo: istZahl(zeit.aboMinuten) && zeit.aboMinuten > 0 ? `über Abo: ${stundenDe(zeit.aboMinuten)}` : '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die feste Zeile „Ohne Projekt“ am Ende der Liste. */
|
||||||
|
export function ohneProjektZeile(ohne, { stripeOk = true } = {}) {
|
||||||
|
const stripe = stripeOk !== false
|
||||||
|
const anzahl = istZahl(ohne?.anzahl) ? ohne.anzahl : 0
|
||||||
|
const meta = anzahl <= 0 ? 'keine Rechnungen' : anzahl === 1 ? '1 Rechnung' : `${anzahl} Rechnungen`
|
||||||
|
return {
|
||||||
|
key: 'ohne-projekt',
|
||||||
|
projektId: '',
|
||||||
|
name: 'Ohne Projekt',
|
||||||
|
meta,
|
||||||
|
vereinbart: PLATZHALTER,
|
||||||
|
berechnet: stripe ? euro(ohne?.berechnetCents ?? 0) : PLATZHALTER,
|
||||||
|
bezahlt: stripe ? `bezahlt ${euro(ohne?.bezahltCents ?? 0)}` : '',
|
||||||
|
offen: PLATZHALTER,
|
||||||
|
zeit: { stunden: PLATZHALTER, wert: '', abo: '' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spalte „Art“ der Rechnungstabelle. */
|
||||||
|
export function artLabel(art) {
|
||||||
|
return typeof art === 'string' && eigen(ART_LABEL, art) ? ART_LABEL[art] : PLATZHALTER
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spalte „Projekt“ der Rechnungstabelle. */
|
||||||
|
export function projektSpalte(invoice) {
|
||||||
|
return String(invoice?.projektName ?? '').trim() || PLATZHALTER
|
||||||
|
}
|
||||||
|
|
||||||
|
function abgerechnetWert(abgerechnet, worksheetId) {
|
||||||
|
if (!eigen(abgerechnet, worksheetId)) return null
|
||||||
|
const wert = abgerechnet[worksheetId]
|
||||||
|
return typeof wert === 'string' && wert.trim() ? wert.trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abzeichen am Arbeitsblatt: 'abgerechnet (WEBKLAR-0009)' oder 'abgerechnet (Entwurf)'; sonst null. */
|
||||||
|
export function abgerechnetText(abgerechnet, worksheetId) {
|
||||||
|
const wert = abgerechnetWert(abgerechnet, worksheetId)
|
||||||
|
return wert ? `abgerechnet (${wert})` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Warnung beim Ändern der Zeit eines abgerechneten Arbeitsblatts; sonst null. */
|
||||||
|
export function abgerechnetWarnung(abgerechnet, worksheetId) {
|
||||||
|
const wert = abgerechnetWert(abgerechnet, worksheetId)
|
||||||
|
return wert ? `Dieses Arbeitsblatt ist abgerechnet (${wert}). Eine Änderung ändert die Rechnung nicht.` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eindeutige Ticket-Kennungen der Arbeitsblätter in Reihenfolge des ersten Auftretens, höchstens fünf. */
|
||||||
|
export function ticketIdsOf(worksheets) {
|
||||||
|
if (!Array.isArray(worksheets)) return []
|
||||||
|
const ids = []
|
||||||
|
for (const w of worksheets) {
|
||||||
|
const id = String(w?.workorderId ?? '').trim()
|
||||||
|
if (id && !ids.includes(id)) ids.push(id)
|
||||||
|
if (ids.length >= 5) break
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// Anfragen des Rechnungsdialogs (rein, ohne React und Appwrite; unter Node testbar).
|
||||||
|
// Aufgaben: Körper für die Vorschau und für „Rechnung anlegen“ bauen und prüfen, Status der Vorschau, Zeilentexte der
|
||||||
|
// Aufwandsrechnung und das Absenden mit Rückfrage, Adress-Sperre und Doppelklick-Sperre.
|
||||||
|
// Die Eingabe `e` des Dialogs ist ein flaches Objekt (siehe rechnungAnfrage). Beträge sind Cent.
|
||||||
|
import {
|
||||||
|
INVOICE_LIMITS,
|
||||||
|
ADDRESS_INCOMPLETE_TEXT,
|
||||||
|
customerName,
|
||||||
|
finalizedFilter,
|
||||||
|
invoiceFailureMessage,
|
||||||
|
invoiceSendConfirmText,
|
||||||
|
invoiceSendGate,
|
||||||
|
isFinalizedNotSent,
|
||||||
|
parseQuantity,
|
||||||
|
parseUnitPrice,
|
||||||
|
validateInvoiceForm,
|
||||||
|
} from './financeView'
|
||||||
|
import { ausgewaehlteIds } from './rechnungDialogView'
|
||||||
|
|
||||||
|
export const LEISTUNGSZEITRAUM_MAX = 140
|
||||||
|
export const BEZEICHNUNG_MAX = 60
|
||||||
|
export const VORSCHAU_ARTEN = ['aufwand', 'anzahlung', 'schluss']
|
||||||
|
|
||||||
|
const ZEILEN_ART = /^(\d{4}-\d{2}-\d{2}|KW-\d{4}-\d{2})$/
|
||||||
|
const TRENNER = ' – ' // Leerzeichen, Gedankenstrich (U+2013), Leerzeichen
|
||||||
|
|
||||||
|
const text = (v) => String(v ?? '').trim()
|
||||||
|
|
||||||
|
/** Zusatzzeilen prüfen; eine Zeile ohne Beschreibung und ohne Preis zählt nicht. */
|
||||||
|
function zusatzPruefen(rows, label = 'Zusatzposition') {
|
||||||
|
const items = []
|
||||||
|
const liste = Array.isArray(rows) ? rows : []
|
||||||
|
for (let i = 0; i < liste.length; i += 1) {
|
||||||
|
const row = liste[i] || {}
|
||||||
|
const description = text(row.description)
|
||||||
|
if (!description && !text(row.price)) continue
|
||||||
|
const name = `${label} ${i + 1}`
|
||||||
|
if (!description) return { ok: false, error: `${name}: Bitte eine Beschreibung eingeben.` }
|
||||||
|
if (description.length > INVOICE_LIMITS.maxDescription) {
|
||||||
|
return { ok: false, error: `${name}: Die Beschreibung darf höchstens ${INVOICE_LIMITS.maxDescription} Zeichen lang sein.` }
|
||||||
|
}
|
||||||
|
const quantity = parseQuantity(row.quantity)
|
||||||
|
if (quantity === null) return { ok: false, error: `${name}: Die Menge muss eine ganze Zahl von 1 bis ${INVOICE_LIMITS.maxQuantity} sein.` }
|
||||||
|
const unit = parseUnitPrice(row.price)
|
||||||
|
if (unit === null) return { ok: false, error: `${name}: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).` }
|
||||||
|
items.push({ description, quantity, unitAmountCents: unit })
|
||||||
|
}
|
||||||
|
return { ok: true, items }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '33,5' -> 33.5; nur 0 < Wert <= 100 mit höchstens zwei Nachkommastellen, sonst null. */
|
||||||
|
function parseProzent(value) {
|
||||||
|
const s = text(value).replace(',', '.')
|
||||||
|
if (!/^[0-9]{1,3}(\.[0-9]{1,2})?$/.test(s)) return null
|
||||||
|
const n = Number(s)
|
||||||
|
return n > 0 && n <= 100 ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Körper für die Vorschau; `error: ''` heißt „keine Vorschau für diese Art“. */
|
||||||
|
export function vorschauAnfrage(e) {
|
||||||
|
const art = e?.art
|
||||||
|
if (!VORSCHAU_ARTEN.includes(art)) return { ok: false, error: '' }
|
||||||
|
if (!e.kunde) return { ok: false, error: 'Bitte einen Kunden wählen.' }
|
||||||
|
const body = { customerId: e.kunde.$id, art, projektId: e.projektId }
|
||||||
|
|
||||||
|
if (art === 'aufwand') {
|
||||||
|
const ids = ausgewaehlteIds(e.blaetter, e.abgewaehlt)
|
||||||
|
if (ids.length === 0) return { ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
|
||||||
|
body.arbeitsblattIds = ids
|
||||||
|
} else {
|
||||||
|
if (!e.auftragId) return { ok: false, error: 'Bitte eine Auftragsbestätigung wählen.' }
|
||||||
|
body.auftragId = e.auftragId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (art === 'anzahlung') {
|
||||||
|
const a = e.anzahlung || {}
|
||||||
|
const betrag = {}
|
||||||
|
if (a.modus === 'betrag') {
|
||||||
|
const cents = parseUnitPrice(a.wert)
|
||||||
|
if (cents === null) return { ok: false, error: 'Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.' }
|
||||||
|
betrag.betragCents = cents
|
||||||
|
} else {
|
||||||
|
const prozent = parseProzent(a.wert)
|
||||||
|
if (prozent === null) return { ok: false, error: 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.' }
|
||||||
|
betrag.prozent = prozent
|
||||||
|
}
|
||||||
|
const bezeichnung = text(a.bezeichnung)
|
||||||
|
if (!bezeichnung || bezeichnung.length > BEZEICHNUNG_MAX) {
|
||||||
|
return { ok: false, error: `Bitte eine Bezeichnung eingeben (höchstens ${BEZEICHNUNG_MAX} Zeichen).` }
|
||||||
|
}
|
||||||
|
body.anzahlung = { ...betrag, bezeichnung }
|
||||||
|
} else {
|
||||||
|
const z = zusatzPruefen(e.zusatz)
|
||||||
|
if (!z.ok) return z
|
||||||
|
if (z.items.length) body.items = z.items
|
||||||
|
}
|
||||||
|
|
||||||
|
if (art === 'aufwand') {
|
||||||
|
const zeilentexte = {}
|
||||||
|
for (const [schluessel, t] of Object.entries(e.zeilentexte || {})) {
|
||||||
|
if (text(t)) zeilentexte[schluessel] = text(t)
|
||||||
|
}
|
||||||
|
if (Object.keys(zeilentexte).length) body.zeilentexte = zeilentexte
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeitraum = leistungszeitraumFeld(e)
|
||||||
|
if (zeitraum.error) return { ok: false, error: zeitraum.error }
|
||||||
|
if (zeitraum.wert) body.leistungszeitraum = zeitraum.wert
|
||||||
|
return { ok: true, body }
|
||||||
|
}
|
||||||
|
|
||||||
|
function leistungszeitraumFeld(e) {
|
||||||
|
const wert = e?.zeitraumGeaendert ? text(e.zeitraum) : ''
|
||||||
|
if (wert.length > LEISTUNGSZEITRAUM_MAX) {
|
||||||
|
return { error: `Der Leistungszeitraum darf höchstens ${LEISTUNGSZEITRAUM_MAX} Zeichen lang sein.`, wert: '' }
|
||||||
|
}
|
||||||
|
return { error: '', wert }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Körper für „Rechnung anlegen“ (ohne `senden`); `totalCents` nur bei freien Positionen und Festbetrag. */
|
||||||
|
export function rechnungAnfrage(e) {
|
||||||
|
const art = e?.art
|
||||||
|
if (art === 'frei' || art === 'festbetrag') {
|
||||||
|
// die Sätze und Grenzen für Kunde, Positionen, Zahlungsziel und Notiz gelten unverändert aus dem bisherigen Dialog
|
||||||
|
const form = validateInvoiceForm({ customer: e.kunde, items: e.items, days: e.days, memo: e.memo })
|
||||||
|
if (!form.ok) return { ok: false, error: form.error }
|
||||||
|
if (art === 'festbetrag' && !e.projektId) return { ok: false, error: 'Bitte ein Projekt wählen.' }
|
||||||
|
const zeitraum = leistungszeitraumFeld(e)
|
||||||
|
if (zeitraum.error) return { ok: false, error: zeitraum.error }
|
||||||
|
const body = { ...form.payload, art }
|
||||||
|
if (art === 'festbetrag') body.projektId = e.projektId
|
||||||
|
if (zeitraum.wert) body.leistungszeitraum = zeitraum.wert
|
||||||
|
return { ok: true, body, totalCents: form.totalCents }
|
||||||
|
}
|
||||||
|
const v = vorschauAnfrage(e)
|
||||||
|
if (!v.ok) return v
|
||||||
|
const form = validateInvoiceForm({
|
||||||
|
customer: e.kunde,
|
||||||
|
items: [{ description: 'x', quantity: '1', price: '1' }],
|
||||||
|
days: e.days,
|
||||||
|
memo: e.memo,
|
||||||
|
})
|
||||||
|
if (!form.ok) return { ok: false, error: form.error }
|
||||||
|
const body = { ...v.body, daysUntilDue: form.payload.daysUntilDue }
|
||||||
|
if (form.payload.memo) body.memo = form.payload.memo
|
||||||
|
return { ok: true, body, totalCents: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schlüssel der Eingabe, die Zahlen der Vorschau ändert (Texte und Leistungszeitraum zählen nicht). */
|
||||||
|
export function vorschauSchluessel(body) {
|
||||||
|
if (!body) return ''
|
||||||
|
const { zeilentexte, leistungszeitraum, ...rest } = body
|
||||||
|
void zeilentexte
|
||||||
|
void leistungszeitraum
|
||||||
|
if (Array.isArray(rest.arbeitsblattIds)) rest.arbeitsblattIds = [...rest.arbeitsblattIds].sort()
|
||||||
|
return JSON.stringify(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zustand der Vorschau: nicht-noetig | ungueltig | laedt | fehler | bereit. */
|
||||||
|
export function vorschauStatus({ art, anfrage, vorschau, schluessel }) {
|
||||||
|
if (!VORSCHAU_ARTEN.includes(art)) return { state: 'nicht-noetig', message: '' }
|
||||||
|
if (!anfrage?.ok) return { state: 'ungueltig', message: anfrage?.error || '' }
|
||||||
|
if (!vorschau || vorschau.key !== schluessel || vorschau.status === 'laedt') return { state: 'laedt', message: '' }
|
||||||
|
if (vorschau.status === 'fehler') return { state: 'fehler', message: vorschau.message || 'Die Vorschau konnte nicht geladen werden.' }
|
||||||
|
if (!(Number(vorschau.daten?.summeCents) > 0)) return { state: 'fehler', message: 'Nichts mehr offen.' }
|
||||||
|
return { state: 'bereit', message: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entwurf und Senden sind nur erlaubt, wenn keine Vorschau nötig ist oder sie zur aktuellen Eingabe gehört. */
|
||||||
|
export function darfSpeichern(status) {
|
||||||
|
return status?.state === 'bereit' || status?.state === 'nicht-noetig'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeile der Aufwandsrechnung am letzten ' – ' teilen: vorn bearbeitbar, hinten fest. */
|
||||||
|
export function zeileTeilen(beschreibung) {
|
||||||
|
const s = String(beschreibung ?? '')
|
||||||
|
const i = s.lastIndexOf(TRENNER)
|
||||||
|
if (i < 0) return { vorn: s, hinten: '' }
|
||||||
|
return { vorn: s.slice(0, i), hinten: s.slice(i) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur Zeilen mit Tag oder Kalenderwoche als Schlüssel und festem Hinten-Teil sind bearbeitbar. */
|
||||||
|
export function zeileBearbeitbar(position) {
|
||||||
|
return ZEILEN_ART.test(String(position?.schluessel ?? '')) && zeileTeilen(position?.beschreibung).hinten !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Höchstlänge des bearbeitbaren Teils, damit die ganze Zeile bei 200 Zeichen bleibt. */
|
||||||
|
export function maxVorn(hinten) {
|
||||||
|
return Math.max(1, INVOICE_LIMITS.maxDescription - String(hinten ?? '').length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absenden mit Rückfrage und Sperren. `submit({ anfrage, senden, kunde, totalCents })` liefert
|
||||||
|
* ok | abgebrochen | laeuft | fehler | festgeschrieben. Ein zweiter Aufruf, solange der erste läuft, wird verworfen.
|
||||||
|
*/
|
||||||
|
export function createSubmitter({ create, confirm }) {
|
||||||
|
let running = false
|
||||||
|
return async function submit({ anfrage, senden, kunde, totalCents }) {
|
||||||
|
if (running) return { status: 'laeuft' }
|
||||||
|
if (!anfrage?.ok) return { status: 'fehler', message: anfrage?.error || '' }
|
||||||
|
if (senden) {
|
||||||
|
if (!invoiceSendGate(kunde, totalCents).sendAllowed) return { status: 'fehler', message: ADDRESS_INCOMPLETE_TEXT }
|
||||||
|
const frage = invoiceSendConfirmText({ name: customerName(kunde), email: kunde?.email, totalCents })
|
||||||
|
if (!confirm(frage)) return { status: 'abgebrochen' }
|
||||||
|
}
|
||||||
|
running = true
|
||||||
|
try {
|
||||||
|
const data = await create({ ...anfrage.body, senden: Boolean(senden) })
|
||||||
|
return { status: 'ok', rechnung: data?.rechnung || null, senden: Boolean(senden) }
|
||||||
|
} catch (err) {
|
||||||
|
if (isFinalizedNotSent(err)) {
|
||||||
|
return { status: 'festgeschrieben', notice: invoiceFailureMessage(err), finalizedFilter: finalizedFilter(err) }
|
||||||
|
}
|
||||||
|
const out = { status: 'fehler', message: invoiceFailureMessage(err) }
|
||||||
|
if (Number.isInteger(err?.status)) out.httpStatus = err.status
|
||||||
|
return out
|
||||||
|
} finally {
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { databases, DATABASE_ID, COLLECTIONS, Query } from './appwrite'
|
||||||
|
|
||||||
|
/** Tickets eines Kunden, neueste zuerst (für „Weiteres Ticket hinzunehmen“). */
|
||||||
|
export async function listKundenTickets(customerId) {
|
||||||
|
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
|
||||||
|
Query.equal('customerId', customerId),
|
||||||
|
Query.orderDesc('$createdAt'),
|
||||||
|
Query.limit(100),
|
||||||
|
])
|
||||||
|
return res.documents
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Arbeitsblätter eines Tickets (nach Ticketnummer `woid`). */
|
||||||
|
export async function listTicketBlaetter(woid) {
|
||||||
|
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKSHEETS, [
|
||||||
|
Query.equal('woid', woid),
|
||||||
|
Query.orderAsc('$createdAt'),
|
||||||
|
Query.limit(5000),
|
||||||
|
])
|
||||||
|
return res.documents
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Zustand des Rechnungsdialogs „Rechnung schreiben“ (rein, ohne React; unter Node testbar).
|
||||||
|
// `anfangszustand` liefert den leeren Zustand, `reduce` verarbeitet die Aktionen des Dialogs. Unbekannte Aktionen,
|
||||||
|
// unbekannte Listen und Felder sowie Aktionen ohne Wirkung geben denselben Zustand (dieselbe Referenz) zurück.
|
||||||
|
import { INVOICE_LIMITS } from './financeView'
|
||||||
|
|
||||||
|
const LISTEN = ['items', 'zusatz']
|
||||||
|
const FELDER = ['description', 'quantity', 'price']
|
||||||
|
const MINDESTENS = { items: 1, zusatz: 0 }
|
||||||
|
|
||||||
|
const leereZeile = (uid) => ({ uid, description: '', quantity: '1', price: '' })
|
||||||
|
|
||||||
|
/** Startzustand. projektId: null = noch zu wählen, '' = Ohne Projekt, Text = dieses Projekt. */
|
||||||
|
export function anfangszustand({ projektId = null } = {}) {
|
||||||
|
return {
|
||||||
|
projektId,
|
||||||
|
art: '',
|
||||||
|
auftragId: '',
|
||||||
|
items: [leereZeile(1)],
|
||||||
|
zusatz: [],
|
||||||
|
abgewaehlt: [],
|
||||||
|
weitereTickets: [],
|
||||||
|
anzahlung: {},
|
||||||
|
zeilentexte: {},
|
||||||
|
zeitraum: '',
|
||||||
|
zeitraumGeaendert: false,
|
||||||
|
days: String(INVOICE_LIMITS.defaultDays),
|
||||||
|
tageGeaendert: false,
|
||||||
|
memo: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function neueUid(s) {
|
||||||
|
return 1 + Math.max(0, ...s.items.map((z) => z.uid), ...s.zusatz.map((z) => z.uid))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduce(s, a) {
|
||||||
|
switch (a?.type) {
|
||||||
|
case 'projekt':
|
||||||
|
return { ...anfangszustand({ projektId: a.projektId }), days: s.days, tageGeaendert: s.tageGeaendert, memo: s.memo }
|
||||||
|
case 'art':
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
art: a.art,
|
||||||
|
auftragId: '',
|
||||||
|
zeilentexte: {},
|
||||||
|
zeitraum: s.zeitraumGeaendert ? s.zeitraum : '',
|
||||||
|
}
|
||||||
|
case 'auftrag':
|
||||||
|
return { ...s, auftragId: a.auftragId, anzahlung: {} }
|
||||||
|
case 'position': {
|
||||||
|
if (!LISTEN.includes(a.liste) || !FELDER.includes(a.feld)) return s
|
||||||
|
return { ...s, [a.liste]: s[a.liste].map((z) => (z.uid === a.uid ? { ...z, [a.feld]: a.wert } : z)) }
|
||||||
|
}
|
||||||
|
case 'position+': {
|
||||||
|
if (!LISTEN.includes(a.liste) || s[a.liste].length >= INVOICE_LIMITS.maxItems) return s
|
||||||
|
return { ...s, [a.liste]: [...s[a.liste], leereZeile(neueUid(s))] }
|
||||||
|
}
|
||||||
|
case 'position-': {
|
||||||
|
if (!LISTEN.includes(a.liste) || s[a.liste].length <= MINDESTENS[a.liste]) return s
|
||||||
|
return { ...s, [a.liste]: s[a.liste].filter((z) => z.uid !== a.uid) }
|
||||||
|
}
|
||||||
|
case 'blatt':
|
||||||
|
return { ...s, abgewaehlt: s.abgewaehlt.includes(a.id) ? s.abgewaehlt.filter((id) => id !== a.id) : [...s.abgewaehlt, a.id] }
|
||||||
|
case 'ticket+':
|
||||||
|
return s.weitereTickets.some((t) => t.ticketId === a.ticket.ticketId) ? s : { ...s, weitereTickets: [...s.weitereTickets, a.ticket] }
|
||||||
|
case 'ticket-': {
|
||||||
|
const weg = Array.isArray(a.blattIds) ? a.blattIds : []
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
weitereTickets: s.weitereTickets.filter((t) => t.ticketId !== a.ticketId),
|
||||||
|
abgewaehlt: s.abgewaehlt.filter((id) => !weg.includes(id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'anzahlung':
|
||||||
|
return { ...s, anzahlung: { ...s.anzahlung, ...a.patch } }
|
||||||
|
case 'zeilentext': {
|
||||||
|
const zeilentexte = { ...s.zeilentexte }
|
||||||
|
if (!String(a.text ?? '').trim() || a.text === a.original) delete zeilentexte[a.schluessel]
|
||||||
|
else zeilentexte[a.schluessel] = a.text
|
||||||
|
return { ...s, zeilentexte }
|
||||||
|
}
|
||||||
|
case 'zeitraum':
|
||||||
|
return { ...s, zeitraum: a.text, zeitraumGeaendert: true }
|
||||||
|
case 'zeitraum-vorschlag':
|
||||||
|
if (s.zeitraumGeaendert || s.zeitraum === a.text) return s
|
||||||
|
return { ...s, zeitraum: a.text }
|
||||||
|
case 'tage':
|
||||||
|
return { ...s, days: a.wert, tageGeaendert: true }
|
||||||
|
case 'tage-vorschlag':
|
||||||
|
// das Zahlungsziel der gewählten AB; eine Eingabe des Nutzers überschreibt er nie
|
||||||
|
if (s.tageGeaendert || s.days === a.wert) return s
|
||||||
|
return { ...s, days: a.wert }
|
||||||
|
case 'notiz':
|
||||||
|
return { ...s, memo: a.wert }
|
||||||
|
default:
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// Dialoglogik der Rechnung „Rechnung schreiben“ (rein, ohne React und Appwrite; unter Node testbar).
|
||||||
|
// Aufgaben: Projektwahl, waehlbare Rechnungsarten, gültige Auftragsbestätigungen (AB), Vorgaben für Anzahlung und Standardart,
|
||||||
|
// Arbeitsblätter (Auswahl, Summen, Umwandlung aus Appwrite-Dokumenten) und die Ticketliste für „Weiteres Ticket hinzunehmen“.
|
||||||
|
import { euro, dateDe, ymdBerlin } from './financeFormat'
|
||||||
|
import { INVOICE_LIMITS } from './financeView'
|
||||||
|
import { steuernummerFehlt } from './kundenFelder'
|
||||||
|
import { worksheetMinutes } from './support'
|
||||||
|
import { germanDateToIso } from './ticketForm'
|
||||||
|
import { ticketsText } from './projektkontoView'
|
||||||
|
|
||||||
|
export const ART_OPTION_LABEL = {
|
||||||
|
frei: 'Freie Positionen',
|
||||||
|
anzahlung: 'Anzahlung',
|
||||||
|
schluss: 'Schlussrechnung',
|
||||||
|
aufwand: 'Nach Aufwand',
|
||||||
|
festbetrag: 'Festbetrag',
|
||||||
|
}
|
||||||
|
export const AB_HINWEIS = 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.'
|
||||||
|
export const ENTWURF_HINWEIS = 'Zu dieser Auftragsbestätigung gibt es noch einen Entwurf. Erst senden oder löschen.'
|
||||||
|
export const OHNE_PROJEKT = 'Ohne Projekt'
|
||||||
|
export const STRIPE_UNBEKANNT = 'Stripe nicht erreichbar – Beträge unbekannt'
|
||||||
|
export const STEUERNUMMER_RECHNUNG_HINWEIS =
|
||||||
|
'Die Steuernummer fehlt noch – sie gehört auf jede Rechnung (§ 34a UStDV). Eintragen unter Finanzen → Einstellungen.'
|
||||||
|
|
||||||
|
/** Hinweis im Rechnungsdialog, solange die Steuernummer fehlt; ohne Einstellungen (lädt, Ladefehler) kein Hinweis. */
|
||||||
|
export function steuernummerHinweis(einstellungen) {
|
||||||
|
return steuernummerFehlt(einstellungen) ? STEUERNUMMER_RECHNUNG_HINWEIS : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUELTIG = ['versendet', 'bestaetigt']
|
||||||
|
const FINALISIERT = ['offen', 'ueberfaellig', 'bezahlt', 'uneinbringlich']
|
||||||
|
|
||||||
|
// --- Projektwahl --------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Vorauswahl der Aufrufer: undefined = keine (null), null = „Ohne Projekt“ (''), Text = dieses Projekt. */
|
||||||
|
export function startProjekt(propProjektId) {
|
||||||
|
if (propProjektId === undefined) return null
|
||||||
|
if (propProjektId === null) return ''
|
||||||
|
return String(propProjektId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auswahlliste: Projekte mit Ticketnummer, am Ende „Ohne Projekt“. */
|
||||||
|
export function projektOptionen(konten) {
|
||||||
|
const liste = Array.isArray(konten) ? konten : []
|
||||||
|
const optionen = liste
|
||||||
|
.filter((k) => k && String(k.projektId ?? '') !== '')
|
||||||
|
.map((k) => {
|
||||||
|
const name = String(k.name ?? '').trim() || 'Projekt'
|
||||||
|
const tickets = ticketsText(k.tickets)
|
||||||
|
return { value: String(k.projektId), label: tickets ? `${name} · ${tickets}` : name }
|
||||||
|
})
|
||||||
|
optionen.push({ value: '', label: OHNE_PROJEKT })
|
||||||
|
return optionen
|
||||||
|
}
|
||||||
|
|
||||||
|
/** null = noch zu wählen, '' = Ohne Projekt. Ein Kunde ohne Projekte hat nur „Ohne Projekt“. */
|
||||||
|
export function wirksamesProjekt(wahl, konten) {
|
||||||
|
if (wahl === '') return ''
|
||||||
|
if (typeof wahl === 'string' && wahl) {
|
||||||
|
if (!Array.isArray(konten)) return wahl
|
||||||
|
return konten.some((k) => k?.projektId === wahl) ? wahl : null
|
||||||
|
}
|
||||||
|
return Array.isArray(konten) && konten.length === 0 ? '' : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auftragsbestätigungen ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function neuesteZuerst(liste) {
|
||||||
|
const zeit = (a) => {
|
||||||
|
const t = Date.parse(a?.gesendetAm)
|
||||||
|
return Number.isFinite(t) ? t : 0
|
||||||
|
}
|
||||||
|
return [...liste].sort((a, b) => zeit(b) - zeit(a) || String(b?.nummer ?? '').localeCompare(String(a?.nummer ?? '')))
|
||||||
|
}
|
||||||
|
|
||||||
|
function gueltige(auftraege, art) {
|
||||||
|
if (!Array.isArray(auftraege)) return []
|
||||||
|
return neuesteZuerst(auftraege.filter((a) => a && a.art === art && GUELTIG.includes(a.status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gültige Festpreis-ABs (versendet oder bestätigt), neueste zuerst. */
|
||||||
|
export function gueltigeFestpreisAbs(auftraege) {
|
||||||
|
return gueltige(auftraege, 'festpreis')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jüngste gültige Aufwands-AB oder null. */
|
||||||
|
export function aufwandAuftrag(auftraege) {
|
||||||
|
return gueltige(auftraege, 'aufwand')[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'AB-2026-0002 vom 06.10.2026 · 800,00 €' bzw. '… · nach Aufwand'. */
|
||||||
|
export function abLabel(auftrag) {
|
||||||
|
let text = String(auftrag?.nummer ?? '')
|
||||||
|
const datum = dateDe(auftrag?.gesendetAm)
|
||||||
|
if (datum !== '–') text += ` vom ${datum}`
|
||||||
|
text += auftrag?.art === 'aufwand' ? ' · nach Aufwand' : ` · ${euro(auftrag?.summeCents)}`
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summe der finalisierten Rechnungen zu dieser AB (Cent). */
|
||||||
|
export function berechnetFuerAuftrag(rechnungen, auftragId) {
|
||||||
|
if (!Array.isArray(rechnungen)) return 0
|
||||||
|
return rechnungen
|
||||||
|
.filter((r) => r && r.auftragId === auftragId && FINALISIERT.includes(r.status))
|
||||||
|
.reduce((summe, r) => summe + (Number(r.totalCents) || 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt es zu dieser AB einen noch nicht gesendeten Entwurf? */
|
||||||
|
export function hatEntwurfZurAuftrag(rechnungen, auftragId) {
|
||||||
|
return Array.isArray(rechnungen) && rechnungen.some((r) => r && r.auftragId === auftragId && r.status === 'entwurf')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeile unter der AB-Wahl. Ist Stripe nicht erreichbar, sind die berechneten Beträge unbekannt (nicht 0,00 €). */
|
||||||
|
export function abSummenText({ ab, rechnungen, stripeOk }) {
|
||||||
|
const summe = `Auftragssumme ${euro(ab?.summeCents)}`
|
||||||
|
if (stripeOk === false) return `${summe} · ${STRIPE_UNBEKANNT}`
|
||||||
|
const berechnet = berechnetFuerAuftrag(rechnungen, ab?.id)
|
||||||
|
return `${summe} · bereits berechnet ${euro(berechnet)} · noch nicht berechnet ${euro(Math.max(0, (Number(ab?.summeCents) || 0) - berechnet))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufträge und Rechnungen für „Ohne Projekt“ aus der Auftragsliste des Kunden ({ auftraege }) und seiner Rechnungsliste
|
||||||
|
* ({ rechnungen }): nur ABs ohne Projekt und die Rechnungen, die zu ihnen gehören. -> { auftraege, rechnungen }
|
||||||
|
*/
|
||||||
|
export function ohneProjektAbDaten(auftragsAntwort, rechnungsAntwort) {
|
||||||
|
const abs = Array.isArray(auftragsAntwort?.auftraege) ? auftragsAntwort.auftraege.filter((a) => a && !a.projektId) : []
|
||||||
|
const ids = new Set(abs.map((a) => a.id))
|
||||||
|
const alle = Array.isArray(rechnungsAntwort?.rechnungen) ? rechnungsAntwort.rechnungen : []
|
||||||
|
return { auftraege: abs, rechnungen: alle.filter((r) => r && r.auftragId && ids.has(r.auftragId)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rechnungsarten -----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Wählbare Arten mit Sperrgrund: [{ key, label, enabled, hint }]. */
|
||||||
|
export function artOptionen({ projektId, auftraege, rechnungen, auftraegeAktiv }) {
|
||||||
|
if (projektId === null || projektId === undefined) return []
|
||||||
|
const option = (key, enabled, hint = '') => ({ key, label: ART_OPTION_LABEL[key], enabled, hint: enabled ? '' : hint })
|
||||||
|
if (projektId === '') {
|
||||||
|
// Eine gültige Festpreis-AB ohne Projekt erlaubt Anzahlung und Schlussrechnung (der Server nimmt sie ohne Projekt an)
|
||||||
|
const ohne = auftraegeAktiv ? gueltigeFestpreisAbs(Array.isArray(auftraege) ? auftraege.filter((a) => a && !a.projektId) : []) : []
|
||||||
|
if (ohne.length === 0) return [option('frei', true)]
|
||||||
|
const ohneEntwurf = ohne.filter((a) => !hatEntwurfZurAuftrag(rechnungen, a.id))
|
||||||
|
return [option('frei', true), option('anzahlung', true), option('schluss', ohneEntwurf.length > 0, ENTWURF_HINWEIS)]
|
||||||
|
}
|
||||||
|
const abs = auftraegeAktiv ? gueltigeFestpreisAbs(auftraege) : []
|
||||||
|
const freie = abs.filter((a) => !hatEntwurfZurAuftrag(rechnungen, a.id))
|
||||||
|
const schlussHinweis = abs.length === 0 ? AB_HINWEIS : ENTWURF_HINWEIS
|
||||||
|
return [
|
||||||
|
option('anzahlung', abs.length > 0, AB_HINWEIS),
|
||||||
|
option('schluss', freie.length > 0, schlussHinweis),
|
||||||
|
option('aufwand', true),
|
||||||
|
option('festbetrag', true),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorbelegte Art: Anzahlung, Schlussrechnung, Aufwand oder Festbetrag je nach Lage ('' ohne Wahl, auch bei Stripe-Ausfall). */
|
||||||
|
export function standardArt(optionen, { auftraege, rechnungen, offeneMinuten, stripeOk = true }) {
|
||||||
|
const liste = Array.isArray(optionen) ? optionen : []
|
||||||
|
if (liste.length === 0) return ''
|
||||||
|
if (liste.some((o) => o.key === 'frei' && o.enabled)) return 'frei' // ohne Projekt
|
||||||
|
// Ist Stripe nicht erreichbar, sind die berechneten Beträge unbekannt: keine Art vorschlagen
|
||||||
|
if (stripeOk === false) return ''
|
||||||
|
const waehlbar = (key) => liste.some((o) => o.key === key && o.enabled)
|
||||||
|
const jung = gueltigeFestpreisAbs(auftraege)[0]
|
||||||
|
if (
|
||||||
|
waehlbar('anzahlung') &&
|
||||||
|
jung?.zahlungsplan?.art === 'anzahlung' &&
|
||||||
|
berechnetFuerAuftrag(rechnungen, jung.id) === 0 &&
|
||||||
|
!hatEntwurfZurAuftrag(rechnungen, jung.id)
|
||||||
|
) {
|
||||||
|
return 'anzahlung'
|
||||||
|
}
|
||||||
|
if (waehlbar('schluss')) return 'schluss'
|
||||||
|
return Number(offeneMinuten) > 0 ? 'aufwand' : 'festbetrag'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorbelegte AB: jüngste passende; bei Schlussrechnung ohne offenen Entwurf; sonst ''. */
|
||||||
|
export function standardAuftrag(art, auftraege, rechnungen = []) {
|
||||||
|
const abs = gueltigeFestpreisAbs(auftraege)
|
||||||
|
if (art === 'anzahlung') return abs[0]?.id ?? ''
|
||||||
|
if (art === 'schluss') return abs.find((a) => !hatEntwurfZurAuftrag(rechnungen, a.id))?.id ?? ''
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorbelegung „Zahlungsziel in Tagen“: bei Anzahlung und Schlussrechnung das Ziel der gewählten AB, bei Aufwand das der
|
||||||
|
* jüngsten gültigen Aufwands-AB (`auftraege`), sonst 14. Nur ganze Zahlen von 0 bis 60 zählen. -> Text
|
||||||
|
*/
|
||||||
|
export function zahlungszielVorschlag({ art, ab = null, auftraege = [] } = {}) {
|
||||||
|
const standard = String(INVOICE_LIMITS.defaultDays)
|
||||||
|
let quelle = null
|
||||||
|
if (art === 'anzahlung' || art === 'schluss') quelle = ab
|
||||||
|
else if (art === 'aufwand') quelle = aufwandAuftrag(auftraege)
|
||||||
|
const tage = quelle?.zahlungszielTage
|
||||||
|
return Number.isSafeInteger(tage) && tage >= 0 && tage <= INVOICE_LIMITS.maxDays ? String(tage) : standard
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorgabe der Anzahlung: Prozent aus dem Zahlungsplan, sonst 50. */
|
||||||
|
export function anzahlungStandard(auftrag) {
|
||||||
|
const plan = auftrag?.zahlungsplan
|
||||||
|
const prozent = plan?.art === 'anzahlung' && Number(plan.prozent) > 0 ? Number(plan.prozent) : 50
|
||||||
|
return { modus: 'prozent', wert: String(prozent).replace('.', ','), bezeichnung: 'Anzahlung' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Arbeitsblätter -----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Kennungen der gewählten Blätter (alle außer den abgewählten) in Reihenfolge der Liste. */
|
||||||
|
export function ausgewaehlteIds(blaetter, abgewaehlt) {
|
||||||
|
if (!Array.isArray(blaetter)) return []
|
||||||
|
const weg = Array.isArray(abgewaehlt) ? abgewaehlt : []
|
||||||
|
return blaetter.map((b) => b.id).filter((id) => !weg.includes(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anzahl und Minutensumme der Blätter mit diesen Kennungen. */
|
||||||
|
export function blaetterSumme(blaetter, ids) {
|
||||||
|
const liste = (Array.isArray(blaetter) ? blaetter : []).filter((b) => Array.isArray(ids) && ids.includes(b.id))
|
||||||
|
return { anzahl: liste.length, minuten: liste.reduce((s, b) => s + (Number(b.minuten) || 0), 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appwrite-Arbeitsblatt -> Blatt der Portal-Form; null, wenn es nicht abrechenbar ist oder schon abgerechnet wurde. */
|
||||||
|
export function blattAusAppwrite(ws, abgerechnet) {
|
||||||
|
if (!ws || !ws.$id) return null
|
||||||
|
if (ws.isComment || ws.countsToPlan) return null
|
||||||
|
if (abgerechnet && Object.prototype.hasOwnProperty.call(abgerechnet, ws.$id)) return null
|
||||||
|
const roh = worksheetMinutes(ws)
|
||||||
|
if (!(roh > 0)) return null
|
||||||
|
const zeile = String(ws.details ?? '')
|
||||||
|
.split('\n')
|
||||||
|
.map((z) => z.trim())
|
||||||
|
.find(Boolean)
|
||||||
|
return {
|
||||||
|
id: ws.$id,
|
||||||
|
ticketId: ws.workorderId,
|
||||||
|
woid: ws.woid,
|
||||||
|
datum: germanDateToIso(ws.startDate || ws.endDate) || ymdBerlin(ws.$createdAt) || '',
|
||||||
|
minuten: Math.ceil(roh / 15) * 15,
|
||||||
|
text: (zeile ?? '').slice(0, 120),
|
||||||
|
aboZeit: false,
|
||||||
|
kommentar: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tickets des Kunden mit Nummer, ohne die schon gewählten. */
|
||||||
|
export function ticketOptionen(tickets, { ausgeschlossen = [] } = {}) {
|
||||||
|
if (!Array.isArray(tickets)) return []
|
||||||
|
return tickets
|
||||||
|
.filter((t) => t && t.$id && t.woid && !ausgeschlossen.includes(t.$id))
|
||||||
|
.map((t) => ({
|
||||||
|
value: t.$id,
|
||||||
|
ticketId: t.$id,
|
||||||
|
woid: t.woid,
|
||||||
|
label: `#${t.woid} · ${String(t.topic || t.title || '').trim() || 'Kein Betreff'}`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { account } from './appwrite'
|
import { account } from './appwrite'
|
||||||
import { createFinanceRequest } from './financeRequest'
|
import { createFinanceRequest } from './financeRequest'
|
||||||
import { createJwtCache } from './serverpulsAuth'
|
import { createJwtCache } from './serverpulsAuth'
|
||||||
|
import { abgerechnetPfad } from './auftraegeEndpoints'
|
||||||
import { anleitungPfad, featureBody, entscheidungBody } from './funktionenView'
|
import { anleitungPfad, featureBody, entscheidungBody } from './funktionenView'
|
||||||
import { ersatzPfad, zeitenTeamPfad } from './zeitenView'
|
import { ersatzPfad, zeitenTeamPfad } from './zeitenView'
|
||||||
|
|
||||||
@@ -127,6 +128,10 @@ export const getZeitenErsatz = (customerId, projectId, plan) => request(ersatzPf
|
|||||||
/** Team-Einstellungen (Rumpf: teamBody in zeitenView): { team, teamVersion }; 400 ungueltig, 409 veraltet (err.code ZEITEN_VERALTET). PATCH, weil das Ticketsystem kein PUT durchlaesst. */
|
/** Team-Einstellungen (Rumpf: teamBody in zeitenView): { team, teamVersion }; 400 ungueltig, 409 veraltet (err.code ZEITEN_VERALTET). PATCH, weil das Ticketsystem kein PUT durchlaesst. */
|
||||||
export const patchZeitenTeam = (customerId, body) => request(zeitenTeamPfad(customerId), { method: 'PATCH', body })
|
export const patchZeitenTeam = (customerId, body) => request(zeitenTeamPfad(customerId), { method: 'PATCH', body })
|
||||||
|
|
||||||
|
// --- Abgerechnete Arbeitsblaetter (alle Mitarbeiter, ohne Betraege) ---
|
||||||
|
/** { abgerechnet: { <worksheetId>: '<Rechnungsnummer>' | 'Entwurf' } }; unbekanntes Ticket: { abgerechnet: {} } */
|
||||||
|
export const getAbgerechnet = (ticketId) => request(abgerechnetPfad(ticketId))
|
||||||
|
|
||||||
// --- Abo-Status (alle Mitarbeiter) ---
|
// --- Abo-Status (alle Mitarbeiter) ---
|
||||||
|
|
||||||
/** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */
|
/** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
|
|||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||||
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Dialog, Field } from '../components/ui'
|
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Dialog, Field } from '../components/ui'
|
||||||
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
||||||
import CustomerInvoices from '../components/finance/CustomerInvoices'
|
import AuftraegeUndRechnungen from '../components/auftraege/AuftraegeUndRechnungen'
|
||||||
import AssignedProjectCard from '../components/AssignedProjectCard'
|
import AssignedProjectCard from '../components/AssignedProjectCard'
|
||||||
import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
|
import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
|
||||||
import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '../lib/customerAdminApi'
|
import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '../lib/customerAdminApi'
|
||||||
@@ -12,6 +12,7 @@ import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
|||||||
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
|
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
|
||||||
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer, markLost, reopenAsLead } from '../lib/leads'
|
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer, markLost, reopenAsLead } from '../lib/leads'
|
||||||
import { lastRejection } from '../lib/leadDuplicates'
|
import { lastRejection } from '../lib/leadDuplicates'
|
||||||
|
import { KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartAenderung } from '../lib/kundenFelder'
|
||||||
import { customerStage, isArchived, customerBadges, customerEyebrow, customerTitle } from '../lib/customerList'
|
import { customerStage, isArchived, customerBadges, customerEyebrow, customerTitle } from '../lib/customerList'
|
||||||
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
|
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
|
||||||
import { ticketNumber } from '../lib/ticketView'
|
import { ticketNumber } from '../lib/ticketView'
|
||||||
@@ -121,7 +122,7 @@ export default function CustomerDetailPage() {
|
|||||||
{/* key: nach Absage/Umstufung neu aufbauen, sonst ueberschreibt ein spaeteres Speichern die neuen Notizen */}
|
{/* key: nach Absage/Umstufung neu aufbauen, sonst ueberschreibt ein spaeteres Speichern die neuen Notizen */}
|
||||||
{activeTab === 'overview' && <OverviewTab key={customer.$updatedAt} customer={customer} stage={stage} onSaved={load} />}
|
{activeTab === 'overview' && <OverviewTab key={customer.$updatedAt} customer={customer} stage={stage} onSaved={load} />}
|
||||||
{activeTab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
|
{activeTab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
|
||||||
{activeTab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />}
|
{activeTab === 'invoices' && <AuftraegeUndRechnungen key={customer.$id} customer={customer} onShowOverview={() => setTab('overview')} />}
|
||||||
{activeTab === 'projects' && <CustomerProjects customerId={customer.$id} />}
|
{activeTab === 'projects' && <CustomerProjects customerId={customer.$id} />}
|
||||||
{activeTab === 'chat' && <Card><ChatThread customerId={customer.$id} customerName={customer.name} /></Card>}
|
{activeTab === 'chat' && <Card><ChatThread customerId={customer.$id} customerName={customer.name} /></Card>}
|
||||||
{activeTab === 'abo' && (
|
{activeTab === 'abo' && (
|
||||||
@@ -154,11 +155,15 @@ function OverviewTab({ customer, stage, onSaved }) {
|
|||||||
notes: customer.notes || '',
|
notes: customer.notes || '',
|
||||||
importantCustomer: !!customer.importantCustomer,
|
importantCustomer: !!customer.importantCustomer,
|
||||||
})
|
})
|
||||||
|
const [kundenart, setKundenart] = useState(customer.kundenart || '')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
|
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
|
||||||
const [showLost, setShowLost] = useState(false)
|
const [showLost, setShowLost] = useState(false)
|
||||||
const { user, employee } = useAuth()
|
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 })
|
const ready = isInvoiceReady({ ...customer, ...form })
|
||||||
|
|
||||||
// Absage: ungespeicherte Notizen gehen mit, damit der Vermerk nichts ueberschreibt
|
// Absage: ungespeicherte Notizen gehen mit, damit der Vermerk nichts ueberschreibt
|
||||||
@@ -182,7 +187,7 @@ function OverviewTab({ customer, stage, onSaved }) {
|
|||||||
const save = async () => {
|
const save = async () => {
|
||||||
setBusy(true); setMsg(null)
|
setBusy(true); setMsg(null)
|
||||||
try {
|
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' })
|
setMsg({ tone: 'ok', text: 'Gespeichert' })
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (err) {
|
} 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 }
|
if (!ready) { setMsg({ tone: 'warn', text: 'Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.' }); return }
|
||||||
setBusy(true); setMsg(null)
|
setBusy(true); setMsg(null)
|
||||||
try {
|
try {
|
||||||
await upgradeToCustomer(customer, form)
|
await upgradeToCustomer(customer, felder())
|
||||||
setMsg({ tone: 'ok', text: 'Zum festen Kunden gemacht.' })
|
setMsg({ tone: 'ok', text: 'Zum festen Kunden gemacht.' })
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -249,6 +254,13 @@ function OverviewTab({ customer, stage, onSaved }) {
|
|||||||
<Card title="Rechnungsdaten">
|
<Card title="Rechnungsdaten">
|
||||||
{!ready && <div className="kunde-hinweis"><Badge tone="warn">Adresse unvollständig</Badge></div>}
|
{!ready && <div className="kunde-hinweis"><Badge tone="warn">Adresse unvollständig</Badge></div>}
|
||||||
<dl className="kv kunde-kv">
|
<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>
|
<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>
|
<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>
|
<dt><label htmlFor="kunde-postal">PLZ</label></dt>
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const {
|
||||||
|
KONTEN_FEHLER, AUFTRAEGE_FEHLER, ladeAuftraegeAnsicht, ansichtsZustand, kundeMitKundenart, erstelltMeldung, auftragMeldung,
|
||||||
|
} = await loadModule('src/lib/auftraegeAnsicht.js')
|
||||||
|
|
||||||
|
// Antwort von getProjektkonten (V5) mit den Feldern, die die Ansicht braucht
|
||||||
|
const konten = (extra = {}) => ({
|
||||||
|
auftraegeAktiv: false,
|
||||||
|
kunde: { id: 'k1', name: 'Anelia Wolf', kundenart: 'privat' },
|
||||||
|
konten: [{ projektId: 'p1', name: 'Website' }],
|
||||||
|
ohneProjekt: { berechnetCents: 5000, bezahltCents: 0, anzahl: 1 },
|
||||||
|
stripeOk: true,
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
const OHNE_PROJEKT_LEER = { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }
|
||||||
|
|
||||||
|
// Attrappe für getProjektkonten und listAuftraege: gibt Werte zurück, wirft Fehler und merkt sich die Aufrufe
|
||||||
|
function fakeApi({ projektkonten, auftraege } = {}) {
|
||||||
|
const calls = []
|
||||||
|
const antwort = (wert) => {
|
||||||
|
if (wert instanceof Error) throw wert
|
||||||
|
return wert
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
getProjektkonten: async (id) => { calls.push(['konten', id]); return antwort(projektkonten) },
|
||||||
|
listAuftraege: async (id) => { calls.push(['auftraege', id]); return antwort(auftraege) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Schalter aus: die Aufträge werden gar nicht erst abgerufen', async () => {
|
||||||
|
const api = fakeApi({ projektkonten: konten({ auftraegeAktiv: false }), auftraege: new Error('404 darf nie ankommen') })
|
||||||
|
const result = await ladeAuftraegeAnsicht('k1', api)
|
||||||
|
assert.deepEqual(api.calls, [['konten', 'k1']])
|
||||||
|
assert.equal(result.konten.auftraegeAktiv, false)
|
||||||
|
assert.equal(result.auftraege, null)
|
||||||
|
assert.equal(result.auftraegeFehler, '')
|
||||||
|
// ein fehlendes Feld gilt wie „aus“, nur true schaltet an; eine Antwort ohne Objekt wird zu {}
|
||||||
|
const fehlt = fakeApi({ projektkonten: { konten: [] } })
|
||||||
|
assert.equal((await ladeAuftraegeAnsicht('k1', fehlt)).auftraege, null)
|
||||||
|
assert.deepEqual(fehlt.calls, [['konten', 'k1']])
|
||||||
|
const nichts = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: null }))
|
||||||
|
assert.deepEqual(nichts, { konten: {}, auftraege: null, auftraegeFehler: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Schalter an: erst die Projektkonten, dann die Aufträge des Kunden', async () => {
|
||||||
|
const liste = [{ id: 'a1', nummer: 'AB-2026-0001', status: 'versendet' }]
|
||||||
|
const api = fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: { auftraege: liste } })
|
||||||
|
const result = await ladeAuftraegeAnsicht('k1', api)
|
||||||
|
assert.deepEqual(api.calls, [['konten', 'k1'], ['auftraege', 'k1']])
|
||||||
|
assert.deepEqual(result.auftraege, liste)
|
||||||
|
assert.equal(result.auftraegeFehler, '')
|
||||||
|
// eine Antwort ohne Liste gibt eine leere Liste statt eines Absturzes
|
||||||
|
const leer = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: {} }))
|
||||||
|
assert.deepEqual(leer.auftraege, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Aufträge scheitern allein: die Projektkonten bleiben, der Fehler steht eigens da', async () => {
|
||||||
|
const api = fakeApi({
|
||||||
|
projektkonten: konten({ auftraegeAktiv: true }),
|
||||||
|
auftraege: Object.assign(new Error('Interner Fehler.'), { status: 500 }),
|
||||||
|
})
|
||||||
|
const result = await ladeAuftraegeAnsicht('k1', api)
|
||||||
|
assert.equal(result.konten.konten.length, 1)
|
||||||
|
assert.equal(result.auftraege, null)
|
||||||
|
assert.equal(result.auftraegeFehler, 'Interner Fehler.')
|
||||||
|
const ohneText = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: new Error('') }))
|
||||||
|
assert.equal(ohneText.auftraegeFehler, AUFTRAEGE_FEHLER)
|
||||||
|
assert.equal(AUFTRAEGE_FEHLER, 'Die Aufträge konnten nicht geladen werden.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Projektkonten scheitern, Aufträge laden: der Abschnitt Aufträge bleibt bedienbar, der Konten-Fehler steht eigens da', async () => {
|
||||||
|
const fehler = Object.assign(new Error('Interner Fehler.'), { status: 500 })
|
||||||
|
const liste = [{ id: 'a1', nummer: 'AB-2026-0001', status: 'versendet' }]
|
||||||
|
const api = fakeApi({ projektkonten: fehler, auftraege: { auftraege: liste } })
|
||||||
|
const result = await ladeAuftraegeAnsicht('k1', api)
|
||||||
|
assert.deepEqual(api.calls, [['konten', 'k1'], ['auftraege', 'k1']])
|
||||||
|
assert.deepEqual(result, { konten: {}, kontenFehler: 'Interner Fehler.', auftraege: liste, auftraegeFehler: '' })
|
||||||
|
const ohneText = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: new Error(''), auftraege: { auftraege: liste } }))
|
||||||
|
assert.equal(ohneText.kontenFehler, KONTEN_FEHLER)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Projektkonten und Aufträge scheitern: der Fehler der Projektkonten geht weiter (Schalter aus gibt 404)', async () => {
|
||||||
|
const fehler = Object.assign(new Error('Interner Fehler.'), { status: 500 })
|
||||||
|
const api = fakeApi({ projektkonten: fehler, auftraege: Object.assign(new Error('Nicht gefunden.'), { status: 404 }) })
|
||||||
|
await assert.rejects(ladeAuftraegeAnsicht('k1', api), (e) => e === fehler)
|
||||||
|
assert.deepEqual(api.calls, [['konten', 'k1'], ['auftraege', 'k1']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ansichtsZustand: Projekte-Fehler mit geladenen Aufträgen – Projekte „fehler“, Aufträge „bereit“', () => {
|
||||||
|
const liste = [{ id: 'a1' }]
|
||||||
|
const z = ansichtsZustand({ data: { konten: {}, kontenFehler: 'Interner Fehler.', auftraege: liste, auftraegeFehler: '' }, error: '' })
|
||||||
|
assert.equal(z.projekteStatus, 'fehler')
|
||||||
|
assert.equal(z.fehler, 'Interner Fehler.')
|
||||||
|
assert.equal(z.auftraegeAktiv, true) // der Server hat die Liste geliefert, der Schalter ist also an
|
||||||
|
assert.equal(z.auftraegeStatus, 'bereit')
|
||||||
|
assert.deepEqual(z.auftraege, liste)
|
||||||
|
assert.deepEqual(z.konten, [])
|
||||||
|
assert.equal(z.nachladeFehler, '')
|
||||||
|
// scheitert auch ein späteres Neuladen, steht dessen Meldung im Fehlerkasten; die Aufträge bleiben stehen
|
||||||
|
const spaeter = ansichtsZustand({ data: { konten: {}, kontenFehler: 'Interner Fehler.', auftraege: liste, auftraegeFehler: '' }, error: 'Netz weg' })
|
||||||
|
assert.equal(spaeter.projekteStatus, 'fehler')
|
||||||
|
assert.equal(spaeter.fehler, 'Netz weg')
|
||||||
|
assert.equal(spaeter.auftraegeStatus, 'bereit')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ansichtsZustand: vor der ersten Antwort „laden“, nach einem Fehler „fehler“ mit der Meldung', () => {
|
||||||
|
const laden = ansichtsZustand({ data: null, error: '' })
|
||||||
|
assert.equal(laden.projekteStatus, 'laden')
|
||||||
|
assert.equal(laden.auftraegeStatus, 'aus') // der Schalter ist noch unbekannt: kein Abschnitt, kein Flackern
|
||||||
|
assert.deepEqual(laden.konten, [])
|
||||||
|
assert.equal(ansichtsZustand().projekteStatus, 'laden')
|
||||||
|
const fehler = ansichtsZustand({ data: null, error: KONTEN_FEHLER })
|
||||||
|
assert.equal(fehler.projekteStatus, 'fehler')
|
||||||
|
assert.equal(fehler.fehler, KONTEN_FEHLER)
|
||||||
|
assert.equal(fehler.auftraegeStatus, 'aus') // ohne Projektkonten kein Schalter, also auch keine Aufträge
|
||||||
|
assert.equal(KONTEN_FEHLER, 'Die Projektübersicht konnte nicht geladen werden.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ansichtsZustand: Schalter aus – Projekte bereit, kein Aufträge-Abschnitt', () => {
|
||||||
|
const z = ansichtsZustand({ data: { konten: konten({ auftraegeAktiv: false }), auftraege: null, auftraegeFehler: '' }, error: '' })
|
||||||
|
assert.equal(z.projekteStatus, 'bereit')
|
||||||
|
assert.equal(z.auftraegeAktiv, false)
|
||||||
|
assert.equal(z.auftraegeStatus, 'aus')
|
||||||
|
assert.equal(z.konten.length, 1)
|
||||||
|
assert.deepEqual(z.ohneProjekt, { berechnetCents: 5000, bezahltCents: 0, anzahl: 1 })
|
||||||
|
assert.equal(z.stripeOk, true)
|
||||||
|
assert.deepEqual(z.kunde, { id: 'k1', name: 'Anelia Wolf', kundenart: 'privat' })
|
||||||
|
assert.equal(z.nachladeFehler, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ansichtsZustand: Schalter an – Aufträge bereit oder mit eigenem Fehler, die Projekte bleiben', () => {
|
||||||
|
const liste = [{ id: 'a1' }]
|
||||||
|
const bereit = ansichtsZustand({ data: { konten: konten({ auftraegeAktiv: true }), auftraege: liste, auftraegeFehler: '' }, error: '' })
|
||||||
|
assert.equal(bereit.auftraegeAktiv, true)
|
||||||
|
assert.equal(bereit.auftraegeStatus, 'bereit')
|
||||||
|
assert.deepEqual(bereit.auftraege, liste)
|
||||||
|
const kaputt = ansichtsZustand({
|
||||||
|
data: { konten: konten({ auftraegeAktiv: true }), auftraege: null, auftraegeFehler: 'Interner Fehler.' },
|
||||||
|
error: '',
|
||||||
|
})
|
||||||
|
assert.equal(kaputt.projekteStatus, 'bereit')
|
||||||
|
assert.equal(kaputt.auftraegeStatus, 'fehler')
|
||||||
|
assert.equal(kaputt.auftraegeFehler, 'Interner Fehler.')
|
||||||
|
assert.deepEqual(kaputt.auftraege, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ansichtsZustand: Neuladen scheitert – die alten Daten bleiben, die Meldung steht daneben', () => {
|
||||||
|
const data = { konten: konten({ auftraegeAktiv: true }), auftraege: [{ id: 'a1' }], auftraegeFehler: '' }
|
||||||
|
const z = ansichtsZustand({ data, error: 'Das Kundenportal ist gerade nicht erreichbar.' })
|
||||||
|
assert.equal(z.projekteStatus, 'bereit')
|
||||||
|
assert.equal(z.fehler, '')
|
||||||
|
assert.equal(z.nachladeFehler, 'Das Kundenportal ist gerade nicht erreichbar.')
|
||||||
|
assert.equal(z.konten.length, 1)
|
||||||
|
assert.equal(z.auftraegeStatus, 'bereit')
|
||||||
|
assert.equal(z.auftraege.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ansichtsZustand: unvollständige Antworten brechen die Ansicht nicht', () => {
|
||||||
|
for (const data of [{}, { konten: null }, { konten: {} }, { konten: { konten: 'x', ohneProjekt: 5, kunde: 'k' } }]) {
|
||||||
|
const z = ansichtsZustand({ data, error: '' })
|
||||||
|
assert.equal(z.projekteStatus, 'bereit')
|
||||||
|
assert.deepEqual(z.konten, [])
|
||||||
|
assert.deepEqual(z.ohneProjekt, OHNE_PROJEKT_LEER)
|
||||||
|
assert.equal(z.stripeOk, true)
|
||||||
|
assert.equal(z.kunde, null)
|
||||||
|
assert.equal(z.auftraegeAktiv, false)
|
||||||
|
assert.equal(z.auftraegeStatus, 'aus')
|
||||||
|
}
|
||||||
|
assert.equal(ansichtsZustand({ data: { konten: konten({ stripeOk: false }) }, error: '' }).stripeOk, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kundeMitKundenart: ein gültiger Wert des Portals gewinnt gegen das veraltete Kundendokument', () => {
|
||||||
|
const doc = { $id: 'k1', name: 'Anelia Wolf', kundenart: '' }
|
||||||
|
assert.deepEqual(kundeMitKundenart(doc, { id: 'k1', kundenart: 'privat' }), { ...doc, kundenart: 'privat' })
|
||||||
|
assert.equal(doc.kundenart, '') // das Dokument der Seite wird nicht verändert
|
||||||
|
assert.equal(kundeMitKundenart({ ...doc, kundenart: 'unternehmen' }, { kundenart: 'privat' }).kundenart, 'privat')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kundeMitKundenart: ohne gültigen Wert vom Portal bleibt das Dokument (dieselbe Referenz)', () => {
|
||||||
|
const doc = { $id: 'k1', kundenart: 'unternehmen' }
|
||||||
|
for (const info of [null, undefined, {}, { kundenart: '' }, { kundenart: 'firma' }, { kundenart: 5 }, 'privat']) {
|
||||||
|
assert.equal(kundeMitKundenart(doc, info), doc)
|
||||||
|
}
|
||||||
|
assert.equal(kundeMitKundenart(null, { kundenart: 'privat' }), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('erstelltMeldung: gesendet, Entwurf und die Meldung nach festgeschriebener, nicht gesendeter Rechnung', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
erstelltMeldung({ number: 'WEBKLAR-0009', customerEmail: 'a@b.de' }, { senden: true }),
|
||||||
|
{ tone: 'ok', text: 'Rechnung WEBKLAR-0009 wurde an a@b.de gesendet.' },
|
||||||
|
)
|
||||||
|
assert.deepEqual(erstelltMeldung({ id: 'in_1' }, { senden: false }), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
|
||||||
|
assert.deepEqual(erstelltMeldung(null, undefined), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
|
||||||
|
assert.deepEqual(erstelltMeldung(null, null), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
|
||||||
|
const text = 'Rechnung WEBKLAR-0010 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.'
|
||||||
|
assert.deepEqual(erstelltMeldung(null, { senden: true, notice: text, tone: 'warn', finalizedFilter: 'offen' }), { tone: 'warn', text })
|
||||||
|
assert.deepEqual(erstelltMeldung(null, { senden: true, notice: text }), { tone: 'ok', text })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('auftragMeldung: Entwurf, gesendet und gesendet ohne zugestellte Mail', () => {
|
||||||
|
assert.deepEqual(auftragMeldung({ status: 'entwurf', nummer: '' }), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
|
||||||
|
assert.deepEqual(
|
||||||
|
auftragMeldung({ status: 'versendet', nummer: 'AB-2026-0001', gesendetAn: 'k@x.de', mailStatus: 'gesendet' }),
|
||||||
|
{ tone: 'ok', text: 'Auftragsbestätigung AB-2026-0001 wurde an k@x.de gesendet.' },
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
auftragMeldung({ status: 'versendet', nummer: 'AB-2026-0001', gesendetAn: 'k@x.de', mailStatus: 'fehler' }),
|
||||||
|
{ tone: 'warn', text: 'Auftragsbestätigung AB-2026-0001 ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.' },
|
||||||
|
)
|
||||||
|
assert.deepEqual(auftragMeldung(null), { tone: 'ok', text: 'Die Auftragsbestätigung wurde gespeichert.' })
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const { createAuftraegeEndpoints, abgerechnetPfad } = await loadModule('src/lib/auftraegeEndpoints.js')
|
||||||
|
const { createFinanceRequest } = await loadModule('src/lib/financeRequest.js')
|
||||||
|
|
||||||
|
const BASE = 'https://portal.example.invalid/api/admin/finanzen'
|
||||||
|
|
||||||
|
// Echte Anfrage-Schicht mit Fetch-Attrappe: geprüft wird, was das Portal erreicht (URL, Methode, Kopf, JSON-Körper)
|
||||||
|
function setup(answer = () => ({ ok: true, status: 200, json: async () => ({ ok: true }) })) {
|
||||||
|
const calls = []
|
||||||
|
const request = createFinanceRequest({
|
||||||
|
baseUrl: BASE,
|
||||||
|
createJWT: async () => ({ jwt: 'jwt-1' }),
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
calls.push({ url, method: init.method, body: init.body, auth: init.headers.Authorization })
|
||||||
|
return answer()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { api: createAuftraegeEndpoints(request), calls }
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Funktion, Argumente, Methode, Pfad ab BASE, Körper]
|
||||||
|
const FAELLE = [
|
||||||
|
['getProjektkonten', ['k1'], 'GET', '/projektkonten?kunde=k1', undefined],
|
||||||
|
['getProjektkonto', ['p1'], 'GET', '/projektkonten/p1', undefined],
|
||||||
|
['previewInvoice', [{ customerId: 'k1', art: 'aufwand', projektId: 'p1' }], 'POST', '/rechnungen/vorschau', { customerId: 'k1', art: 'aufwand', projektId: 'p1' }],
|
||||||
|
['listAuftraege', ['k1'], 'GET', '/auftraege?kunde=k1', undefined],
|
||||||
|
['getAuftrag', ['a1'], 'GET', '/auftraege/a1', undefined],
|
||||||
|
['createAuftrag', [{ kundenId: 'k1', titel: 'Website' }], 'POST', '/auftraege', { kundenId: 'k1', titel: 'Website' }],
|
||||||
|
['patchAuftrag', ['a1', { titel: 'Neu' }], 'PATCH', '/auftraege/a1', { titel: 'Neu' }],
|
||||||
|
['deleteAuftrag', ['a1'], 'DELETE', '/auftraege/a1', undefined],
|
||||||
|
['sendAuftrag', ['a1'], 'POST', '/auftraege/a1/senden', undefined],
|
||||||
|
['erinnereAuftrag', ['a1'], 'POST', '/auftraege/a1/erinnern', undefined],
|
||||||
|
['neueFassungAuftrag', ['a1'], 'POST', '/auftraege/a1/neue-fassung', undefined],
|
||||||
|
['storniereAuftrag', ['a1'], 'POST', '/auftraege/a1/stornieren', undefined],
|
||||||
|
['ordneAuftragProjektZu', ['a1', 'p1'], 'POST', '/auftraege/a1/projekt', { projektId: 'p1' }],
|
||||||
|
]
|
||||||
|
|
||||||
|
test('alle 14 Funktionen sind da (V8 samt „Projekt zuordnen“ aus V5)', () => {
|
||||||
|
const { api } = setup()
|
||||||
|
assert.deepEqual(Object.keys(api).sort(), [
|
||||||
|
'createAuftrag', 'deleteAuftrag', 'erinnereAuftrag', 'getAuftrag', 'getAuftragPdf', 'getProjektkonten', 'getProjektkonto',
|
||||||
|
'listAuftraege', 'neueFassungAuftrag', 'ordneAuftragProjektZu', 'patchAuftrag', 'previewInvoice', 'sendAuftrag', 'storniereAuftrag',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pfad, Methode, Kopf und Körper jeder Funktion (die Vorschau trägt keinen Schlüssel dazu)', async () => {
|
||||||
|
for (const [name, args, method, path, body] of FAELLE) {
|
||||||
|
const { api, calls } = setup()
|
||||||
|
await api[name](...args)
|
||||||
|
assert.equal(calls.length, 1, name)
|
||||||
|
assert.equal(calls[0].url, `${BASE}${path}`, name)
|
||||||
|
assert.equal(calls[0].method, method, name)
|
||||||
|
assert.equal(calls[0].auth, 'Bearer jwt-1', name)
|
||||||
|
assert.equal(calls[0].body, body === undefined ? undefined : JSON.stringify(body), name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Kennungen werden URL-kodiert, auch im Pfad der abgerechneten Arbeitsblätter', async () => {
|
||||||
|
const { api, calls } = setup()
|
||||||
|
await api.getProjektkonten('k 1&x=2')
|
||||||
|
await api.getAuftrag('a/b?c')
|
||||||
|
await api.storniereAuftrag('a#1')
|
||||||
|
await api.ordneAuftragProjektZu('a b', 'p/1')
|
||||||
|
assert.deepEqual(calls.map((c) => c.url.slice(BASE.length)), [
|
||||||
|
'/projektkonten?kunde=k%201%26x%3D2',
|
||||||
|
'/auftraege/a%2Fb%3Fc',
|
||||||
|
'/auftraege/a%231/stornieren',
|
||||||
|
'/auftraege/a%20b/projekt',
|
||||||
|
])
|
||||||
|
assert.equal(calls[3].body, JSON.stringify({ projektId: 'p/1' })) // die Projektkennung steht im Körper, nicht im Pfad
|
||||||
|
assert.equal(abgerechnetPfad('100131'), '/arbeitsblaetter/abgerechnet?ticketId=100131')
|
||||||
|
assert.equal(abgerechnetPfad('t 1/2'), '/arbeitsblaetter/abgerechnet?ticketId=t%201%2F2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getAuftragPdf liefert das PDF als Blob; eine Fehlerantwort kommt als Meldung des Portals', async () => {
|
||||||
|
const datei = new Blob(['%PDF-1.7'], { type: 'application/pdf' })
|
||||||
|
const ok = setup(() => ({ ok: true, status: 200, json: async () => assert.fail('kein JSON lesen'), blob: async () => datei }))
|
||||||
|
assert.equal(await ok.api.getAuftragPdf('a1'), datei)
|
||||||
|
assert.equal(ok.calls[0].url, `${BASE}/auftraege/a1/pdf`)
|
||||||
|
assert.equal(ok.calls[0].method, 'GET')
|
||||||
|
const fehler = setup(() => ({ ok: false, status: 404, json: async () => ({ error: 'PDF nicht gefunden.' }), blob: async () => assert.fail('kein Blob lesen') }))
|
||||||
|
await assert.rejects(fehler.api.getAuftragPdf('a1'), (e) => e.message === 'PDF nicht gefunden.' && e.status === 404)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -4,11 +4,11 @@ import { loadModule } from './load.mjs'
|
|||||||
|
|
||||||
const { customerTabs, resolveCustomerTab } = await loadModule('src/lib/customerTabs.js')
|
const { customerTabs, resolveCustomerTab } = await loadModule('src/lib/customerTabs.js')
|
||||||
|
|
||||||
test('customerTabs: volle Reiterfolge der Spec, Rechnungen nur für Admins', () => {
|
test('customerTabs: volle Reiterfolge der Spec, „Aufträge und Rechnungen“ nur für Admins', () => {
|
||||||
assert.deepEqual(customerTabs(true), [
|
assert.deepEqual(customerTabs(true), [
|
||||||
{ id: 'overview', label: 'Übersicht' },
|
{ id: 'overview', label: 'Übersicht' },
|
||||||
{ id: 'tickets', label: 'Tickets' },
|
{ id: 'tickets', label: 'Tickets' },
|
||||||
{ id: 'invoices', label: 'Rechnungen' },
|
{ id: 'invoices', label: 'Aufträge und Rechnungen' },
|
||||||
{ id: 'projects', label: 'Projekte' },
|
{ id: 'projects', label: 'Projekte' },
|
||||||
{ id: 'chat', label: 'Chat' },
|
{ id: 'chat', label: 'Chat' },
|
||||||
{ id: 'abo', label: 'Abo & Angebote' },
|
{ id: 'abo', label: 'Abo & Angebote' },
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const { createFinanceRequest, PORTAL_DOWN_TEXT } = await loadModule('src/lib/financeRequest.js')
|
||||||
|
|
||||||
|
const BASE = 'https://portal.example.invalid/api/admin/finanzen'
|
||||||
|
|
||||||
|
/** Antwort wie fetch: { ok, status, json(), blob() }; ohne `json` ist der Körper kein JSON (json() wirft). */
|
||||||
|
const reply = (status, { json, blob } = {}) => ({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: async () => {
|
||||||
|
if (json === undefined) throw new SyntaxError('Unexpected token % in JSON')
|
||||||
|
return json
|
||||||
|
},
|
||||||
|
blob: async () => blob,
|
||||||
|
})
|
||||||
|
|
||||||
|
function client({ responses = [], createJWT } = {}) {
|
||||||
|
const calls = []
|
||||||
|
const queue = [...responses]
|
||||||
|
const api = createFinanceRequest({
|
||||||
|
baseUrl: BASE,
|
||||||
|
createJWT: createJWT || (async () => ({ jwt: 'jwt-1' })),
|
||||||
|
fetchImpl: async (url, init) => {
|
||||||
|
calls.push({ url, init })
|
||||||
|
return queue.shift() || reply(200, { json: {} })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { api, calls }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fails = async (promise) => promise.then(() => assert.fail('hätte scheitern müssen'), (err) => err)
|
||||||
|
const pdf = () => new Blob(['%PDF-1.7 Probe'], { type: 'application/pdf' })
|
||||||
|
|
||||||
|
test('as blob: Erfolg liefert den rohen Inhalt als Blob, mit Bearer-JWT, ohne JSON zu lesen', async () => {
|
||||||
|
const { api, calls } = client({
|
||||||
|
responses: [{ ...reply(200, { blob: pdf() }), json: async () => assert.fail('json() darf bei as blob nicht gelesen werden') }],
|
||||||
|
})
|
||||||
|
const result = await api('/auftraege/a1/pdf', { as: 'blob' })
|
||||||
|
assert.ok(result instanceof Blob)
|
||||||
|
assert.equal(result.type, 'application/pdf')
|
||||||
|
assert.equal(await result.text(), '%PDF-1.7 Probe')
|
||||||
|
assert.equal(calls[0].url, `${BASE}/auftraege/a1/pdf`)
|
||||||
|
assert.equal(calls[0].init.method, 'GET')
|
||||||
|
assert.equal(calls[0].init.headers.Authorization, 'Bearer jwt-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('as blob: Fehler kommen weiter als Meldung des Portals mit Status (kein kaputtes PDF)', async () => {
|
||||||
|
const { api } = client({ responses: [reply(404, { json: { error: 'PDF nicht gefunden.' } })] })
|
||||||
|
const err = await fails(api('/auftraege/a1/pdf', { as: 'blob' }))
|
||||||
|
assert.equal(err.message, 'PDF nicht gefunden.')
|
||||||
|
assert.equal(err.status, 404)
|
||||||
|
// Proxy-Fehlerseite ohne JSON: dieselbe Meldung wie bei JSON-Anfragen
|
||||||
|
const { api: api2 } = client({ responses: [reply(502)] })
|
||||||
|
assert.equal((await fails(api2('/auftraege/a1/pdf', { as: 'blob' }))).message, PORTAL_DOWN_TEXT)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('as blob: bei 401 einmal mit frischem JWT wiederholt, dann kommt der Blob', async () => {
|
||||||
|
let n = 0
|
||||||
|
const datei = pdf()
|
||||||
|
const { api, calls } = client({
|
||||||
|
createJWT: async () => ({ jwt: `jwt-${++n}` }),
|
||||||
|
responses: [reply(401, { json: { error: 'abgelaufen' } }), reply(200, { blob: datei })],
|
||||||
|
})
|
||||||
|
assert.equal(await api('/auftraege/a1/pdf', { as: 'blob' }), datei)
|
||||||
|
assert.deepEqual(calls.map((c) => c.init.headers.Authorization), ['Bearer jwt-1', 'Bearer jwt-2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ohne as bleibt es JSON (der Blob wird nicht gelesen), ein unbekannter Wert verhält sich ebenso', async () => {
|
||||||
|
const { api } = client({
|
||||||
|
responses: [{ ...reply(200, { json: { ok: 1 } }), blob: async () => assert.fail('blob() darf ohne as nicht gelesen werden') }],
|
||||||
|
})
|
||||||
|
assert.deepEqual(await api('/x'), { ok: 1 })
|
||||||
|
const { api: api2 } = client({ responses: [reply(200, { json: { ok: 2 } })] })
|
||||||
|
assert.deepEqual(await api2('/x', { as: 'text' }), { ok: 2 })
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const {
|
||||||
|
STRIPE_HINWEIS, KEINE_PROJEKTE_HINWEIS,
|
||||||
|
stundenDe, satzText, ticketsText, stripeHinweis, vereinbartText, kontoZeile, ohneProjektZeile,
|
||||||
|
artLabel, projektSpalte, abgerechnetText, abgerechnetWarnung, ticketIdsOf,
|
||||||
|
} = await loadModule('src/lib/projektkontoView.js')
|
||||||
|
|
||||||
|
const konto = (over = {}) => ({
|
||||||
|
projektId: 'p1',
|
||||||
|
name: 'Website Relaunch',
|
||||||
|
projektTyp: 'Website',
|
||||||
|
tickets: [{ id: 't1', woid: '100131' }],
|
||||||
|
zeitquelle: ['t1'],
|
||||||
|
vereinbart: { festpreisCents: 120000, aufwand: null },
|
||||||
|
berechnetCents: 50000,
|
||||||
|
bezahltCents: 20000,
|
||||||
|
offenCents: 70000,
|
||||||
|
zeit: { minuten: 750, aboMinuten: 0, offeneMinuten: 750, satzCents: 10000, wertCents: 125000, offenerWertCents: 125000 },
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
const AUFWAND = { festpreisCents: null, aufwand: { stundensatzCents: 10000, schaetzungMinuten: 600 } }
|
||||||
|
|
||||||
|
test('stundenDe: Dezimalstunden mit Komma, ohne überflüssige Nullen', () => {
|
||||||
|
assert.equal(stundenDe(750), '12,5 Std.')
|
||||||
|
assert.equal(stundenDe(135), '2,25 Std.')
|
||||||
|
assert.equal(stundenDe(60), '1 Std.')
|
||||||
|
assert.equal(stundenDe(20), '0,33 Std.')
|
||||||
|
assert.equal(stundenDe(0), '0 Std.')
|
||||||
|
assert.equal(stundenDe(null), '0 Std.')
|
||||||
|
assert.equal(stundenDe('abc'), '0 Std.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('satzText: ganze Euro ohne Cent, sonst mit Cent', () => {
|
||||||
|
assert.equal(satzText(10000), '100 €/Std.')
|
||||||
|
assert.equal(satzText(8550), '85,50 €/Std.')
|
||||||
|
assert.equal(satzText(null), '–')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ticketsText: Ticketnummern mit Raute, ohne Nummer fällt der Eintrag weg', () => {
|
||||||
|
assert.equal(ticketsText([{ id: 'a', woid: '100131' }]), '#100131')
|
||||||
|
assert.equal(ticketsText([{ id: 'a', woid: '100131' }, { id: 'b', woid: '100140' }]), '#100131, #100140')
|
||||||
|
assert.equal(ticketsText([{ id: 'a' }, null]), '')
|
||||||
|
assert.equal(ticketsText(undefined), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('vereinbartText: Festpreis, Aufwand mit Schätzung, beides, keine AB', () => {
|
||||||
|
assert.equal(vereinbartText({ festpreisCents: 120000, aufwand: null }), '1.200,00 €')
|
||||||
|
assert.equal(vereinbartText(AUFWAND), 'nach Aufwand, 100 €/Std., Schätzung 10 Std.')
|
||||||
|
assert.equal(vereinbartText({ festpreisCents: null, aufwand: { stundensatzCents: 8550, schaetzungMinuten: null } }), 'nach Aufwand, 85,50 €/Std.')
|
||||||
|
assert.equal(vereinbartText({ festpreisCents: 120000, aufwand: { stundensatzCents: 10000, schaetzungMinuten: null } }), '1.200,00 € und nach Aufwand, 100 €/Std.')
|
||||||
|
assert.equal(vereinbartText({ festpreisCents: null, aufwand: null }), 'keine AB')
|
||||||
|
assert.equal(vereinbartText(undefined), 'keine AB')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kontoZeile: Festpreis-AB mit Berechnet, bezahlt, Noch offen und Zeit', () => {
|
||||||
|
assert.deepEqual(kontoZeile(konto()), {
|
||||||
|
key: 'p1',
|
||||||
|
projektId: 'p1',
|
||||||
|
name: 'Website Relaunch',
|
||||||
|
meta: 'Website · #100131',
|
||||||
|
vereinbart: '1.200,00 €',
|
||||||
|
berechnet: '500,00 €',
|
||||||
|
bezahlt: 'bezahlt 200,00 €',
|
||||||
|
offen: '700,00 €',
|
||||||
|
zeit: { stunden: '12,5 Std.', wert: 'Wert 1.250,00 €', abo: '' },
|
||||||
|
})
|
||||||
|
// ohne Art und ohne Ticket bleibt nur der Name
|
||||||
|
assert.equal(kontoZeile(konto({ projektTyp: '', tickets: [] })).meta, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kontoZeile: Aufwand-AB (Noch offen = Wert der offenen Zeit) und Abo-Zeit getrennt', () => {
|
||||||
|
const z = kontoZeile(konto({
|
||||||
|
vereinbart: AUFWAND,
|
||||||
|
offenCents: null,
|
||||||
|
zeit: { minuten: 300, aboMinuten: 90, offeneMinuten: 180, satzCents: 10000, wertCents: 50000, offenerWertCents: 30000 },
|
||||||
|
}))
|
||||||
|
assert.equal(z.vereinbart, 'nach Aufwand, 100 €/Std., Schätzung 10 Std.')
|
||||||
|
assert.equal(z.offen, '300,00 €')
|
||||||
|
assert.deepEqual(z.zeit, { stunden: '5 Std.', wert: 'Wert 500,00 €', abo: 'über Abo: 1,5 Std.' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kontoZeile: ohne AB steht „keine AB“ und Noch offen ist „–“', () => {
|
||||||
|
const z = kontoZeile(konto({ vereinbart: { festpreisCents: null, aufwand: null }, offenCents: null }))
|
||||||
|
assert.equal(z.vereinbart, 'keine AB')
|
||||||
|
assert.equal(z.offen, '–')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kontoZeile: Stripe nicht erreichbar zeigt „–“ statt Nullen, Zeit und Vereinbartes bleiben', () => {
|
||||||
|
const z = kontoZeile(konto({ berechnetCents: 0, bezahltCents: 0, offenCents: null }), { stripeOk: false })
|
||||||
|
assert.deepEqual([z.vereinbart, z.berechnet, z.bezahlt, z.offen], ['1.200,00 €', '–', '', '–'])
|
||||||
|
assert.equal(z.zeit.wert, 'Wert 1.250,00 €')
|
||||||
|
// Aufwand: der Wert der offenen Zeit braucht kein Stripe
|
||||||
|
const a = kontoZeile(konto({ vereinbart: AUFWAND, offenCents: null }), { stripeOk: false })
|
||||||
|
assert.equal(a.offen, '1.250,00 €')
|
||||||
|
assert.equal(stripeHinweis(false), STRIPE_HINWEIS)
|
||||||
|
assert.equal(stripeHinweis(true), '')
|
||||||
|
assert.equal(stripeHinweis(undefined), '')
|
||||||
|
assert.equal(STRIPE_HINWEIS, 'Stripe ist gerade nicht erreichbar. Berechnete und bezahlte Beträge sind deshalb nicht verfügbar.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ohneProjektZeile: Rechnungen ohne Projekt, ohne Vereinbart und Zeit', () => {
|
||||||
|
assert.deepEqual(ohneProjektZeile({ berechnetCents: 30000, bezahltCents: 10000, anzahl: 3 }), {
|
||||||
|
key: 'ohne-projekt',
|
||||||
|
projektId: '',
|
||||||
|
name: 'Ohne Projekt',
|
||||||
|
meta: '3 Rechnungen',
|
||||||
|
vereinbart: '–',
|
||||||
|
berechnet: '300,00 €',
|
||||||
|
bezahlt: 'bezahlt 100,00 €',
|
||||||
|
offen: '–',
|
||||||
|
zeit: { stunden: '–', wert: '', abo: '' },
|
||||||
|
})
|
||||||
|
assert.equal(ohneProjektZeile({ berechnetCents: 0, bezahltCents: 0, anzahl: 1 }).meta, '1 Rechnung')
|
||||||
|
assert.equal(ohneProjektZeile({ berechnetCents: 0, bezahltCents: 0, anzahl: 0 }).meta, 'keine Rechnungen')
|
||||||
|
assert.equal(ohneProjektZeile(undefined).meta, 'keine Rechnungen')
|
||||||
|
const s = ohneProjektZeile({ berechnetCents: 30000, bezahltCents: 10000, anzahl: 3 }, { stripeOk: false })
|
||||||
|
assert.deepEqual([s.berechnet, s.bezahlt], ['–', ''])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Hinweis für Kunden ohne Projekte', () => {
|
||||||
|
assert.equal(KEINE_PROJEKTE_HINWEIS, 'Dieser Kunde hat noch keine Projekte. Rechnungen entstehen ohne Projektbezug.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artLabel: Spalte Art der Rechnungstabelle', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss', 'abo'].map((a) => artLabel(a)),
|
||||||
|
['Ohne Projekt', 'Festbetrag', 'Aufwand', 'Anzahlung', 'Schlussrechnung', 'Abo']
|
||||||
|
)
|
||||||
|
assert.equal(artLabel(undefined), '–')
|
||||||
|
assert.equal(artLabel('unbekannt'), '–')
|
||||||
|
assert.equal(artLabel('constructor'), '–')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('projektSpalte: Projektname der Rechnung oder „–“', () => {
|
||||||
|
assert.equal(projektSpalte({ projektName: ' Website Relaunch ' }), 'Website Relaunch')
|
||||||
|
assert.equal(projektSpalte({ projektName: '' }), '–')
|
||||||
|
assert.equal(projektSpalte({}), '–')
|
||||||
|
assert.equal(projektSpalte(null), '–')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('abgerechnetText und abgerechnetWarnung: Rechnungsnummer oder Entwurf', () => {
|
||||||
|
const map = { w1: 'WEBKLAR-0009', w2: 'Entwurf' }
|
||||||
|
assert.equal(abgerechnetText(map, 'w1'), 'abgerechnet (WEBKLAR-0009)')
|
||||||
|
assert.equal(abgerechnetText(map, 'w2'), 'abgerechnet (Entwurf)')
|
||||||
|
assert.equal(abgerechnetText(map, 'w3'), null)
|
||||||
|
assert.equal(abgerechnetText(map, 'constructor'), null)
|
||||||
|
assert.equal(abgerechnetText(null, 'w1'), null)
|
||||||
|
assert.equal(abgerechnetWarnung(map, 'w1'), 'Dieses Arbeitsblatt ist abgerechnet (WEBKLAR-0009). Eine Änderung ändert die Rechnung nicht.')
|
||||||
|
assert.equal(abgerechnetWarnung(map, 'w2'), 'Dieses Arbeitsblatt ist abgerechnet (Entwurf). Eine Änderung ändert die Rechnung nicht.')
|
||||||
|
assert.equal(abgerechnetWarnung(map, 'w3'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ticketIdsOf: eindeutige Ticket-Kennungen der Arbeitsblätter, höchstens fünf', () => {
|
||||||
|
assert.deepEqual(ticketIdsOf([{ workorderId: 't1' }, { workorderId: 't1' }, { workorderId: 't2' }, {}, null]), ['t1', 't2'])
|
||||||
|
assert.deepEqual(ticketIdsOf(undefined), [])
|
||||||
|
assert.equal(ticketIdsOf(Array.from({ length: 7 }, (_, i) => ({ workorderId: `t${i}` }))).length, 5)
|
||||||
|
})
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const {
|
||||||
|
LEISTUNGSZEITRAUM_MAX, BEZEICHNUNG_MAX,
|
||||||
|
vorschauAnfrage, rechnungAnfrage, vorschauSchluessel, vorschauStatus, darfSpeichern,
|
||||||
|
zeileTeilen, zeileBearbeitbar, maxVorn, createSubmitter,
|
||||||
|
} = await loadModule('src/lib/rechnungAnfrage.js')
|
||||||
|
const { ADDRESS_INCOMPLETE_TEXT } = await loadModule('src/lib/financeView.js')
|
||||||
|
|
||||||
|
const kunde = { $id: 'c1', name: 'Anelia Wolf', email: 'anelia@example.de', street: 'Hauptstr. 1', postalCode: '67655', city: 'Kaiserslautern', customerStatus: 'customer' }
|
||||||
|
const zeile = (uid, description, quantity, price) => ({ uid, description, quantity, price })
|
||||||
|
const bl = (id, minuten) => ({ id, ticketId: 't1', woid: '100131', datum: '2026-10-06', minuten, text: 'Startseite', aboZeit: false, kommentar: false })
|
||||||
|
const B = [bl('b1', 60), bl('b2', 90), bl('b3', 45)]
|
||||||
|
const basis = (over = {}) => ({
|
||||||
|
kunde, art: 'festbetrag', projektId: 'p1', auftragId: '',
|
||||||
|
items: [zeile(1, 'Erstellung der Website, Pauschalpreis laut Absprache', '1', '500,00')], zusatz: [],
|
||||||
|
blaetter: [], abgewaehlt: [], anzahlung: { modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' },
|
||||||
|
zeilentexte: {}, zeitraum: '', zeitraumGeaendert: false, days: '14', memo: '', ...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechnungAnfrage: Festbetrag ohne AB (500 € laut Absprache)', () => {
|
||||||
|
const r = rechnungAnfrage(basis())
|
||||||
|
assert.equal(r.ok, true)
|
||||||
|
assert.deepEqual(r.body, {
|
||||||
|
customerId: 'c1', art: 'festbetrag', projektId: 'p1', daysUntilDue: 14,
|
||||||
|
items: [{ description: 'Erstellung der Website, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }],
|
||||||
|
})
|
||||||
|
assert.equal(r.totalCents, 50000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechnungAnfrage: ohne Projekt ist die Art frei und der Körper trägt kein projektId', () => {
|
||||||
|
const r = rechnungAnfrage(basis({ art: 'frei', projektId: '' }))
|
||||||
|
assert.equal(r.ok, true)
|
||||||
|
assert.equal(r.body.art, 'frei')
|
||||||
|
assert.equal('projektId' in r.body, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechnungAnfrage: Notiz und selbst geänderter Leistungszeitraum gehen mit, ein Vorschlag nicht', () => {
|
||||||
|
assert.equal(LEISTUNGSZEITRAUM_MAX, 140)
|
||||||
|
const r = rechnungAnfrage(basis({ memo: ' Danke ', zeitraum: ' Oktober 2026 ', zeitraumGeaendert: true }))
|
||||||
|
assert.equal(r.body.memo, 'Danke')
|
||||||
|
assert.equal(r.body.leistungszeitraum, 'Oktober 2026')
|
||||||
|
assert.equal('leistungszeitraum' in rechnungAnfrage(basis({ zeitraum: '01.10.–20.10.2026', zeitraumGeaendert: false })).body, false)
|
||||||
|
assert.equal('leistungszeitraum' in rechnungAnfrage(basis({ zeitraum: ' ', zeitraumGeaendert: true })).body, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechnungAnfrage: Festbetrag prüft Kunde, Projekt, Positionen, Zahlungsziel und Zeitraum', () => {
|
||||||
|
assert.deepEqual(rechnungAnfrage(basis({ projektId: '' })), { ok: false, error: 'Bitte ein Projekt wählen.' })
|
||||||
|
assert.equal(rechnungAnfrage(basis({ kunde: null })).error, 'Bitte einen Kunden wählen.')
|
||||||
|
assert.equal(rechnungAnfrage(basis({ items: [zeile(1, '', '1', '10,00')] })).error, 'Position 1: Bitte eine Beschreibung eingeben.')
|
||||||
|
assert.equal(rechnungAnfrage(basis({ days: 'abc' })).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.')
|
||||||
|
assert.equal(
|
||||||
|
rechnungAnfrage(basis({ zeitraum: 'x'.repeat(141), zeitraumGeaendert: true })).error,
|
||||||
|
'Der Leistungszeitraum darf höchstens 140 Zeichen lang sein.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechnungAnfrage: Aufwand sendet nur die gewählten Arbeitsblätter, Zusatzpositionen und Zeilentexte', () => {
|
||||||
|
const r = rechnungAnfrage(basis({
|
||||||
|
art: 'aufwand', blaetter: B, abgewaehlt: ['b2'],
|
||||||
|
zeilentexte: { '2026-10-06': '06.10.2026: Startseite', leer: ' ' },
|
||||||
|
zusatz: [zeile(2, 'Domain-Registrierung', '1', '12,00'), zeile(3, '', '1', '')],
|
||||||
|
}))
|
||||||
|
assert.equal(r.ok, true)
|
||||||
|
assert.deepEqual(r.body, {
|
||||||
|
customerId: 'c1', art: 'aufwand', projektId: 'p1', daysUntilDue: 14,
|
||||||
|
arbeitsblattIds: ['b1', 'b3'],
|
||||||
|
items: [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1200 }],
|
||||||
|
zeilentexte: { '2026-10-06': '06.10.2026: Startseite' },
|
||||||
|
})
|
||||||
|
assert.equal(r.totalCents, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechnungAnfrage: Aufwand ohne Arbeitsblatt und unvollständige Zusatzpositionen', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, abgewaehlt: ['b1', 'b2', 'b3'] })),
|
||||||
|
{ ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, 'Domain', '1', '')] })).error,
|
||||||
|
'Zusatzposition 1: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).'
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, '', '1', '5,00')] })).error,
|
||||||
|
'Zusatzposition 1: Bitte eine Beschreibung eingeben.'
|
||||||
|
)
|
||||||
|
// eine ganz leere Zeile zählt nicht
|
||||||
|
const r = rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, '', '1', '')] }))
|
||||||
|
assert.equal(r.ok, true)
|
||||||
|
assert.equal('items' in r.body, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechnungAnfrage: Anzahlung in Prozent oder in Euro mit Bezeichnung', () => {
|
||||||
|
const prozent = rechnungAnfrage(basis({ art: 'anzahlung', auftragId: 'a1', anzahlung: { modus: 'prozent', wert: '33,5', bezeichnung: ' Teilrechnung ' } }))
|
||||||
|
assert.deepEqual(prozent.body, {
|
||||||
|
customerId: 'c1', art: 'anzahlung', projektId: 'p1', auftragId: 'a1', daysUntilDue: 14,
|
||||||
|
anzahlung: { prozent: 33.5, bezeichnung: 'Teilrechnung' },
|
||||||
|
})
|
||||||
|
const betrag = rechnungAnfrage(basis({ art: 'anzahlung', auftragId: 'a1', anzahlung: { modus: 'betrag', wert: '400,00', bezeichnung: 'Anzahlung' } }))
|
||||||
|
assert.deepEqual(betrag.body.anzahlung, { betragCents: 40000, bezeichnung: 'Anzahlung' })
|
||||||
|
assert.equal(BEZEICHNUNG_MAX, 60)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechnungAnfrage: Anzahlung prüft AB, Prozent, Betrag und Bezeichnung', () => {
|
||||||
|
const an = (anzahlung, auftragId = 'a1') => rechnungAnfrage(basis({ art: 'anzahlung', auftragId, anzahlung }))
|
||||||
|
assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }, '').error, 'Bitte eine Auftragsbestätigung wählen.')
|
||||||
|
for (const wert of ['0', '0,00', '101', 'abc', '']) {
|
||||||
|
assert.equal(an({ modus: 'prozent', wert, bezeichnung: 'Anzahlung' }).error, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.', wert)
|
||||||
|
}
|
||||||
|
for (const wert of ['0,5', '100']) assert.equal(an({ modus: 'prozent', wert, bezeichnung: 'Anzahlung' }).ok, true, wert)
|
||||||
|
assert.equal(an({ modus: 'betrag', wert: 'abc', bezeichnung: 'Anzahlung' }).error, 'Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.')
|
||||||
|
assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: ' ' }).error, 'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).')
|
||||||
|
assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: 'x'.repeat(61) }).error, 'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rechnungAnfrage: Schlussrechnung mit Nachträgen; die Vorschau trägt weder Zahlungsziel noch Notiz', () => {
|
||||||
|
const e = basis({ art: 'schluss', auftragId: 'a1', zusatz: [zeile(2, 'Nachtrag Logo', '1', '150,00')], memo: 'Danke' })
|
||||||
|
const items = [{ description: 'Nachtrag Logo', quantity: 1, unitAmountCents: 15000 }]
|
||||||
|
assert.deepEqual(rechnungAnfrage(e).body, {
|
||||||
|
customerId: 'c1', art: 'schluss', projektId: 'p1', auftragId: 'a1', items, daysUntilDue: 14, memo: 'Danke',
|
||||||
|
})
|
||||||
|
assert.deepEqual(vorschauAnfrage(e), { ok: true, body: { customerId: 'c1', art: 'schluss', projektId: 'p1', auftragId: 'a1', items } })
|
||||||
|
assert.deepEqual(rechnungAnfrage(basis({ art: 'schluss', auftragId: '' })), { ok: false, error: 'Bitte eine Auftragsbestätigung wählen.' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('vorschauAnfrage: Festbetrag hat keine Vorschau; Zahlungsziel und Notiz blockieren sie nie', () => {
|
||||||
|
assert.deepEqual(vorschauAnfrage(basis()), { ok: false, error: '' })
|
||||||
|
assert.deepEqual(vorschauAnfrage(basis({ art: 'frei', projektId: '' })), { ok: false, error: '' })
|
||||||
|
const e = basis({ art: 'schluss', auftragId: 'a1', days: 'abc', memo: 'x'.repeat(1001) })
|
||||||
|
assert.equal(vorschauAnfrage(e).ok, true)
|
||||||
|
assert.equal(rechnungAnfrage(e).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.')
|
||||||
|
assert.deepEqual(vorschauAnfrage(basis({ art: 'aufwand', kunde: null })), { ok: false, error: 'Bitte einen Kunden wählen.' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('vorschauSchluessel: gleiche Auswahl in anderer Reihenfolge ist derselbe Schlüssel, Texte zählen nicht', () => {
|
||||||
|
const key = (over) => vorschauSchluessel(vorschauAnfrage(basis({ art: 'aufwand', blaetter: B, ...over })).body)
|
||||||
|
const k1 = key({})
|
||||||
|
assert.equal(key({ blaetter: [B[2], B[0], B[1]] }), k1)
|
||||||
|
assert.notEqual(key({ abgewaehlt: ['b2'] }), k1)
|
||||||
|
assert.equal(key({ zeilentexte: { '2026-10-06': 'x' }, zeitraum: 'Oktober', zeitraumGeaendert: true }), k1)
|
||||||
|
assert.notEqual(key({ zusatz: [zeile(2, 'Domain', '1', '12,00')] }), k1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('vorschauStatus: nicht nötig, ungültig, lädt, veraltet, Fehler, bereit', () => {
|
||||||
|
const ok = { ok: true, body: {} }
|
||||||
|
const fertig = { key: 'k1', status: 'ok', daten: { summeCents: 30000, positionen: [] } }
|
||||||
|
const status = (args) => vorschauStatus({ art: 'aufwand', anfrage: ok, vorschau: fertig, schluessel: 'k1', ...args })
|
||||||
|
assert.deepEqual(
|
||||||
|
vorschauStatus({ art: 'festbetrag', anfrage: { ok: false, error: '' }, vorschau: null, schluessel: '' }),
|
||||||
|
{ state: 'nicht-noetig', message: '' }
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
status({ anfrage: { ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }, schluessel: '' }),
|
||||||
|
{ state: 'ungueltig', message: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
|
||||||
|
)
|
||||||
|
assert.deepEqual(status({ vorschau: null }), { state: 'laedt', message: '' })
|
||||||
|
assert.equal(status({ vorschau: { key: 'k1', status: 'laedt' } }).state, 'laedt')
|
||||||
|
assert.equal(status({ schluessel: 'k2' }).state, 'laedt') // veraltet: die Eingabe hat sich geändert
|
||||||
|
assert.deepEqual(
|
||||||
|
status({ vorschau: { key: 'k1', status: 'fehler', message: 'Arbeitsblatt bereits abgerechnet' } }),
|
||||||
|
{ state: 'fehler', message: 'Arbeitsblatt bereits abgerechnet' }
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
status({ vorschau: { key: 'k1', status: 'ok', daten: { summeCents: 0, positionen: [] } } }),
|
||||||
|
{ state: 'fehler', message: 'Nichts mehr offen.' }
|
||||||
|
)
|
||||||
|
assert.deepEqual(status({}), { state: 'bereit', message: '' })
|
||||||
|
assert.equal(darfSpeichern({ state: 'bereit' }), true)
|
||||||
|
assert.equal(darfSpeichern({ state: 'nicht-noetig' }), true)
|
||||||
|
for (const state of ['laedt', 'ungueltig', 'fehler']) assert.equal(darfSpeichern({ state }), false, state)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Abwahl eines Arbeitsblatts macht die Vorschau veraltet und sperrt Entwurf und Senden', () => {
|
||||||
|
const e1 = basis({ art: 'aufwand', blaetter: B })
|
||||||
|
const a1 = vorschauAnfrage(e1)
|
||||||
|
const k1 = vorschauSchluessel(a1.body)
|
||||||
|
const vorschau = { key: k1, status: 'ok', daten: { summeCents: 32500, positionen: [] } }
|
||||||
|
assert.equal(darfSpeichern(vorschauStatus({ art: 'aufwand', anfrage: a1, vorschau, schluessel: k1 })), true)
|
||||||
|
const a2 = vorschauAnfrage({ ...e1, abgewaehlt: ['b2'] })
|
||||||
|
const k2 = vorschauSchluessel(a2.body)
|
||||||
|
assert.deepEqual(a2.body.arbeitsblattIds, ['b1', 'b3'])
|
||||||
|
assert.notEqual(k2, k1)
|
||||||
|
assert.equal(darfSpeichern(vorschauStatus({ art: 'aufwand', anfrage: a2, vorschau, schluessel: k2 })), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeileTeilen, zeileBearbeitbar und maxVorn: nur Aufwandszeilen sind bearbeitbar, geteilt wird am letzten Gedankenstrich', () => {
|
||||||
|
const tag = '06.10.2026: Startseite, Kontaktformular – 2,25 Std. à 100,00 €'
|
||||||
|
assert.deepEqual(zeileTeilen(tag), { vorn: '06.10.2026: Startseite, Kontaktformular', hinten: ' – 2,25 Std. à 100,00 €' })
|
||||||
|
assert.deepEqual(zeileTeilen('KW 41/2026: A – B – 6,5 Std. à 100,00 €'), { vorn: 'KW 41/2026: A – B', hinten: ' – 6,5 Std. à 100,00 €' })
|
||||||
|
assert.deepEqual(zeileTeilen('Domain-Registrierung'), { vorn: 'Domain-Registrierung', hinten: '' })
|
||||||
|
const pos = (schluessel, beschreibung = tag) => ({ schluessel, beschreibung })
|
||||||
|
assert.equal(zeileBearbeitbar(pos('2026-10-06')), true)
|
||||||
|
assert.equal(zeileBearbeitbar(pos('KW-2026-41')), true)
|
||||||
|
for (const schluessel of ['ab-1', 'zusatz-1', 'abzug-in_123', 'anzahlung']) assert.equal(zeileBearbeitbar(pos(schluessel)), false, schluessel)
|
||||||
|
assert.equal(zeileBearbeitbar(pos('2026-10-06', 'ohne Strich')), false)
|
||||||
|
const hinten = ' – 2,25 Std. à 100,00 €'
|
||||||
|
assert.equal(maxVorn(hinten), 200 - hinten.length)
|
||||||
|
assert.equal(maxVorn('x'.repeat(250)), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const okAntwort = { rechnung: { id: 'in_1', number: 'WEBKLAR-0009', status: 'offen', customerEmail: 'anelia@example.de' } }
|
||||||
|
const anfrage = rechnungAnfrage(basis())
|
||||||
|
|
||||||
|
test('createSubmitter: Entwurf fragt nicht nach und sendet senden=false', async () => {
|
||||||
|
const gesendet = []
|
||||||
|
const submit = createSubmitter({
|
||||||
|
create: async (body) => (gesendet.push(body), okAntwort),
|
||||||
|
confirm: () => assert.fail('beim Entwurf gibt es keine Rückfrage'),
|
||||||
|
})
|
||||||
|
const r = await submit({ anfrage, senden: false, kunde, totalCents: 50000 })
|
||||||
|
assert.deepEqual(r, { status: 'ok', rechnung: okAntwort.rechnung, senden: false })
|
||||||
|
assert.equal(gesendet[0].senden, false)
|
||||||
|
assert.equal(gesendet[0].art, 'festbetrag')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('createSubmitter: Senden fragt vorher nach, bei „Abbrechen“ geht nichts raus', async () => {
|
||||||
|
let frage = ''
|
||||||
|
let create = 0
|
||||||
|
const submit = createSubmitter({ create: async () => { create += 1; return okAntwort }, confirm: (text) => { frage = text; return false } })
|
||||||
|
assert.deepEqual(await submit({ anfrage, senden: true, kunde, totalCents: 50000 }), { status: 'abgebrochen' })
|
||||||
|
assert.equal(create, 0)
|
||||||
|
assert.equal(frage, 'Rechnung für Anelia Wolf über 500,00 € jetzt festschreiben und an anelia@example.de senden? Danach lässt sie sich nur noch stornieren.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('createSubmitter: Adress-Sperre über 250 € ohne Anschrift gilt auch hier', async () => {
|
||||||
|
let create = 0
|
||||||
|
const submit = createSubmitter({ create: async () => { create += 1; return okAntwort }, confirm: () => true })
|
||||||
|
const ohneAdresse = { ...kunde, street: '' }
|
||||||
|
assert.deepEqual(await submit({ anfrage, senden: true, kunde: ohneAdresse, totalCents: 25001 }), { status: 'fehler', message: ADDRESS_INCOMPLETE_TEXT })
|
||||||
|
assert.equal(create, 0)
|
||||||
|
assert.equal((await submit({ anfrage, senden: true, kunde: ohneAdresse, totalCents: 25000 })).status, 'ok') // Kleinbetrag
|
||||||
|
assert.equal((await submit({ anfrage, senden: false, kunde: ohneAdresse, totalCents: 25001 })).status, 'ok') // Entwurf bleibt möglich
|
||||||
|
})
|
||||||
|
|
||||||
|
test('createSubmitter: Doppelklick auf Senden legt nur eine Rechnung an', async () => {
|
||||||
|
let create = 0
|
||||||
|
let fertig
|
||||||
|
const submit = createSubmitter({
|
||||||
|
create: () => { create += 1; return new Promise((resolve) => { fertig = () => resolve(okAntwort) }) },
|
||||||
|
confirm: () => true,
|
||||||
|
})
|
||||||
|
const erster = submit({ anfrage, senden: true, kunde, totalCents: 50000 })
|
||||||
|
const zweiter = await submit({ anfrage, senden: true, kunde, totalCents: 50000 })
|
||||||
|
assert.deepEqual(zweiter, { status: 'laeuft' })
|
||||||
|
fertig()
|
||||||
|
assert.equal((await erster).status, 'ok')
|
||||||
|
assert.equal(create, 1)
|
||||||
|
// danach geht wieder ein Aufruf (nach einem Fehler muss der Nutzer es erneut versuchen können)
|
||||||
|
const dritter = submit({ anfrage, senden: false, kunde, totalCents: 50000 })
|
||||||
|
fertig()
|
||||||
|
assert.equal((await dritter).status, 'ok')
|
||||||
|
assert.equal(create, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('createSubmitter: Fehler des Servers kommen als Meldung zurück, auch 409 und „festgeschrieben“', async () => {
|
||||||
|
const wirft = (err) => createSubmitter({ create: async () => { throw err }, confirm: () => true })
|
||||||
|
const konflikt = Object.assign(new Error('Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).'), { status: 409 })
|
||||||
|
assert.deepEqual(await wirft(konflikt)({ anfrage, senden: false, kunde, totalCents: 1 }), {
|
||||||
|
status: 'fehler', message: 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).', httpStatus: 409,
|
||||||
|
})
|
||||||
|
const ohneStatus = new Error('Irgendwas ist schiefgegangen.')
|
||||||
|
assert.deepEqual(await wirft(ohneStatus)({ anfrage, senden: false, kunde, totalCents: 1 }), { status: 'fehler', message: 'Irgendwas ist schiefgegangen.' })
|
||||||
|
const festgeschrieben = Object.assign(new Error('Rechnung WEBKLAR-0009 wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.'), {
|
||||||
|
status: 502, invoiceId: 'in_9', invoiceNumber: 'WEBKLAR-0009',
|
||||||
|
})
|
||||||
|
assert.deepEqual(await wirft(festgeschrieben)({ anfrage, senden: true, kunde, totalCents: 50000 }), {
|
||||||
|
status: 'festgeschrieben',
|
||||||
|
notice: 'Rechnung WEBKLAR-0009 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.',
|
||||||
|
finalizedFilter: 'offen',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('createSubmitter: eine ungültige Anfrage geht nie an den Server', async () => {
|
||||||
|
let create = 0
|
||||||
|
const submit = createSubmitter({ create: async () => { create += 1 }, confirm: () => true })
|
||||||
|
const r = await submit({ anfrage: { ok: false, error: 'Bitte einen Kunden wählen.' }, senden: false, kunde, totalCents: 0 })
|
||||||
|
assert.deepEqual(r, { status: 'fehler', message: 'Bitte einen Kunden wählen.' })
|
||||||
|
assert.equal(create, 0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const { anfangszustand, reduce } = await loadModule('src/lib/rechnungDialogState.js')
|
||||||
|
|
||||||
|
test('anfangszustand: nichts gewählt, eine leere Position, Zahlungsziel 14', () => {
|
||||||
|
assert.deepEqual(anfangszustand({ projektId: 'p1' }), {
|
||||||
|
projektId: 'p1', art: '', auftragId: '',
|
||||||
|
items: [{ uid: 1, description: '', quantity: '1', price: '' }], zusatz: [],
|
||||||
|
abgewaehlt: [], weitereTickets: [], anzahlung: {}, zeilentexte: {},
|
||||||
|
zeitraum: '', zeitraumGeaendert: false, days: '14', tageGeaendert: false, memo: '',
|
||||||
|
})
|
||||||
|
assert.equal(anfangszustand().projektId, null)
|
||||||
|
assert.equal(anfangszustand({ projektId: '' }).projektId, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('projekt: setzt alles zurück, behält aber Zahlungsziel und Notiz', () => {
|
||||||
|
let s = anfangszustand({ projektId: 'p1' })
|
||||||
|
s = reduce(s, { type: 'tage', wert: '30' })
|
||||||
|
s = reduce(s, { type: 'notiz', wert: 'Danke' })
|
||||||
|
s = reduce(s, { type: 'art', art: 'aufwand' })
|
||||||
|
s = reduce(s, { type: 'blatt', id: 'b1' })
|
||||||
|
s = reduce(s, { type: 'zeilentext', schluessel: '2026-10-06', text: 'Neu', original: 'Alt' })
|
||||||
|
s = reduce(s, { type: 'projekt', projektId: 'p2' })
|
||||||
|
assert.equal(s.projektId, 'p2')
|
||||||
|
assert.equal(s.art, '')
|
||||||
|
assert.deepEqual(s.abgewaehlt, [])
|
||||||
|
assert.deepEqual(s.zeilentexte, {})
|
||||||
|
assert.equal(s.days, '30')
|
||||||
|
assert.equal(s.memo, 'Danke')
|
||||||
|
assert.equal(reduce(s, { type: 'projekt', projektId: null }).projektId, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('art: Positionen für Festbetrag und Zusatzpositionen bleiben getrennt und erhalten', () => {
|
||||||
|
let s = anfangszustand({ projektId: 'p1' })
|
||||||
|
s = reduce(s, { type: 'position', liste: 'items', uid: 1, feld: 'description', wert: 'Pauschale' })
|
||||||
|
s = reduce(s, { type: 'position+', liste: 'zusatz' })
|
||||||
|
s = reduce(s, { type: 'position', liste: 'zusatz', uid: 2, feld: 'price', wert: '12,00' })
|
||||||
|
s = reduce(s, { type: 'art', art: 'aufwand' })
|
||||||
|
assert.equal(s.art, 'aufwand')
|
||||||
|
assert.equal(s.items[0].description, 'Pauschale')
|
||||||
|
assert.deepEqual(s.zusatz, [{ uid: 2, description: '', quantity: '1', price: '12,00' }])
|
||||||
|
// eine unbekannte Liste oder ein unbekanntes Feld ändert nichts
|
||||||
|
assert.equal(reduce(s, { type: 'position', liste: 'x', uid: 1, feld: 'description', wert: 'y' }), s)
|
||||||
|
assert.equal(reduce(s, { type: 'position', liste: 'items', uid: 1, feld: 'uid', wert: 9 }), s)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('position+ und position-: Positionen mindestens 1, Zusatzpositionen mindestens 0, höchstens 20', () => {
|
||||||
|
let s = anfangszustand({ projektId: 'p1' })
|
||||||
|
assert.equal(reduce(s, { type: 'position-', liste: 'items', uid: 1 }), s)
|
||||||
|
s = reduce(s, { type: 'position+', liste: 'zusatz' })
|
||||||
|
assert.equal(s.zusatz[0].uid, 2)
|
||||||
|
s = reduce(s, { type: 'position-', liste: 'zusatz', uid: 2 })
|
||||||
|
assert.deepEqual(s.zusatz, [])
|
||||||
|
for (let i = 0; i < 25; i += 1) s = reduce(s, { type: 'position+', liste: 'zusatz' })
|
||||||
|
assert.equal(s.zusatz.length, 20)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blatt: Abwählen und Wiederanwählen', () => {
|
||||||
|
let s = anfangszustand({ projektId: 'p1' })
|
||||||
|
s = reduce(s, { type: 'blatt', id: 'b2' })
|
||||||
|
assert.deepEqual(s.abgewaehlt, ['b2'])
|
||||||
|
s = reduce(s, { type: 'blatt', id: 'b2' })
|
||||||
|
assert.deepEqual(s.abgewaehlt, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ticket+ und ticket-: ein Ticket nur einmal, beim Entfernen verschwindet die Abwahl seiner Blätter', () => {
|
||||||
|
const ticket = { ticketId: 't2', woid: '100140', titel: 'Kontaktformular' }
|
||||||
|
let s = anfangszustand({ projektId: 'p1' })
|
||||||
|
s = reduce(s, { type: 'ticket+', ticket })
|
||||||
|
s = reduce(s, { type: 'ticket+', ticket })
|
||||||
|
assert.deepEqual(s.weitereTickets, [ticket])
|
||||||
|
s = reduce(s, { type: 'blatt', id: 'w1' })
|
||||||
|
s = reduce(s, { type: 'blatt', id: 'b9' })
|
||||||
|
s = reduce(s, { type: 'ticket-', ticketId: 't2', blattIds: ['w1'] })
|
||||||
|
assert.deepEqual(s.weitereTickets, [])
|
||||||
|
assert.deepEqual(s.abgewaehlt, ['b9'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeitraum-vorschlag gilt nur, solange der Nutzer das Feld nicht selbst geändert hat', () => {
|
||||||
|
let s = anfangszustand({ projektId: 'p1' })
|
||||||
|
s = reduce(s, { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' })
|
||||||
|
assert.deepEqual([s.zeitraum, s.zeitraumGeaendert], ['01.10.–20.10.2026', false])
|
||||||
|
assert.equal(reduce(s, { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' }), s) // gleicher Text: kein neuer Zustand
|
||||||
|
s = reduce(s, { type: 'zeitraum', text: 'Oktober 2026' })
|
||||||
|
assert.deepEqual([s.zeitraum, s.zeitraumGeaendert], ['Oktober 2026', true])
|
||||||
|
assert.equal(reduce(s, { type: 'zeitraum-vorschlag', text: '02.10.–21.10.2026' }).zeitraum, 'Oktober 2026')
|
||||||
|
// ein Wechsel der Art verwirft einen Vorschlag, nicht aber die Eingabe des Nutzers
|
||||||
|
const vorschlag = reduce(anfangszustand({ projektId: 'p1' }), { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' })
|
||||||
|
assert.equal(reduce(vorschlag, { type: 'art', art: 'festbetrag' }).zeitraum, '')
|
||||||
|
assert.equal(reduce(s, { type: 'art', art: 'festbetrag' }).zeitraum, 'Oktober 2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeilentext: ein unveränderter oder leerer Text entfernt den Eintrag', () => {
|
||||||
|
let s = anfangszustand({ projektId: 'p1' })
|
||||||
|
const setze = (text) => reduce(s, { type: 'zeilentext', schluessel: '2026-10-06', text, original: 'Alt' })
|
||||||
|
s = setze('Neu')
|
||||||
|
assert.deepEqual(s.zeilentexte, { '2026-10-06': 'Neu' })
|
||||||
|
s = setze('Alt')
|
||||||
|
assert.deepEqual(s.zeilentexte, {})
|
||||||
|
s = setze('Neu')
|
||||||
|
s = setze(' ')
|
||||||
|
assert.deepEqual(s.zeilentexte, {})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('anzahlung und auftrag: Eingaben ergänzen sich, eine andere AB setzt sie zurück', () => {
|
||||||
|
let s = anfangszustand({ projektId: 'p1' })
|
||||||
|
s = reduce(s, { type: 'anzahlung', patch: { modus: 'betrag' } })
|
||||||
|
s = reduce(s, { type: 'anzahlung', patch: { wert: '400,00' } })
|
||||||
|
assert.deepEqual(s.anzahlung, { modus: 'betrag', wert: '400,00' })
|
||||||
|
s = reduce(s, { type: 'auftrag', auftragId: 'a2' })
|
||||||
|
assert.deepEqual([s.auftragId, s.anzahlung], ['a2', {}])
|
||||||
|
assert.equal(reduce(s, { type: 'gibtsnicht' }), s)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('tage-vorschlag: übernimmt das Zahlungsziel der AB, solange der Nutzer es nicht selbst geändert hat', () => {
|
||||||
|
let s = anfangszustand({ projektId: 'p1' })
|
||||||
|
s = reduce(s, { type: 'tage-vorschlag', wert: '30' })
|
||||||
|
assert.equal(s.days, '30')
|
||||||
|
assert.equal(s.tageGeaendert, false)
|
||||||
|
assert.equal(reduce(s, { type: 'tage-vorschlag', wert: '30' }), s) // derselbe Wert: nichts zu tun
|
||||||
|
s = reduce(s, { type: 'tage-vorschlag', wert: '14' }) // andere AB gewählt: der Vorschlag folgt
|
||||||
|
assert.equal(s.days, '14')
|
||||||
|
// eine Eingabe des Nutzers wird nie überschrieben, auch nicht nach einem Projektwechsel
|
||||||
|
s = reduce(s, { type: 'tage', wert: '7' })
|
||||||
|
assert.equal(s.tageGeaendert, true)
|
||||||
|
assert.equal(reduce(s, { type: 'tage-vorschlag', wert: '30' }), s)
|
||||||
|
s = reduce(s, { type: 'projekt', projektId: 'p2' })
|
||||||
|
assert.equal(s.days, '7')
|
||||||
|
assert.equal(reduce(s, { type: 'tage-vorschlag', wert: '30' }).days, '7')
|
||||||
|
})
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const {
|
||||||
|
AB_HINWEIS, ENTWURF_HINWEIS,
|
||||||
|
startProjekt, projektOptionen, wirksamesProjekt,
|
||||||
|
gueltigeFestpreisAbs, aufwandAuftrag, abLabel, berechnetFuerAuftrag, hatEntwurfZurAuftrag,
|
||||||
|
artOptionen, standardArt, standardAuftrag, anzahlungStandard,
|
||||||
|
ausgewaehlteIds, blaetterSumme, blattAusAppwrite, ticketOptionen, zahlungszielVorschlag,
|
||||||
|
STEUERNUMMER_RECHNUNG_HINWEIS, steuernummerHinweis,
|
||||||
|
STRIPE_UNBEKANNT, abSummenText, ohneProjektAbDaten,
|
||||||
|
} = await loadModule('src/lib/rechnungDialogView.js')
|
||||||
|
|
||||||
|
const ab = (over = {}) => ({
|
||||||
|
id: 'a1', nummer: 'AB-2026-0002', status: 'bestaetigt', art: 'festpreis', summeCents: 80000,
|
||||||
|
gesendetAm: '2026-10-06T08:15:00.000Z', zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, stundensatzCents: null, ...over,
|
||||||
|
})
|
||||||
|
const rk = (over = {}) => ({
|
||||||
|
id: 'in_1', number: 'WEBKLAR-0007', status: 'offen', createdDate: '2026-10-08', totalCents: 40000, paidCents: 0, art: 'anzahlung', auftragId: 'a1', ...over,
|
||||||
|
})
|
||||||
|
const bl = (id, minuten, over = {}) => ({
|
||||||
|
id, ticketId: 't1', woid: '100131', datum: '2026-10-06', minuten, text: 'Startseite', aboZeit: false, kommentar: false, ...over,
|
||||||
|
})
|
||||||
|
const KONTEN = [
|
||||||
|
{ projektId: 'p1', name: 'Website Relaunch', tickets: [{ id: 't1', woid: '100131' }] },
|
||||||
|
{ projektId: 'p2', name: 'Shop', tickets: [] },
|
||||||
|
]
|
||||||
|
const optionen = (args) => artOptionen({ projektId: 'p1', auftraege: [], rechnungen: [], auftraegeAktiv: true, ...args })
|
||||||
|
const zustand = (o) => o.map((x) => [x.key, x.enabled])
|
||||||
|
|
||||||
|
test('startProjekt: Vorauswahl der Aufrufer (undefined = keine, null = Ohne Projekt, Text = Projekt)', () => {
|
||||||
|
assert.equal(startProjekt(undefined), null)
|
||||||
|
assert.equal(startProjekt(null), '')
|
||||||
|
assert.equal(startProjekt('p1'), 'p1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('projektOptionen: Projekte mit Ticketnummer, am Ende „Ohne Projekt“', () => {
|
||||||
|
assert.deepEqual(projektOptionen(KONTEN), [
|
||||||
|
{ value: 'p1', label: 'Website Relaunch · #100131' },
|
||||||
|
{ value: 'p2', label: 'Shop' },
|
||||||
|
{ value: '', label: 'Ohne Projekt' },
|
||||||
|
])
|
||||||
|
assert.deepEqual(projektOptionen([]), [{ value: '', label: 'Ohne Projekt' }])
|
||||||
|
assert.deepEqual(projektOptionen(undefined), [{ value: '', label: 'Ohne Projekt' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wirksamesProjekt: null = noch zu wählen, „“ = Ohne Projekt, Kunde ohne Projekte = Ohne Projekt', () => {
|
||||||
|
assert.equal(wirksamesProjekt('', KONTEN), '')
|
||||||
|
assert.equal(wirksamesProjekt('p2', KONTEN), 'p2')
|
||||||
|
assert.equal(wirksamesProjekt('gibtsnicht', KONTEN), null)
|
||||||
|
assert.equal(wirksamesProjekt('p1', undefined), 'p1') // Liste noch nicht geladen: die Vorauswahl bleibt
|
||||||
|
assert.equal(wirksamesProjekt(null, KONTEN), null)
|
||||||
|
assert.equal(wirksamesProjekt(null, []), '') // Kunde ohne Projekte: gleich „Ohne Projekt“
|
||||||
|
assert.equal(wirksamesProjekt(null, undefined), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('gueltigeFestpreisAbs: nur Festpreis mit Status versendet oder bestätigt, neueste zuerst', () => {
|
||||||
|
const list = [
|
||||||
|
ab({ id: 'a1' }),
|
||||||
|
ab({ id: 'a2', status: 'entwurf' }),
|
||||||
|
ab({ id: 'a3', art: 'aufwand' }),
|
||||||
|
ab({ id: 'a4', status: 'abgerechnet' }),
|
||||||
|
ab({ id: 'a5', status: 'versendet', gesendetAm: '2026-10-07T08:00:00.000Z' }),
|
||||||
|
ab({ id: 'a6', status: 'storniert' }),
|
||||||
|
]
|
||||||
|
assert.deepEqual(gueltigeFestpreisAbs(list).map((a) => a.id), ['a5', 'a1'])
|
||||||
|
assert.deepEqual(gueltigeFestpreisAbs(undefined), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('aufwandAuftrag: jüngste gültige Aufwands-AB', () => {
|
||||||
|
const list = [
|
||||||
|
ab({ id: 'x1', art: 'aufwand', status: 'bestaetigt', gesendetAm: '2026-10-01T08:00:00.000Z' }),
|
||||||
|
ab({ id: 'x2', art: 'aufwand', status: 'versendet', gesendetAm: '2026-10-05T08:00:00.000Z' }),
|
||||||
|
ab({ id: 'x3', art: 'aufwand', status: 'storniert', gesendetAm: '2026-10-09T08:00:00.000Z' }),
|
||||||
|
ab({ id: 'a1' }),
|
||||||
|
]
|
||||||
|
assert.equal(aufwandAuftrag(list).id, 'x2')
|
||||||
|
assert.equal(aufwandAuftrag([ab()]), null)
|
||||||
|
assert.equal(aufwandAuftrag(undefined), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('abLabel: Nummer, Datum des Sendens und Summe bzw. „nach Aufwand“', () => {
|
||||||
|
assert.equal(abLabel(ab()), 'AB-2026-0002 vom 06.10.2026 · 800,00 €')
|
||||||
|
assert.equal(abLabel(ab({ art: 'aufwand', summeCents: null })), 'AB-2026-0002 vom 06.10.2026 · nach Aufwand')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('berechnetFuerAuftrag und hatEntwurfZurAuftrag: nur finalisierte zählen, ein Entwurf blockiert', () => {
|
||||||
|
const list = [
|
||||||
|
rk(),
|
||||||
|
rk({ id: 'in_2', status: 'bezahlt', totalCents: 10000 }),
|
||||||
|
rk({ id: 'in_3', status: 'entwurf', totalCents: 5000 }),
|
||||||
|
rk({ id: 'in_4', status: 'storniert', totalCents: 7000 }),
|
||||||
|
rk({ id: 'in_5', auftragId: 'a2', totalCents: 9000 }),
|
||||||
|
]
|
||||||
|
assert.equal(berechnetFuerAuftrag(list, 'a1'), 50000)
|
||||||
|
assert.equal(berechnetFuerAuftrag(list, 'a2'), 9000)
|
||||||
|
assert.equal(berechnetFuerAuftrag(undefined, 'a1'), 0)
|
||||||
|
assert.equal(hatEntwurfZurAuftrag(list, 'a1'), true)
|
||||||
|
assert.equal(hatEntwurfZurAuftrag(list, 'a2'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artOptionen: mit gültiger Festpreis-AB sind alle vier Arten wählbar', () => {
|
||||||
|
const o = optionen({ auftraege: [ab()] })
|
||||||
|
assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', true], ['aufwand', true], ['festbetrag', true]])
|
||||||
|
assert.deepEqual(o.map((x) => x.label), ['Anzahlung', 'Schlussrechnung', 'Nach Aufwand', 'Festbetrag'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artOptionen: ohne AB oder bei ausgeschaltetem Schalter erklärt ein Hinweis die Sperre', () => {
|
||||||
|
for (const args of [{ auftraege: [] }, { auftraege: [ab()], auftraegeAktiv: false }, { auftraege: [ab({ status: 'entwurf' })] }]) {
|
||||||
|
const o = optionen(args)
|
||||||
|
assert.deepEqual(zustand(o), [['anzahlung', false], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
|
||||||
|
assert.equal(o[0].hint, AB_HINWEIS)
|
||||||
|
assert.equal(o[1].hint, AB_HINWEIS)
|
||||||
|
}
|
||||||
|
assert.equal(AB_HINWEIS, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artOptionen: ein offener Entwurf zur AB sperrt die Schlussrechnung, nicht die Anzahlung', () => {
|
||||||
|
const o = optionen({ auftraege: [ab()], rechnungen: [rk({ status: 'entwurf' })] })
|
||||||
|
assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
|
||||||
|
assert.equal(o[1].hint, ENTWURF_HINWEIS)
|
||||||
|
assert.equal(ENTWURF_HINWEIS, 'Zu dieser Auftragsbestätigung gibt es noch einen Entwurf. Erst senden oder löschen.')
|
||||||
|
// zwei ABs: die Schlussrechnung bleibt wählbar, solange eine AB ohne Entwurf da ist
|
||||||
|
const zwei = optionen({ auftraege: [ab(), ab({ id: 'a2', nummer: 'AB-2026-0003' })], rechnungen: [rk({ status: 'entwurf' })] })
|
||||||
|
assert.equal(zwei[1].enabled, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artOptionen: ohne Wahl keine Arten, ohne Projekt nur die freie Rechnung', () => {
|
||||||
|
assert.deepEqual(artOptionen({ projektId: null, auftraege: [ab()], rechnungen: [], auftraegeAktiv: true }), [])
|
||||||
|
const frei = artOptionen({ projektId: '', auftraege: [ab({ projektId: 'p1' })], rechnungen: [], auftraegeAktiv: true })
|
||||||
|
assert.deepEqual(zustand(frei), [['frei', true]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('standardArt: Anzahlung, Schlussrechnung, Aufwand oder Festbetrag je nach Lage', () => {
|
||||||
|
const lage = (args, extra = {}) => {
|
||||||
|
const eingabe = { auftraege: [], rechnungen: [], offeneMinuten: 0, ...args, ...extra }
|
||||||
|
return standardArt(optionen({ auftraege: eingabe.auftraege, rechnungen: eingabe.rechnungen }), eingabe)
|
||||||
|
}
|
||||||
|
assert.equal(lage({ auftraege: [ab()] }), 'anzahlung') // Zahlungsplan Anzahlung, noch nichts berechnet
|
||||||
|
assert.equal(lage({ auftraege: [ab()], rechnungen: [rk()] }), 'schluss') // Anzahlung schon berechnet
|
||||||
|
const nachAbnahme = ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })
|
||||||
|
assert.equal(lage({ auftraege: [nachAbnahme] }), 'schluss')
|
||||||
|
assert.equal(lage({}, { offeneMinuten: 90 }), 'aufwand')
|
||||||
|
assert.equal(lage({}), 'festbetrag')
|
||||||
|
const ohneProjekt = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: true })
|
||||||
|
assert.equal(standardArt(ohneProjekt, { auftraege: [], rechnungen: [], offeneMinuten: 0 }), 'frei')
|
||||||
|
assert.equal(standardArt([], { auftraege: [], rechnungen: [], offeneMinuten: 0 }), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('standardAuftrag: die jüngste passende AB, bei Schlussrechnung ohne offenen Entwurf', () => {
|
||||||
|
const neu = ab({ id: 'a5', gesendetAm: '2026-10-07T08:00:00.000Z' })
|
||||||
|
assert.equal(standardAuftrag('anzahlung', [ab(), neu]), 'a5')
|
||||||
|
assert.equal(standardAuftrag('schluss', [ab(), neu]), 'a5')
|
||||||
|
assert.equal(standardAuftrag('schluss', [ab(), neu], [rk({ auftragId: 'a5', status: 'entwurf' })]), 'a1')
|
||||||
|
assert.equal(standardAuftrag('anzahlung', []), '')
|
||||||
|
assert.equal(standardAuftrag('aufwand', [ab({ id: 'x2', art: 'aufwand' })]), '') // die Aufwands-AB ordnet der Server zu
|
||||||
|
assert.equal(standardAuftrag('festbetrag', [ab()]), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('anzahlungStandard: Prozent aus dem Zahlungsplan, sonst 50', () => {
|
||||||
|
const vorgabe = { modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }
|
||||||
|
assert.deepEqual(anzahlungStandard(ab()), vorgabe)
|
||||||
|
assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'anzahlung', prozent: 33.5, text: '' } })), { ...vorgabe, wert: '33,5' })
|
||||||
|
assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })), vorgabe)
|
||||||
|
assert.deepEqual(anzahlungStandard(null), vorgabe)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ausgewaehlteIds und blaetterSumme: alle sind vorausgewählt, abgewählte fallen heraus', () => {
|
||||||
|
const B = [bl('b1', 60), bl('b2', 90), bl('b3', 45)]
|
||||||
|
assert.deepEqual(ausgewaehlteIds(B, []), ['b1', 'b2', 'b3'])
|
||||||
|
assert.deepEqual(ausgewaehlteIds(B, ['b2']), ['b1', 'b3'])
|
||||||
|
assert.deepEqual(ausgewaehlteIds(undefined, []), [])
|
||||||
|
assert.deepEqual(blaetterSumme(B, ['b1', 'b3']), { anzahl: 2, minuten: 105 })
|
||||||
|
assert.deepEqual(blaetterSumme(B, []), { anzahl: 0, minuten: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
const ws = (over = {}) => ({
|
||||||
|
$id: 'w1', woid: '100140', workorderId: 't2', totalTime: 60, supporters: '', isComment: false, countsToPlan: false,
|
||||||
|
startDate: '06.10.2026', endDate: '06.10.2026', details: 'Kontaktformular gebaut', $createdAt: '2026-10-06T07:00:00.000Z', ...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blattAusAppwrite: abrechenbares Blatt mit Minuten (inkl. Unterstützung), Datum und kurzem Text', () => {
|
||||||
|
assert.deepEqual(blattAusAppwrite(ws(), {}), {
|
||||||
|
id: 'w1', ticketId: 't2', woid: '100140', datum: '2026-10-06', minuten: 60, text: 'Kontaktformular gebaut', aboZeit: false, kommentar: false,
|
||||||
|
})
|
||||||
|
// Unterstützung zählt mit: 60 + 1 × 30
|
||||||
|
const mit = ws({ supporters: JSON.stringify([{ employeeId: 'e1', name: 'A', task: 'Review' }]) })
|
||||||
|
assert.equal(blattAusAppwrite(mit, {}).minuten, 90)
|
||||||
|
// ein Altwert außerhalb des 15-Minuten-Rasters wird aufgerundet
|
||||||
|
assert.equal(blattAusAppwrite(ws({ totalTime: 70 }), {}).minuten, 75)
|
||||||
|
// ohne gültiges Datum zählt der Erstellungstag, der Text ist die erste Zeile, höchstens 120 Zeichen
|
||||||
|
assert.equal(blattAusAppwrite(ws({ startDate: '', endDate: '' }), {}).datum, '2026-10-06')
|
||||||
|
assert.equal(blattAusAppwrite(ws({ details: 'Zeile 1\nZeile 2' }), {}).text, 'Zeile 1')
|
||||||
|
assert.equal(blattAusAppwrite(ws({ details: 'a'.repeat(300) }), {}).text.length, 120)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blattAusAppwrite: Kommentar, Abo-Zeit, 0 Minuten und schon Abgerechnetes sind null', () => {
|
||||||
|
assert.equal(blattAusAppwrite(ws({ isComment: true }), {}), null)
|
||||||
|
assert.equal(blattAusAppwrite(ws({ countsToPlan: true }), {}), null)
|
||||||
|
assert.equal(blattAusAppwrite(ws({ totalTime: 0 }), {}), null)
|
||||||
|
assert.equal(blattAusAppwrite(ws(), { w1: 'WEBKLAR-0009' }), null)
|
||||||
|
assert.equal(blattAusAppwrite(ws(), { w1: 'Entwurf' }), null)
|
||||||
|
assert.equal(blattAusAppwrite({}, {}), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ticketOptionen: Tickets des Kunden mit Nummer, ohne die schon gewählten', () => {
|
||||||
|
const T = [
|
||||||
|
{ $id: 't1', woid: '100131', topic: 'Relaunch' },
|
||||||
|
{ $id: 't2', woid: '100140', topic: 'Kontaktformular' },
|
||||||
|
{ $id: 't3', woid: '', topic: '', title: 'Alt' },
|
||||||
|
{ $id: 't4', woid: '100150', topic: '', title: '' },
|
||||||
|
]
|
||||||
|
assert.deepEqual(ticketOptionen(T, { ausgeschlossen: ['t1'] }), [
|
||||||
|
{ value: 't2', ticketId: 't2', woid: '100140', label: '#100140 · Kontaktformular' },
|
||||||
|
{ value: 't4', ticketId: 't4', woid: '100150', label: '#100150 · Kein Betreff' },
|
||||||
|
])
|
||||||
|
assert.deepEqual(ticketOptionen(undefined), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zahlungszielVorschlag: Anzahlung und Schluss übernehmen das Zahlungsziel der gewählten AB, sonst 14', () => {
|
||||||
|
const dreissig = ab({ zahlungszielTage: 30 })
|
||||||
|
assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: dreissig }), '30')
|
||||||
|
assert.equal(zahlungszielVorschlag({ art: 'schluss', ab: dreissig }), '30')
|
||||||
|
assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: ab({ zahlungszielTage: 0 }) }), '0') // sofort fällig ist erlaubt
|
||||||
|
// Aufwand: die jüngste gültige Aufwands-AB
|
||||||
|
const aufwand = ab({ id: 'x2', art: 'aufwand', zahlungszielTage: 21 })
|
||||||
|
assert.equal(zahlungszielVorschlag({ art: 'aufwand', auftraege: [ab(), aufwand] }), '21')
|
||||||
|
// ohne AB, ohne Wert oder mit unbrauchbarem Wert bleibt es bei 14; frei und Festbetrag kennen keine AB
|
||||||
|
assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: undefined }), '14')
|
||||||
|
assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: ab() }), '14')
|
||||||
|
for (const kaputt of [-1, 61, 1.5, '30', null, NaN]) assert.equal(zahlungszielVorschlag({ art: 'schluss', ab: ab({ zahlungszielTage: kaputt }) }), '14')
|
||||||
|
assert.equal(zahlungszielVorschlag({ art: 'festbetrag', ab: dreissig }), '14')
|
||||||
|
assert.equal(zahlungszielVorschlag({ art: 'frei', ab: dreissig }), '14')
|
||||||
|
assert.equal(zahlungszielVorschlag({ art: '', auftraege: [aufwand] }), '14')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('steuernummerHinweis: nicht sperrender Hinweis, solange die Steuernummer in den Einstellungen leer ist', () => {
|
||||||
|
assert.equal(
|
||||||
|
STEUERNUMMER_RECHNUNG_HINWEIS,
|
||||||
|
'Die Steuernummer fehlt noch – sie gehört auf jede Rechnung (§ 34a UStDV). Eintragen unter Finanzen → Einstellungen.'
|
||||||
|
)
|
||||||
|
for (const leer of [{}, { steuernummer: '' }, { steuernummer: ' ' }, { steuernummer: null }]) {
|
||||||
|
assert.equal(steuernummerHinweis(leer), STEUERNUMMER_RECHNUNG_HINWEIS)
|
||||||
|
}
|
||||||
|
assert.equal(steuernummerHinweis({ steuernummer: '12/345/67890' }), '')
|
||||||
|
// noch nicht geladen (undefined) und Ladefehler (null) behaupten nichts
|
||||||
|
assert.equal(steuernummerHinweis(undefined), '')
|
||||||
|
assert.equal(steuernummerHinweis(null), '')
|
||||||
|
assert.equal(steuernummerHinweis([]), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('abSummenText: Auftragssumme, berechnet und noch offen; bei Stripe-Ausfall „Beträge unbekannt“ statt 0,00 €', () => {
|
||||||
|
assert.equal(
|
||||||
|
abSummenText({ ab: ab(), rechnungen: [rk()], stripeOk: true }),
|
||||||
|
'Auftragssumme 800,00 € · bereits berechnet 400,00 € · noch nicht berechnet 400,00 €'
|
||||||
|
)
|
||||||
|
assert.equal(abSummenText({ ab: ab(), rechnungen: [], stripeOk: undefined }), 'Auftragssumme 800,00 € · bereits berechnet 0,00 € · noch nicht berechnet 800,00 €')
|
||||||
|
assert.equal(STRIPE_UNBEKANNT, 'Stripe nicht erreichbar – Beträge unbekannt')
|
||||||
|
const aus = abSummenText({ ab: ab(), rechnungen: [], stripeOk: false })
|
||||||
|
assert.equal(aus, 'Auftragssumme 800,00 € · Stripe nicht erreichbar – Beträge unbekannt')
|
||||||
|
assert.ok(!aus.includes('bereits berechnet'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('standardArt: bei Stripe-Ausfall wird keine Art vorgeschlagen (Beträge unbekannt)', () => {
|
||||||
|
const lage = { auftraege: [ab()], rechnungen: [], offeneMinuten: 90 }
|
||||||
|
const o = optionen({ auftraege: [ab()] })
|
||||||
|
assert.equal(standardArt(o, lage), 'anzahlung')
|
||||||
|
assert.equal(standardArt(o, { ...lage, stripeOk: true }), 'anzahlung')
|
||||||
|
assert.equal(standardArt(o, { ...lage, stripeOk: false }), '')
|
||||||
|
// ohne Projekt gibt es nichts zu raten: die freie Rechnung bleibt die Vorgabe
|
||||||
|
const frei = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: true })
|
||||||
|
assert.equal(standardArt(frei, { ...lage, stripeOk: false }), 'frei')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('artOptionen ohne Projekt: eine gültige AB ohne Projekt macht Anzahlung und Schlussrechnung wählbar', () => {
|
||||||
|
const ohne = (args = {}) => artOptionen({ projektId: '', auftraege: [ab()], rechnungen: [], auftraegeAktiv: true, ...args })
|
||||||
|
assert.deepEqual(zustand(ohne()), [['frei', true], ['anzahlung', true], ['schluss', true]])
|
||||||
|
assert.deepEqual(ohne().map((x) => x.label), ['Freie Positionen', 'Anzahlung', 'Schlussrechnung'])
|
||||||
|
// eine AB mit Projekt, ein Entwurf, ein falscher Status oder der ausgeschaltete Schalter: wie bisher nur frei bzw. gesperrt
|
||||||
|
assert.deepEqual(zustand(ohne({ auftraege: [ab({ projektId: 'p1' })] })), [['frei', true]])
|
||||||
|
assert.deepEqual(zustand(ohne({ auftraege: [ab({ status: 'entwurf' })] })), [['frei', true]])
|
||||||
|
assert.deepEqual(zustand(ohne({ auftraegeAktiv: false })), [['frei', true]])
|
||||||
|
const entwurf = ohne({ rechnungen: [rk({ status: 'entwurf' })] })
|
||||||
|
assert.deepEqual(zustand(entwurf), [['frei', true], ['anzahlung', true], ['schluss', false]])
|
||||||
|
assert.equal(entwurf[2].hint, ENTWURF_HINWEIS)
|
||||||
|
// Vorgabe bleibt die freie Rechnung
|
||||||
|
assert.equal(standardArt(ohne(), { auftraege: [ab()], rechnungen: [], offeneMinuten: 0 }), 'frei')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ohneProjektAbDaten: nur ABs ohne Projekt und deren Rechnungen aus Auftrags- und Rechnungsliste', () => {
|
||||||
|
const daten = ohneProjektAbDaten(
|
||||||
|
{ auftraege: [ab({ id: 'a1', projektId: '' }), ab({ id: 'a2', projektId: 'p1' }), ab({ id: 'a3' })] },
|
||||||
|
{ rechnungen: [rk({ auftragId: 'a1' }), rk({ id: 'in_2', auftragId: 'a2' }), rk({ id: 'in_3', auftragId: '' }), rk({ id: 'in_4', auftragId: 'a3', status: 'entwurf' })] }
|
||||||
|
)
|
||||||
|
assert.deepEqual(daten.auftraege.map((a) => a.id), ['a1', 'a3'])
|
||||||
|
assert.deepEqual(daten.rechnungen.map((r) => r.id), ['in_1', 'in_4'])
|
||||||
|
assert.deepEqual(ohneProjektAbDaten(undefined, null), { auftraege: [], rechnungen: [] })
|
||||||
|
assert.deepEqual(ohneProjektAbDaten({ auftraege: 'x' }, { rechnungen: {} }), { auftraege: [], rechnungen: [] })
|
||||||
|
})
|
||||||
Reference in new issue
Block a user