Compare commits

...
35 Commits
Author SHA1 Message Date
ClaudeandClaude Sonnet 5.5 d01a804cea Kontrolle: Projektname in der Projektliste behält overflow-wrap anywhere
Mit break-word weitete ein langer Projektname ohne Umbruchstelle die Seite auf (Handy 390 px);
nur Beträge und Nebenzeilen wechseln zu break-word und nowrap.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:27:06 +00:00
ClaudeandClaude Sonnet 5.5 8faf61e8bb Kontrolle: fehlenden Import useOhneProjektAbs im Rechnungsdialog ergänzt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:23:28 +00:00
ClaudeandClaude Sonnet 5.5 514d1c2d4e Kontrolle: Wortbruch in schmalen Spalten nur noch bei Bedarf, Beträge brechen nie um
overflow-wrap: anywhere wird break-word (Projektname 'Fachanwaltsseiten' bricht nicht mehr
mitten im Wort); Beträge und Stunden der Projektliste stehen auf white-space: nowrap,
damit das Eurozeichen nicht allein steht.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:20:05 +00:00
ClaudeandClaude Sonnet 5.5 4b184f744f Kontrolle: AB ohne Projekt im Rechnungsdialog für Anzahlung und Schluss wählbar
Der Server (rechnungsbau.js, baueAbRechnung) nimmt Anzahlung und Schlussrechnung ohne
Projekt an. Unter 'Ohne Projekt' lädt der Dialog die ABs des Kunden ohne Projekt und
ihre Rechnungen; frei bleibt die Vorgabe.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:19:35 +00:00
ClaudeandClaude Sonnet 5.5 79f230ea2c Kontrolle: Rechnungsdialog zeigt bei Stripe-Ausfall keine Beträge und keine Vorgabe
Bei stripeOk false steht 'Stripe nicht erreichbar – Beträge unbekannt' statt 'bereits berechnet
0,00 €', und es wird keine Rechnungsart vorgeschlagen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:19:17 +00:00
ClaudeandClaude Sonnet 5.5 07d36f64e9 Kontrolle: Abschnitt Aufträge lädt auch bei ausgefallenen Projektkonten
Scheitert getProjektkonten, wird die Auftragsliste trotzdem geholt; kommt sie, bleibt der
Abschnitt bedienbar und der Fehler der Projekte steht eigens daneben.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:18:03 +00:00
ClaudeandClaude Sonnet 5.5 b556a459bb Kontrolle: Rechnungsdialog weist auf die fehlende Steuernummer hin
Nicht sperrender Hinweis (§ 34a UStDV), solange financeSettings.steuernummer leer ist;
die Einstellungen werden einmal geladen, bei einem Ladefehler erscheint kein Hinweis.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:17:29 +00:00
ClaudeandClaude Sonnet 5.5 dec7bea7d2 Kontrolle: Rechnungsdialog übernimmt das Zahlungsziel der gewählten AB
Bei Anzahlung, Schlussrechnung und Aufwand wird 'Zahlungsziel in Tagen' mit
auftrag.zahlungszielTage vorbelegt (sonst 14); eine Eingabe des Nutzers bleibt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 15:17:08 +00:00
ClaudeandClaude Sonnet 5.5 b7b3e834f8 Merge auftraege/w2-auftraege: Auftrags-UI (Strang W2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:48:55 +00:00
ClaudeandClaude Sonnet 5.5 05170068bc Merge auftraege/w1-rechnungen: Rechnungs-UI (Strang W1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:48:49 +00:00
ClaudeandClaude Sonnet 5.5 fbd3990e64 Projektkonto: Kommentar in projektkontoView ohne Raute-Beispiel (Hygienesuche)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:46:17 +00:00
ClaudeandClaude Sonnet 5.5 af43c70288 Rechnungsdialog: alter InvoiceCreateModal entfällt, RechnungDialog ersetzt ihn überall
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:46:02 +00:00
ClaudeandClaude Sonnet 5.5 f89ecec8ff Arbeitsblätter: Abzeichen „abgerechnet (Rechnungsnummer)“ für alle, Warnung beim Ändern der Zeit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:45:56 +00:00
ClaudeandClaude Sonnet 5.5 62b64bb357 Auftragsbestaetigung: Prueffunde behoben (Zeilenraster ohne Tabellenrahmen, Radiotexte umbrechen, Auswahl Projekt zuordnen nicht abgeschnitten)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:45:37 +00:00
ClaudeandClaude Sonnet 5.5 65526ca9b3 Rechnungstabelle: Spalten Projekt und Art, Knopf Rechnung schreiben öffnet den neuen Dialog
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:44:40 +00:00
ClaudeandClaude Sonnet 5.5 2aa4abeadb Rechnung schreiben: neuer Dialog mit Projektwahl, Zusammenfassung, Art, Aufwand mit Vorschau, Anzahlung und Schlussrechnung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:43:17 +00:00
ClaudeandClaude Sonnet 5.5 746244a4a6 Auftragsbestaetigung: Dialog Schritt 4 (Vorschau, Entwurf speichern, Senden mit Rueckfrage und Sperre)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:36:59 +00:00
ClaudeandClaude Sonnet 5.5 5060d150ea Auftragsbestaetigung: Dialog-Schritte 1 bis 3 (Projekt, Auftrag, Zahlung und Termine)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:35:49 +00:00
ClaudeandClaude Sonnet 5.5 8ab78b7b2c Rechnungsdialog: Bausteine für Projektzusammenfassung, Positionen, Vorschauzeilen und Arbeitsblattauswahl
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:35:04 +00:00
ClaudeandClaude Sonnet 5.5 3650573445 Auftragsbestaetigung: Liste mit Status, Zusatzzeilen, Aktionen und Projekt zuordnen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:34:47 +00:00
ClaudeandClaude Sonnet 5.5 6a5f943034 Rechnungsdialog: Zustand mit Reducer, Appwrite-Lesezugriffe und Hooks für Projektkonto, Vorschau und weitere Tickets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:34:22 +00:00
ClaudeandClaude Sonnet 5.5 8b055a5d42 Auftragsbestaetigung: Listenlogik (Status, Aktionen, Meldungen, Fehlerklassen, Sperre) und PDF-Hilfe mit Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:33:28 +00:00
ClaudeandClaude Sonnet 5.5 0f1dbabd98 Rechnungsdialog: Anfragekörper, Vorschau-Status, Zeilentexte und Absenden mit Doppelklick-Sperre samt Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:33:21 +00:00
ClaudeandClaude Sonnet 5.5 e37c0a664e Rechnungsdialog: Logik für Projektwahl, Rechnungsarten, Auftragsbestätigungen und Arbeitsblätter mit Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:32:20 +00:00
ClaudeandClaude Sonnet 5.5 cc95018083 Projektkonto: Projektliste mit Vereinbart, Berechnet, Noch offen und Zeit, Knöpfe Rechnung und Auftragsbestätigung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:31:35 +00:00
ClaudeandClaude Sonnet 5.5 45dd73d8cc Auftragsbestaetigung: Dialoglogik (Parser, Validierung, Nutzlast, Zahlungsplan-Vorschau) mit Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:31:31 +00:00
ClaudeandClaude Sonnet 5.5 241f7cdec6 Projektkonto: Anzeigelogik für Zeilen, Rechnungsspalten und Abzeichen „abgerechnet“ mit Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:29:30 +00:00
ClaudeandClaude Sonnet 5.5 674cce26f0 Kundenart in der Kunden-Uebersicht und Steuernummer in den Finanz-Einstellungen (Helfer mit Tests)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:28:49 +00:00
ClaudeandClaude Sonnet 5.5 99ab4bd47b Kundenseite: Reiter „Aufträge und Rechnungen“ zeigt den neuen Container, CustomerInvoices entfällt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:25:28 +00:00
ClaudeandClaude Sonnet 5.5 8f90fdc05a Aufträge und Rechnungen: Container des Kundenreiters und vier Platzhalter für W1 und W2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:25:22 +00:00
ClaudeandClaude Sonnet 5.5 e1c9b57249 Aufträge und Rechnungen: frische Kundenart und Rückmeldungen nach Rechnung und Auftrag (mit Tests)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:24:47 +00:00
ClaudeandClaude Sonnet 5.5 312705efc4 Aufträge und Rechnungen: Laden und Ladezustand des Reiters als reine Funktionen (mit Tests)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:24:20 +00:00
ClaudeandClaude Sonnet 5.5 e7bd146641 Kundenreiter „Rechnungen“ heißt jetzt „Aufträge und Rechnungen“ (id bleibt invoices)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:24:03 +00:00
ClaudeandClaude Sonnet 5.5 e116e804e3 Finanzen-API: Projektkonten, Auftragsbestätigungen (samt Projekt zuordnen), Rechnungsvorschau und abgerechnete Arbeitsblätter (Pfade rein und getestet)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:23:39 +00:00
ClaudeandClaude Sonnet 5.5 eaf9fce1bc Finanzen-Anfrage: Dateien lesen (as: 'blob'), z. B. das PDF einer Auftragsbestätigung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:23:05 +00:00
48 changed files with 6387 additions and 367 deletions

No files matched your search

+17
View File
@@ -1,6 +1,8 @@
import { EmptyState } from './ui'
import { needsTimeEntry, isGitWorksheet } from '../lib/ticketForm'
import { parseSupporters } from '../lib/support'
import { useAbgerechnet } from '../hooks/useAbgerechnet'
import { abgerechnetText, ticketIdsOf } from '../lib/projektkontoView'
import {
showAboToggle,
statusAfterLabel,
@@ -23,6 +25,8 @@ export default function WorksheetList({
onOwnSupport,
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 (!worksheets || worksheets.length === 0) {
@@ -35,6 +39,7 @@ export default function WorksheetList({
const when = worksheetWhen(ws)
const kind = worksheetKindLabel(ws)
const badge = worksheetBadge(ws)
const billed = abgerechnetText(abgerechnet, ws.$id)
const lines = supportLines(ws)
const status = statusAfterLabel(ws)
const time = timeLabel(ws)
@@ -58,6 +63,18 @@ export default function WorksheetList({
{kind && <span className="muted">{kind}</span>}
<span className="faint xs num">WSID {ws.wsid}</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 ? (
<button
type="button"
+10
View File
@@ -5,6 +5,8 @@ import { Button, DateTimeInput, Dialog, DurationSelect, Field } from './ui'
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
import { parseSupporters, validateSupporters } from '../lib/support'
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
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 }) {
const isGit = isGitWorksheet(worksheet)
const abgerechnet = useAbgerechnet(ticketIdsOf([worksheet]))
const warnung = abgerechnetWarnung(abgerechnet, worksheet.$id)
const [formData, setFormData] = useState(() => formFromWorksheet(worksheet))
// 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.
@@ -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.</>}
</p>
{warnung && (
<p className="msg warn" role="alert">
{warnung}
</p>
)}
<Field label="Beginn" htmlFor="worksheet-times-start">
<DateTimeInput
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}
/>
)}
</>
)
}
+275
View File
@@ -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>
)
}
+244
View File
@@ -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>
)
}
+103
View File
@@ -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>
)
}
+631
View File
@@ -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>
)
}
+95
View File
@@ -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; }
}
+30
View File
@@ -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>
)
}
+10 -3
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Badge, Button, Card, EmptyState, Stat } from '../ui'
import InvoiceCreateModal from './InvoiceCreateModal'
import RechnungDialog from '../auftraege/RechnungDialog'
import Pager from './Pager'
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
import { pageOf } from '../../lib/financeFormat'
import { noticeProps } from '../../lib/financeUi'
import { artLabel, projektSpalte } from '../../lib/projektkontoView'
import {
ADDRESS_INCOMPLETE_TEXT,
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
* 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.
* 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
* `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">
<th role="columnheader" scope="col">Nr.</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">Fällig</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>}
</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-due" data-label="Fällig">{row.dueText}</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>
</Card>
<InvoiceCreateModal
<RechnungDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={handleCreated}
fixedCustomer={customer}
kunde={customer}
onShowOverview={onShowOverview}
/>
</section>
+25 -4
View File
@@ -13,6 +13,7 @@ import {
settingsToForm,
validateSettingsForm,
} from '../../lib/financeSettings'
import { STEUERNUMMER_HINWEIS, steuernummerAenderung } from '../../lib/kundenFelder'
const LOAD_ERROR = 'Die Einstellungen konnten nicht geladen werden.'
const fieldId = (name) => `fin-set-${name}`
@@ -44,6 +45,8 @@ export default function SettingsCard() {
function SettingsForm({ initial }) {
const [form, setForm] = useState(() => settingsToForm(initial))
const [steuernummer, setSteuernummer] = useState(() => (typeof initial?.steuernummer === 'string' ? initial.steuernummer : ''))
const gespeichertRef = useRef(typeof initial?.steuernummer === 'string' ? initial.steuernummer : '')
const [errors, setErrors] = useState({})
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null) // { tone: 'ok' | 'warn', text }
@@ -68,10 +71,12 @@ function SettingsForm({ initial }) {
e.preventDefault()
if (lockRef.current) return
const check = validateSettingsForm(form)
if (!check.ok) {
setErrors(check.errors)
const stn = steuernummerAenderung(steuernummer, gespeichertRef.current)
if (!check.ok || !stn.ok) {
const fehler = { ...(check.errors || {}), ...(stn.ok ? {} : { steuernummer: stn.fehler }) }
setErrors(fehler)
setNotice({ tone: 'warn', text: 'Bitte die markierten Felder prüfen. Es wurde nichts gespeichert.' })
const first = SETTINGS_FIELD_ORDER.find((name) => check.errors[name])
const first = [...SETTINGS_FIELD_ORDER, 'steuernummer'].find((name) => fehler[name])
if (first) document.getElementById(fieldId(first))?.focus()
return
}
@@ -80,9 +85,12 @@ function SettingsForm({ initial }) {
setErrors({})
setNotice(null)
try {
const data = await patchSettings(check.payload)
const data = await patchSettings({ ...check.payload, ...stn.payload })
if (!aliveRef.current) return
if (data?.einstellungen) setForm(settingsToForm(data.einstellungen))
const gespeichert = typeof data?.einstellungen?.steuernummer === 'string' ? data.einstellungen.steuernummer : steuernummer.trim()
setSteuernummer(gespeichert)
gespeichertRef.current = gespeichert
setNotice({ tone: 'ok', text: 'Gespeichert.' })
} catch (err) {
if (aliveRef.current) setNotice({ tone: 'warn', text: apiErrorMessage(err) })
@@ -148,6 +156,19 @@ function SettingsForm({ initial }) {
<div className={`fin-counter${tooLong ? ' fin-counter-over' : ''}`}>
{length} / {SETTINGS_LIMITS.maxBankDetails} Zeichen
</div>
<FormField id={fieldId('steuernummer')} label="Steuernummer" hint={STEUERNUMMER_HINWEIS} error={errors.steuernummer}>
<input
className="input"
autoComplete="off"
value={steuernummer}
onChange={(e) => {
setSteuernummer(e.target.value)
setErrors((er) => (er.steuernummer ? { ...er, steuernummer: undefined } : er))
if (notice) setNotice(null)
}}
placeholder="z. B. 12/345/67890 oder DE123456789"
/>
</FormField>
</fieldset>
{notice && <p {...noticeProps(notice.tone, 'fin-notice')}>{notice.text}</p>}
+21
View File
@@ -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
}
+227
View File
@@ -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
}
+128
View File
@@ -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.' }
}
+52
View File
@@ -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' }),
}
}
+23
View File
@@ -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 }
)
}
+650
View File
@@ -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
}
},
}
}
+3 -3
View File
@@ -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.
const BASE_TABS = [
{ id: 'overview', label: 'Übersicht' },
{ id: 'tickets', label: 'Tickets' },
{ id: 'invoices', label: 'Rechnungen', adminOnly: true },
{ id: 'invoices', label: 'Aufträge und Rechnungen', adminOnly: true },
{ id: 'projects', label: 'Projekte' },
// Chat mit dem Kunden (derselbe Gespraechsbaustein wie im Messenger unter Kunden -> Chat)
{ id: 'chat', label: 'Chat' },
@@ -16,7 +16,7 @@ const BASE_TABS = [
{ 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) {
return BASE_TABS.filter((t) => !t.adminOnly || isAdmin === true).map(({ id, label }) => ({ id, label }))
}
+21
View File
@@ -1,5 +1,6 @@
import { account } from './appwrite'
import { createFinanceRequest } from './financeRequest'
import { createAuftraegeEndpoints } from './auftraegeEndpoints'
/**
* 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.
// Koerper: { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum,
// daysUntilDue, memo, senden } (wird unveraendert durchgereicht)
export const createInvoice = (body) =>
request('/rechnungen', { method: 'POST', body: { ...body, idempotencyKey: crypto.randomUUID() } })
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 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 ---
export const getTransactions = () => request('/buchungen')
// target: genau eins von { invoiceId } | { payoutId } | { fixedCostId } | { category }, optional note
+5 -2
View File
@@ -9,13 +9,14 @@ export const SESSION_EXPIRED_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
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:
* - 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)
* - createJWT scheitert (Sitzung abgelaufen): „Anmeldung abgelaufen. Bitte neu anmelden.“ (status 401)
* - sonst die Meldung des Portals (`error`), ohne sie „API-Fehler <Status>“
* 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 }) {
// 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) =>
portalFetch(`${baseUrl}${path}`, {
method,
@@ -47,6 +48,8 @@ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args)
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
try {
data = await res.json()
+67
View File
@@ -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() === ''
}
+154
View File
@@ -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
}
+221
View File
@@ -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
}
}
}
+21
View File
@@ -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
}
+98
View File
@@ -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
}
}
+254
View File
@@ -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'}`,
}))
}
+5
View File
@@ -1,6 +1,7 @@
import { account } from './appwrite'
import { createFinanceRequest } from './financeRequest'
import { createJwtCache } from './serverpulsAuth'
import { abgerechnetPfad } from './auftraegeEndpoints'
import { anleitungPfad, featureBody, entscheidungBody } from './funktionenView'
import { ersatzPfad, zeitenTeamPfad } from './zeitenView'
@@ -119,6 +120,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. */
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) ---
/** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */
+16 -4
View File
@@ -3,7 +3,7 @@ import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Dialog, Field } from '../components/ui'
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 CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '../lib/customerAdminApi'
@@ -12,6 +12,7 @@ import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer, markLost, reopenAsLead } from '../lib/leads'
import { lastRejection } from '../lib/leadDuplicates'
import { KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartAenderung } from '../lib/kundenFelder'
import { customerStage, isArchived, customerBadges, customerEyebrow, customerTitle } from '../lib/customerList'
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
import { ticketNumber } from '../lib/ticketView'
@@ -121,7 +122,7 @@ export default function CustomerDetailPage() {
{/* 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 === '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 === 'chat' && <Card><ChatThread customerId={customer.$id} customerName={customer.name} /></Card>}
{activeTab === 'abo' && (
@@ -154,11 +155,15 @@ function OverviewTab({ customer, stage, onSaved }) {
notes: customer.notes || '',
importantCustomer: !!customer.importantCustomer,
})
const [kundenart, setKundenart] = useState(customer.kundenart || '')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
const [showLost, setShowLost] = useState(false)
const { user, employee } = useAuth()
// Die Kundenart geht nur ins Dokument, wenn sie geaendert wurde (kein Fehler bei noch nicht angelegtem Attribut)
const felder = () => ({ ...form, ...kundenartAenderung(kundenart, customer.kundenart) })
const ready = isInvoiceReady({ ...customer, ...form })
// Absage: ungespeicherte Notizen gehen mit, damit der Vermerk nichts ueberschreibt
@@ -182,7 +187,7 @@ function OverviewTab({ customer, stage, onSaved }) {
const save = async () => {
setBusy(true); setMsg(null)
try {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, felder())
setMsg({ tone: 'ok', text: 'Gespeichert' })
onSaved()
} catch (err) {
@@ -194,7 +199,7 @@ function OverviewTab({ customer, stage, onSaved }) {
if (!ready) { setMsg({ tone: 'warn', text: 'Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.' }); return }
setBusy(true); setMsg(null)
try {
await upgradeToCustomer(customer, form)
await upgradeToCustomer(customer, felder())
setMsg({ tone: 'ok', text: 'Zum festen Kunden gemacht.' })
onSaved()
} catch (err) {
@@ -249,6 +254,13 @@ function OverviewTab({ customer, stage, onSaved }) {
<Card title="Rechnungsdaten">
{!ready && <div className="kunde-hinweis"><Badge tone="warn">Adresse unvollständig</Badge></div>}
<dl className="kv kunde-kv">
<dt><label htmlFor="kunde-kundenart">Kundenart</label></dt>
<dd>
<select id="kunde-kundenart" className="select" value={kundenart} onChange={(e) => setKundenart(e.target.value)}>
{KUNDENART_OPTIONEN.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<div className="hint">{KUNDENART_HINWEIS}</div>
</dd>
<dt><label htmlFor="kunde-street">Strasse + Nr.</label></dt>
<dd><input id="kunde-street" className="input" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></dd>
<dt><label htmlFor="kunde-postal">PLZ</label></dt>
+215
View File
@@ -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.' })
})
+86
View File
@@ -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)
})
+51
View File
@@ -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)
})
+680
View File
@@ -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)
})
+2 -2
View File
@@ -4,11 +4,11 @@ import { loadModule } from './load.mjs'
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), [
{ id: 'overview', label: 'Übersicht' },
{ id: 'tickets', label: 'Tickets' },
{ id: 'invoices', label: 'Rechnungen' },
{ id: 'invoices', label: 'Aufträge und Rechnungen' },
{ id: 'projects', label: 'Projekte' },
{ id: 'chat', label: 'Chat' },
{ id: 'abo', label: 'Abo & Angebote' },
+78
View File
@@ -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 })
})
+79
View File
@@ -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))
})
+160
View File
@@ -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)
})
+281
View File
@@ -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)
})
+132
View File
@@ -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')
})
+300
View File
@@ -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: [] })
})