From 241f7cdec6c0a230af3f92622b98c3b2f0cff82e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:29:30 +0000 Subject: [PATCH 01/11] =?UTF-8?q?Projektkonto:=20Anzeigelogik=20f=C3=BCr?= =?UTF-8?q?=20Zeilen,=20Rechnungsspalten=20und=20Abzeichen=20=E2=80=9Eabge?= =?UTF-8?q?rechnet=E2=80=9C=20mit=20Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/lib/projektkontoView.js | 154 ++++++++++++++++++++++++++ tests/unit/projektkontoView.test.mjs | 160 +++++++++++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 src/lib/projektkontoView.js create mode 100644 tests/unit/projektkontoView.test.mjs diff --git a/src/lib/projektkontoView.js b/src/lib/projektkontoView.js new file mode 100644 index 0000000..b882ae9 --- /dev/null +++ b/src/lib/projektkontoView.js @@ -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.` +} + +/** [{ woid: '100131' }, …] -> '#100131, #100140'; 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 +} diff --git a/tests/unit/projektkontoView.test.mjs b/tests/unit/projektkontoView.test.mjs new file mode 100644 index 0000000..4eb367e --- /dev/null +++ b/tests/unit/projektkontoView.test.mjs @@ -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) +}) From cc950180834ef3395b77ba39e653c19d1b19688a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:31:35 +0000 Subject: [PATCH 02/11] =?UTF-8?q?Projektkonto:=20Projektliste=20mit=20Vere?= =?UTF-8?q?inbart,=20Berechnet,=20Noch=20offen=20und=20Zeit,=20Kn=C3=B6pfe?= =?UTF-8?q?=20Rechnung=20und=20Auftragsbest=C3=A4tigung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../auftraege/ProjektkontoListe.jsx | 84 +++++++++++++++++-- src/components/auftraege/projektkonto.css | 27 ++++++ 2 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 src/components/auftraege/projektkonto.css diff --git a/src/components/auftraege/ProjektkontoListe.jsx b/src/components/auftraege/ProjektkontoListe.jsx index 4b4bd06..73da2f5 100644 --- a/src/components/auftraege/ProjektkontoListe.jsx +++ b/src/components/auftraege/ProjektkontoListe.jsx @@ -1,11 +1,81 @@ +import './projektkonto.css' +import { Card } from '../ui' +import { kontoZeile, ohneProjektZeile, stripeHinweis, KEINE_PROJEKTE_HINWEIS } from '../../lib/projektkontoView' + /** - * Platzhalter von W0: Strang W1 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben). - * Die fertige Komponente rendert ihren Abschnitt selbst: (ein
; den Abstand zu den - * Nachbarn liefert `.page section + section`). - * - * Props (V8): konten (Projektkonten), ohneProjekt ({ berechnetCents, bezahltCents, anzahl }), auftraegeAktiv, stripeOk, - * onRechnung(projektId) und onAuftrag(projektId); die Kennung ist die des Projekts, `null` die Zeile „Ohne Projekt“. + * 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 }) { - return null + const liste = Array.isArray(konten) ? konten : [] + const zeilen = [...liste.map((k) => kontoZeile(k, { stripeOk })), ohneProjektZeile(ohneProjekt, { stripeOk })] + const hinweis = stripeHinweis(stripeOk) + + return ( + +
+ {hinweis && ( +

+ {hinweis} +

+ )} + {liste.length === 0 &&

{KEINE_PROJEKTE_HINWEIS}

} + + + + + + + + + + + + + + {zeilen.map((z) => { + const id = z.projektId || null + const fuer = id === null ? 'ohne Projekt' : `für ${z.name}` + return ( + + + + + + + + + ) + })} + +
Projektkonten
ProjektVereinbartBerechnetNoch offenZeit erfasstAktionen
+
{z.name}
+ {z.meta &&
{z.meta}
} +
{z.vereinbart} +
{z.berechnet}
+ {z.bezahlt &&
{z.bezahlt}
} +
+ {z.offen} + +
{z.zeit.stunden}
+ {z.zeit.wert &&
{z.zeit.wert}
} + {z.zeit.abo &&
{z.zeit.abo}
} +
+
+ {auftraegeAktiv && ( + + )} + +
+
+
+
+ ) } diff --git a/src/components/auftraege/projektkonto.css b/src/components/auftraege/projektkonto.css new file mode 100644 index 0000000..94901fd --- /dev/null +++ b/src/components/auftraege/projektkonto.css @@ -0,0 +1,27 @@ +/* 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, .pk .pk-table td .cell-sub { overflow-wrap: anywhere; } +.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); } +} From e37c0a664e4b9ca9d7491fdd9d97ba044249780d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:32:20 +0000 Subject: [PATCH 03/11] =?UTF-8?q?Rechnungsdialog:=20Logik=20f=C3=BCr=20Pro?= =?UTF-8?q?jektwahl,=20Rechnungsarten,=20Auftragsbest=C3=A4tigungen=20und?= =?UTF-8?q?=20Arbeitsbl=C3=A4tter=20mit=20Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/lib/rechnungDialogView.js | 204 +++++++++++++++++++++++ tests/unit/rechnungDialogView.test.mjs | 217 +++++++++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 src/lib/rechnungDialogView.js create mode 100644 tests/unit/rechnungDialogView.test.mjs diff --git a/src/lib/rechnungDialogView.js b/src/lib/rechnungDialogView.js new file mode 100644 index 0000000..52e4f8a --- /dev/null +++ b/src/lib/rechnungDialogView.js @@ -0,0 +1,204 @@ +// 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 { 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' + +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') +} + +// --- 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 === '') return [option('frei', true)] + 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). */ +export function standardArt(optionen, { auftraege, rechnungen, offeneMinuten }) { + const liste = Array.isArray(optionen) ? optionen : [] + if (liste.length === 0) return '' + if (liste.length === 1 && liste[0].key === 'frei') return 'frei' + 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 '' +} + +/** 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'}`, + })) +} diff --git a/tests/unit/rechnungDialogView.test.mjs b/tests/unit/rechnungDialogView.test.mjs new file mode 100644 index 0000000..3a3c532 --- /dev/null +++ b/tests/unit/rechnungDialogView.test.mjs @@ -0,0 +1,217 @@ +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, +} = 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()], 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), []) +}) From 0f1dbabd980c55ef9476d36f7d3cd25ba9e5b715 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:33:21 +0000 Subject: [PATCH 04/11] =?UTF-8?q?Rechnungsdialog:=20Anfragek=C3=B6rper,=20?= =?UTF-8?q?Vorschau-Status,=20Zeilentexte=20und=20Absenden=20mit=20Doppelk?= =?UTF-8?q?lick-Sperre=20samt=20Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/lib/rechnungAnfrage.js | 221 ++++++++++++++++++++++ tests/unit/rechnungAnfrage.test.mjs | 281 ++++++++++++++++++++++++++++ 2 files changed, 502 insertions(+) create mode 100644 src/lib/rechnungAnfrage.js create mode 100644 tests/unit/rechnungAnfrage.test.mjs diff --git a/src/lib/rechnungAnfrage.js b/src/lib/rechnungAnfrage.js new file mode 100644 index 0000000..e03e5c3 --- /dev/null +++ b/src/lib/rechnungAnfrage.js @@ -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 + } + } +} diff --git a/tests/unit/rechnungAnfrage.test.mjs b/tests/unit/rechnungAnfrage.test.mjs new file mode 100644 index 0000000..855c31f --- /dev/null +++ b/tests/unit/rechnungAnfrage.test.mjs @@ -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) +}) From 6a5f94303484c7f74de739c724c14006212b5306 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:34:22 +0000 Subject: [PATCH 05/11] =?UTF-8?q?Rechnungsdialog:=20Zustand=20mit=20Reduce?= =?UTF-8?q?r,=20Appwrite-Lesezugriffe=20und=20Hooks=20f=C3=BCr=20Projektko?= =?UTF-8?q?nto,=20Vorschau=20und=20weitere=20Tickets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/hooks/useRechnungDaten.js | 189 ++++++++++++++++++++++++ src/lib/rechnungDialogApi.js | 21 +++ src/lib/rechnungDialogState.js | 93 ++++++++++++ tests/unit/rechnungDialogState.test.mjs | 115 ++++++++++++++ 4 files changed, 418 insertions(+) create mode 100644 src/hooks/useRechnungDaten.js create mode 100644 src/lib/rechnungDialogApi.js create mode 100644 src/lib/rechnungDialogState.js create mode 100644 tests/unit/rechnungDialogState.test.mjs diff --git a/src/hooks/useRechnungDaten.js b/src/hooks/useRechnungDaten.js new file mode 100644 index 0000000..c1cf410 --- /dev/null +++ b/src/hooks/useRechnungDaten.js @@ -0,0 +1,189 @@ +// 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 { getProjektkonten, getProjektkonto, previewInvoice } from '../lib/financeApi' +import { getAbgerechnet } from '../lib/teamApi' +import { listKundenTickets, listTicketBlaetter } from '../lib/rechnungDialogApi' +import { blattAusAppwrite } 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, + } +} + +/** 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.') +} + +/** + * 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) + + useEffect(() => { + if (!aktiv || !bodyRef.current || !schluessel) { + datenRef.current = null + return undefined + } + 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 +} diff --git a/src/lib/rechnungDialogApi.js b/src/lib/rechnungDialogApi.js new file mode 100644 index 0000000..61c007c --- /dev/null +++ b/src/lib/rechnungDialogApi.js @@ -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 +} diff --git a/src/lib/rechnungDialogState.js b/src/lib/rechnungDialogState.js new file mode 100644 index 0000000..5181ce8 --- /dev/null +++ b/src/lib/rechnungDialogState.js @@ -0,0 +1,93 @@ +// 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), + 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, 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 } + case 'notiz': + return { ...s, memo: a.wert } + default: + return s + } +} diff --git a/tests/unit/rechnungDialogState.test.mjs b/tests/unit/rechnungDialogState.test.mjs new file mode 100644 index 0000000..067b7e8 --- /dev/null +++ b/tests/unit/rechnungDialogState.test.mjs @@ -0,0 +1,115 @@ +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', 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) +}) From 8ab78b7b2c62bb3dfd5786bb733515a676adede7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:35:04 +0000 Subject: [PATCH 06/11] =?UTF-8?q?Rechnungsdialog:=20Bausteine=20f=C3=BCr?= =?UTF-8?q?=20Projektzusammenfassung,=20Positionen,=20Vorschauzeilen=20und?= =?UTF-8?q?=20Arbeitsblattauswahl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/components/auftraege/AufwandAuswahl.jsx | 103 ++++++++++++++++++ .../auftraege/KontoZusammenfassung.jsx | 36 ++++++ .../auftraege/RechnungPositionen.jsx | 100 +++++++++++++++++ src/components/auftraege/RechnungVorschau.jsx | 69 ++++++++++++ src/components/auftraege/rechnung-dialog.css | 48 ++++++++ 5 files changed, 356 insertions(+) create mode 100644 src/components/auftraege/AufwandAuswahl.jsx create mode 100644 src/components/auftraege/KontoZusammenfassung.jsx create mode 100644 src/components/auftraege/RechnungPositionen.jsx create mode 100644 src/components/auftraege/RechnungVorschau.jsx create mode 100644 src/components/auftraege/rechnung-dialog.css diff --git a/src/components/auftraege/AufwandAuswahl.jsx b/src/components/auftraege/AufwandAuswahl.jsx new file mode 100644 index 0000000..9a144bc --- /dev/null +++ b/src/components/auftraege/AufwandAuswahl.jsx @@ -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 ( +
+ Arbeitsblätter +
+ {satzInfo &&

{satzInfo}

} + {liste.length === 0 ? ( +

Keine offenen Arbeitsblätter.

+ ) : ( +
+ {liste.map((b) => ( + + ))} +
+ )} +

+ {summe.anzahl} von {liste.length} ausgewählt · {stundenDe(summe.minuten)} +

+ + + {ticketsFehler && ( +

+ {ticketsFehler} +

+ )} +
+ {weitere.map((t) => { + const z = zustand[t.ticketId] + return ( +
+ + #{t.woid} · {t.titel} + + {z?.status === 'laedt' && Arbeitsblätter werden geladen …} + {z?.status === 'fehler' && ( + + {z.message} + + )} + +
+ ) + })} +
+
+ ) +} diff --git a/src/components/auftraege/KontoZusammenfassung.jsx b/src/components/auftraege/KontoZusammenfassung.jsx new file mode 100644 index 0000000..782eec9 --- /dev/null +++ b/src/components/auftraege/KontoZusammenfassung.jsx @@ -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 ( + <> +
+
+
Vereinbart
+
{zeile.vereinbart}
+
+
+
Berechnet
+
{zeile.berechnet}
+ {zeile.bezahlt &&
{zeile.bezahlt}
} +
+
+
Noch offen
+
{zeile.offen}
+
+
+
Zeit erfasst
+
{zeile.zeit.stunden}
+
{zeile.zeit.wert}
+ {zeile.zeit.abo &&
{zeile.zeit.abo}
} +
+
+ {hinweis && ( +

+ {hinweis} +

+ )} + + ) +} diff --git a/src/components/auftraege/RechnungPositionen.jsx b/src/components/auftraege/RechnungPositionen.jsx new file mode 100644 index 0000000..44cf029 --- /dev/null +++ b/src/components/auftraege/RechnungPositionen.jsx @@ -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 ( +
+ {legende} +
+ {zeilen.map((zeile, index) => { + const n = index + 1 + const summe = lineTotalCents(zeile) + return ( +
+
+ + onChange(zeile.uid, 'description', e.target.value)} + /> +
+
+ + onChange(zeile.uid, 'quantity', e.target.value)} + /> +
+
+ + onChange(zeile.uid, 'price', e.target.value)} + /> +
+
+ Summe + {summe === null ? '–' : euro(summe)} +
+
+ +
+
+ ) + })} +
+ + {zeigeSumme && ( +
+ Summe + {euro(invoiceTotalCents(zeilen))} +
+ )} +
+ {zeigeSumme &&

Gemäß § 19 UStG ohne Umsatzsteuer.

} +
+
+ ) +} diff --git a/src/components/auftraege/RechnungVorschau.jsx b/src/components/auftraege/RechnungVorschau.jsx new file mode 100644 index 0000000..2491fd9 --- /dev/null +++ b/src/components/auftraege/RechnungVorschau.jsx @@ -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 ( +
+ + + + + + + + + + {liste.map((p, index) => { + const betrag = euro(p.betragCents) + const bearbeitbarEs = bearbeitbar && zeileBearbeitbar(p) + const { vorn, hinten } = zeileTeilen(p.beschreibung) + return ( + + + + + ) + })} + +
Rechnungspositionen
PositionBetrag
+ {bearbeitbarEs ? ( + <> + onZeilentext(p.schluessel, e.target.value, vorn)} + /> + {hinten} + + ) : ( + <> + {p.beschreibung} + {p.menge > 1 && ( +
+ {p.menge} × {euro(p.einzelpreisCents)} +
+ )} + + )} +
{betrag}
+
+ Summe + {euro(summeCents)} +
+

Gemäß § 19 UStG ohne Umsatzsteuer.

+ {mitEingabe &&

Die Beschreibung der Zeilen lässt sich ändern, Stunden und Beträge stehen fest.

} +
+ ) +} diff --git a/src/components/auftraege/rechnung-dialog.css b/src/components/auftraege/rechnung-dialog.css new file mode 100644 index 0000000..40118bf --- /dev/null +++ b/src/components/auftraege/rechnung-dialog.css @@ -0,0 +1,48 @@ +/* Rechnung schreiben (Dialog). Dieses Stylesheet steht im Bundle vor global.css: Regeln, die globale Klassen überstimmen + müssen, tragen zwei Klassen. Task 9 hängt am Ende die Spalten der Rechnungstabelle an. */ +.rd-group { border: 0; margin: 0; padding: 0; min-width: 0; } +.rd-group > legend { padding: 0; margin: 0 0 6px; font-weight: 500; } +.rd-stack { display: grid; gap: 8px; } +.rd-facts.facts { margin: 0; } +.seg.rd-seg { flex-wrap: wrap; max-width: 100%; justify-self: start; } +.rd-hint { margin: 0; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; } + +/* Positionen */ +.rd-item { display: grid; grid-template-columns: minmax(0, 1fr) 80px 120px 110px auto; gap: 8px 12px; align-items: end; } +.rd-item .input { width: 100%; } +.rd-item-field { min-width: 0; display: grid; gap: 4px; } +.rd-item-label { font-size: 12px; color: var(--text-2); } +.rd-item-sum { text-align: right; white-space: nowrap; padding-bottom: 6px; } +.rd-items-foot { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; } +.rd-total { display: flex; gap: 16px; font-weight: 600; } +@media (max-width: 860px) { + .rd-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) auto; grid-template-areas: "desc desc desc desc" "qty price sum remove"; } + .rd-item-desc { grid-area: desc; } + .rd-item-qty { grid-area: qty; } + .rd-item-price { grid-area: price; } + .rd-item-sumfeld { grid-area: sum; } + .rd-item-remove { grid-area: remove; } +} + +/* Arbeitsblätter */ +.rd-blaetter { border-top: 1px solid var(--line); } +.rd-blatt { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 4px 12px; align-items: start; padding: 8px 0; border-bottom: 1px solid var(--line); cursor: pointer; } +.rd-blatt input { accent-color: var(--accent); width: 15px; height: 15px; margin-top: 3px; } +.rd-blatt-text { min-width: 0; overflow-wrap: anywhere; } +.rd-blatt-zeit { white-space: nowrap; font-variant-numeric: tabular-nums; } +.rd-weiter { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; } + +/* Vorschau der Rechnungszeilen */ +.rd-lines { table-layout: fixed; } +.rd-lines .rd-line-text { overflow-wrap: anywhere; } +.rd-lines .fin-amount { width: 7.5rem; } +.rd-line-input { width: 100%; min-width: 0; } +.rd-line-tail { display: block; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; } +.rd-dim { opacity: 0.5; } + +/* nur für Screenreader sichtbar */ +.rd-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } + +/* Fuß des Dialogs */ +.rd-foot { display: flex; flex: 1; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } +@media (max-width: 860px) { .rd-foot .btn { flex: 1 1 auto; } } From 2aa4abeadbd51e1482c44018641665808ba11ebf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:43:17 +0000 Subject: [PATCH 07/11] Rechnung schreiben: neuer Dialog mit Projektwahl, Zusammenfassung, Art, Aufwand mit Vorschau, Anzahlung und Schlussrechnung Co-Authored-By: Claude Sonnet 5.5 --- src/components/auftraege/RechnungDialog.jsx | 604 ++++++++++++++++++- src/components/auftraege/rechnung-dialog.css | 3 +- src/hooks/useRechnungDaten.js | 7 + 3 files changed, 604 insertions(+), 10 deletions(-) diff --git a/src/components/auftraege/RechnungDialog.jsx b/src/components/auftraege/RechnungDialog.jsx index 1daa360..cfdd282 100644 --- a/src/components/auftraege/RechnungDialog.jsx +++ b/src/components/auftraege/RechnungDialog.jsx @@ -1,13 +1,599 @@ +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, + useProjektkontoDetail, + useProjektkonten, + useRechnungVorschau, + useWeitereBlaetter, +} from '../../hooks/useRechnungDaten' +import { createInvoice } from '../../lib/financeApi' +import { canInvoice, euro } 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, + aufwandAuftrag, + berechnetFuerAuftrag, + gueltigeFestpreisAbs, + projektOptionen, + standardArt, + standardAuftrag, + startProjekt, + ticketOptionen, + wirksamesProjekt, +} from '../../lib/rechnungDialogView' +import { + BEZEICHNUNG_MAX, + VORSCHAU_ARTEN, + createSubmitter, + darfSpeichern, + rechnungAnfrage, + vorschauAnfrage, + vorschauSchluessel, + vorschauStatus, +} from '../../lib/rechnungAnfrage' +import { anfangszustand, reduce } from '../../lib/rechnungDialogState' + /** - * Platzhalter von W0: Strang W1 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben). + * Dialog „Rechnung schreiben“ (ersetzt InvoiceCreateModal). 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 (V8): open, onClose, onCreated(rechnung, info), kunde (Kundendokument oder null = Kundenwahl im Dialog), - * projektId (undefined = keine Vorauswahl, null = „Ohne Projekt“, Text = dieses Projekt), auftraegeAktiv (nur der Startwert, - * danach gilt getProjektkonten, das der Dialog selbst lädt), onShowOverview (optional, „Zur Übersicht“ in der Adresswarnung). - * `info` von onCreated wird unverändert weitergereicht wie im bisherigen InvoiceCreateModal ({ senden }, bei festgeschriebener, - * nicht versendeter Rechnung { senden: true, notice, tone: 'warn', finalizedFilter }); danach ruft der Dialog onClose(). - * Der Dialog sitzt zusätzlich in InvoiceTable.jsx (dort ersetzt W1 das Modal), FinancePage.jsx bleibt unverändert. + * 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, projektId, auftraegeAktiv, onShowOverview }) { - return null +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 ? : +} + +function KundenWahl(props) { + const { customers, loading, error } = useCustomers() + return +} + +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 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 || '') + const auftraege = detail.data?.auftraege || [] + const rechnungen = detail.data?.rechnungen || [] + + 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 art = optionen.some((o) => o.key === state.art && o.enabled) + ? state.art + : standardArt(optionen, { auftraege, rechnungen, offeneMinuten: detail.data?.konto?.zeit?.offeneMinuten || 0 }) + + 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 !== '' + + // --- 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(detail.reload) + detailReloadRef.current = detail.reload + 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) detail.reload() + } 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 = ( +
+ + + +
+ ) + + // --- Abschnitte ------------------------------------------------------------------------------------------- + const mitPositionen = (liste, legende, labelPrefix, idPrefix, minZeilen, addLabel, zeigeSumme) => ( + 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 + const berechnet = berechnetFuerAuftrag(rechnungen, ab.id) + return ( +
+ {festpreisAbs.length > 1 ? ( + + + + ) : ( +

{abLabel(ab)}

+ )} +

+ Auftragssumme {euro(ab.summeCents)} · bereits berechnet {euro(berechnet)} · noch nicht berechnet {euro(Math.max(0, ab.summeCents - berechnet))} +

+
+ ) + } + + const anzahlungFelder = () => { + const a = e.anzahlung + const prozent = a.modus !== 'betrag' + return ( +
+
+ + +
+
+ + act({ type: 'anzahlung', patch: { wert: ev.target.value } })} + /> + + + act({ type: 'anzahlung', patch: { bezeichnung: ev.target.value } })} + /> + +
+
+ ) + } + + const statusBereich = () => ( + <> + {status.state === 'laedt' && ( +

+ Vorschau wird aktualisiert … +

+ )} + {status.state === 'ungueltig' && status.message &&

{status.message}

} + {status.state === 'fehler' && ( +

+ {status.message} +

+ )} + + ) + + const vorschauZeilen = () => { + const daten = vorschau?.daten + if ((status.state !== 'bereit' && status.state !== 'laedt') || !daten) return null + return ( + 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 ( + <> + 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 ( + ev.preventDefault()} footer={footer}> + {error && ( +
+ {error} +
+ )} + +
+ + {festerKunde ? ( +

+ {customerName(festerKunde) || 'Kunde'} + {festerKunde.email ? · {festerKunde.email} : null} +

+ ) : quelle?.loading ? ( +

+ Kunden werden geladen … +

+ ) : quelle?.error ? ( +

+ Die Kunden konnten nicht geladen werden: {quelle.error} +

+ ) : ( + customerSuggestions(options, t)} + placeholder="Kunde suchen …" + /> + )} +
+ {blockiert && ( +

+ {customerName(kundeDoc)}: {festerKunde ? canInvoice(festerKunde).reason : resolved.option?.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden. +

+ )} + {!festerKunde && resolved.state === 'unknown' && ( +

Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.

+ )} + {!festerKunde && kunde && kunde.email &&

Die Rechnung geht an {kunde.email}.

} + {kunde && gate.warn && ( + { + if (busy !== null) return + onClose() + onShowOverview() + } + : undefined + } + /> + )} + + {kunde && ( + <> + + + + {konten.loading && ( +

+ Projekte werden geladen … +

+ )} + {konten.error && ( +

+ Die Projekte konnten nicht geladen werden: {konten.error}{' '} + +

+ )} + {konten.data && konten.data.konten.length === 0 &&

{KEINE_PROJEKTE_HINWEIS}

} + + )} + + {kunde && projektBereit && projekt !== '' && ( + <> + {detail.loading && !detail.data && ( +

+ Projektkonto wird geladen … +

+ )} + {detail.error && !detail.data && ( +

+ Das Projektkonto konnte nicht geladen werden: {detail.error}{' '} + +

+ )} + {detail.data && ( + + )} + + )} + + {kunde && projektBereit && kontoBereit && optionen.length > 1 && ( +
+ Art der Rechnung +
+
+ {optionen.map((o) => ( + + ))} +
+ {hinweise.map((h) => ( +

+ {h} +

+ ))} +
+
+ )} + + {kunde && projektBereit && kontoBereit && art !== '' && inhalt()} + + {kunde && projektBereit && kontoBereit && art !== '' && ( + <> + + act({ type: 'zeitraum', text: ev.target.value })} + /> + +
+ + act({ type: 'tage', wert: ev.target.value })} + /> + + +