From eaf9fce1bca20a8a402b2ad761676006337a5ef3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:23:05 +0000 Subject: [PATCH 01/33] =?UTF-8?q?Finanzen-Anfrage:=20Dateien=20lesen=20(as?= =?UTF-8?q?:=20'blob'),=20z.=20B.=20das=20PDF=20einer=20Auftragsbest=C3=A4?= =?UTF-8?q?tigung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/lib/financeRequest.js | 7 ++- tests/unit/financeRequestBlob.test.mjs | 78 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 tests/unit/financeRequestBlob.test.mjs diff --git a/src/lib/financeRequest.js b/src/lib/financeRequest.js index 6f09cf9..c8e6baf 100644 --- a/src/lib/financeRequest.js +++ b/src/lib/financeRequest.js @@ -9,13 +9,14 @@ export const SESSION_EXPIRED_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.' const statusError = (message, status) => Object.assign(new Error(message), { status }) /** - * request(path, { method, body }) -> geparstes JSON oder ein Error mit `status` (und bei 502 nach dem Festschreiben + * request(path, { method, body, as }) -> geparstes JSON oder ein Error mit `status` (und bei 502 nach dem Festschreiben * `invoiceId` / `invoiceNumber`). Meldungen: * - fetch wirft (Netz, Portal aus): „Das Kundenportal ist gerade nicht erreichbar.“ (status 0) * - 502/503/504 ohne JSON-Antwort (Proxy-Fehlerseite): dieselbe Meldung (status = HTTP-Status) * - createJWT scheitert (Sitzung abgelaufen): „Anmeldung abgelaufen. Bitte neu anmelden.“ (status 401) * - sonst die Meldung des Portals (`error`), ohne sie „API-Fehler “ * Das JWT wird 10 Minuten gecacht; bei 401 einmal mit frischem JWT wiederholt. + * Mit `as: 'blob'` liefert eine erfolgreiche Antwort den rohen Inhalt (Blob); Fehler kommen weiter als Meldung des Portals. */ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args) => globalThis.fetch(...args), now }) { // JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer) @@ -39,7 +40,7 @@ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args) } } - return async function request(path, { method = 'GET', body } = {}) { + return async function request(path, { method = 'GET', body, as } = {}) { const res = await fetchWithRetry(jwtCache, (jwt) => portalFetch(`${baseUrl}${path}`, { method, @@ -47,6 +48,8 @@ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args) body: body === undefined ? undefined : JSON.stringify(body), }) ) + // Dateien (z. B. das PDF einer Auftragsbestätigung) kommen roh; Fehlerantworten bleiben JSON mit Meldung + if (as === 'blob' && res.ok) return res.blob() let data = null try { data = await res.json() diff --git a/tests/unit/financeRequestBlob.test.mjs b/tests/unit/financeRequestBlob.test.mjs new file mode 100644 index 0000000..435dde8 --- /dev/null +++ b/tests/unit/financeRequestBlob.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { createFinanceRequest, PORTAL_DOWN_TEXT } = await loadModule('src/lib/financeRequest.js') + +const BASE = 'https://portal.example.invalid/api/admin/finanzen' + +/** Antwort wie fetch: { ok, status, json(), blob() }; ohne `json` ist der Körper kein JSON (json() wirft). */ +const reply = (status, { json, blob } = {}) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => { + if (json === undefined) throw new SyntaxError('Unexpected token % in JSON') + return json + }, + blob: async () => blob, +}) + +function client({ responses = [], createJWT } = {}) { + const calls = [] + const queue = [...responses] + const api = createFinanceRequest({ + baseUrl: BASE, + createJWT: createJWT || (async () => ({ jwt: 'jwt-1' })), + fetchImpl: async (url, init) => { + calls.push({ url, init }) + return queue.shift() || reply(200, { json: {} }) + }, + }) + return { api, calls } +} + +const fails = async (promise) => promise.then(() => assert.fail('hätte scheitern müssen'), (err) => err) +const pdf = () => new Blob(['%PDF-1.7 Probe'], { type: 'application/pdf' }) + +test('as blob: Erfolg liefert den rohen Inhalt als Blob, mit Bearer-JWT, ohne JSON zu lesen', async () => { + const { api, calls } = client({ + responses: [{ ...reply(200, { blob: pdf() }), json: async () => assert.fail('json() darf bei as blob nicht gelesen werden') }], + }) + const result = await api('/auftraege/a1/pdf', { as: 'blob' }) + assert.ok(result instanceof Blob) + assert.equal(result.type, 'application/pdf') + assert.equal(await result.text(), '%PDF-1.7 Probe') + assert.equal(calls[0].url, `${BASE}/auftraege/a1/pdf`) + assert.equal(calls[0].init.method, 'GET') + assert.equal(calls[0].init.headers.Authorization, 'Bearer jwt-1') +}) + +test('as blob: Fehler kommen weiter als Meldung des Portals mit Status (kein kaputtes PDF)', async () => { + const { api } = client({ responses: [reply(404, { json: { error: 'PDF nicht gefunden.' } })] }) + const err = await fails(api('/auftraege/a1/pdf', { as: 'blob' })) + assert.equal(err.message, 'PDF nicht gefunden.') + assert.equal(err.status, 404) + // Proxy-Fehlerseite ohne JSON: dieselbe Meldung wie bei JSON-Anfragen + const { api: api2 } = client({ responses: [reply(502)] }) + assert.equal((await fails(api2('/auftraege/a1/pdf', { as: 'blob' }))).message, PORTAL_DOWN_TEXT) +}) + +test('as blob: bei 401 einmal mit frischem JWT wiederholt, dann kommt der Blob', async () => { + let n = 0 + const datei = pdf() + const { api, calls } = client({ + createJWT: async () => ({ jwt: `jwt-${++n}` }), + responses: [reply(401, { json: { error: 'abgelaufen' } }), reply(200, { blob: datei })], + }) + assert.equal(await api('/auftraege/a1/pdf', { as: 'blob' }), datei) + assert.deepEqual(calls.map((c) => c.init.headers.Authorization), ['Bearer jwt-1', 'Bearer jwt-2']) +}) + +test('ohne as bleibt es JSON (der Blob wird nicht gelesen), ein unbekannter Wert verhält sich ebenso', async () => { + const { api } = client({ + responses: [{ ...reply(200, { json: { ok: 1 } }), blob: async () => assert.fail('blob() darf ohne as nicht gelesen werden') }], + }) + assert.deepEqual(await api('/x'), { ok: 1 }) + const { api: api2 } = client({ responses: [reply(200, { json: { ok: 2 } })] }) + assert.deepEqual(await api2('/x', { as: 'text' }), { ok: 2 }) +}) From e116e804e339f61384f09ef612b617ac47ee9360 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:23:39 +0000 Subject: [PATCH 02/33] =?UTF-8?q?Finanzen-API:=20Projektkonten,=20Auftrags?= =?UTF-8?q?best=C3=A4tigungen=20(samt=20Projekt=20zuordnen),=20Rechnungsvo?= =?UTF-8?q?rschau=20und=20abgerechnete=20Arbeitsbl=C3=A4tter=20(Pfade=20re?= =?UTF-8?q?in=20und=20getestet)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/lib/auftraegeEndpoints.js | 52 ++++++++++++++++ src/lib/financeApi.js | 21 +++++++ src/lib/teamApi.js | 5 ++ tests/unit/auftraegeEndpoints.test.mjs | 86 ++++++++++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 src/lib/auftraegeEndpoints.js create mode 100644 tests/unit/auftraegeEndpoints.test.mjs diff --git a/src/lib/auftraegeEndpoints.js b/src/lib/auftraegeEndpoints.js new file mode 100644 index 0000000..a0f6b54 --- /dev/null +++ b/src/lib/auftraegeEndpoints.js @@ -0,0 +1,52 @@ +// Pfade und Methoden der neuen Portal-Aufrufe für Projektkonten, Auftragsbestätigungen (AB) und die Rechnungsvorschau +// (V5/V8 des Vorhabens „Aufträge und Rechnungen“). Rein (ohne Appwrite-Import): `request` kommt aus createFinanceRequest +// (financeRequest.js) und wird injiziert, damit Pfade, Methoden und Körper unter Node mit einer Fetch-Attrappe testbar sind. +// financeApi.js verdrahtet die Funktionen mit dem Appwrite-JWT. Alle Pfade gelten relativ zu /api/admin/finanzen. + +const enc = encodeURIComponent + +/** + * Pfad der abgerechneten Arbeitsblätter eines Tickets, relativ zu /api/team (teamApi.js: getAbgerechnet). + * Antwort: { abgerechnet: { : '' | 'Entwurf' } } + */ +export const abgerechnetPfad = (ticketId) => `/arbeitsblaetter/abgerechnet?ticketId=${enc(ticketId)}` + +/** createAuftraegeEndpoints(request) -> die 14 Funktionen von V8 (jede Kennung wird URL-kodiert). */ +export function createAuftraegeEndpoints(request) { + const post = (path, body) => request(path, { method: 'POST', ...(body === undefined ? {} : { body }) }) + + return { + // --- Projektkonten --- + /** { auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk } */ + getProjektkonten: (kundeId) => request(`/projektkonten?kunde=${enc(kundeId)}`), + /** { konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk } */ + getProjektkonto: (projektId) => request(`/projektkonten/${enc(projektId)}`), + + // --- Rechnungsvorschau (ohne Schreibzugriff, ohne Schlüssel) --- + /** { positionen, summeCents, leistungszeitraum, customFields } */ + previewInvoice: (body) => post('/rechnungen/vorschau', body), + + // --- Auftragsbestätigungen --- + /** { auftraege } (404, wenn der Schalter AUFTRAEGE_ENABLED für den Kunden aus ist) */ + listAuftraege: (kundeId) => request(`/auftraege?kunde=${enc(kundeId)}`), + getAuftrag: (id) => request(`/auftraege/${enc(id)}`), + /** { auftrag } */ + createAuftrag: (body) => post('/auftraege', body), + /** { auftrag } */ + patchAuftrag: (id, body) => request(`/auftraege/${enc(id)}`, { method: 'PATCH', body }), + /** { ok: true } */ + deleteAuftrag: (id) => request(`/auftraege/${enc(id)}`, { method: 'DELETE' }), + /** { auftrag, mailFehler } */ + sendAuftrag: (id) => post(`/auftraege/${enc(id)}/senden`), + /** { auftrag, mailFehler } */ + erinnereAuftrag: (id) => post(`/auftraege/${enc(id)}/erinnern`), + /** { auftrag } (neuer Entwurf) */ + neueFassungAuftrag: (id) => post(`/auftraege/${enc(id)}/neue-fassung`), + /** { auftrag } */ + storniereAuftrag: (id) => post(`/auftraege/${enc(id)}/stornieren`), + /** { auftrag }; 409, wenn die AB schon ein Projekt hat oder storniert, ersetzt oder widerrufen ist */ + ordneAuftragProjektZu: (id, projektId) => post(`/auftraege/${enc(id)}/projekt`, { projektId }), + /** PDF der AB (gesendet: gespeicherte Datei, Entwurf: Vorschau) als Blob */ + getAuftragPdf: (id) => request(`/auftraege/${enc(id)}/pdf`, { as: 'blob' }), + } +} diff --git a/src/lib/financeApi.js b/src/lib/financeApi.js index d74ae1e..fd1f2cf 100644 --- a/src/lib/financeApi.js +++ b/src/lib/financeApi.js @@ -1,5 +1,6 @@ import { account } from './appwrite' import { createFinanceRequest } from './financeRequest' +import { createAuftraegeEndpoints } from './auftraegeEndpoints' /** * Client fuer die Finanzen-API des Kundenportals (/api/admin/finanzen). @@ -26,6 +27,8 @@ export function getInvoices({ status, kunde } = {}) { } // Jeder Aufruf bekommt einen neuen Schluessel, damit ein Doppelklick keine zweite Rechnung erzeugt. +// Koerper: { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, +// daysUntilDue, memo, senden } (wird unveraendert durchgereicht) export const createInvoice = (body) => request('/rechnungen', { method: 'POST', body: { ...body, idempotencyKey: crypto.randomUUID() } }) export const sendInvoice = (id) => request(`/rechnungen/${enc(id)}/senden`, { method: 'POST' }) @@ -34,6 +37,24 @@ export const resendInvoice = sendInvoice export const voidInvoice = (id) => request(`/rechnungen/${enc(id)}/stornieren`, { method: 'POST' }) export const deleteInvoice = (id) => request(`/rechnungen/${enc(id)}`, { method: 'DELETE' }) +// --- Projektkonten, Auftraege und Rechnungsvorschau (Pfade und Methoden: auftraegeEndpoints.js, getestet) --- +export const { + getProjektkonten, + getProjektkonto, + previewInvoice, + listAuftraege, + getAuftrag, + createAuftrag, + patchAuftrag, + deleteAuftrag, + sendAuftrag, + erinnereAuftrag, + neueFassungAuftrag, + storniereAuftrag, + ordneAuftragProjektZu, + getAuftragPdf, +} = createAuftraegeEndpoints(request) + // --- Buchungen --- export const getTransactions = () => request('/buchungen') // target: genau eins von { invoiceId } | { payoutId } | { fixedCostId } | { category }, optional note diff --git a/src/lib/teamApi.js b/src/lib/teamApi.js index d0c55c1..c8d10d6 100644 --- a/src/lib/teamApi.js +++ b/src/lib/teamApi.js @@ -1,6 +1,7 @@ import { account } from './appwrite' import { createFinanceRequest } from './financeRequest' import { createJwtCache } from './serverpulsAuth' +import { abgerechnetPfad } from './auftraegeEndpoints' import { anleitungPfad, featureBody, entscheidungBody } from './funktionenView' import { ersatzPfad, zeitenTeamPfad } from './zeitenView' @@ -119,6 +120,10 @@ export const getZeitenErsatz = (customerId, projectId, plan) => request(ersatzPf /** Team-Einstellungen (Rumpf: teamBody in zeitenView): { team, teamVersion }; 400 ungueltig, 409 veraltet (err.code ZEITEN_VERALTET). PATCH, weil das Ticketsystem kein PUT durchlaesst. */ export const patchZeitenTeam = (customerId, body) => request(zeitenTeamPfad(customerId), { method: 'PATCH', body }) +// --- Abgerechnete Arbeitsblaetter (alle Mitarbeiter, ohne Betraege) --- +/** { abgerechnet: { : '' | 'Entwurf' } }; unbekanntes Ticket: { abgerechnet: {} } */ +export const getAbgerechnet = (ticketId) => request(abgerechnetPfad(ticketId)) + // --- Abo-Status (alle Mitarbeiter) --- /** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */ diff --git a/tests/unit/auftraegeEndpoints.test.mjs b/tests/unit/auftraegeEndpoints.test.mjs new file mode 100644 index 0000000..2935f10 --- /dev/null +++ b/tests/unit/auftraegeEndpoints.test.mjs @@ -0,0 +1,86 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { createAuftraegeEndpoints, abgerechnetPfad } = await loadModule('src/lib/auftraegeEndpoints.js') +const { createFinanceRequest } = await loadModule('src/lib/financeRequest.js') + +const BASE = 'https://portal.example.invalid/api/admin/finanzen' + +// Echte Anfrage-Schicht mit Fetch-Attrappe: geprüft wird, was das Portal erreicht (URL, Methode, Kopf, JSON-Körper) +function setup(answer = () => ({ ok: true, status: 200, json: async () => ({ ok: true }) })) { + const calls = [] + const request = createFinanceRequest({ + baseUrl: BASE, + createJWT: async () => ({ jwt: 'jwt-1' }), + fetchImpl: async (url, init) => { + calls.push({ url, method: init.method, body: init.body, auth: init.headers.Authorization }) + return answer() + }, + }) + return { api: createAuftraegeEndpoints(request), calls } +} + +// [Funktion, Argumente, Methode, Pfad ab BASE, Körper] +const FAELLE = [ + ['getProjektkonten', ['k1'], 'GET', '/projektkonten?kunde=k1', undefined], + ['getProjektkonto', ['p1'], 'GET', '/projektkonten/p1', undefined], + ['previewInvoice', [{ customerId: 'k1', art: 'aufwand', projektId: 'p1' }], 'POST', '/rechnungen/vorschau', { customerId: 'k1', art: 'aufwand', projektId: 'p1' }], + ['listAuftraege', ['k1'], 'GET', '/auftraege?kunde=k1', undefined], + ['getAuftrag', ['a1'], 'GET', '/auftraege/a1', undefined], + ['createAuftrag', [{ kundenId: 'k1', titel: 'Website' }], 'POST', '/auftraege', { kundenId: 'k1', titel: 'Website' }], + ['patchAuftrag', ['a1', { titel: 'Neu' }], 'PATCH', '/auftraege/a1', { titel: 'Neu' }], + ['deleteAuftrag', ['a1'], 'DELETE', '/auftraege/a1', undefined], + ['sendAuftrag', ['a1'], 'POST', '/auftraege/a1/senden', undefined], + ['erinnereAuftrag', ['a1'], 'POST', '/auftraege/a1/erinnern', undefined], + ['neueFassungAuftrag', ['a1'], 'POST', '/auftraege/a1/neue-fassung', undefined], + ['storniereAuftrag', ['a1'], 'POST', '/auftraege/a1/stornieren', undefined], + ['ordneAuftragProjektZu', ['a1', 'p1'], 'POST', '/auftraege/a1/projekt', { projektId: 'p1' }], +] + +test('alle 14 Funktionen sind da (V8 samt „Projekt zuordnen“ aus V5)', () => { + const { api } = setup() + assert.deepEqual(Object.keys(api).sort(), [ + 'createAuftrag', 'deleteAuftrag', 'erinnereAuftrag', 'getAuftrag', 'getAuftragPdf', 'getProjektkonten', 'getProjektkonto', + 'listAuftraege', 'neueFassungAuftrag', 'ordneAuftragProjektZu', 'patchAuftrag', 'previewInvoice', 'sendAuftrag', 'storniereAuftrag', + ]) +}) + +test('Pfad, Methode, Kopf und Körper jeder Funktion (die Vorschau trägt keinen Schlüssel dazu)', async () => { + for (const [name, args, method, path, body] of FAELLE) { + const { api, calls } = setup() + await api[name](...args) + assert.equal(calls.length, 1, name) + assert.equal(calls[0].url, `${BASE}${path}`, name) + assert.equal(calls[0].method, method, name) + assert.equal(calls[0].auth, 'Bearer jwt-1', name) + assert.equal(calls[0].body, body === undefined ? undefined : JSON.stringify(body), name) + } +}) + +test('Kennungen werden URL-kodiert, auch im Pfad der abgerechneten Arbeitsblätter', async () => { + const { api, calls } = setup() + await api.getProjektkonten('k 1&x=2') + await api.getAuftrag('a/b?c') + await api.storniereAuftrag('a#1') + await api.ordneAuftragProjektZu('a b', 'p/1') + assert.deepEqual(calls.map((c) => c.url.slice(BASE.length)), [ + '/projektkonten?kunde=k%201%26x%3D2', + '/auftraege/a%2Fb%3Fc', + '/auftraege/a%231/stornieren', + '/auftraege/a%20b/projekt', + ]) + assert.equal(calls[3].body, JSON.stringify({ projektId: 'p/1' })) // die Projektkennung steht im Körper, nicht im Pfad + assert.equal(abgerechnetPfad('100131'), '/arbeitsblaetter/abgerechnet?ticketId=100131') + assert.equal(abgerechnetPfad('t 1/2'), '/arbeitsblaetter/abgerechnet?ticketId=t%201%2F2') +}) + +test('getAuftragPdf liefert das PDF als Blob; eine Fehlerantwort kommt als Meldung des Portals', async () => { + const datei = new Blob(['%PDF-1.7'], { type: 'application/pdf' }) + const ok = setup(() => ({ ok: true, status: 200, json: async () => assert.fail('kein JSON lesen'), blob: async () => datei })) + assert.equal(await ok.api.getAuftragPdf('a1'), datei) + assert.equal(ok.calls[0].url, `${BASE}/auftraege/a1/pdf`) + assert.equal(ok.calls[0].method, 'GET') + const fehler = setup(() => ({ ok: false, status: 404, json: async () => ({ error: 'PDF nicht gefunden.' }), blob: async () => assert.fail('kein Blob lesen') })) + await assert.rejects(fehler.api.getAuftragPdf('a1'), (e) => e.message === 'PDF nicht gefunden.' && e.status === 404) +}) From e7bd146641d94defe7c91ba2eb80b6cb3f9af9e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:24:03 +0000 Subject: [PATCH 03/33] =?UTF-8?q?Kundenreiter=20=E2=80=9ERechnungen?= =?UTF-8?q?=E2=80=9C=20hei=C3=9Ft=20jetzt=20=E2=80=9EAuftr=C3=A4ge=20und?= =?UTF-8?q?=20Rechnungen=E2=80=9C=20(id=20bleibt=20invoices)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/lib/customerTabs.js | 6 +++--- tests/unit/customerTabs.test.mjs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/customerTabs.js b/src/lib/customerTabs.js index 3e3b12b..48ee6d9 100644 --- a/src/lib/customerTabs.js +++ b/src/lib/customerTabs.js @@ -1,10 +1,10 @@ -// Reiter der Kundenansicht (rein, ohne React; unter Node testbar). „Rechnungen“ ruft die Finanzen-API des Portals auf, +// Reiter der Kundenansicht (rein, ohne React; unter Node testbar). „Aufträge und Rechnungen“ ruft die Finanzen-API des Portals auf, // die nur Admins freigibt: für alle anderen gibt es den Reiter nicht. const BASE_TABS = [ { id: 'overview', label: 'Übersicht' }, { id: 'tickets', label: 'Tickets' }, - { id: 'invoices', label: 'Rechnungen', adminOnly: true }, + { id: 'invoices', label: 'Aufträge und Rechnungen', adminOnly: true }, { id: 'projects', label: 'Projekte' }, // Chat mit dem Kunden (derselbe Gespraechsbaustein wie im Messenger unter Kunden -> Chat) { id: 'chat', label: 'Chat' }, @@ -16,7 +16,7 @@ const BASE_TABS = [ { id: 'protokoll', label: 'Protokoll' }, ] -/** Reiter für Tabs: [{ id, label }]; „Rechnungen“ nur für Admins. */ +/** Reiter für Tabs: [{ id, label }]; „Aufträge und Rechnungen“ nur für Admins. */ export function customerTabs(isAdmin) { return BASE_TABS.filter((t) => !t.adminOnly || isAdmin === true).map(({ id, label }) => ({ id, label })) } diff --git a/tests/unit/customerTabs.test.mjs b/tests/unit/customerTabs.test.mjs index e0f8b58..618ab47 100644 --- a/tests/unit/customerTabs.test.mjs +++ b/tests/unit/customerTabs.test.mjs @@ -4,11 +4,11 @@ import { loadModule } from './load.mjs' const { customerTabs, resolveCustomerTab } = await loadModule('src/lib/customerTabs.js') -test('customerTabs: volle Reiterfolge der Spec, Rechnungen nur für Admins', () => { +test('customerTabs: volle Reiterfolge der Spec, „Aufträge und Rechnungen“ nur für Admins', () => { assert.deepEqual(customerTabs(true), [ { id: 'overview', label: 'Übersicht' }, { id: 'tickets', label: 'Tickets' }, - { id: 'invoices', label: 'Rechnungen' }, + { id: 'invoices', label: 'Aufträge und Rechnungen' }, { id: 'projects', label: 'Projekte' }, { id: 'chat', label: 'Chat' }, { id: 'abo', label: 'Abo & Angebote' }, From 312705efc46c200380ed450e78c78881c56bdcc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:24:20 +0000 Subject: [PATCH 04/33] =?UTF-8?q?Auftr=C3=A4ge=20und=20Rechnungen:=20Laden?= =?UTF-8?q?=20und=20Ladezustand=20des=20Reiters=20als=20reine=20Funktionen?= =?UTF-8?q?=20(mit=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/auftraegeAnsicht.js | 69 +++++++++++++ tests/unit/auftraegeAnsicht.test.mjs | 144 +++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 src/lib/auftraegeAnsicht.js create mode 100644 tests/unit/auftraegeAnsicht.test.mjs diff --git a/src/lib/auftraegeAnsicht.js b/src/lib/auftraegeAnsicht.js new file mode 100644 index 0000000..8732565 --- /dev/null +++ b/src/lib/auftraegeAnsicht.js @@ -0,0 +1,69 @@ +// Ansichtslogik des Reiters „Aufträge und Rechnungen“ (rein, ohne React und ohne Appwrite; unter Node testbar). +// Die API-Funktionen kommen als Argument. Der Container (AuftraegeUndRechnungen.jsx) verdrahtet diese Funktionen mit +// useFinanceResource und den Abschnitten des Reiters. + +export const KONTEN_FEHLER = 'Die Projektübersicht konnte nicht geladen werden.' +export const AUFTRAEGE_FEHLER = 'Die Aufträge konnten nicht geladen werden.' + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const ohneProjektLeer = () => ({ berechnetCents: 0, bezahltCents: 0, anzahl: 0 }) + +/** + * Lädt Projektkonten und, nur bei eingeschaltetem Schalter (`auftraegeAktiv === true`), die Aufträge des Kunden. + * Scheitert getProjektkonten, geht der Fehler weiter (keine Aufträge, sie würden ohne Schalter 404 liefern). + * Scheitert nur listAuftraege, bleiben die Projektkonten und der Fehler steht eigens da. + * -> { konten, auftraege: Liste | null, auftraegeFehler: Text | '' } + */ +export async function ladeAuftraegeAnsicht(kundeId, { getProjektkonten, listAuftraege }) { + const antwort = await getProjektkonten(kundeId) + const konten = istObjekt(antwort) ? antwort : {} + if (konten.auftraegeAktiv !== true) return { konten, auftraege: null, auftraegeFehler: '' } + try { + const liste = await listAuftraege(kundeId) + return { konten, auftraege: Array.isArray(liste?.auftraege) ? liste.auftraege : [], auftraegeFehler: '' } + } catch (err) { + return { konten, auftraege: null, auftraegeFehler: (err && err.message) || AUFTRAEGE_FEHLER } + } +} + +/** + * Zustand der Ansicht aus dem Ergebnis von useFinanceResource ({ data, error }). + * -> { projekteStatus: 'laden' | 'fehler' | 'bereit', fehler, nachladeFehler, + * auftraegeAktiv, auftraegeStatus: 'aus' | 'bereit' | 'fehler', auftraegeFehler, + * konten, ohneProjekt, stripeOk, kunde, auftraege } + * Ohne Daten gibt es keinen Aufträge-Abschnitt (der Schalter ist noch unbekannt). Scheitert das Neuladen, nachdem Daten + * da sind, bleiben die alten Daten stehen und die Meldung kommt als `nachladeFehler` daneben. + */ +export function ansichtsZustand({ data, error } = {}) { + if (!data) { + return { + projekteStatus: error ? 'fehler' : 'laden', + fehler: error || '', + nachladeFehler: '', + auftraegeAktiv: false, + auftraegeStatus: 'aus', + auftraegeFehler: '', + konten: [], + ohneProjekt: ohneProjektLeer(), + stripeOk: true, + kunde: null, + auftraege: [], + } + } + const k = istObjekt(data.konten) ? data.konten : {} + const auftraegeAktiv = k.auftraegeAktiv === true + const auftraegeStatus = !auftraegeAktiv ? 'aus' : data.auftraegeFehler ? 'fehler' : 'bereit' + return { + projekteStatus: 'bereit', + fehler: '', + nachladeFehler: error || '', + auftraegeAktiv, + auftraegeStatus, + auftraegeFehler: auftraegeStatus === 'fehler' ? data.auftraegeFehler : '', + konten: Array.isArray(k.konten) ? k.konten : [], + ohneProjekt: istObjekt(k.ohneProjekt) ? k.ohneProjekt : ohneProjektLeer(), + stripeOk: k.stripeOk !== false, + kunde: istObjekt(k.kunde) ? k.kunde : null, + auftraege: auftraegeAktiv && Array.isArray(data.auftraege) ? data.auftraege : [], + } +} diff --git a/tests/unit/auftraegeAnsicht.test.mjs b/tests/unit/auftraegeAnsicht.test.mjs new file mode 100644 index 0000000..857036c --- /dev/null +++ b/tests/unit/auftraegeAnsicht.test.mjs @@ -0,0 +1,144 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { KONTEN_FEHLER, AUFTRAEGE_FEHLER, ladeAuftraegeAnsicht, ansichtsZustand } = await loadModule('src/lib/auftraegeAnsicht.js') + +// Antwort von getProjektkonten (V5) mit den Feldern, die die Ansicht braucht +const konten = (extra = {}) => ({ + auftraegeAktiv: false, + kunde: { id: 'k1', name: 'Anelia Wolf', kundenart: 'privat' }, + konten: [{ projektId: 'p1', name: 'Website' }], + ohneProjekt: { berechnetCents: 5000, bezahltCents: 0, anzahl: 1 }, + stripeOk: true, + ...extra, +}) +const OHNE_PROJEKT_LEER = { berechnetCents: 0, bezahltCents: 0, anzahl: 0 } + +// Attrappe für getProjektkonten und listAuftraege: gibt Werte zurück, wirft Fehler und merkt sich die Aufrufe +function fakeApi({ projektkonten, auftraege } = {}) { + const calls = [] + const antwort = (wert) => { + if (wert instanceof Error) throw wert + return wert + } + return { + calls, + getProjektkonten: async (id) => { calls.push(['konten', id]); return antwort(projektkonten) }, + listAuftraege: async (id) => { calls.push(['auftraege', id]); return antwort(auftraege) }, + } +} + +test('Schalter aus: die Aufträge werden gar nicht erst abgerufen', async () => { + const api = fakeApi({ projektkonten: konten({ auftraegeAktiv: false }), auftraege: new Error('404 darf nie ankommen') }) + const result = await ladeAuftraegeAnsicht('k1', api) + assert.deepEqual(api.calls, [['konten', 'k1']]) + assert.equal(result.konten.auftraegeAktiv, false) + assert.equal(result.auftraege, null) + assert.equal(result.auftraegeFehler, '') + // ein fehlendes Feld gilt wie „aus“, nur true schaltet an; eine Antwort ohne Objekt wird zu {} + const fehlt = fakeApi({ projektkonten: { konten: [] } }) + assert.equal((await ladeAuftraegeAnsicht('k1', fehlt)).auftraege, null) + assert.deepEqual(fehlt.calls, [['konten', 'k1']]) + const nichts = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: null })) + assert.deepEqual(nichts, { konten: {}, auftraege: null, auftraegeFehler: '' }) +}) + +test('Schalter an: erst die Projektkonten, dann die Aufträge des Kunden', async () => { + const liste = [{ id: 'a1', nummer: 'AB-2026-0001', status: 'versendet' }] + const api = fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: { auftraege: liste } }) + const result = await ladeAuftraegeAnsicht('k1', api) + assert.deepEqual(api.calls, [['konten', 'k1'], ['auftraege', 'k1']]) + assert.deepEqual(result.auftraege, liste) + assert.equal(result.auftraegeFehler, '') + // eine Antwort ohne Liste gibt eine leere Liste statt eines Absturzes + const leer = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: {} })) + assert.deepEqual(leer.auftraege, []) +}) + +test('Aufträge scheitern allein: die Projektkonten bleiben, der Fehler steht eigens da', async () => { + const api = fakeApi({ + projektkonten: konten({ auftraegeAktiv: true }), + auftraege: Object.assign(new Error('Interner Fehler.'), { status: 500 }), + }) + const result = await ladeAuftraegeAnsicht('k1', api) + assert.equal(result.konten.konten.length, 1) + assert.equal(result.auftraege, null) + assert.equal(result.auftraegeFehler, 'Interner Fehler.') + const ohneText = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: new Error('') })) + assert.equal(ohneText.auftraegeFehler, AUFTRAEGE_FEHLER) + assert.equal(AUFTRAEGE_FEHLER, 'Die Aufträge konnten nicht geladen werden.') +}) + +test('Projektkonten scheitern: der Fehler geht weiter, die Aufträge werden nicht abgerufen', async () => { + const fehler = Object.assign(new Error('Interner Fehler.'), { status: 500 }) + const api = fakeApi({ projektkonten: fehler, auftraege: { auftraege: [] } }) + await assert.rejects(ladeAuftraegeAnsicht('k1', api), (e) => e === fehler) + assert.deepEqual(api.calls, [['konten', 'k1']]) +}) + +test('ansichtsZustand: vor der ersten Antwort „laden“, nach einem Fehler „fehler“ mit der Meldung', () => { + const laden = ansichtsZustand({ data: null, error: '' }) + assert.equal(laden.projekteStatus, 'laden') + assert.equal(laden.auftraegeStatus, 'aus') // der Schalter ist noch unbekannt: kein Abschnitt, kein Flackern + assert.deepEqual(laden.konten, []) + assert.equal(ansichtsZustand().projekteStatus, 'laden') + const fehler = ansichtsZustand({ data: null, error: KONTEN_FEHLER }) + assert.equal(fehler.projekteStatus, 'fehler') + assert.equal(fehler.fehler, KONTEN_FEHLER) + assert.equal(fehler.auftraegeStatus, 'aus') // ohne Projektkonten kein Schalter, also auch keine Aufträge + assert.equal(KONTEN_FEHLER, 'Die Projektübersicht konnte nicht geladen werden.') +}) + +test('ansichtsZustand: Schalter aus – Projekte bereit, kein Aufträge-Abschnitt', () => { + const z = ansichtsZustand({ data: { konten: konten({ auftraegeAktiv: false }), auftraege: null, auftraegeFehler: '' }, error: '' }) + assert.equal(z.projekteStatus, 'bereit') + assert.equal(z.auftraegeAktiv, false) + assert.equal(z.auftraegeStatus, 'aus') + assert.equal(z.konten.length, 1) + assert.deepEqual(z.ohneProjekt, { berechnetCents: 5000, bezahltCents: 0, anzahl: 1 }) + assert.equal(z.stripeOk, true) + assert.deepEqual(z.kunde, { id: 'k1', name: 'Anelia Wolf', kundenart: 'privat' }) + assert.equal(z.nachladeFehler, '') +}) + +test('ansichtsZustand: Schalter an – Aufträge bereit oder mit eigenem Fehler, die Projekte bleiben', () => { + const liste = [{ id: 'a1' }] + const bereit = ansichtsZustand({ data: { konten: konten({ auftraegeAktiv: true }), auftraege: liste, auftraegeFehler: '' }, error: '' }) + assert.equal(bereit.auftraegeAktiv, true) + assert.equal(bereit.auftraegeStatus, 'bereit') + assert.deepEqual(bereit.auftraege, liste) + const kaputt = ansichtsZustand({ + data: { konten: konten({ auftraegeAktiv: true }), auftraege: null, auftraegeFehler: 'Interner Fehler.' }, + error: '', + }) + assert.equal(kaputt.projekteStatus, 'bereit') + assert.equal(kaputt.auftraegeStatus, 'fehler') + assert.equal(kaputt.auftraegeFehler, 'Interner Fehler.') + assert.deepEqual(kaputt.auftraege, []) +}) + +test('ansichtsZustand: Neuladen scheitert – die alten Daten bleiben, die Meldung steht daneben', () => { + const data = { konten: konten({ auftraegeAktiv: true }), auftraege: [{ id: 'a1' }], auftraegeFehler: '' } + const z = ansichtsZustand({ data, error: 'Das Kundenportal ist gerade nicht erreichbar.' }) + assert.equal(z.projekteStatus, 'bereit') + assert.equal(z.fehler, '') + assert.equal(z.nachladeFehler, 'Das Kundenportal ist gerade nicht erreichbar.') + assert.equal(z.konten.length, 1) + assert.equal(z.auftraegeStatus, 'bereit') + assert.equal(z.auftraege.length, 1) +}) + +test('ansichtsZustand: unvollständige Antworten brechen die Ansicht nicht', () => { + for (const data of [{}, { konten: null }, { konten: {} }, { konten: { konten: 'x', ohneProjekt: 5, kunde: 'k' } }]) { + const z = ansichtsZustand({ data, error: '' }) + assert.equal(z.projekteStatus, 'bereit') + assert.deepEqual(z.konten, []) + assert.deepEqual(z.ohneProjekt, OHNE_PROJEKT_LEER) + assert.equal(z.stripeOk, true) + assert.equal(z.kunde, null) + assert.equal(z.auftraegeAktiv, false) + assert.equal(z.auftraegeStatus, 'aus') + } + assert.equal(ansichtsZustand({ data: { konten: konten({ stripeOk: false }) }, error: '' }).stripeOk, false) +}) From e1c9b572497aa7ff15c6b0d37001308d0265d88a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:24:47 +0000 Subject: [PATCH 05/33] =?UTF-8?q?Auftr=C3=A4ge=20und=20Rechnungen:=20frisc?= =?UTF-8?q?he=20Kundenart=20und=20R=C3=BCckmeldungen=20nach=20Rechnung=20u?= =?UTF-8?q?nd=20Auftrag=20(mit=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/auftraegeAnsicht.js | 38 +++++++++++++++++++++++ tests/unit/auftraegeAnsicht.test.mjs | 45 +++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/lib/auftraegeAnsicht.js b/src/lib/auftraegeAnsicht.js index 8732565..16258dc 100644 --- a/src/lib/auftraegeAnsicht.js +++ b/src/lib/auftraegeAnsicht.js @@ -1,6 +1,7 @@ // Ansichtslogik des Reiters „Aufträge und Rechnungen“ (rein, ohne React und ohne Appwrite; unter Node testbar). // Die API-Funktionen kommen als Argument. Der Container (AuftraegeUndRechnungen.jsx) verdrahtet diese Funktionen mit // useFinanceResource und den Abschnitten des Reiters. +import { invoiceDoneMessage } from './financeView' export const KONTEN_FEHLER = 'Die Projektübersicht konnte nicht geladen werden.' export const AUFTRAEGE_FEHLER = 'Die Aufträge konnten nicht geladen werden.' @@ -67,3 +68,40 @@ export function ansichtsZustand({ data, error } = {}) { auftraege: auftraegeAktiv && Array.isArray(data.auftraege) ? data.auftraege : [], } } + +// Wertebereich von customers.kundenart (V1); bewusst lokal, auftragView.js gehört dem Strang W2 +const KUNDENARTEN = ['unternehmen', 'privat'] + +/** + * Kundendokument mit der frischen Kundenart des Portals (getProjektkonten -> kunde.kundenart). Gilt nur ein gültiger Wert + * („unternehmen“ oder „privat“), kommt eine Kopie mit diesem Wert zurück, das Dokument der Seite bleibt unverändert. + * Sonst dieselbe Referenz (null bleibt null). So fragt der AB-Dialog nicht erneut, wenn die Kundenart eben gespeichert wurde + * und die Kundenseite noch den alten Datensatz hält. + */ +export function kundeMitKundenart(customer, kundeInfo) { + const art = istObjekt(kundeInfo) ? kundeInfo.kundenart : undefined + if (!customer || !KUNDENARTEN.includes(art)) return customer + return { ...customer, kundenart: art } +} + +/** + * Rückmeldung nach dem Anlegen einer Rechnung (wie InvoiceTable.handleCreated): `info.notice` (festgeschrieben, aber nicht + * versendet) gewinnt mit seinem Ton, sonst Entwurf oder gesendet. `info` darf fehlen oder null sein. -> { tone, text } + */ +export function erstelltMeldung(rechnung, info) { + const i = istObjekt(info) ? info : {} + if (typeof i.notice === 'string' && i.notice) return { tone: i.tone || 'ok', text: i.notice } + return { tone: 'ok', text: invoiceDoneMessage(i.senden ? 'create-send' : 'create-draft', rechnung) } +} + +/** Rückmeldung nach dem Speichern oder Senden einer Auftragsbestätigung (onSaved des AB-Dialogs). -> { tone, text } */ +export function auftragMeldung(auftrag) { + const a = istObjekt(auftrag) ? auftrag : {} + const name = a.nummer ? `Auftragsbestätigung ${a.nummer}` : 'Die Auftragsbestätigung' + if (a.status === 'entwurf') return { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' } + if (a.mailStatus === 'fehler') { + return { tone: 'warn', text: `${name} ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.` } + } + if (a.nummer) return { tone: 'ok', text: `${name} wurde${a.gesendetAn ? ` an ${a.gesendetAn}` : ''} gesendet.` } + return { tone: 'ok', text: 'Die Auftragsbestätigung wurde gespeichert.' } +} diff --git a/tests/unit/auftraegeAnsicht.test.mjs b/tests/unit/auftraegeAnsicht.test.mjs index 857036c..3f744ff 100644 --- a/tests/unit/auftraegeAnsicht.test.mjs +++ b/tests/unit/auftraegeAnsicht.test.mjs @@ -2,7 +2,9 @@ import test from 'node:test' import assert from 'node:assert/strict' import { loadModule } from './load.mjs' -const { KONTEN_FEHLER, AUFTRAEGE_FEHLER, ladeAuftraegeAnsicht, ansichtsZustand } = await loadModule('src/lib/auftraegeAnsicht.js') +const { + KONTEN_FEHLER, AUFTRAEGE_FEHLER, ladeAuftraegeAnsicht, ansichtsZustand, kundeMitKundenart, erstelltMeldung, auftragMeldung, +} = await loadModule('src/lib/auftraegeAnsicht.js') // Antwort von getProjektkonten (V5) mit den Feldern, die die Ansicht braucht const konten = (extra = {}) => ({ @@ -142,3 +144,44 @@ test('ansichtsZustand: unvollständige Antworten brechen die Ansicht nicht', () } assert.equal(ansichtsZustand({ data: { konten: konten({ stripeOk: false }) }, error: '' }).stripeOk, false) }) + +test('kundeMitKundenart: ein gültiger Wert des Portals gewinnt gegen das veraltete Kundendokument', () => { + const doc = { $id: 'k1', name: 'Anelia Wolf', kundenart: '' } + assert.deepEqual(kundeMitKundenart(doc, { id: 'k1', kundenart: 'privat' }), { ...doc, kundenart: 'privat' }) + assert.equal(doc.kundenart, '') // das Dokument der Seite wird nicht verändert + assert.equal(kundeMitKundenart({ ...doc, kundenart: 'unternehmen' }, { kundenart: 'privat' }).kundenart, 'privat') +}) + +test('kundeMitKundenart: ohne gültigen Wert vom Portal bleibt das Dokument (dieselbe Referenz)', () => { + const doc = { $id: 'k1', kundenart: 'unternehmen' } + for (const info of [null, undefined, {}, { kundenart: '' }, { kundenart: 'firma' }, { kundenart: 5 }, 'privat']) { + assert.equal(kundeMitKundenart(doc, info), doc) + } + assert.equal(kundeMitKundenart(null, { kundenart: 'privat' }), null) +}) + +test('erstelltMeldung: gesendet, Entwurf und die Meldung nach festgeschriebener, nicht gesendeter Rechnung', () => { + assert.deepEqual( + erstelltMeldung({ number: 'WEBKLAR-0009', customerEmail: 'a@b.de' }, { senden: true }), + { tone: 'ok', text: 'Rechnung WEBKLAR-0009 wurde an a@b.de gesendet.' }, + ) + assert.deepEqual(erstelltMeldung({ id: 'in_1' }, { senden: false }), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' }) + assert.deepEqual(erstelltMeldung(null, undefined), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' }) + assert.deepEqual(erstelltMeldung(null, null), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' }) + const text = 'Rechnung WEBKLAR-0010 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.' + assert.deepEqual(erstelltMeldung(null, { senden: true, notice: text, tone: 'warn', finalizedFilter: 'offen' }), { tone: 'warn', text }) + assert.deepEqual(erstelltMeldung(null, { senden: true, notice: text }), { tone: 'ok', text }) +}) + +test('auftragMeldung: Entwurf, gesendet und gesendet ohne zugestellte Mail', () => { + assert.deepEqual(auftragMeldung({ status: 'entwurf', nummer: '' }), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' }) + assert.deepEqual( + auftragMeldung({ status: 'versendet', nummer: 'AB-2026-0001', gesendetAn: 'k@x.de', mailStatus: 'gesendet' }), + { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0001 wurde an k@x.de gesendet.' }, + ) + assert.deepEqual( + auftragMeldung({ status: 'versendet', nummer: 'AB-2026-0001', gesendetAn: 'k@x.de', mailStatus: 'fehler' }), + { tone: 'warn', text: 'Auftragsbestätigung AB-2026-0001 ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.' }, + ) + assert.deepEqual(auftragMeldung(null), { tone: 'ok', text: 'Die Auftragsbestätigung wurde gespeichert.' }) +}) From 8f90fdc05aada37b86d35063f15db8ae17a93af8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:25:22 +0000 Subject: [PATCH 06/33] =?UTF-8?q?Auftr=C3=A4ge=20und=20Rechnungen:=20Conta?= =?UTF-8?q?iner=20des=20Kundenreiters=20und=20vier=20Platzhalter=20f=C3=BC?= =?UTF-8?q?r=20W1=20und=20W2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../auftraege/AuftraegeUndRechnungen.jsx | 171 ++++++++++++++++++ src/components/auftraege/AuftragDialog.jsx | 12 ++ src/components/auftraege/AuftragListe.jsx | 11 ++ .../auftraege/ProjektkontoListe.jsx | 11 ++ src/components/auftraege/RechnungDialog.jsx | 13 ++ 5 files changed, 218 insertions(+) create mode 100644 src/components/auftraege/AuftraegeUndRechnungen.jsx create mode 100644 src/components/auftraege/AuftragDialog.jsx create mode 100644 src/components/auftraege/AuftragListe.jsx create mode 100644 src/components/auftraege/ProjektkontoListe.jsx create mode 100644 src/components/auftraege/RechnungDialog.jsx diff --git a/src/components/auftraege/AuftraegeUndRechnungen.jsx b/src/components/auftraege/AuftraegeUndRechnungen.jsx new file mode 100644 index 0000000..4d8afed --- /dev/null +++ b/src/components/auftraege/AuftraegeUndRechnungen.jsx @@ -0,0 +1,171 @@ +import { useRef, useState } from 'react' +import { Button, Card, EmptyState } from '../ui' +import AddressWarning from '../finance/AddressWarning' +import InvoiceTable from '../finance/InvoiceTable' +import ProjektkontoListe from './ProjektkontoListe' +import RechnungDialog from './RechnungDialog' +import AuftragListe from './AuftragListe' +import AuftragDialog from './AuftragDialog' +import { useFinanceResource } from '../../hooks/useFinanceResource' +import { useScrollIntoView } from '../../hooks/useScrollIntoView' +import { getProjektkonten, listAuftraege } from '../../lib/financeApi' +import { customerInvoicesGate } from '../../lib/financeView' +import { noticeProps } from '../../lib/financeUi' +import { + KONTEN_FEHLER, + ladeAuftraegeAnsicht, + ansichtsZustand, + kundeMitKundenart, + erstelltMeldung, + auftragMeldung, +} from '../../lib/auftraegeAnsicht' + +/** + * Reiter „Aufträge und Rechnungen“ am Kunden (ersetzt CustomerInvoices). Kann für den Kunden keine Rechnung geschrieben + * werden (noch Lead, abgesagt, keine E-Mail), steht der Grund da, mit „Daten erfassen“; es werden keine Abrufe gestartet. + * Sonst von oben nach unten: Adresswarnung, Rückmeldung, Hinweis zum Neuladen, Projekte (Projektkonten), Aufträge (nur + * mit eingeschaltetem Schalter AUFTRAEGE_ENABLED) und die Rechnungsliste. Die Rechnungsliste und die Adresswarnung hängen + * nie vom Laden der Projekte oder Aufträge ab: fällt eines davon aus, bleibt „Rechnung schreiben“ bedienbar. + * Die Dialoge stehen als Geschwister hinter den Abschnitten (nie in InvoiceTable oder .fin-body). + */ +export default function AuftraegeUndRechnungen({ customer, onShowOverview }) { + const gate = customerInvoicesGate(customer) + if (!gate.ok) { + return ( + Daten erfassen : null} + /> + ) + } + return +} + +function AuftraegeInhalt({ customer, onShowOverview }) { + const res = useFinanceResource(() => ladeAuftraegeAnsicht(customer.$id, { getProjektkonten, listAuftraege }), KONTEN_FEHLER) + const ansicht = ansichtsZustand(res) + const reload = res.reload + + const [meldung, setMeldung] = useState(null) // { tone, text } oder null + const [rechnungsDialog, setRechnungsDialog] = useState(null) // { projektId } oder null + const [auftragsDialog, setAuftragsDialog] = useState(null) // { projektId, auftrag } oder null + const [listeToken, setListeToken] = useState(0) + const meldungRef = useRef(null) + useScrollIntoView(meldungRef, meldung) + + // Die Dialoge bekommen die frische Kundenart des Portals (die Kundenseite hält ggf. noch den alten Datensatz) + const kunde = kundeMitKundenart(customer, ansicht.kunde) + + function openRechnung(projektId) { + setMeldung(null) + setRechnungsDialog({ projektId }) + } + + function openAuftrag(projektId, auftrag = null) { + setMeldung(null) + setAuftragsDialog({ projektId, auftrag }) + } + + function handleCreated(rechnung, info) { + setRechnungsDialog(null) + setMeldung(erstelltMeldung(rechnung, info)) + setListeToken((n) => n + 1) + reload() + } + + function handleSaved(auftrag) { + setAuftragsDialog(null) + setMeldung(auftragMeldung(auftrag)) + reload() + } + + return ( + <> + + + {meldung && ( +

+ {meldung.text} +

+ )} + + {ansicht.projekteStatus === 'bereit' && ansicht.nachladeFehler && ( +

+ Die Übersicht konnte nicht neu geladen werden: {ansicht.nachladeFehler}{' '} + +

+ )} + + {ansicht.projekteStatus === 'laden' && ( + +
Projekte werden geladen …
+
+ )} + {ansicht.projekteStatus === 'fehler' && ( + +
+

{ansicht.fehler}

+ +
+
+ )} + {ansicht.projekteStatus === 'bereit' && ( + openAuftrag(projektId)} + /> + )} + + {ansicht.auftraegeStatus === 'bereit' && ( + openAuftrag(auftrag.projektId || null, auftrag)} + onShowOverview={onShowOverview} + /> + )} + {ansicht.auftraegeStatus === 'fehler' && ( + +
+

{ansicht.auftraegeFehler}

+ +
+
+ )} + + + + {rechnungsDialog && ( + setRechnungsDialog(null)} + onCreated={handleCreated} + kunde={kunde} + projektId={rechnungsDialog.projektId} + auftraegeAktiv={ansicht.auftraegeAktiv} + onShowOverview={onShowOverview} + /> + )} + {auftragsDialog && ( + setAuftragsDialog(null)} + onSaved={handleSaved} + kunde={kunde} + projektId={auftragsDialog.projektId} + auftrag={auftragsDialog.auftrag} + konten={ansicht.konten} + onShowOverview={onShowOverview} + /> + )} + + ) +} diff --git a/src/components/auftraege/AuftragDialog.jsx b/src/components/auftraege/AuftragDialog.jsx new file mode 100644 index 0000000..564cbc6 --- /dev/null +++ b/src/components/auftraege/AuftragDialog.jsx @@ -0,0 +1,12 @@ +/** + * Platzhalter von W0: Strang W2 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben). + * + * Props (V8): open, onClose, onSaved(auftrag) (AuftragTeam mit mailStatus; nach Speichern oder Senden, danach onClose()), + * kunde (kommt mit aktueller kundenart: der Container gleicht sie über kundeMitKundenart ab), projektId (vorausgewähltes + * Projekt oder null = freier Titel), auftrag (Entwurf zum Bearbeiten oder null), konten (Projektkonten für die Projektwahl), + * onShowOverview (Weg „Zur Übersicht“). + * Die PDF-Vorschau öffnet openInNewTab(async () => URL.createObjectURL(await getAuftragPdf(id))). + */ +export default function AuftragDialog({ open, onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview }) { + return null +} diff --git a/src/components/auftraege/AuftragListe.jsx b/src/components/auftraege/AuftragListe.jsx new file mode 100644 index 0000000..3525a91 --- /dev/null +++ b/src/components/auftraege/AuftragListe.jsx @@ -0,0 +1,11 @@ +/** + * Platzhalter von W0: Strang W2 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben). + * Die fertige Komponente rendert selbst und ruft nach jeder Aktion onChanged(). + * + * Props (V8): auftraege (AuftragTeam[], V3), konten (Projektname der Zeile und Auswahl für „Projekt zuordnen“, einer Aktion für + * ABs ohne Projekt über ordneAuftragProjektZu(id, projektId) aus financeApi.js), onChanged(), onBearbeiten(auftrag) + * („Entwurf bearbeiten“; nach „Neue Fassung“ mit dem neuen Entwurf) und onShowOverview (Weg „Zur Übersicht“ aus Meldungen). + */ +export default function AuftragListe({ auftraege, konten, onChanged, onBearbeiten, onShowOverview }) { + return null +} diff --git a/src/components/auftraege/ProjektkontoListe.jsx b/src/components/auftraege/ProjektkontoListe.jsx new file mode 100644 index 0000000..4b4bd06 --- /dev/null +++ b/src/components/auftraege/ProjektkontoListe.jsx @@ -0,0 +1,11 @@ +/** + * 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“. + */ +export default function ProjektkontoListe({ konten, ohneProjekt, auftraegeAktiv, stripeOk, onRechnung, onAuftrag }) { + return null +} diff --git a/src/components/auftraege/RechnungDialog.jsx b/src/components/auftraege/RechnungDialog.jsx new file mode 100644 index 0000000..1daa360 --- /dev/null +++ b/src/components/auftraege/RechnungDialog.jsx @@ -0,0 +1,13 @@ +/** + * Platzhalter von W0: Strang W1 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben). + * + * 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. + */ +export default function RechnungDialog({ open, onClose, onCreated, kunde, projektId, auftraegeAktiv, onShowOverview }) { + return null +} From 99ab4bd47b1d65321d19214bb107c40160d23168 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:25:28 +0000 Subject: [PATCH 07/33] =?UTF-8?q?Kundenseite:=20Reiter=20=E2=80=9EAuftr?= =?UTF-8?q?=C3=A4ge=20und=20Rechnungen=E2=80=9C=20zeigt=20den=20neuen=20Co?= =?UTF-8?q?ntainer,=20CustomerInvoices=20entf=C3=A4llt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/components/finance/CustomerInvoices.jsx | 29 --------------------- src/pages/CustomerDetailPage.jsx | 4 +-- 2 files changed, 2 insertions(+), 31 deletions(-) delete mode 100644 src/components/finance/CustomerInvoices.jsx diff --git a/src/components/finance/CustomerInvoices.jsx b/src/components/finance/CustomerInvoices.jsx deleted file mode 100644 index 0cb8e33..0000000 --- a/src/components/finance/CustomerInvoices.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Button, EmptyState } from '../ui' -import AddressWarning from './AddressWarning' -import InvoiceTable from './InvoiceTable' -import { customerInvoicesGate } from '../../lib/financeView' - -/** - * Reiter „Rechnungen“ am Kunden. Kann für den Kunden keine Rechnung geschrieben werden (noch Lead, abgesagt, keine - * E-Mail), steht der Grund da, mit „Daten erfassen“ (onShowOverview wechselt zum Reiter Übersicht). Sonst die - * Rechnungen dieses Kunden mit „Rechnung schreiben“ (der Kunde steht im Dialog fest). Fehlt dem Kunden die Anschrift - * (Straße, PLZ, Ort), steht darüber eine Warnung mit Knopf zur Übersicht; „Senden“ ist dann über 250 € gesperrt. - */ -export default function CustomerInvoices({ customer, onShowOverview }) { - const gate = customerInvoicesGate(customer) - if (!gate.ok) { - return ( - Daten erfassen : null} - /> - ) - } - return ( - <> - - - - ) -} diff --git a/src/pages/CustomerDetailPage.jsx b/src/pages/CustomerDetailPage.jsx index 3bf4f91..d7d3059 100644 --- a/src/pages/CustomerDetailPage.jsx +++ b/src/pages/CustomerDetailPage.jsx @@ -3,7 +3,7 @@ import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom' import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite' import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Dialog, Field } from '../components/ui' import { StatusPill, UrgentPill } from '../components/ui/StatusPill' -import CustomerInvoices from '../components/finance/CustomerInvoices' +import AuftraegeUndRechnungen from '../components/auftraege/AuftraegeUndRechnungen' import AssignedProjectCard from '../components/AssignedProjectCard' import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential' import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '../lib/customerAdminApi' @@ -121,7 +121,7 @@ export default function CustomerDetailPage() { {/* key: nach Absage/Umstufung neu aufbauen, sonst ueberschreibt ein spaeteres Speichern die neuen Notizen */} {activeTab === 'overview' && } {activeTab === 'tickets' && } - {activeTab === 'invoices' && setTab('overview')} />} + {activeTab === 'invoices' && setTab('overview')} />} {activeTab === 'projects' && } {activeTab === 'chat' && } {activeTab === 'abo' && ( From 674cce26f0f1b275cf85e3c0247992e144257466 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:28:49 +0000 Subject: [PATCH 08/33] Kundenart in der Kunden-Uebersicht und Steuernummer in den Finanz-Einstellungen (Helfer mit Tests) Co-Authored-By: Claude Sonnet 5.5 --- src/components/finance/SettingsCard.jsx | 29 +++++++-- src/lib/kundenFelder.js | 67 +++++++++++++++++++++ src/pages/CustomerDetailPage.jsx | 16 ++++- tests/unit/kundenFelder.test.mjs | 79 +++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 src/lib/kundenFelder.js create mode 100644 tests/unit/kundenFelder.test.mjs diff --git a/src/components/finance/SettingsCard.jsx b/src/components/finance/SettingsCard.jsx index 4e87f4a..9570475 100644 --- a/src/components/finance/SettingsCard.jsx +++ b/src/components/finance/SettingsCard.jsx @@ -13,6 +13,7 @@ import { settingsToForm, validateSettingsForm, } from '../../lib/financeSettings' +import { STEUERNUMMER_HINWEIS, steuernummerAenderung } from '../../lib/kundenFelder' const LOAD_ERROR = 'Die Einstellungen konnten nicht geladen werden.' const fieldId = (name) => `fin-set-${name}` @@ -44,6 +45,8 @@ export default function SettingsCard() { function SettingsForm({ initial }) { const [form, setForm] = useState(() => settingsToForm(initial)) + const [steuernummer, setSteuernummer] = useState(() => (typeof initial?.steuernummer === 'string' ? initial.steuernummer : '')) + const gespeichertRef = useRef(typeof initial?.steuernummer === 'string' ? initial.steuernummer : '') const [errors, setErrors] = useState({}) const [busy, setBusy] = useState(false) const [notice, setNotice] = useState(null) // { tone: 'ok' | 'warn', text } @@ -68,10 +71,12 @@ function SettingsForm({ initial }) { e.preventDefault() if (lockRef.current) return const check = validateSettingsForm(form) - if (!check.ok) { - setErrors(check.errors) + const stn = steuernummerAenderung(steuernummer, gespeichertRef.current) + if (!check.ok || !stn.ok) { + const fehler = { ...(check.errors || {}), ...(stn.ok ? {} : { steuernummer: stn.fehler }) } + setErrors(fehler) setNotice({ tone: 'warn', text: 'Bitte die markierten Felder prüfen. Es wurde nichts gespeichert.' }) - const first = SETTINGS_FIELD_ORDER.find((name) => check.errors[name]) + const first = [...SETTINGS_FIELD_ORDER, 'steuernummer'].find((name) => fehler[name]) if (first) document.getElementById(fieldId(first))?.focus() return } @@ -80,9 +85,12 @@ function SettingsForm({ initial }) { setErrors({}) setNotice(null) try { - const data = await patchSettings(check.payload) + const data = await patchSettings({ ...check.payload, ...stn.payload }) if (!aliveRef.current) return if (data?.einstellungen) setForm(settingsToForm(data.einstellungen)) + const gespeichert = typeof data?.einstellungen?.steuernummer === 'string' ? data.einstellungen.steuernummer : steuernummer.trim() + setSteuernummer(gespeichert) + gespeichertRef.current = gespeichert setNotice({ tone: 'ok', text: 'Gespeichert.' }) } catch (err) { if (aliveRef.current) setNotice({ tone: 'warn', text: apiErrorMessage(err) }) @@ -148,6 +156,19 @@ function SettingsForm({ initial }) {
{length} / {SETTINGS_LIMITS.maxBankDetails} Zeichen
+ + { + setSteuernummer(e.target.value) + setErrors((er) => (er.steuernummer ? { ...er, steuernummer: undefined } : er)) + if (notice) setNotice(null) + }} + placeholder="z. B. 12/345/67890 oder DE123456789" + /> + {notice &&

{notice.text}

} diff --git a/src/lib/kundenFelder.js b/src/lib/kundenFelder.js new file mode 100644 index 0000000..acaca55 --- /dev/null +++ b/src/lib/kundenFelder.js @@ -0,0 +1,67 @@ +// Reine Helfer fuer zwei Felder der Auftragsbestaetigung: die Kundenart am Kunden und die Steuernummer in den +// Finanz-Einstellungen; kein React, kein Appwrite, unter Node testbar. Texte deutsch, sachlich. + +/** Werte der Kundenart (Vertrag V2); '' = nicht festgelegt. */ +export const KUNDENARTEN = ['unternehmen', 'privat'] + +export const KUNDENART_OPTIONEN = [ + { value: '', label: 'Nicht festgelegt' }, + { value: 'unternehmen', label: 'Unternehmen' }, + { value: 'privat', label: 'Privatperson' }, +] + +export const KUNDENART_HINWEIS = 'Für Auftragsbestätigungen: Privatpersonen bekommen die Widerrufsbelehrung.' + +const text = (v) => (typeof v === 'string' ? v : '') + +/** Beschriftung der Kundenart; alles Unbekannte heisst „Nicht festgelegt“. */ +export function kundenartLabel(value) { + const treffer = KUNDENART_OPTIONEN.find((o) => o.value !== '' && o.value === value) + return treffer ? treffer.label : KUNDENART_OPTIONEN[0].label +} + +/** + * Was beim Speichern der Kundendaten zusaetzlich ins Dokument soll: nur eine geaenderte, bekannte Kundenart + * (so scheitert das Speichern der uebrigen Stammdaten nie an einem noch nicht angelegten Attribut). + */ +export function kundenartAenderung(neu, gespeichert) { + const n = text(neu) + const alt = text(gespeichert) + if (n !== '' && !KUNDENARTEN.includes(n)) return {} + if (n === alt) return {} + return { kundenart: n } +} + +export const STEUERNUMMER_MAX = 40 + +export const STEUERNUMMER_HINWEIS = 'Steht auf jeder Auftragsbestätigung. Für Rechnungen bitte zusätzlich im Stripe-Dashboard hinterlegen.' + +export const STEUERNUMMER_FEHLT_HINWEIS = + 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.' + +const STEUERNUMMER_ZEICHEN = /^[A-Za-z0-9/ .-]*$/ + +/** + * Steuernummer fuer PATCH /einstellungen. Der Client prueft wie der Server (Vertrag V4): getrimmt hoechstens 40 Zeichen aus + * ASCII-Buchstaben, Ziffern, Leerzeichen, „/“, „-“ und „.“; leer loescht den Wert. Unveraendert wird nichts gesendet. + */ +export function steuernummerAenderung(eingabe, gespeichert) { + const neu = text(eingabe).trim() + const alt = text(gespeichert).trim() + if (neu === alt) return { ok: true, payload: {} } + if (neu.length > STEUERNUMMER_MAX) return { ok: false, fehler: `Die Steuernummer darf höchstens ${STEUERNUMMER_MAX} Zeichen lang sein.` } + if (!STEUERNUMMER_ZEICHEN.test(neu)) { + return { ok: false, fehler: 'Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.' } + } + return { ok: true, payload: { steuernummer: neu } } +} + +/** + * Fehlt die Steuernummer? Nur wahr, wenn die Einstellungen da sind (Objekt) und der Wert leer ist; „noch nicht geladen“ + * (undefined) und „nicht ladbar“ (null) behaupten nichts. + */ +export function steuernummerFehlt(einstellungen) { + if (!einstellungen || typeof einstellungen !== 'object' || Array.isArray(einstellungen)) return false + const wert = einstellungen.steuernummer + return typeof wert !== 'string' || wert.trim() === '' +} diff --git a/src/pages/CustomerDetailPage.jsx b/src/pages/CustomerDetailPage.jsx index d7d3059..54c0d18 100644 --- a/src/pages/CustomerDetailPage.jsx +++ b/src/pages/CustomerDetailPage.jsx @@ -12,6 +12,7 @@ import { useWebsiteProjects } from '../hooks/useWebsiteProjects' import { customerTabs, resolveCustomerTab } from '../lib/customerTabs' import { isInvoiceReady, setCustomerArchived, upgradeToCustomer, markLost, reopenAsLead } from '../lib/leads' import { lastRejection } from '../lib/leadDuplicates' +import { KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartAenderung } from '../lib/kundenFelder' import { customerStage, isArchived, customerBadges, customerEyebrow, customerTitle } from '../lib/customerList' import { ticketPath, ticketRef } from '../lib/ticketRoutes' import { ticketNumber } from '../lib/ticketView' @@ -154,11 +155,15 @@ function OverviewTab({ customer, stage, onSaved }) { notes: customer.notes || '', importantCustomer: !!customer.importantCustomer, }) + const [kundenart, setKundenart] = useState(customer.kundenart || '') const [busy, setBusy] = useState(false) const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text } const [showLost, setShowLost] = useState(false) const { user, employee } = useAuth() + // Die Kundenart geht nur ins Dokument, wenn sie geaendert wurde (kein Fehler bei noch nicht angelegtem Attribut) + const felder = () => ({ ...form, ...kundenartAenderung(kundenart, customer.kundenart) }) + const ready = isInvoiceReady({ ...customer, ...form }) // Absage: ungespeicherte Notizen gehen mit, damit der Vermerk nichts ueberschreibt @@ -182,7 +187,7 @@ function OverviewTab({ customer, stage, onSaved }) { const save = async () => { setBusy(true); setMsg(null) try { - await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form) + await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, felder()) setMsg({ tone: 'ok', text: 'Gespeichert' }) onSaved() } catch (err) { @@ -194,7 +199,7 @@ function OverviewTab({ customer, stage, onSaved }) { if (!ready) { setMsg({ tone: 'warn', text: 'Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.' }); return } setBusy(true); setMsg(null) try { - await upgradeToCustomer(customer, form) + await upgradeToCustomer(customer, felder()) setMsg({ tone: 'ok', text: 'Zum festen Kunden gemacht.' }) onSaved() } catch (err) { @@ -249,6 +254,13 @@ function OverviewTab({ customer, stage, onSaved }) { {!ready &&
Adresse unvollständig
}
+
+
+ +
{KUNDENART_HINWEIS}
+
setForm({ ...form, street: e.target.value })} />
diff --git a/tests/unit/kundenFelder.test.mjs b/tests/unit/kundenFelder.test.mjs new file mode 100644 index 0000000..2904c3f --- /dev/null +++ b/tests/unit/kundenFelder.test.mjs @@ -0,0 +1,79 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { + KUNDENARTEN, KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartLabel, kundenartAenderung, + STEUERNUMMER_MAX, STEUERNUMMER_HINWEIS, steuernummerAenderung, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt, +} = await loadModule('src/lib/kundenFelder.js') + +test('Kundenart: Werte (Vertrag V2), Auswahl und Beschriftung', () => { + assert.deepEqual(KUNDENARTEN, ['unternehmen', 'privat']) + assert.deepEqual(KUNDENART_OPTIONEN, [ + { value: '', label: 'Nicht festgelegt' }, + { value: 'unternehmen', label: 'Unternehmen' }, + { value: 'privat', label: 'Privatperson' }, + ]) + assert.equal(KUNDENART_HINWEIS, 'Für Auftragsbestätigungen: Privatpersonen bekommen die Widerrufsbelehrung.') + assert.equal(kundenartLabel('unternehmen'), 'Unternehmen') + assert.equal(kundenartLabel('privat'), 'Privatperson') + for (const v of ['', undefined, null, 'firma']) assert.equal(kundenartLabel(v), 'Nicht festgelegt') +}) + +test('kundenartAenderung: nur eine geänderte, bekannte Kundenart wird geschrieben', () => { + assert.deepEqual(kundenartAenderung('', ''), {}) + assert.deepEqual(kundenartAenderung('', undefined), {}) + assert.deepEqual(kundenartAenderung('privat', 'privat'), {}) + assert.deepEqual(kundenartAenderung('privat', ''), { kundenart: 'privat' }) + assert.deepEqual(kundenartAenderung('privat', undefined), { kundenart: 'privat' }) + assert.deepEqual(kundenartAenderung('unternehmen', 'privat'), { kundenart: 'unternehmen' }) + assert.deepEqual(kundenartAenderung('', 'privat'), { kundenart: '' }) + // unbekannte Werte werden nie geschrieben + assert.deepEqual(kundenartAenderung('firma', ''), {}) + assert.deepEqual(kundenartAenderung('firma', 'privat'), {}) +}) + +test('Steuernummer: Hinweis und Grenze', () => { + assert.equal(STEUERNUMMER_HINWEIS, 'Steht auf jeder Auftragsbestätigung. Für Rechnungen bitte zusätzlich im Stripe-Dashboard hinterlegen.') + assert.equal(STEUERNUMMER_MAX, 40) +}) + +test('steuernummerAenderung: unverändert sendet nichts, geändert den getrimmten Wert, leer löscht', () => { + assert.deepEqual(steuernummerAenderung('12/345/67890', '12/345/67890'), { ok: true, payload: {} }) + assert.deepEqual(steuernummerAenderung('', ''), { ok: true, payload: {} }) + assert.deepEqual(steuernummerAenderung('', null), { ok: true, payload: {} }) + assert.deepEqual(steuernummerAenderung(' 12/345/67890 ', '12/345/67890'), { ok: true, payload: {} }) + assert.deepEqual(steuernummerAenderung(' 12/345/67890 ', ''), { ok: true, payload: { steuernummer: '12/345/67890' } }) + assert.deepEqual(steuernummerAenderung('', '12/345/67890'), { ok: true, payload: { steuernummer: '' } }) + assert.equal(steuernummerAenderung('123 456 789-0', '').ok, true) + assert.equal(steuernummerAenderung('1'.repeat(40), '').ok, true) +}) + +test('steuernummerAenderung: Buchstaben, Ziffern, Leerzeichen, „/“, „-“ und „.“ sind erlaubt wie auf dem Server (V4)', () => { + for (const gut of ['12/345/67890', '12.345.67890', 'DE123456789', 'de 123 456 789', 'DE-123.456/789', 'a', 'USt-IdNr 12/345']) { + assert.deepEqual(steuernummerAenderung(gut, ''), { ok: true, payload: { steuernummer: gut } }, gut) + } + assert.deepEqual(steuernummerAenderung(' DE123456789 ', ''), { ok: true, payload: { steuernummer: 'DE123456789' } }) + assert.deepEqual(steuernummerAenderung('A'.repeat(40), ''), { ok: true, payload: { steuernummer: 'A'.repeat(40) } }) +}) + +test('steuernummerAenderung: andere Zeichen und zu lang', () => { + const zeichen = 'Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.' + for (const schlecht of ['12_345', '12,345', '12#34', 'Müller 12', 'ß12', '12\t34', '12\n34', '12+34', '123']) { + assert.deepEqual(steuernummerAenderung(schlecht, ''), { ok: false, fehler: zeichen }, JSON.stringify(schlecht)) + } + const LANG = 'Die Steuernummer darf höchstens 40 Zeichen lang sein.' + assert.deepEqual(steuernummerAenderung('1'.repeat(41), ''), { ok: false, fehler: LANG }) + assert.deepEqual(steuernummerAenderung('A'.repeat(41), ''), { ok: false, fehler: LANG }) + assert.equal(steuernummerAenderung('_'.repeat(41), '').fehler, LANG) // die Länge wird zuerst geprüft +}) + +test('steuernummerFehlt: Hinweis nur, wenn die Einstellungen da sind und die Steuernummer leer ist', () => { + assert.equal(STEUERNUMMER_FEHLT_HINWEIS, 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.') + for (const leer of [{ steuernummer: '' }, { steuernummer: ' ' }, { steuernummer: null }, { steuernummer: 12 }, {}, { bankDetailsText: 'IBAN' }]) { + assert.equal(steuernummerFehlt(leer), true, JSON.stringify(leer)) + } + for (const da of [{ steuernummer: '12/345/67890' }, { steuernummer: ' DE123456789 ' }]) assert.equal(steuernummerFehlt(da), false) + // noch nicht geladen oder nicht ladbar: es wird nichts behauptet + for (const unbekannt of [undefined, null, 'x', 5, []]) assert.equal(steuernummerFehlt(unbekannt), false, String(unbekannt)) +}) From 241f7cdec6c0a230af3f92622b98c3b2f0cff82e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:29:30 +0000 Subject: [PATCH 09/33] =?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 45dd73d8cc03884166fc64674549ff1fcf5f34ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:31:31 +0000 Subject: [PATCH 10/33] Auftragsbestaetigung: Dialoglogik (Parser, Validierung, Nutzlast, Zahlungsplan-Vorschau) mit Tests Co-Authored-By: Claude Sonnet 5.5 --- src/lib/auftragView.js | 419 ++++++++++++++++++++++++++++++++ tests/unit/auftragView.test.mjs | 400 ++++++++++++++++++++++++++++++ 2 files changed, 819 insertions(+) create mode 100644 src/lib/auftragView.js create mode 100644 tests/unit/auftragView.test.mjs diff --git a/src/lib/auftragView.js b/src/lib/auftragView.js new file mode 100644 index 0000000..6944401 --- /dev/null +++ b/src/lib/auftragView.js @@ -0,0 +1,419 @@ +// Reine Ansichtslogik der Auftragsbestaetigungen (AB) in WOMS: Eingaben pruefen, Cent und Minuten, Formular <-> Anfrage, +// Zahlungsplan-Vorschau, Hinweise, Status und Aktionen der Liste, Meldungen und Fehlerklassen. Kein React, kein Appwrite, +// unter Node testbar. Betraege sind ganze Cent, in Formularen Euro-Texte (de-DE); Texte deutsch und sachlich (keine Anrede). +// Die Grenzen entsprechen dem Vertrag V2 (LIMITS) der Uebersicht, der Server entscheidet. +import { canInvoice, dateDe, euro, ymdBerlin } from './financeFormat' +import { ADDRESS_INCOMPLETE_TEXT, addressComplete, invoiceTotalCents, parseQuantity, parseUnitPrice } from './financeView' +import { euroField } from './financeSettings' +import { ticketNumber } from './ticketView' +import { KUNDENARTEN, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt } from './kundenFelder' + +// --- Konstanten -------------------------------------------------------------------------------------------------------- + +export const AUFTRAG_LIMITS = { + maxPositionen: 20, + maxBeschreibung: 200, + maxMenge: 999, + maxEinzelpreisCents: 10_000_000, + maxSummeCents: 99_999_999, + maxTitel: 200, + maxZahlungsplanText: 500, + maxZahlungszielTage: 60, + standardZahlungszielTage: 14, + standardAnzahlungProzent: 50, + maxZeitquellen: 10, + maxSchaetzungMinuten: 99_960, + maxLeistungszeitraum: 140, +} + +export const STUNDENSATZ_STANDARD_CENTS = 10000 + +export const AUFTRAG_WEGE = [ + { value: 'muendlich', label: 'Mündlich' }, + { value: 'telefon', label: 'Telefonisch' }, + { value: 'email', label: 'Per E-Mail' }, + { value: 'vor_ort', label: 'Vor Ort' }, + { value: 'chat', label: 'Im Chat' }, +] + +export const ZAHLUNGSPLAENE = { + nach_abnahme: 'Alles nach Abnahme', + anzahlung: 'Anzahlung bei Auftrag, Rest nach Abnahme', + monatlich: 'Monatlich nach Aufwand', + frei: 'Frei (eigener Text)', +} + +export const SCHRITTE = [ + { id: 1, label: 'Projekt' }, + { id: 2, label: 'Auftrag' }, + { id: 3, label: 'Zahlung und Termine' }, + { id: 4, label: 'Vorschau' }, +] + +const ARTEN = ['festpreis', 'aufwand'] + +/** Auswahl des Zahlungsplans je Art: „Anzahlung“ nur beim Festpreis, „Monatlich“ nur nach Aufwand. */ +export function zahlungsplanOptionen(art) { + const werte = art === 'aufwand' ? ['nach_abnahme', 'monatlich', 'frei'] : ['nach_abnahme', 'anzahlung', 'frei'] + return werte.map((value) => ({ value, label: ZAHLUNGSPLAENE[value] })) +} + +// --- Hilfen ------------------------------------------------------------------------------------------------------------ + +const text = (v) => (typeof v === 'string' ? v : '') +const trimmed = (v) => text(v).trim() +const istObjekt = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v) +const objekte = (v) => (Array.isArray(v) ? v.filter(istObjekt) : []) + +/** 'YYYY-MM-DD' und ein wirklich existierender Tag. */ +function istTag(wert) { + return /^\d{4}-\d{2}-\d{2}$/.test(text(wert)) && dateDe(wert) !== '–' +} + +// --- Eingaben ---------------------------------------------------------------------------------------------------------- + +/** Stunden mit Komma in 15-Minuten-Schritten („10“, „12,5“, „2 Std.“) -> Minuten; ungueltig, leer oder 0 -> null. */ +export function parseStunden(eingabe) { + const s = trimmed(eingabe).replace(/\s*(std\.?|h)$/i, '') + const m = /^(\d{1,4})(?:,(\d{1,2}))?$/.exec(s) + if (!m) return null + const hundertstel = Number(m[1]) * 60 * 100 + Number((m[2] || '').padEnd(2, '0') || 0) * 60 + if (hundertstel % 100 !== 0) return null + const minuten = hundertstel / 100 + if (!(minuten > 0) || minuten % 15 !== 0 || minuten > AUFTRAG_LIMITS.maxSchaetzungMinuten) return null + return minuten +} + +/** Minuten -> Stunden mit Komma ohne ueberfluessige Nullen (150 -> „2,5“); keine Zahl -> „–“. */ +export function stundenText(minuten) { + if (typeof minuten !== 'number' || !Number.isFinite(minuten)) return '–' + return String(Number((minuten / 60).toFixed(2))).replace('.', ',') +} + +/** „50“ oder „30 %“ -> 1..99, sonst null. */ +export function parseProzent(eingabe) { + const s = trimmed(eingabe).replace(/\s*%$/, '') + if (!/^\d{1,2}$/.test(s)) return null + const n = Number(s) + return n >= 1 && n <= 99 ? n : null +} + +/** Zahlungsziel in Tagen 0..60, sonst null. */ +export function parseTage(eingabe) { + const s = trimmed(eingabe) + if (!/^\d{1,3}$/.test(s)) return null + const n = Number(s) + return n <= AUFTRAG_LIMITS.maxZahlungszielTage ? n : null +} + +/** Berliner Kalendertag („YYYY-MM-DD“), nicht der UTC-Tag. */ +export function heuteBerlin(now = Date.now()) { + return ymdBerlin(now) +} + +// --- Formular ---------------------------------------------------------------------------------------------------------- + +let naechsteUid = 1 + +/** Leere Position des Formulars; `uid` ist nur der React-Schluessel. */ +export function neuePosition() { + const uid = naechsteUid + naechsteUid += 1 + return { uid, description: '', quantity: '1', price: '' } +} + +const kontoMit = (konten, projektId) => + projektId && Array.isArray(konten) ? objekte(konten).find((k) => k.projektId === projektId) || null : null + +const ticketIds = (konto) => (konto && Array.isArray(konto.tickets) ? konto.tickets.map((t) => t?.id).filter((id) => typeof id === 'string' && id) : []) + +/** Neues Formular; ein vorgewaehltes Projekt fuellt Titel und Zeitquelle. */ +export function neuesFormular({ projektId = '', konten = [], heute = heuteBerlin() } = {}) { + const konto = kontoMit(konten, projektId) + return { + projektId: konto ? konto.projektId : '', + titel: konto ? text(konto.name) : '', + titelGeaendert: false, + kundenart: '', + art: 'festpreis', + auftragVom: heute, + auftragWeg: '', + positionen: [neuePosition()], + stundensatz: euroField(STUNDENSATZ_STANDARD_CENTS), + schaetzung: '', + zahlungsart: 'nach_abnahme', + prozent: String(AUFTRAG_LIMITS.standardAnzahlungProzent), + zahlungstext: '', + zahlungsziel: String(AUFTRAG_LIMITS.standardZahlungszielTage), + startAm: '', + fertigBis: '', + zeitquelle: ticketIds(konto), + zeitquelleGeaendert: false, + } +} + +/** Entwurf vom Server -> Formular (Euro- und Stundentexte). Titel und Zeitquelle gelten als von Hand gesetzt. */ +export function auftragZuFormular(auftrag) { + const a = istObjekt(auftrag) ? auftrag : {} + const positionen = objekte(a.positionen).map((p) => ({ + uid: neuePosition().uid, + description: text(p.beschreibung), + quantity: String(p.menge ?? ''), + price: euroField(p.einzelpreisCents), + })) + const plan = istObjekt(a.zahlungsplan) ? a.zahlungsplan : {} + return { + projektId: text(a.projektId), + titel: text(a.titel), + titelGeaendert: true, + kundenart: '', + art: ARTEN.includes(a.art) ? a.art : 'festpreis', + auftragVom: text(a.auftragVom), + auftragWeg: text(a.auftragWeg), + positionen: positionen.length ? positionen : [neuePosition()], + stundensatz: euroField(typeof a.stundensatzCents === 'number' ? a.stundensatzCents : STUNDENSATZ_STANDARD_CENTS), + schaetzung: typeof a.schaetzungMinuten === 'number' ? stundenText(a.schaetzungMinuten) : '', + zahlungsart: text(plan.art) || 'nach_abnahme', + prozent: typeof plan.prozent === 'number' ? String(plan.prozent) : String(AUFTRAG_LIMITS.standardAnzahlungProzent), + zahlungstext: text(plan.text), + zahlungsziel: String(typeof a.zahlungszielTage === 'number' ? a.zahlungszielTage : AUFTRAG_LIMITS.standardZahlungszielTage), + startAm: text(a.startAm), + fertigBis: text(a.fertigBis), + zeitquelle: Array.isArray(a.zeitquelle) ? a.zeitquelle.filter((id) => typeof id === 'string') : [], + zeitquelleGeaendert: true, + } +} + +/** Projekt waehlen: Titel und Zeitquelle folgen dem Projekt, solange sie nicht von Hand geaendert wurden. */ +export function waehleProjekt(form, projektId, konten) { + const konto = kontoMit(konten, projektId) + const neu = { ...form, projektId: konto ? konto.projektId : '' } + if (!form.titelGeaendert) neu.titel = konto ? text(konto.name) : '' + if (!form.zeitquelleGeaendert) neu.zeitquelle = ticketIds(konto) + return neu +} + +/** Art wechseln; ein unpassender Zahlungsplan wird zu „Alles nach Abnahme“. */ +export function wechselArt(form, art) { + if (!ARTEN.includes(art)) return form + const passt = zahlungsplanOptionen(art).some((o) => o.value === form.zahlungsart) + return { ...form, art, zahlungsart: passt ? form.zahlungsart : 'nach_abnahme' } +} + +/** Summe der gueltigen Positionen in Cent (Festpreis), bei Aufwand null. */ +export function formularSummeCents(form) { + return form?.art === 'festpreis' ? invoiceTotalCents(form.positionen) : null +} + +// --- Pruefen ----------------------------------------------------------------------------------------------------------- + +const MELDUNG = { + titel: `Bitte einen Titel eingeben (höchstens ${AUFTRAG_LIMITS.maxTitel} Zeichen).`, + kundenart: 'Bitte die Kundenart wählen.', + auftragVom: 'Bitte das Datum der Absprache angeben.', + auftragWeg: 'Bitte den Weg der Absprache wählen.', + art: 'Bitte die Art des Auftrags wählen.', + anzahl: `Eine Auftragsbestätigung braucht 1 bis ${AUFTRAG_LIMITS.maxPositionen} Positionen.`, + menge: `Die Menge muss eine ganze Zahl von 1 bis ${AUFTRAG_LIMITS.maxMenge} sein.`, + preis: 'Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).', + summe: `Die Summe ist zu hoch (höchstens ${euro(AUFTRAG_LIMITS.maxSummeCents)}).`, + stundensatz: 'Der Stundensatz muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 100,00).', + schaetzung: `Die Schätzung geht in Schritten von 15 Minuten (z. B. 10 oder 12,5 Stunden, höchstens ${AUFTRAG_LIMITS.maxSchaetzungMinuten / 60}).`, + zahlungsart: 'Dieser Zahlungsplan passt nicht zur Art des Auftrags.', + prozent: 'Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.', + zahlungstext: `Bitte den Zahlungsplan als Text eingeben (höchstens ${AUFTRAG_LIMITS.maxZahlungsplanText} Zeichen).`, + zahlungsziel: `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${AUFTRAG_LIMITS.maxZahlungszielTage} Tagen sein.`, + datum: 'Bitte ein gültiges Datum wählen.', + fertigVorStart: 'Die Fertigstellung liegt vor dem Start.', + zeitquelle: `Höchstens ${AUFTRAG_LIMITS.maxZeitquellen} Tickets als Zeitquelle.`, +} + +function positionenFehler(positionen) { + const liste = objekte(positionen) + if (liste.length < 1 || liste.length > AUFTRAG_LIMITS.maxPositionen) return MELDUNG.anzahl + for (let i = 0; i < liste.length; i += 1) { + const p = liste[i] + const vor = `Position ${i + 1}: ` + const beschreibung = trimmed(p.description) + if (!beschreibung) return `${vor}Bitte eine Beschreibung eingeben.` + if (beschreibung.length > AUFTRAG_LIMITS.maxBeschreibung) return `${vor}Die Beschreibung darf höchstens ${AUFTRAG_LIMITS.maxBeschreibung} Zeichen lang sein.` + if (parseQuantity(p.quantity) === null) return `${vor}${MELDUNG.menge}` + if (parseUnitPrice(p.price) === null) return `${vor}${MELDUNG.preis}` + } + if (invoiceTotalCents(liste) > AUFTRAG_LIMITS.maxSummeCents) return MELDUNG.summe + return '' +} + +/** Prueft einen Schritt (1 bis 3). Ergebnis { ok, fehler: { feld: Meldung }, erstes } (erstes = erstes fehlerhaftes Feld). */ +export function pruefeSchritt(schritt, form, { kunde } = {}) { + const f = istObjekt(form) ? form : {} + const fehler = {} + if (schritt === 1) { + const titel = trimmed(f.titel) + if (titel.length < 1 || titel.length > AUFTRAG_LIMITS.maxTitel) fehler.titel = MELDUNG.titel + if (!trimmed(kunde?.kundenart) && !KUNDENARTEN.includes(f.kundenart)) fehler.kundenart = MELDUNG.kundenart + } else if (schritt === 2) { + if (!istTag(f.auftragVom)) fehler.auftragVom = MELDUNG.auftragVom + if (!AUFTRAG_WEGE.some((w) => w.value === f.auftragWeg)) fehler.auftragWeg = MELDUNG.auftragWeg + if (!ARTEN.includes(f.art)) { + fehler.art = MELDUNG.art + } else if (f.art === 'festpreis') { + const meldung = positionenFehler(f.positionen) + if (meldung) fehler.positionen = meldung + } else { + if (parseUnitPrice(f.stundensatz) === null) fehler.stundensatz = MELDUNG.stundensatz + if (trimmed(f.schaetzung) !== '' && parseStunden(f.schaetzung) === null) fehler.schaetzung = MELDUNG.schaetzung + } + } else if (schritt === 3) { + if (!zahlungsplanOptionen(f.art).some((o) => o.value === f.zahlungsart)) fehler.zahlungsart = MELDUNG.zahlungsart + if (f.zahlungsart === 'anzahlung' && parseProzent(f.prozent) === null) fehler.prozent = MELDUNG.prozent + if (f.zahlungsart === 'frei') { + const t = trimmed(f.zahlungstext) + if (t.length < 1 || t.length > AUFTRAG_LIMITS.maxZahlungsplanText) fehler.zahlungstext = MELDUNG.zahlungstext + } + if (parseTage(f.zahlungsziel) === null) fehler.zahlungsziel = MELDUNG.zahlungsziel + const start = trimmed(f.startAm) + const fertig = trimmed(f.fertigBis) + if (start !== '' && !istTag(start)) fehler.startAm = MELDUNG.datum + if (fertig !== '' && !istTag(fertig)) fehler.fertigBis = MELDUNG.datum + if (start !== '' && fertig !== '' && istTag(start) && istTag(fertig) && fertig < start) fehler.fertigBis = MELDUNG.fertigVorStart + if (Array.isArray(f.zeitquelle) && f.zeitquelle.length > AUFTRAG_LIMITS.maxZeitquellen) fehler.zeitquelle = MELDUNG.zeitquelle + } + const erstes = Object.keys(fehler)[0] || '' + return { ok: erstes === '', fehler, erstes } +} + +/** + * Formular -> Anfrage fuer POST/PATCH …/auftraege. Prueft die Schritte 1 bis 3 der Reihe nach. + * { ok: true, payload, summeCents } oder { ok: false, schritt, erstes, fehler }. `kundenId` setzt der Aufrufer. + */ +export function formularZuNutzlast(form, { kunde } = {}) { + for (const schritt of [1, 2, 3]) { + const r = pruefeSchritt(schritt, form, { kunde }) + if (!r.ok) return { ok: false, schritt, erstes: r.erstes, fehler: r.fehler } + } + const festpreis = form.art === 'festpreis' + const plan = form.zahlungsart + const schaetzung = trimmed(form.schaetzung) + const payload = { + projektId: text(form.projektId), + titel: trimmed(form.titel), + art: form.art, + positionen: festpreis + ? form.positionen.map((p) => ({ beschreibung: trimmed(p.description), menge: parseQuantity(p.quantity), einzelpreisCents: parseUnitPrice(p.price) })) + : [], + stundensatzCents: festpreis ? null : parseUnitPrice(form.stundensatz), + schaetzungMinuten: festpreis || schaetzung === '' ? null : parseStunden(schaetzung), + zahlungsplan: { + art: plan, + prozent: plan === 'anzahlung' ? parseProzent(form.prozent) : null, + text: plan === 'frei' ? trimmed(form.zahlungstext) : '', + }, + zahlungszielTage: parseTage(form.zahlungsziel), + auftragVom: trimmed(form.auftragVom), + auftragWeg: form.auftragWeg, + startAm: trimmed(form.startAm), + fertigBis: trimmed(form.fertigBis), + zeitquelle: [...form.zeitquelle], + } + if (!trimmed(kunde?.kundenart)) payload.kundenart = form.kundenart + return { ok: true, payload, summeCents: festpreis ? invoiceTotalCents(form.positionen) : null } +} + +// --- Texte ------------------------------------------------------------------------------------------------------------- + +/** Sinngemaesser Zahlungsplan wie in der AB; massgeblich bleibt der Wortlaut im PDF. Unbekannt oder unvollstaendig: ''. */ +export function zahlungsplanVorschau({ art, prozent, text: freitext, summeCents, zielTage } = {}) { + const tage = Number.isInteger(zielTage) ? zielTage : AUFTRAG_LIMITS.standardZahlungszielTage + const ziel = tage === 0 ? 'sofort nach Rechnung' : tage === 1 ? 'innerhalb von 1 Tag nach Rechnung' : `innerhalb von ${tage} Tagen nach Rechnung` + const summe = Number.isInteger(summeCents) ? summeCents : null + if (art === 'nach_abnahme') { + return `${summe === null ? 'Alles' : `Gesamtbetrag (${euro(summe)})`} nach Abnahme; zahlbar ${ziel}.` + } + if (art === 'anzahlung') { + if (!Number.isInteger(prozent)) return '' + if (summe === null) return `Anzahlung ${prozent} % nach Auftragsbestätigung, Rest nach Abnahme; zahlbar jeweils ${ziel}.` + const anzahlung = Math.round((summe * prozent) / 100) + return `Anzahlung ${prozent} % (${euro(anzahlung)}) nach Auftragsbestätigung, Rest (${euro(summe - anzahlung)}) nach Abnahme; zahlbar jeweils ${ziel}.` + } + if (art === 'monatlich') return `Monatlich nach Aufwand; zahlbar ${ziel}.` + if (art === 'frei') { + const t = trimmed(freitext) + return t ? `${t} (zahlbar ${ziel}).` : '' + } + return '' +} + +/** + * Darf gesendet werden? { erlaubt, grund, zurUebersicht }. Der Entwurf laesst sich immer speichern; `zurUebersicht` + * heisst: der Grund liegt in den Kundendaten (Reiter Uebersicht). + */ +export function sendeGate(kunde, kundenart = '') { + if (!kunde) return { erlaubt: false, grund: 'Kein Kunde ausgewählt', zurUebersicht: false } + const check = canInvoice(kunde) + if (!check.ok) return { erlaubt: false, grund: check.reason, zurUebersicht: true } + if (!addressComplete(kunde)) return { erlaubt: false, grund: ADDRESS_INCOMPLETE_TEXT, zurUebersicht: true } + if (!trimmed(kunde.kundenart) && !trimmed(kundenart)) { + return { erlaubt: false, grund: 'Die Kundenart fehlt. Bitte im ersten Schritt wählen.', zurUebersicht: false } + } + return { erlaubt: true, grund: '', zurUebersicht: false } +} + +// --- Zeitquelle -------------------------------------------------------------------------------------------------------- + +/** + * Tickets der Zeitquelle: erst die Tickets des Projekts, dann die uebrigen gewaehlten, dann alle weiteren. Eine gewaehlte + * Kennung, die nicht (mehr) in `tickets` steht, bleibt sichtbar („Ticket “). + */ +export function zeitquelleOptionen(tickets, auswahl, projektTicketIds) { + const liste = objekte(tickets) + const gewaehlt = Array.isArray(auswahl) ? auswahl : [] + const projekt = Array.isArray(projektTicketIds) ? projektTicketIds : [] + const nachId = new Map(liste.map((t) => [t.$id, t])) + const reihenfolge = [] + const gesehen = new Set() + for (const id of [...projekt, ...gewaehlt, ...liste.map((t) => t.$id)]) { + if (typeof id !== 'string' || !id || gesehen.has(id)) continue + gesehen.add(id) + reihenfolge.push(id) + } + return reihenfolge.map((id) => { + const ticket = nachId.get(id) + const titel = ticket ? trimmed(ticket.topic) || trimmed(ticket.title) : '' + const label = ticket ? `#${ticketNumber(ticket)}${titel ? ` ${titel}` : ''}` : `Ticket ${id.slice(-5)}` + return { id, label, gewaehlt: gewaehlt.includes(id), projekt: projekt.includes(id) } + }) +} + +/** Ticket zur Auswahl hinzufuegen (hoechstens 10, keine Doppelten) oder entfernen; liefert eine neue Liste. */ +export function schalteZeitquelle(auswahl, id, an) { + const liste = Array.isArray(auswahl) ? [...auswahl] : [] + if (!an) return liste.filter((x) => x !== id) + if (liste.includes(id) || liste.length >= AUFTRAG_LIMITS.maxZeitquellen) return liste + return [...liste, id] +} + +// --- Hinweise (nie blockierend) ---------------------------------------------------------------------------------------- + +/** Wirksame Kundenart: die am Kunden gespeicherte hat Vorrang (der Server schreibt sie nur, wenn sie dort leer ist). */ +export function kundenartWirksam(kunde, kundenart) { + if (KUNDENARTEN.includes(kunde?.kundenart)) return kunde.kundenart + if (KUNDENARTEN.includes(kundenart)) return kundenart + return '' +} + +/** § 312f Abs. 1 BGB: bei ausserhalb von Geschaeftsraeumen geschlossenen Vertraegen mit Verbrauchern eine Papierfassung geben. */ +export const VOR_ORT_HINWEIS = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.' + +export function vorOrtHinweis(weg, kundenart) { + return weg === 'vor_ort' && kundenart === 'privat' ? VOR_ORT_HINWEIS : '' +} + +/** Hinweise fuer Schritt 4 (Steuernummer fehlt; „Vor Ort“ bei Privatperson). Reine Texte, sie sperren nichts. */ +export function auftragHinweise({ kunde, form, einstellungen } = {}) { + const liste = [] + if (steuernummerFehlt(einstellungen)) liste.push({ key: 'steuernummer', text: STEUERNUMMER_FEHLT_HINWEIS }) + const vorOrt = vorOrtHinweis(form?.auftragWeg, kundenartWirksam(kunde, form?.kundenart)) + if (vorOrt) liste.push({ key: 'vorOrt', text: vorOrt }) + return liste +} diff --git a/tests/unit/auftragView.test.mjs b/tests/unit/auftragView.test.mjs new file mode 100644 index 0000000..71df20a --- /dev/null +++ b/tests/unit/auftragView.test.mjs @@ -0,0 +1,400 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { + AUFTRAG_LIMITS, STUNDENSATZ_STANDARD_CENTS, AUFTRAG_WEGE, SCHRITTE, zahlungsplanOptionen, + parseStunden, stundenText, parseProzent, parseTage, heuteBerlin, + neuePosition, neuesFormular, auftragZuFormular, waehleProjekt, wechselArt, formularSummeCents, + pruefeSchritt, formularZuNutzlast, zahlungsplanVorschau, sendeGate, zeitquelleOptionen, schalteZeitquelle, + kundenartWirksam, VOR_ORT_HINWEIS, vorOrtHinweis, auftragHinweise, +} = await loadModule('src/lib/auftragView.js') + +const KONTEN = [ + { projektId: 'p1', name: 'Website Praxis Müller', tickets: [{ id: 't1', woid: '100131' }] }, + { projektId: 'p2', name: 'Shop Relaunch', tickets: [{ id: 't2', woid: '100140' }, { id: 't3', woid: '100141' }] }, +] +const KUNDE = { $id: 'c1', name: 'Praxis Müller', email: 'info@praxis.test', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', kundenart: '', customerStatus: 'customer' } +const festpreisForm = (over = {}) => ({ + ...neuesFormular({ heute: '2026-10-06' }), + titel: 'Website', kundenart: 'privat', auftragWeg: 'telefon', + positionen: [{ uid: 1, description: 'Startseite', quantity: '1', price: '800,00' }], + ...over, +}) +const aufwandForm = (over = {}) => festpreisForm({ art: 'aufwand', positionen: [], ...over }) +const OK = { ok: true, fehler: {}, erstes: '' } + +test('Konstanten gleichen dem Vertrag V2 und der Spec (Limits, Wege, Zahlungspläne, Schritte)', () => { + assert.deepEqual(AUFTRAG_LIMITS, { + maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000, + maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60, + standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10, + maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140, + }) + assert.equal(STUNDENSATZ_STANDARD_CENTS, 10000) + assert.deepEqual(AUFTRAG_WEGE, [ + { value: 'muendlich', label: 'Mündlich' }, { value: 'telefon', label: 'Telefonisch' }, + { value: 'email', label: 'Per E-Mail' }, { value: 'vor_ort', label: 'Vor Ort' }, { value: 'chat', label: 'Im Chat' }, + ]) + assert.deepEqual(zahlungsplanOptionen('festpreis'), [ + { value: 'nach_abnahme', label: 'Alles nach Abnahme' }, + { value: 'anzahlung', label: 'Anzahlung bei Auftrag, Rest nach Abnahme' }, + { value: 'frei', label: 'Frei (eigener Text)' }, + ]) + assert.deepEqual(zahlungsplanOptionen('aufwand'), [ + { value: 'nach_abnahme', label: 'Alles nach Abnahme' }, + { value: 'monatlich', label: 'Monatlich nach Aufwand' }, + { value: 'frei', label: 'Frei (eigener Text)' }, + ]) + assert.deepEqual(SCHRITTE, [ + { id: 1, label: 'Projekt' }, { id: 2, label: 'Auftrag' }, { id: 3, label: 'Zahlung und Termine' }, { id: 4, label: 'Vorschau' }, + ]) +}) + +test('parseStunden: Stunden mit Komma in 15-Minuten-Schritten werden Minuten', () => { + const gut = { '10': 600, '10,25': 615, '10,5': 630, '10,50': 630, '10,75': 645, '0,25': 15, '1': 60, '12,5 h': 750, '2 Std.': 120, ' 8 ': 480, '1666': 99960 } + for (const [text, minuten] of Object.entries(gut)) assert.equal(parseStunden(text), minuten, text) + for (const schlecht of ['', '0', '0,00', '10,3', '10,30', '10,1', '10.5', '-1', 'abc', '1666,25', '1667', '10,255', '1e3', ',5', '10 Minuten', null, undefined]) { + assert.equal(parseStunden(schlecht), null, String(schlecht)) + } +}) + +test('stundenText: Minuten als Stunden mit Komma, ohne überflüssige Nullen', () => { + assert.deepEqual([15, 30, 45, 60, 75, 120, 135, 150, 600, 99960].map(stundenText), ['0,25', '0,5', '0,75', '1', '1,25', '2', '2,25', '2,5', '10', '1666']) + assert.equal(stundenText(null), '–') + assert.equal(stundenText('x'), '–') +}) + +test('parseProzent und parseTage', () => { + assert.deepEqual(['50', ' 30 % ', '1', '99'].map(parseProzent), [50, 30, 1, 99]) + for (const schlecht of ['', '0', '100', '-5', '33,3', '5a', 'x', null]) assert.equal(parseProzent(schlecht), null, String(schlecht)) + assert.deepEqual(['14', '0', '60', ' 7 '].map(parseTage), [14, 0, 60, 7]) + for (const schlecht of ['', '61', '-1', '1,5', 'x', '1000', null]) assert.equal(parseTage(schlecht), null, String(schlecht)) +}) + +test('heuteBerlin: Berliner Kalendertag, nicht der UTC-Tag', () => { + assert.equal(heuteBerlin(Date.parse('2026-10-05T22:30:00.000Z')), '2026-10-06') + assert.equal(heuteBerlin(Date.parse('2026-10-05T21:59:00.000Z')), '2026-10-05') + assert.match(heuteBerlin(), /^\d{4}-\d{2}-\d{2}$/) +}) + +test('neuesFormular: Vorgaben; ein Projekt füllt Titel und Zeitquelle', () => { + const f = neuesFormular({ projektId: 'p2', konten: KONTEN, heute: '2026-10-06' }) + const { positionen, ...rest } = f + assert.equal(positionen.length, 1) + const { uid, ...zeile } = positionen[0] + assert.equal(typeof uid, 'number') + assert.deepEqual(zeile, { description: '', quantity: '1', price: '' }) + assert.deepEqual(rest, { + projektId: 'p2', titel: 'Shop Relaunch', titelGeaendert: false, kundenart: '', art: 'festpreis', + auftragVom: '2026-10-06', auftragWeg: '', + stundensatz: '100,00', schaetzung: '', + zahlungsart: 'nach_abnahme', prozent: '50', zahlungstext: '', zahlungsziel: '14', + startAm: '', fertigBis: '', zeitquelle: ['t2', 't3'], zeitquelleGeaendert: false, + }) + assert.deepEqual(neuesFormular({ konten: KONTEN, heute: '2026-10-06' }).zeitquelle, []) + assert.equal(neuesFormular({ projektId: 'gibtsnicht', konten: KONTEN, heute: '2026-10-06' }).projektId, '') + assert.equal(neuesFormular({ projektId: 'p1', heute: '2026-10-06' }).projektId, '') + assert.notEqual(neuePosition().uid, neuePosition().uid) +}) + +test('auftragZuFormular: Entwurf vom Server wird zum Formular (Euro- und Stundentexte)', () => { + const entwurf = { + id: 'a1', status: 'entwurf', projektId: 'p1', titel: 'Website', art: 'festpreis', + positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 }], + stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, + zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21, + auftragVom: '2026-10-05', auftragWeg: 'email', startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'], + } + const f = auftragZuFormular(entwurf) + assert.deepEqual(f.positionen.map(({ uid, ...p }) => p), [{ description: 'Startseite', quantity: '2', price: '400,00' }]) + const { positionen, ...rest } = f + assert.deepEqual(rest, { + projektId: 'p1', titel: 'Website', titelGeaendert: true, kundenart: '', art: 'festpreis', + auftragVom: '2026-10-05', auftragWeg: 'email', + stundensatz: '100,00', schaetzung: '', + zahlungsart: 'anzahlung', prozent: '30', zahlungstext: '', zahlungsziel: '21', + startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'], zeitquelleGeaendert: true, + }) + const aufwand = auftragZuFormular({ + ...entwurf, art: 'aufwand', positionen: [], stundensatzCents: 9550, schaetzungMinuten: 750, summeCents: null, + zahlungsplan: { art: 'frei', prozent: null, text: 'Nach Absprache' }, + }) + assert.equal(aufwand.stundensatz, '95,50') + assert.equal(aufwand.schaetzung, '12,5') + assert.equal(aufwand.positionen.length, 1) // eine leere Zeile für den Wechsel auf Festpreis + assert.deepEqual([aufwand.zahlungsart, aufwand.prozent, aufwand.zahlungstext], ['frei', '50', 'Nach Absprache']) +}) + +test('waehleProjekt: Titel und Zeitquelle folgen dem Projekt, bis sie von Hand geändert wurden', () => { + const f0 = neuesFormular({ konten: KONTEN, heute: '2026-10-06' }) + const f1 = waehleProjekt(f0, 'p1', KONTEN) + assert.deepEqual([f1.projektId, f1.titel, f1.zeitquelle], ['p1', 'Website Praxis Müller', ['t1']]) + const f2 = waehleProjekt(f1, 'p2', KONTEN) + assert.deepEqual([f2.titel, f2.zeitquelle], ['Shop Relaunch', ['t2', 't3']]) + const f3 = waehleProjekt(f2, '', KONTEN) + assert.deepEqual([f3.projektId, f3.titel, f3.zeitquelle], ['', '', []]) + assert.equal(waehleProjekt(f1, 'gibtsnicht', KONTEN).projektId, '') + const hand = { ...f1, titel: 'Eigener Titel', titelGeaendert: true, zeitquelle: ['t7'], zeitquelleGeaendert: true } + const f4 = waehleProjekt(hand, 'p2', KONTEN) + assert.deepEqual([f4.projektId, f4.titel, f4.zeitquelle], ['p2', 'Eigener Titel', ['t7']]) + assert.equal(f0.projektId, '') // die Eingabe bleibt unverändert +}) + +test('wechselArt: ein unpassender Zahlungsplan wird zurückgesetzt', () => { + const f = { ...neuesFormular({ heute: '2026-10-06' }), zahlungsart: 'anzahlung' } + assert.equal(wechselArt(f, 'aufwand').art, 'aufwand') + assert.equal(wechselArt(f, 'aufwand').zahlungsart, 'nach_abnahme') + assert.equal(wechselArt(f, 'festpreis').zahlungsart, 'anzahlung') + assert.equal(wechselArt({ ...f, art: 'aufwand', zahlungsart: 'monatlich' }, 'festpreis').zahlungsart, 'nach_abnahme') + assert.equal(wechselArt({ ...f, zahlungsart: 'frei' }, 'aufwand').zahlungsart, 'frei') + assert.equal(wechselArt(f, 'quatsch'), f) +}) + +test('formularSummeCents: Festpreis summiert gültige Positionen, Aufwand hat keine Summe', () => { + const f = festpreisForm({ + positionen: [ + { uid: 1, description: 'a', quantity: '2', price: '100,00' }, + { uid: 2, description: 'b', quantity: '1', price: '50' }, + { uid: 3, description: 'c', quantity: 'x', price: '9,99' }, + ], + }) + assert.equal(formularSummeCents(f), 25000) + assert.equal(formularSummeCents(festpreisForm({ positionen: [] })), 0) + assert.equal(formularSummeCents(aufwandForm()), null) +}) + +test('pruefeSchritt 1: Titel ist Pflicht, die Kundenart nur, wenn sie am Kunden fehlt', () => { + const f = neuesFormular({ heute: '2026-10-06' }) + const r = pruefeSchritt(1, f, { kunde: KUNDE }) + assert.equal(r.ok, false) + assert.deepEqual(r.fehler, { titel: 'Bitte einen Titel eingeben (höchstens 200 Zeichen).', kundenart: 'Bitte die Kundenart wählen.' }) + assert.equal(r.erstes, 'titel') + assert.deepEqual(pruefeSchritt(1, { ...f, titel: ' Website ', kundenart: 'privat' }, { kunde: KUNDE }), OK) + assert.deepEqual(pruefeSchritt(1, { ...f, titel: 'x' }, { kunde: { ...KUNDE, kundenart: 'privat' } }), OK) + assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(201), kundenart: 'privat' }, { kunde: KUNDE }).ok, false) + assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(200), kundenart: 'privat' }, { kunde: KUNDE }).ok, true) + assert.equal(pruefeSchritt(1, { ...f, titel: 'x', kundenart: 'firma' }, { kunde: KUNDE }).fehler.kundenart, 'Bitte die Kundenart wählen.') +}) + +test('pruefeSchritt 2 (Festpreis): Datum, Weg, Positionen und Summe', () => { + const pruefe = (over) => pruefeSchritt(2, festpreisForm(over), { kunde: KUNDE }) + assert.deepEqual(pruefe(), OK) + const r = pruefe({ auftragVom: '', auftragWeg: '' }) + assert.deepEqual(r.fehler, { auftragVom: 'Bitte das Datum der Absprache angeben.', auftragWeg: 'Bitte den Weg der Absprache wählen.' }) + assert.equal(r.erstes, 'auftragVom') + for (const schlecht of ['2026-02-30', '06.10.2026', '2026-1-1', 'morgen']) { + assert.equal(pruefe({ auftragVom: schlecht }).fehler.auftragVom, 'Bitte das Datum der Absprache angeben.', schlecht) + } + assert.equal(pruefe({ auftragWeg: 'brieftaube' }).fehler.auftragWeg, 'Bitte den Weg der Absprache wählen.') + assert.equal(pruefe({ art: 'xyz' }).fehler.art, 'Bitte die Art des Auftrags wählen.') + + const pos = (o = {}) => ({ uid: 1, description: 'x', quantity: '1', price: '10,00', ...o }) + const meldung = (positionen) => pruefe({ positionen }).fehler.positionen + const PREIS = 'Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).' + assert.equal(meldung([pos({ description: ' ' })]), 'Position 1: Bitte eine Beschreibung eingeben.') + assert.equal(meldung([pos(), pos({ description: 'y'.repeat(201) })]), 'Position 2: Die Beschreibung darf höchstens 200 Zeichen lang sein.') + assert.equal(meldung([pos({ quantity: '0' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.') + assert.equal(meldung([pos({ quantity: '1000' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.') + assert.equal(meldung([pos({ price: '' })]), `Position 1: ${PREIS}`) + assert.equal(meldung([pos({ price: '100.000,01' })]), `Position 1: ${PREIS}`) + assert.equal(meldung([]), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.') + assert.equal(meldung(Array.from({ length: 21 }, () => pos())), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.') + assert.equal(meldung(Array.from({ length: 20 }, () => pos())), undefined) + // Summe: genau 999.999,99 € gehen, 1.000.000,00 € nicht + const neun = Array.from({ length: 9 }, () => pos({ price: '100.000,00' })) + assert.equal(meldung([...neun, pos({ price: '99.999,99' })]), undefined) + assert.equal(meldung([...neun, pos({ price: '100.000,00' })]), 'Die Summe ist zu hoch (höchstens 999.999,99 €).') +}) + +test('pruefeSchritt 2 (Aufwand): Stundensatz und Schätzung, Positionen zählen nicht', () => { + const pruefe = (over) => pruefeSchritt(2, aufwandForm(over), { kunde: KUNDE }) + const SATZ = 'Der Stundensatz muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 100,00).' + assert.deepEqual(pruefe(), OK) + assert.equal(pruefe({ schaetzung: '12,5' }).ok, true) + assert.equal(pruefe({ stundensatz: '' }).fehler.stundensatz, SATZ) + assert.equal(pruefe({ stundensatz: '12.50' }).fehler.stundensatz, SATZ) + assert.equal(pruefe({ schaetzung: '10,3' }).fehler.schaetzung, 'Die Schätzung geht in Schritten von 15 Minuten (z. B. 10 oder 12,5 Stunden, höchstens 1666).') + assert.equal(pruefe({ positionen: [{ uid: 1, description: '', quantity: 'x', price: '' }] }).ok, true) +}) + +test('pruefeSchritt 3: Zahlungsplan, Zahlungsziel, Termine, Zeitquelle', () => { + const pruefe = (f) => pruefeSchritt(3, f, { kunde: KUNDE }) + assert.deepEqual(pruefe(festpreisForm()), OK) + assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: '50' })).ok, true) + for (const schlecht of ['', '0', '100', '33,3']) { + assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: schlecht })).fehler.prozent, 'Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.', schlecht) + } + assert.equal(pruefe(festpreisForm({ zahlungsart: 'nach_abnahme', prozent: 'x' })).ok, true) // Prozent zählt nur bei „Anzahlung“ + const TEXT = 'Bitte den Zahlungsplan als Text eingeben (höchstens 500 Zeichen).' + assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' ' })).fehler.zahlungstext, TEXT) + assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(501) })).fehler.zahlungstext, TEXT) + assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(500) })).ok, true) + for (const schlecht of ['', '61', '-1', '1,5']) { + assert.equal(pruefe(festpreisForm({ zahlungsziel: schlecht })).fehler.zahlungsziel, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.', schlecht) + } + assert.equal(pruefe(festpreisForm({ zahlungsziel: '0' })).ok, true) + assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-30' })).ok, true) + assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-01' })).ok, true) + assert.equal(pruefe(festpreisForm({ startAm: '2026-11-02', fertigBis: '2026-11-01' })).fehler.fertigBis, 'Die Fertigstellung liegt vor dem Start.') + assert.equal(pruefe(festpreisForm({ startAm: '2026-13-01' })).fehler.startAm, 'Bitte ein gültiges Datum wählen.') + assert.equal(pruefe(festpreisForm({ fertigBis: 'bald' })).fehler.fertigBis, 'Bitte ein gültiges Datum wählen.') + const ids = (n) => Array.from({ length: n }, (_, i) => `t${i}`) + assert.equal(pruefe(festpreisForm({ zeitquelle: ids(10) })).ok, true) + assert.equal(pruefe(festpreisForm({ zeitquelle: ids(11) })).fehler.zeitquelle, 'Höchstens 10 Tickets als Zeitquelle.') + const PASST = 'Dieser Zahlungsplan passt nicht zur Art des Auftrags.' + assert.equal(pruefe(festpreisForm({ zahlungsart: 'monatlich' })).fehler.zahlungsart, PASST) + assert.equal(pruefe(aufwandForm({ zahlungsart: 'anzahlung' })).fehler.zahlungsart, PASST) +}) + +test('formularZuNutzlast: Festpreis in Cent, getrimmte Texte, Kundenart nur wenn sie am Kunden fehlt', () => { + const f = festpreisForm({ + titel: ' Website Praxis ', projektId: 'p1', + positionen: [ + { uid: 1, description: ' Startseite ', quantity: '2', price: '400,00' }, + { uid: 2, description: 'Kontaktformular', quantity: '1', price: '1.234,50' }, + ], + zahlungsart: 'anzahlung', prozent: '30', zahlungsziel: '21', + startAm: '2026-11-01', fertigBis: '2026-11-30', zeitquelle: ['t1', 't2'], + }) + const r = formularZuNutzlast(f, { kunde: KUNDE }) + assert.equal(r.ok, true) + assert.equal(r.summeCents, 203450) + assert.deepEqual(r.payload, { + projektId: 'p1', titel: 'Website Praxis', kundenart: 'privat', art: 'festpreis', + positionen: [ + { beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 }, + { beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 123450 }, + ], + stundensatzCents: null, schaetzungMinuten: null, + zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21, + auftragVom: '2026-10-06', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '2026-11-30', + zeitquelle: ['t1', 't2'], + }) + assert.equal('kundenart' in formularZuNutzlast(f, { kunde: { ...KUNDE, kundenart: 'unternehmen' } }).payload, false) + const frei = formularZuNutzlast(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' Zahlung in zwei Raten ' }), { kunde: KUNDE }) + assert.deepEqual(frei.payload.zahlungsplan, { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten' }) + assert.equal(frei.payload.projektId, '') + assert.deepEqual([frei.payload.startAm, frei.payload.fertigBis], ['', '']) +}) + +test('formularZuNutzlast: Aufwand mit Satz in Cent und Schätzung in Minuten, ohne Positionen und Summe', () => { + const r = formularZuNutzlast(aufwandForm({ stundensatz: '95,50', schaetzung: '12,5', zahlungsart: 'monatlich' }), { kunde: { ...KUNDE, kundenart: 'privat' } }) + assert.equal(r.ok, true) + assert.equal(r.summeCents, null) + assert.deepEqual(r.payload.positionen, []) + assert.equal(r.payload.art, 'aufwand') + assert.equal(r.payload.stundensatzCents, 9550) + assert.equal(r.payload.schaetzungMinuten, 750) + assert.deepEqual(r.payload.zahlungsplan, { art: 'monatlich', prozent: null, text: '' }) + assert.equal(formularZuNutzlast(aufwandForm({ schaetzung: '' }), { kunde: KUNDE }).payload.schaetzungMinuten, null) +}) + +test('formularZuNutzlast: Fehler nennen den Schritt und das erste Feld', () => { + const r1 = formularZuNutzlast(festpreisForm({ titel: '', zahlungsziel: '99' }), { kunde: KUNDE }) + assert.deepEqual([r1.ok, r1.schritt, r1.erstes], [false, 1, 'titel']) + assert.equal(r1.fehler.titel, 'Bitte einen Titel eingeben (höchstens 200 Zeichen).') + const r2 = formularZuNutzlast(festpreisForm({ auftragWeg: '' }), { kunde: KUNDE }) + assert.deepEqual([r2.ok, r2.schritt, r2.erstes], [false, 2, 'auftragWeg']) + const r3 = formularZuNutzlast(festpreisForm({ zahlungsziel: '99' }), { kunde: KUNDE }) + assert.deepEqual([r3.ok, r3.schritt, r3.erstes], [false, 3, 'zahlungsziel']) +}) + +test('zahlungsplanVorschau: Wortlaut sinngemäß wie in der Auftragsbestätigung', () => { + const v = (o) => zahlungsplanVorschau({ art: 'nach_abnahme', prozent: null, text: '', summeCents: 80000, zielTage: 14, ...o }) + assert.equal(v({}), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.') + assert.equal(v({ summeCents: null }), 'Alles nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.') + assert.equal(v({ art: 'anzahlung', prozent: 50 }), 'Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.') + // Rundung wie im Vertrag V3: Anzahlung = Math.round(Summe × Prozent / 100), Rest = Summe − Anzahlung + assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: 33333 }), 'Anzahlung 50 % (166,67 €) nach Auftragsbestätigung, Rest (166,66 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.') + assert.equal(v({ art: 'anzahlung', prozent: 33, summeCents: 33333 }), 'Anzahlung 33 % (110,00 €) nach Auftragsbestätigung, Rest (223,33 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.') + assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: null }), 'Anzahlung 50 % nach Auftragsbestätigung, Rest nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.') + assert.equal(v({ art: 'anzahlung', prozent: null }), '') + assert.equal(v({ art: 'monatlich', summeCents: null }), 'Monatlich nach Aufwand; zahlbar innerhalb von 14 Tagen nach Rechnung.') + assert.equal(v({ art: 'frei', text: ' Zwei Raten, je zur Hälfte ' }), 'Zwei Raten, je zur Hälfte (zahlbar innerhalb von 14 Tagen nach Rechnung).') + assert.equal(v({ art: 'frei', text: '' }), '') + assert.equal(v({ zielTage: 0 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar sofort nach Rechnung.') + assert.equal(v({ zielTage: 1 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 1 Tag nach Rechnung.') + assert.equal(v({ zielTage: null }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.') + assert.equal(v({ art: 'unbekannt' }), '') +}) + +test('sendeGate: Lead, E-Mail, Anschrift und Kundenart halten das Senden auf, der Entwurf bleibt möglich', () => { + assert.deepEqual(sendeGate(KUNDE, 'privat'), { erlaubt: true, grund: '', zurUebersicht: false }) + assert.deepEqual(sendeGate({ ...KUNDE, kundenart: 'privat' }, ''), { erlaubt: true, grund: '', zurUebersicht: false }) + assert.deepEqual(sendeGate(KUNDE, ''), { erlaubt: false, grund: 'Die Kundenart fehlt. Bitte im ersten Schritt wählen.', zurUebersicht: false }) + assert.deepEqual(sendeGate({ ...KUNDE, street: '' }, 'privat'), { + erlaubt: false, grund: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true, + }) + assert.deepEqual(sendeGate({ ...KUNDE, email: ' ' }, 'privat'), { erlaubt: false, grund: 'E-Mail fehlt', zurUebersicht: true }) + assert.deepEqual(sendeGate({ ...KUNDE, customerStatus: 'lead' }, 'privat'), { erlaubt: false, grund: 'Noch kein fester Kunde', zurUebersicht: true }) + assert.deepEqual(sendeGate(null, 'privat'), { erlaubt: false, grund: 'Kein Kunde ausgewählt', zurUebersicht: false }) +}) + +const TICKETS = [ + { $id: 't9', woid: '100149', topic: 'Neueste Anfrage' }, + { $id: 't2', woid: '100140', topic: 'Shop Relaunch' }, + { $id: 't1', woid: '100131', topic: 'Website Praxis' }, + { $id: 'xyz12345', woid: '', title: 'Ohne Nummer' }, +] + +test('zeitquelleOptionen: Projekt-Tickets zuerst, dann gewählte, dann der Rest; unbekannte Auswahl bleibt sichtbar', () => { + const o = zeitquelleOptionen(TICKETS, ['t9', 'alt77'], ['t1']) + assert.deepEqual(o.map((x) => x.id), ['t1', 't9', 'alt77', 't2', 'xyz12345']) + assert.deepEqual(o.map((x) => x.gewaehlt), [false, true, true, false, false]) + assert.deepEqual(o.map((x) => x.projekt), [true, false, false, false, false]) + assert.equal(o[0].label, '#100131 Website Praxis') + assert.equal(o[1].label, '#100149 Neueste Anfrage') + assert.equal(o[2].label, 'Ticket alt77') + assert.equal(o[4].label, '#12345 Ohne Nummer') + assert.deepEqual(zeitquelleOptionen(null, [], []), []) +}) + +test('schalteZeitquelle: höchstens 10, keine Doppelten, Reihenfolge der Auswahl', () => { + assert.deepEqual(schalteZeitquelle(['a'], 'b', true), ['a', 'b']) + assert.deepEqual(schalteZeitquelle(['a', 'b'], 'a', false), ['b']) + assert.deepEqual(schalteZeitquelle(['a'], 'a', true), ['a']) + assert.deepEqual(schalteZeitquelle(undefined, 'a', true), ['a']) + const zehn = Array.from({ length: 10 }, (_, i) => `t${i}`) + assert.deepEqual(schalteZeitquelle(zehn, 'neu', true), zehn) + assert.deepEqual(schalteZeitquelle(zehn, 't3', false), zehn.filter((id) => id !== 't3')) +}) + +test('kundenartWirksam: die Kundenart am Kunden gilt, sonst die im Formular gewählte', () => { + assert.equal(kundenartWirksam({ kundenart: 'unternehmen' }, 'privat'), 'unternehmen') + assert.equal(kundenartWirksam({ kundenart: '' }, 'privat'), 'privat') + assert.equal(kundenartWirksam({}, 'unternehmen'), 'unternehmen') + assert.equal(kundenartWirksam(null, 'privat'), 'privat') + assert.equal(kundenartWirksam({ kundenart: 'privat' }, ''), 'privat') + assert.equal(kundenartWirksam({ kundenart: 'firma' }, ''), '') + assert.equal(kundenartWirksam({ kundenart: '' }, 'firma'), '') + assert.equal(kundenartWirksam(null, undefined), '') +}) + +test('vorOrtHinweis: nur bei „Vor Ort“ und Privatperson (§ 312f Abs. 1 BGB), nie als Sperre', () => { + const TEXT = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.' + assert.equal(VOR_ORT_HINWEIS, TEXT) + assert.equal(vorOrtHinweis('vor_ort', 'privat'), TEXT) + for (const [weg, art] of [['vor_ort', 'unternehmen'], ['vor_ort', ''], ['telefon', 'privat'], ['muendlich', 'privat'], ['email', 'privat'], ['chat', 'privat'], ['', 'privat'], [undefined, 'privat']]) { + assert.equal(vorOrtHinweis(weg, art), '', `${weg}/${art}`) + } +}) + +test('auftragHinweise: fehlende Steuernummer, dann „vor Ort“ bei Privatperson; sonst nichts', () => { + const STN = 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.' + const VOR = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.' + const hinweise = (o) => auftragHinweise({ kunde: KUNDE, form: festpreisForm(), einstellungen: { steuernummer: '12/345/67890' }, ...o }) + assert.deepEqual(hinweise(), []) // festpreisForm: Privatperson, Weg „telefon“, Steuernummer vorhanden + assert.deepEqual(hinweise({ einstellungen: { steuernummer: '' } }), [{ key: 'steuernummer', text: STN }]) + assert.deepEqual(hinweise({ einstellungen: {} }).map((h) => h.key), ['steuernummer']) + // Einstellungen noch nicht geladen (undefined) oder nicht ladbar (null): keine Behauptung + assert.deepEqual(hinweise({ einstellungen: undefined }), []) + assert.deepEqual(hinweise({ einstellungen: null }), []) + assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort' }) }), [{ key: 'vorOrt', text: VOR }]) + assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: 'unternehmen' }) }), []) + assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }), []) + // die Kundenart des Kunden hat Vorrang vor dem Formular + const privatAmKunden = { kunde: { ...KUNDE, kundenart: 'privat' }, form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) } + assert.deepEqual(hinweise(privatAmKunden).map((h) => h.key), ['vorOrt']) + assert.deepEqual(hinweise({ ...privatAmKunden, einstellungen: { steuernummer: '' } }).map((h) => h.key), ['steuernummer', 'vorOrt']) +}) From cc950180834ef3395b77ba39e653c19d1b19688a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:31:35 +0000 Subject: [PATCH 11/33] =?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 12/33] =?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 13/33] =?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 8b055a5d42d274c3e7319382e00219d7bd313f0e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:33:28 +0000 Subject: [PATCH 14/33] Auftragsbestaetigung: Listenlogik (Status, Aktionen, Meldungen, Fehlerklassen, Sperre) und PDF-Hilfe mit Tests Co-Authored-By: Claude Sonnet 5.5 --- src/lib/auftragPdf.js | 23 +++ src/lib/auftragView.js | 231 ++++++++++++++++++++++++++ tests/unit/auftragPdf.test.mjs | 51 ++++++ tests/unit/auftragView.test.mjs | 280 ++++++++++++++++++++++++++++++++ 4 files changed, 585 insertions(+) create mode 100644 src/lib/auftragPdf.js create mode 100644 tests/unit/auftragPdf.test.mjs diff --git a/src/lib/auftragPdf.js b/src/lib/auftragPdf.js new file mode 100644 index 0000000..3a3f1f4 --- /dev/null +++ b/src/lib/auftragPdf.js @@ -0,0 +1,23 @@ +// PDF der Auftragsbestaetigung in einem neuen Tab oeffnen. Rein (ohne React), damit unter Node testbar. Der Tab geht +// synchron im Klick auf (sonst blockiert der Browser das Pop-up), das PDF kommt erst nach der Anfrage ans Portal. +import { openInNewTab } from './openInNewTab' + +/** Objekt-URLs bleiben fuenf Minuten gueltig, dann werden sie freigegeben. */ +export const PDF_FREIGABE_NACH_MS = 300000 + +/** Blob mit dem Typ application/pdf (damit der Browser es anzeigt); ein passender Blob bleibt unveraendert. */ +export function alsPdfBlob(blob) { + return blob.type === 'application/pdf' ? blob : new Blob([blob], { type: 'application/pdf' }) +} + +/** holeBlob: () => Promise. Fehler und ein blockiertes Pop-up behandelt openInNewTab (Tab zu, Fehler weitergereicht). */ +export async function oeffnePdfImTab(holeBlob, { win = globalThis.window, urlApi = globalThis.URL, timer = globalThis.setTimeout } = {}) { + await openInNewTab( + async () => { + const url = urlApi.createObjectURL(alsPdfBlob(await holeBlob())) + timer(() => urlApi.revokeObjectURL(url), PDF_FREIGABE_NACH_MS) + return url + }, + { win } + ) +} diff --git a/src/lib/auftragView.js b/src/lib/auftragView.js index 6944401..834d516 100644 --- a/src/lib/auftragView.js +++ b/src/lib/auftragView.js @@ -417,3 +417,234 @@ export function auftragHinweise({ kunde, form, einstellungen } = {}) { if (vorOrt) liste.push({ key: 'vorOrt', text: vorOrt }) return liste } + +// --- Liste: Status, Aktionen, Zusatzzeilen ----------------------------------------------------------------------------- + +/** Status der AB (Vertrag V2) mit Beschriftung und Ton des Abzeichens. */ +export const AUFTRAG_STATUS = { + entwurf: { label: 'Entwurf', tone: 'muted' }, + versendet: { label: 'Versendet', tone: 'info' }, + bestaetigt: { label: 'Bestätigt', tone: 'ok' }, + abgerechnet: { label: 'Abgerechnet', tone: 'violet' }, + storniert: { label: 'Storniert', tone: 'plain' }, + ersetzt: { label: 'Ersetzt', tone: 'plain' }, + widerrufen: { label: 'Widerrufen', tone: 'danger' }, // rot: die Erstattung folgt von Hand +} + +/** Status, in denen „Projekt zuordnen“ gilt (der Server lehnt die uebrigen mit 409 ab). */ +export const PROJEKT_ZUORDNEN_STATUS = ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet'] + +export function istEntwurf(auftrag) { + return auftrag?.status === 'entwurf' +} + +/** Projekte zur Auswahl aus den Projektkonten: [{ value: projektId, label: Name }]. */ +export function zuordnenOptionen(konten) { + if (!Array.isArray(konten)) return [] + return konten + .filter((k) => istObjekt(k) && typeof k.projektId === 'string' && k.projektId !== '') + .map((k) => ({ value: k.projektId, label: trimmed(k.name) || k.projektId })) +} + +/** + * Gibt es „Projekt zuordnen“? Nur fuer eine AB ohne Projekt in einem der Status aus PROJEKT_ZUORDNEN_STATUS und nur, wenn + * es mindestens ein Projekt zur Auswahl gibt. Hat die AB schon eine Kennung (auch eine unbekannte), lehnt der Server mit 409 ab. + */ +export function projektZuordnenMoeglich(auftrag, konten) { + if (!istObjekt(auftrag)) return false + if (!PROJEKT_ZUORDNEN_STATUS.includes(auftrag.status)) return false + if (typeof auftrag.projektId === 'string' ? auftrag.projektId !== '' : Boolean(auftrag.projektId)) return false + return zuordnenOptionen(konten).length > 0 +} + +/** + * Aktionen einer Zeile [{ key, label, danger? }]. Bearbeiten, Senden und Loeschen gibt es nur beim Entwurf; danach nur + * „Neue Fassung“. „Projekt zuordnen“ steht unmittelbar vor der ersten gefaehrlichen Aktion (sonst am Ende). + */ +export function auftragAktionen(auftrag, { konten } = {}) { + if (!istObjekt(auftrag)) return [] + const status = auftrag.status + const pdf = (label) => ({ key: 'pdf', label }) + let liste + if (status === 'entwurf') { + liste = [{ key: 'bearbeiten', label: 'Bearbeiten' }, { key: 'senden', label: 'Senden' }, pdf('Vorschau'), { key: 'loeschen', label: 'Löschen', danger: true }] + } else { + liste = [] + if (auftrag.pdfVorhanden === true) liste.push(pdf('PDF')) + const erinnern = { key: 'erinnern', label: auftrag.mailStatus === 'fehler' ? 'Erneut senden' : 'Erinnerung senden' } + if (status === 'versendet') liste.push(erinnern, { key: 'neueFassung', label: 'Neue Fassung' }, { key: 'stornieren', label: 'Stornieren', danger: true }) + else if (status === 'bestaetigt') liste.push({ key: 'neueFassung', label: 'Neue Fassung' }, { key: 'stornieren', label: 'Stornieren', danger: true }) + else if (status === 'abgerechnet') liste.push({ key: 'neueFassung', label: 'Neue Fassung' }) + } + if (projektZuordnenMoeglich(auftrag, konten)) { + const aktion = { key: 'projekt', label: 'Projekt zuordnen' } + const gefahr = liste.findIndex((a) => a.danger) + if (gefahr === -1) liste.push(aktion) + else liste.splice(gefahr, 0, aktion) + } + return liste +} + +/** Zusatzzeilen unter dem Status [{ key, tone, text }]; `alle` sind alle ABs der Liste (fuer Nummern der Fassungen). */ +export function auftragNotizen(auftrag, alle = []) { + if (!istObjekt(auftrag)) return [] + const a = auftrag + const status = a.status + const liste = [] + const nummerVon = (id) => { + const treffer = objekte(alle).find((x) => x.id === id) + return treffer && trimmed(treffer.nummer) ? treffer.nummer : '' + } + if (status === 'versendet' && a.mailStatus === 'fehler') liste.push({ key: 'mail', tone: 'warn', text: 'Mail nicht zugestellt' }) + if (a.bestaetigtAm && (status === 'bestaetigt' || status === 'abgerechnet')) { + liste.push({ key: 'bestaetigt', tone: 'muted', text: `Bestätigt am ${dateDe(a.bestaetigtAm)}` }) + } + if (a.fruehbeginn === true && status !== 'entwurf') liste.push({ key: 'fruehbeginn', tone: 'muted', text: 'Frühbeginn verlangt' }) + if (a.fruehesterBeginn && ['versendet', 'bestaetigt', 'abgerechnet'].includes(status)) { + liste.push({ key: 'beginn', tone: 'muted', text: `Arbeitsbeginn frühestens am ${dateDe(a.fruehesterBeginn)}` }) + } + if (a.ersetztId) { + const nr = nummerVon(a.ersetztId) + liste.push({ key: 'fassung', tone: 'muted', text: nr ? `Neue Fassung zu ${nr}` : 'Neue Fassung' }) + } + if (a.ersetztDurchId) { + const nr = nummerVon(a.ersetztDurchId) + liste.push({ key: 'ersetzt', tone: 'muted', text: nr ? `Ersetzt durch ${nr}` : 'Ersetzt durch eine neue Fassung' }) + } + if (status === 'widerrufen' && a.widerrufenAm) liste.push({ key: 'widerruf', tone: 'muted', text: `Widerrufen am ${dateDe(a.widerrufenAm)}` }) + return liste +} + +/** Zeilen der Tabelle in der Reihenfolge des Servers (neueste zuerst). */ +export function auftragZeilen(auftraege, { konten = [] } = {}) { + const liste = objekte(auftraege) + return liste.map((a) => { + const konto = objekte(konten).find((k) => k.projektId && k.projektId === a.projektId) + const projektName = konto ? trimmed(konto.name) : '' + const aufwand = a.art === 'aufwand' + let summeSub = '' + if (aufwand && typeof a.stundensatzCents === 'number') { + summeSub = `${euro(a.stundensatzCents)} pro Std.` + if (typeof a.schaetzungMinuten === 'number') summeSub += `, Schätzung etwa ${stundenText(a.schaetzungMinuten)} Std.` + } + const status = AUFTRAG_STATUS[a.status] || { label: text(a.status), tone: 'muted' } + return { + key: a.id, + id: a.id, + auftrag: a, + nummerText: trimmed(a.nummer) || 'Entwurf', + istEntwurf: istEntwurf(a), + titel: text(a.titel), + projektText: projektName && projektName !== trimmed(a.titel) ? projektName : '', + datumText: dateDe(a.gesendetAm || a.erstelltAm), + summeText: aufwand ? 'nach Aufwand' : euro(a.summeCents), + summeSub, + statusLabel: status.label, + statusTone: status.tone, + notizen: auftragNotizen(a, liste), + aktionen: auftragAktionen(a, { konten }), + } + }) +} + +// --- Rueckfragen, Meldungen, Fehler ------------------------------------------------------------------------------------ + +/** Rueckfrage vor einer Aktion; '' = keine Rueckfrage. */ +export function auftragBestaetigung(key, auftrag, { email, projektName } = {}) { + const a = istObjekt(auftrag) ? auftrag : {} + const titel = `„${trimmed(a.titel) || 'ohne Titel'}“` + const nummer = trimmed(a.nummer) + const ab = `Die Auftragsbestätigung${nummer ? ` ${nummer}` : ''}` + if (key === 'senden') { + return `Auftragsbestätigung ${titel} jetzt festschreiben und an ${trimmed(email) || 'den Kunden'} senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.` + } + if (key === 'loeschen') return `Den Entwurf ${titel} wirklich löschen? Das lässt sich nicht rückgängig machen.` + if (key === 'stornieren') { + return `${ab} ${titel} wirklich stornieren? Der Kunde bekommt keine Mail, der Bestätigungslink wird ungültig. Das lässt sich nicht rückgängig machen.` + } + if (key === 'erinnern') return `${ab} jetzt noch einmal an den Kunden senden? Der bisherige Bestätigungslink gilt danach nicht mehr.` + if (key === 'projekt') { + if (istEntwurf(a)) return '' // ein Entwurf bleibt ueber „Bearbeiten“ aenderbar + const ziel = trimmed(projektName) ? `dem Projekt „${trimmed(projektName)}“` : 'einem Projekt' + return `${ab} ${titel} ${ziel} zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern.` + } + return '' +} + +/** Meldung nach einer Aktion { tone, text }; „senden“ im selben Wortlaut wie der Container (auftragMeldung in W0). */ +export function auftragErfolg(key, auftrag, { mailFehler = false, projektName = '' } = {}) { + const a = istObjekt(auftrag) ? auftrag : {} + const nummer = trimmed(a.nummer) + const name = nummer ? `Auftragsbestätigung ${nummer}` : 'Die Auftragsbestätigung' + const an = trimmed(a.gesendetAn) + if (key === 'senden') { + if (mailFehler) return { tone: 'warn', text: `${name} ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.` } + return { tone: 'ok', text: an ? `${name} wurde an ${an} gesendet.` : `${name} wurde gesendet.` } + } + if (key === 'erinnern') { + const wen = nummer || 'der Auftragsbestätigung' + if (mailFehler) return { tone: 'warn', text: `Die Erinnerung zu ${wen} konnte nicht zugestellt werden. „Erneut senden“ versucht es noch einmal.` } + return { tone: 'ok', text: `Die Erinnerung zu ${wen} wurde an ${an || 'den Kunden'} gesendet.` } + } + if (key === 'stornieren') return { tone: 'ok', text: `${name} wurde storniert.` } + if (key === 'loeschen') return { tone: 'ok', text: 'Der Entwurf wurde gelöscht.' } + if (key === 'neueFassung') return { tone: 'ok', text: 'Neue Fassung als Entwurf angelegt.' } + if (key === 'projekt') { + const ziel = trimmed(projektName) ? `dem Projekt „${trimmed(projektName)}“` : 'einem Projekt' + return { tone: 'ok', text: `${name} wurde ${ziel} zugeordnet.` } + } + return { tone: 'ok', text: '' } +} + +const KUNDENDATEN_FEHLER = { + kundenart: 'Die Kundenart des Kunden fehlt. Bitte in der Übersicht des Kunden festlegen (Feld „Kundenart“) oder über „Bearbeiten“ und dann erneut senden.', + email: 'Dem Kunden fehlt die E-Mail-Adresse. Bitte in der Übersicht eintragen und dann erneut senden.', + kunde: 'Das ist noch kein fester Kunde. Bitte in der Übersicht zum festen Kunden machen und dann erneut senden.', +} + +/** + * Fehler des Servers -> { art, text, zurUebersicht }. Fehler in den Kundendaten fuehren zur Uebersicht, die uebrigen zeigen + * die Meldung des Servers (Texte: Plan L3 „Meldungstexte“, Plan L4 „Entwurf unvollstaendig“). + */ +export function auftragFehler(err) { + const status = err?.status + const meldung = trimmed(err?.message) + const ergebnis = (art, t, zurUebersicht = false) => ({ art, text: t, zurUebersicht }) + if (status === 409 && /^Entwurf unvollständig/i.test(meldung)) return ergebnis('entwurf', meldung) + if (status === 409 && /adresse/i.test(meldung)) return ergebnis('adresse', ADDRESS_INCOMPLETE_TEXT, true) + if (status === 400 && /kundenart/i.test(meldung)) return ergebnis('kundenart', KUNDENDATEN_FEHLER.kundenart, true) + if (status === 400 && /e-?mail/i.test(meldung)) return ergebnis('email', KUNDENDATEN_FEHLER.email, true) + if (status === 400 && /feste[rn]? kunde|interessent|\blead\b/i.test(meldung)) return ergebnis('kunde', KUNDENDATEN_FEHLER.kunde, true) + if (status === 409) return ergebnis('stand', `${meldung} Die Liste zeigt den aktuellen Stand.`.trim()) + if (status === 400) return ergebnis('eingabe', meldung || 'Die Eingabe ist ungültig.') + if (status === 500 && /pdf/i.test(meldung)) { + return ergebnis('pdf', `${meldung} Die Auftragsbestätigung bleibt ein Entwurf, es wurde keine Nummer vergeben.`) + } + if (typeof status === 'number' && status >= 500) { + return ergebnis('server', `${meldung || 'Interner Fehler.'} Bitte in der Liste prüfen, ob die Auftragsbestätigung gesendet wurde, und sonst erneut versuchen.`) + } + if (status === 0) return ergebnis('netz', meldung || 'Das Kundenportal ist gerade nicht erreichbar.') + return ergebnis('sonst', meldung || 'Die Aktion ist fehlgeschlagen.') +} + +/** + * Sperre gegen Doppelklick: `run(fn)` fuehrt fn nur aus, wenn gerade nichts laeuft (`belegt` gilt sofort beim Aufruf); + * sonst { uebersprungen: true }. Ein Fehler von fn gibt die Sperre frei und wird weitergereicht. + */ +export function neueSperre() { + let belegt = false + return { + get belegt() { + return belegt + }, + async run(fn) { + if (belegt) return { uebersprungen: true } + belegt = true + try { + return { uebersprungen: false, wert: await fn() } + } finally { + belegt = false + } + }, + } +} diff --git a/tests/unit/auftragPdf.test.mjs b/tests/unit/auftragPdf.test.mjs new file mode 100644 index 0000000..31f8202 --- /dev/null +++ b/tests/unit/auftragPdf.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { PDF_FREIGABE_NACH_MS, alsPdfBlob, oeffnePdfImTab } = await loadModule('src/lib/auftragPdf.js') + +// Fester Ablauf: Tab zuerst (der Browser blockiert sonst das Pop-up), dann Blob holen, Objekt-URL erzeugen, später freigeben +function umgebung() { + const ablauf = [] + const tab = { location: {}, opener: 'woms', close: () => ablauf.push('schliessen') } + const win = { open: (...args) => { ablauf.push('oeffnen'); win.args = args; return tab } } + const urlApi = { + createObjectURL: () => { ablauf.push('url'); return 'blob:https://woms.test/abc' }, + revokeObjectURL: (url) => ablauf.push(`frei:${url}`), + } + const timer = (fn, ms) => { ablauf.push(`timer:${ms}`); timer.fn = fn } + return { ablauf, tab, win, urlApi, timer } +} + +test('alsPdfBlob: setzt den Typ application/pdf, ein passender Blob bleibt unverändert', async () => { + const roh = new Blob(['%PDF-1.4'], { type: 'application/octet-stream' }) + const pdf = alsPdfBlob(roh) + assert.equal(pdf.type, 'application/pdf') + assert.equal(await pdf.text(), '%PDF-1.4') + const schon = new Blob(['x'], { type: 'application/pdf' }) + assert.equal(alsPdfBlob(schon), schon) +}) + +test('oeffnePdfImTab: Tab zuerst, dann Blob, Objekt-URL im Tab, später freigeben', async () => { + const u = umgebung() + await oeffnePdfImTab(async () => { u.ablauf.push('holen'); return new Blob(['%PDF'], { type: 'application/pdf' }) }, u) + assert.equal(PDF_FREIGABE_NACH_MS, 300000) + assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'url', 'timer:300000']) + assert.equal(u.tab.location.href, 'blob:https://woms.test/abc') + assert.equal(u.tab.opener, null) + u.timer.fn() + assert.equal(u.ablauf.at(-1), 'frei:blob:https://woms.test/abc') +}) + +test('oeffnePdfImTab: Fehler beim Holen schließt den Tab, es entsteht keine URL', async () => { + const u = umgebung() + await assert.rejects(() => oeffnePdfImTab(async () => { u.ablauf.push('holen'); throw new Error('PDF nicht gefunden.') }, u), /PDF nicht gefunden/) + assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'schliessen']) +}) + +test('oeffnePdfImTab: blockiertes Pop-up, der Blob wird gar nicht geholt', async () => { + let geholt = false + const win = { open: () => null } + await assert.rejects(() => oeffnePdfImTab(async () => { geholt = true; return new Blob() }, { win }), { message: 'Neuer Tab wurde vom Browser blockiert.' }) + assert.equal(geholt, false) +}) diff --git a/tests/unit/auftragView.test.mjs b/tests/unit/auftragView.test.mjs index 71df20a..1845a09 100644 --- a/tests/unit/auftragView.test.mjs +++ b/tests/unit/auftragView.test.mjs @@ -8,6 +8,8 @@ const { neuePosition, neuesFormular, auftragZuFormular, waehleProjekt, wechselArt, formularSummeCents, pruefeSchritt, formularZuNutzlast, zahlungsplanVorschau, sendeGate, zeitquelleOptionen, schalteZeitquelle, kundenartWirksam, VOR_ORT_HINWEIS, vorOrtHinweis, auftragHinweise, + AUFTRAG_STATUS, PROJEKT_ZUORDNEN_STATUS, istEntwurf, zuordnenOptionen, projektZuordnenMoeglich, auftragAktionen, + auftragNotizen, auftragZeilen, auftragBestaetigung, auftragErfolg, auftragFehler, neueSperre, } = await loadModule('src/lib/auftragView.js') const KONTEN = [ @@ -398,3 +400,281 @@ test('auftragHinweise: fehlende Steuernummer, dann „vor Ort“ bei Privatperso assert.deepEqual(hinweise(privatAmKunden).map((h) => h.key), ['vorOrt']) assert.deepEqual(hinweise({ ...privatAmKunden, einstellungen: { steuernummer: '' } }).map((h) => h.key), ['steuernummer', 'vorOrt']) }) + +// --- Liste ------------------------------------------------------------------------------------------------------------- + +const A = (o = {}) => ({ + id: 'a1', nummer: 'AB-2026-0002', status: 'versendet', kundenId: 'c1', projektId: 'p1', titel: 'Website Praxis Müller', + art: 'festpreis', stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, + gesendetAm: '2026-10-06T08:15:00.000+00:00', gesendetAn: 'info@praxis.test', mailStatus: 'gesendet', mailFehler: '', + bestaetigtAm: null, bestaetigtUeber: '', fruehbeginn: false, ersetztId: '', ersetztDurchId: '', widerrufenAm: null, + erstelltAm: '2026-10-05T16:00:00.000+00:00', geaendertAm: '2026-10-06T08:15:00.000+00:00', + pdfVorhanden: true, fruehesterBeginn: null, zahlungsplanText: '', aufwandText: null, ...o, +}) + +test('Status: Beschriftung und Ton für alle sieben Status (Vertrag V2)', () => { + assert.deepEqual(Object.entries(AUFTRAG_STATUS).map(([k, v]) => [k, v.label]), [ + ['entwurf', 'Entwurf'], ['versendet', 'Versendet'], ['bestaetigt', 'Bestätigt'], ['abgerechnet', 'Abgerechnet'], + ['storniert', 'Storniert'], ['ersetzt', 'Ersetzt'], ['widerrufen', 'Widerrufen'], + ]) + assert.deepEqual(Object.values(AUFTRAG_STATUS).map((v) => v.tone), ['muted', 'info', 'ok', 'violet', 'plain', 'plain', 'danger']) + // „Projekt zuordnen“ gilt für diese Status (Übersicht, Nachträge; V5 `POST …/projekt` lehnt die übrigen mit 409 ab) + assert.deepEqual(PROJEKT_ZUORDNEN_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet']) +}) + +test('auftragZeilen: Nummer, Titel, Datum, Summe und Status', () => { + const [z] = auftragZeilen([A()]) + assert.deepEqual([z.id, z.nummerText, z.titel, z.datumText], ['a1', 'AB-2026-0002', 'Website Praxis Müller', '06.10.2026']) + assert.deepEqual([z.summeText, z.summeSub, z.istEntwurf], ['800,00 €', '', false]) + assert.deepEqual([z.statusLabel, z.statusTone], ['Versendet', 'info']) + assert.equal(z.auftrag.id, 'a1') +}) + +test('auftragZeilen: Entwurf ohne Nummer, nach Aufwand, Projektname nur wenn er vom Titel abweicht', () => { + const konten = [{ projektId: 'p1', name: 'Praxis Müller – Relaunch' }] + const entwurf = A({ + id: 'a2', nummer: '', status: 'entwurf', gesendetAm: null, pdfVorhanden: false, art: 'aufwand', summeCents: null, + stundensatzCents: 10000, schaetzungMinuten: 600, erstelltAm: '2026-10-05T22:30:00.000+00:00', + }) + const [z] = auftragZeilen([entwurf], { konten }) + assert.deepEqual([z.nummerText, z.istEntwurf, z.datumText], ['Entwurf', true, '06.10.2026']) + assert.deepEqual([z.summeText, z.summeSub], ['nach Aufwand', '100,00 € pro Std., Schätzung etwa 10 Std.']) + assert.equal(z.projektText, 'Praxis Müller – Relaunch') + assert.equal(auftragZeilen([A()], { konten: [{ projektId: 'p1', name: 'Website Praxis Müller' }] })[0].projektText, '') + assert.equal(auftragZeilen([A({ projektId: '' })], { konten })[0].projektText, '') + assert.equal(auftragZeilen([A({ projektId: 'p9' })], { konten })[0].projektText, '') + assert.equal(auftragZeilen([A()])[0].projektText, '') + assert.equal(auftragZeilen([A({ art: 'aufwand', summeCents: null, stundensatzCents: 9550, schaetzungMinuten: null })])[0].summeSub, '95,50 € pro Std.') + assert.equal(auftragZeilen([A({ summeCents: null })])[0].summeText, '–') +}) + +test('auftragZeilen: Notizen und Aktionen hängen an der Zeile, die Reihenfolge des Servers bleibt', () => { + const zeilen = auftragZeilen([A({ id: 'x2', mailStatus: 'fehler' }), A({ id: 'x1', status: 'entwurf', nummer: '' })]) + assert.deepEqual(zeilen.map((z) => z.id), ['x2', 'x1']) + assert.deepEqual(zeilen[0].notizen.map((n) => n.text), ['Mail nicht zugestellt']) + assert.equal(zeilen[0].aktionen[1].label, 'Erneut senden') + assert.equal(zeilen[1].istEntwurf, true) + assert.deepEqual(auftragZeilen(null), []) + assert.deepEqual(auftragZeilen([null, 'x', A()]).map((z) => z.id), ['a1']) +}) + +test('Zusatzzeilen: Mail, bestätigt am, Frühbeginn, Arbeitsbeginn, Fassungen, Widerruf', () => { + const texte = (o, alle) => auftragNotizen(A(o), alle).map((n) => n.text) + const BESTAETIGT = '2026-10-07T09:00:00.000+00:00' + assert.deepEqual(texte({}), []) + assert.deepEqual(texte({ mailStatus: 'fehler', mailFehler: 'SMTP' }), ['Mail nicht zugestellt']) + assert.deepEqual(auftragNotizen(A({ mailStatus: 'fehler' })).map((n) => n.tone), ['warn']) + // nach der Bestätigung zählt ein früherer Mailfehler nicht mehr + assert.deepEqual(texte({ status: 'bestaetigt', mailStatus: 'fehler', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026']) + assert.deepEqual(texte({ fruehesterBeginn: '2026-10-21' }), ['Arbeitsbeginn frühestens am 21.10.2026']) + assert.deepEqual(texte({ status: 'bestaetigt', fruehbeginn: true, bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Frühbeginn verlangt']) + assert.deepEqual(texte({ status: 'bestaetigt', fruehesterBeginn: '2026-10-21', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Arbeitsbeginn frühestens am 21.10.2026']) + // ohne Wirkung bei Entwurf und bei beendeten ABs + assert.deepEqual(texte({ status: 'entwurf', fruehesterBeginn: '2026-10-21', fruehbeginn: true }), []) + assert.deepEqual(texte({ status: 'storniert', fruehesterBeginn: '2026-10-21' }), []) + assert.deepEqual(texte({ status: 'widerrufen', widerrufenAm: '2026-10-09T10:00:00.000+00:00' }), ['Widerrufen am 09.10.2026']) + const alle = [ + A({ id: 'a1', nummer: 'AB-2026-0002', status: 'ersetzt', ersetztDurchId: 'a3' }), + A({ id: 'a3', nummer: 'AB-2026-0004', ersetztId: 'a1' }), + A({ id: 'a4', nummer: '', status: 'entwurf', ersetztId: 'a3' }), + ] + assert.deepEqual(auftragNotizen(alle[0], alle).map((n) => n.text), ['Ersetzt durch AB-2026-0004']) + assert.deepEqual(auftragNotizen(alle[1], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0002']) + assert.deepEqual(auftragNotizen(alle[2], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0004']) + assert.deepEqual(texte({ ersetztId: 'fremd' }, []), ['Neue Fassung']) + assert.deepEqual(texte({ status: 'ersetzt', ersetztDurchId: 'fremd' }, []), ['Ersetzt durch eine neue Fassung']) +}) + +test('auftragAktionen: je Status genau die erlaubten, nach dem Senden nur noch „Neue Fassung“ statt Bearbeiten', () => { + const keys = (o) => auftragAktionen(A(o)).map((a) => a.key) + assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'loeschen']) + assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'stornieren']) + assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'stornieren']) + assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung']) + for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status) + for (const status of ['versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) { + assert.equal(keys({ status }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false, status) + } + // ohne gespeichertes PDF keine PDF-Aktion (beim Entwurf gibt es die Vorschau immer) + assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'stornieren']) + assert.deepEqual(keys({ status: 'storniert', pdfVorhanden: false }), []) + assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).map((a) => a.label), ['Bearbeiten', 'Senden', 'Vorschau', 'Löschen']) + assert.deepEqual(auftragAktionen(A()).map((a) => a.label), ['PDF', 'Erinnerung senden', 'Neue Fassung', 'Stornieren']) + assert.equal(auftragAktionen(A({ mailStatus: 'fehler' }))[1].label, 'Erneut senden') + assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).filter((a) => a.danger).map((a) => a.key), ['loeschen']) + assert.deepEqual(auftragAktionen(A()).filter((a) => a.danger).map((a) => a.key), ['stornieren']) + assert.deepEqual(auftragAktionen(A({ status: 'xyz', pdfVorhanden: false })), []) + assert.deepEqual(auftragAktionen(null), []) + assert.equal(istEntwurf(A({ status: 'entwurf' })), true) + assert.equal(istEntwurf(A()), false) + assert.equal(istEntwurf(null), false) +}) + +test('Projekt zuordnen: Auswahl aus den Konten, Verfügbarkeit nach Status und Projekt', () => { + const konten = [{ projektId: 'p1', name: 'Website Praxis Müller' }, { projektId: 'p2', name: ' Shop Relaunch ' }, { projektId: 'p3', name: '' }, { name: 'ohne Kennung' }, null] + assert.deepEqual(zuordnenOptionen(konten), [ + { value: 'p1', label: 'Website Praxis Müller' }, { value: 'p2', label: 'Shop Relaunch' }, { value: 'p3', label: 'p3' }, + ]) + assert.deepEqual(zuordnenOptionen(undefined), []) + assert.deepEqual(zuordnenOptionen('x'), []) + const ohne = (o) => A({ projektId: '', ...o }) + for (const status of ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet']) { + assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), true, status) + } + for (const status of ['storniert', 'ersetzt', 'widerrufen', 'xyz']) { + assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), false, status) + } + assert.equal(projektZuordnenMoeglich(ohne({ projektId: null }), konten), true) + assert.equal(projektZuordnenMoeglich(ohne({ projektId: undefined }), konten), true) + // hat die AB schon ein Projekt (auch ein unbekanntes), gibt es die Aktion nicht: der Server lehnt sie mit 409 ab + assert.equal(projektZuordnenMoeglich(A({ projektId: 'p1' }), konten), false) + assert.equal(projektZuordnenMoeglich(A({ projektId: 'p9' }), konten), false) + // ohne Projekt zur Auswahl ist nichts zu tun + assert.equal(projektZuordnenMoeglich(ohne({}), []), false) + assert.equal(projektZuordnenMoeglich(ohne({}), undefined), false) + assert.equal(projektZuordnenMoeglich(null, konten), false) +}) + +test('auftragAktionen mit Konten: „Projekt zuordnen“ nur ohne Projekt, vor den gefährlichen Aktionen', () => { + const konten = [{ projektId: 'p1', name: 'Website' }] + const keys = (o, k = konten) => auftragAktionen(A({ projektId: '', ...o }), { konten: k }).map((a) => a.key) + assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'projekt', 'loeschen']) + assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren']) + assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'projekt', 'stornieren']) + assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung', 'projekt']) + assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'projekt', 'stornieren']) + for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status) + // mit Projekt, ohne Konten und ohne Angabe der Konten gibt es die Aktion nicht + assert.deepEqual(auftragAktionen(A({ status: 'versendet' }), { konten }).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren']) + assert.deepEqual(keys({ status: 'versendet' }, []), ['pdf', 'erinnern', 'neueFassung', 'stornieren']) + assert.deepEqual(auftragAktionen(A({ projektId: '' })).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren']) + // Beschriftung, nie gefährlich; Bearbeiten, Senden und Löschen bleiben dem Entwurf vorbehalten + const aktion = auftragAktionen(A({ projektId: '' }), { konten }).find((a) => a.key === 'projekt') + assert.equal(aktion.label, 'Projekt zuordnen') + assert.notEqual(aktion.danger, true) + assert.equal(keys({ status: 'versendet' }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false) + // die Zeilen geben die Konten weiter + assert.deepEqual(auftragZeilen([A({ projektId: '' })], { konten })[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren']) + assert.deepEqual(auftragZeilen([A({ projektId: '' })])[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren']) +}) + +test('Rückfragen: Senden, Löschen, Stornieren, Erinnerung, Projekt zuordnen', () => { + assert.equal( + auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' }), { email: 'info@praxis.test' }), + 'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an info@praxis.test senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.' + ) + assert.equal( + auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' })), + 'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an den Kunden senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.' + ) + assert.equal(auftragBestaetigung('loeschen', A({ status: 'entwurf' })), 'Den Entwurf „Website Praxis Müller“ wirklich löschen? Das lässt sich nicht rückgängig machen.') + assert.equal( + auftragBestaetigung('stornieren', A()), + 'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ wirklich stornieren? Der Kunde bekommt keine Mail, der Bestätigungslink wird ungültig. Das lässt sich nicht rückgängig machen.' + ) + assert.equal( + auftragBestaetigung('erinnern', A()), + 'Die Auftragsbestätigung AB-2026-0002 jetzt noch einmal an den Kunden senden? Der bisherige Bestätigungslink gilt danach nicht mehr.' + ) + assert.equal( + auftragBestaetigung('projekt', A({ projektId: '' }), { projektName: 'Physio-Website' }), + 'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ dem Projekt „Physio-Website“ zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern.' + ) + assert.match(auftragBestaetigung('projekt', A({ projektId: '' })), /„Website Praxis Müller“ einem Projekt zuordnen\? /) + // ein Entwurf bleibt über „Bearbeiten“ änderbar: keine Rückfrage + assert.equal(auftragBestaetigung('projekt', A({ status: 'entwurf', nummer: '', projektId: '' }), { projektName: 'Physio-Website' }), '') + for (const key of ['bearbeiten', 'pdf', 'neueFassung', 'gibtsnicht']) assert.equal(auftragBestaetigung(key, A()), '', key) + assert.match(auftragBestaetigung('senden', A({ titel: '' })), /„ohne Titel“/) +}) + +test('Erfolgsmeldungen (Wortlaut des Containers von W0 für „senden“)', () => { + const gesendet = A({ nummer: 'AB-2026-0003' }) // gesendetAn: info@praxis.test + assert.deepEqual(auftragErfolg('senden', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde an info@praxis.test gesendet.' }) + assert.deepEqual(auftragErfolg('senden', A({ nummer: 'AB-2026-0003', gesendetAn: '' })), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde gesendet.' }) + assert.deepEqual(auftragErfolg('senden', gesendet, { mailFehler: true }), { + tone: 'warn', + text: 'Auftragsbestätigung AB-2026-0003 ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.', + }) + assert.deepEqual(auftragErfolg('erinnern', gesendet), { tone: 'ok', text: 'Die Erinnerung zu AB-2026-0003 wurde an info@praxis.test gesendet.' }) + assert.deepEqual(auftragErfolg('erinnern', gesendet, { mailFehler: true }), { + tone: 'warn', text: 'Die Erinnerung zu AB-2026-0003 konnte nicht zugestellt werden. „Erneut senden“ versucht es noch einmal.', + }) + assert.deepEqual(auftragErfolg('stornieren', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde storniert.' }) + assert.deepEqual(auftragErfolg('loeschen', A({ nummer: '' })), { tone: 'ok', text: 'Der Entwurf wurde gelöscht.' }) + assert.deepEqual(auftragErfolg('neueFassung', A({ nummer: '' })), { tone: 'ok', text: 'Neue Fassung als Entwurf angelegt.' }) + assert.deepEqual(auftragErfolg('projekt', gesendet, { projektName: 'Physio-Website' }), { + tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde dem Projekt „Physio-Website“ zugeordnet.', + }) + assert.deepEqual(auftragErfolg('projekt', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde einem Projekt zugeordnet.' }) + assert.deepEqual(auftragErfolg('projekt', A({ nummer: '' }), { projektName: 'Physio-Website' }), { + tone: 'ok', text: 'Die Auftragsbestätigung wurde dem Projekt „Physio-Website“ zugeordnet.', + }) + assert.deepEqual(auftragErfolg('senden', null), { tone: 'ok', text: 'Die Auftragsbestätigung wurde gesendet.' }) + assert.deepEqual(auftragErfolg('senden', null, { mailFehler: true }), { + tone: 'warn', text: 'Die Auftragsbestätigung ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.', + }) + assert.deepEqual(auftragErfolg('gibtsnicht', gesendet), { tone: 'ok', text: '' }) +}) + +test('auftragFehler: Kundendaten-Fehler führen zur Übersicht, andere zeigen die Meldung des Servers (Texte aus Plan L3)', () => { + const fehler = (status, message) => auftragFehler(Object.assign(new Error(message), { status })) + assert.deepEqual(fehler(409, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.'), { + art: 'adresse', text: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true, + }) + assert.deepEqual(fehler(400, 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).'), { + art: 'kundenart', + text: 'Die Kundenart des Kunden fehlt. Bitte in der Übersicht des Kunden festlegen (Feld „Kundenart“) oder über „Bearbeiten“ und dann erneut senden.', + zurUebersicht: true, + }) + assert.deepEqual(fehler(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.'), { + art: 'email', text: 'Dem Kunden fehlt die E-Mail-Adresse. Bitte in der Übersicht eintragen und dann erneut senden.', zurUebersicht: true, + }) + assert.deepEqual(fehler(400, 'Auftragsbestätigungen nur für feste Kunden.'), { + art: 'kunde', text: 'Das ist noch kein fester Kunde. Bitte in der Übersicht zum festen Kunden machen und dann erneut senden.', zurUebersicht: true, + }) + // Entwurf unvollständig (L4, wird von L3 beim Senden und bei der Vorschau durchgereicht): Meldung unverändert, kein „aktueller Stand“ + assert.deepEqual(fehler(409, 'Entwurf unvollständig: Titel fehlt.'), { + art: 'entwurf', text: 'Entwurf unvollständig: Titel fehlt.', zurUebersicht: false, + }) + assert.equal(fehler(409, 'Entwurf unvollständig: Kundendaten fehlen.').art, 'entwurf') + assert.deepEqual(fehler(409, 'Nur ein Entwurf lässt sich senden.'), { + art: 'stand', text: 'Nur ein Entwurf lässt sich senden. Die Liste zeigt den aktuellen Stand.', zurUebersicht: false, + }) + assert.deepEqual(fehler(400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.'), { + art: 'eingabe', text: 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.', zurUebersicht: false, + }) + assert.deepEqual(fehler(500, 'Das PDF konnte nicht erzeugt werden.'), { + art: 'pdf', text: 'Das PDF konnte nicht erzeugt werden. Die Auftragsbestätigung bleibt ein Entwurf, es wurde keine Nummer vergeben.', zurUebersicht: false, + }) + assert.deepEqual(fehler(500, 'Interner Fehler.'), { + art: 'server', + text: 'Interner Fehler. Bitte in der Liste prüfen, ob die Auftragsbestätigung gesendet wurde, und sonst erneut versuchen.', + zurUebersicht: false, + }) + assert.deepEqual(fehler(0, 'Das Kundenportal ist gerade nicht erreichbar.'), { art: 'netz', text: 'Das Kundenportal ist gerade nicht erreichbar.', zurUebersicht: false }) + assert.deepEqual(fehler(404, 'Auftragsbestätigung nicht gefunden.'), { art: 'sonst', text: 'Auftragsbestätigung nicht gefunden.', zurUebersicht: false }) + assert.deepEqual(auftragFehler(new Error('')), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false }) + assert.deepEqual(auftragFehler(null), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false }) +}) + +test('neueSperre: ein zweiter Aufruf während des ersten wird ignoriert (Doppelklick)', async () => { + const sperre = neueSperre() + let aufrufe = 0 + let fertig + const warten = new Promise((r) => { fertig = r }) + const erster = sperre.run(async () => { aufrufe += 1; await warten; return 'ok' }) + const zweiter = await sperre.run(async () => { aufrufe += 1; return 'doppelt' }) + assert.deepEqual(zweiter, { uebersprungen: true }) + assert.equal(sperre.belegt, true) + fertig() + assert.deepEqual(await erster, { uebersprungen: false, wert: 'ok' }) + assert.equal(aufrufe, 1) + assert.equal(sperre.belegt, false) + assert.deepEqual(await sperre.run(async () => 'wieder'), { uebersprungen: false, wert: 'wieder' }) +}) + +test('neueSperre: nach einem Fehler wieder frei, der Fehler wird weitergereicht', async () => { + const sperre = neueSperre() + await assert.rejects(() => sperre.run(async () => { throw new Error('kaputt') }), /kaputt/) + assert.equal(sperre.belegt, false) +}) From 6a5f94303484c7f74de739c724c14006212b5306 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:34:22 +0000 Subject: [PATCH 15/33] =?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 3650573445974d200e7dec9300b343bb736beb3f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:34:47 +0000 Subject: [PATCH 16/33] Auftragsbestaetigung: Liste mit Status, Zusatzzeilen, Aktionen und Projekt zuordnen Co-Authored-By: Claude Sonnet 5.5 --- src/components/auftraege/AuftragListe.jsx | 247 +++++++++++++++++++++- src/components/auftraege/auftrag.css | 35 +++ 2 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 src/components/auftraege/auftrag.css diff --git a/src/components/auftraege/AuftragListe.jsx b/src/components/auftraege/AuftragListe.jsx index 3525a91..d66ba50 100644 --- a/src/components/auftraege/AuftragListe.jsx +++ b/src/components/auftraege/AuftragListe.jsx @@ -1,11 +1,244 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Badge, Button, Card, EmptyState } from '../ui' +import { noticeProps } from '../../lib/financeUi' +import { + deleteAuftrag, + erinnereAuftrag, + getAuftragPdf, + neueFassungAuftrag, + ordneAuftragProjektZu, + sendAuftrag, + storniereAuftrag, +} from '../../lib/financeApi' +import { oeffnePdfImTab } from '../../lib/auftragPdf' +import { + auftragBestaetigung, + auftragErfolg, + auftragFehler, + auftragZeilen, + neueSperre, + zuordnenOptionen, +} from '../../lib/auftragView' +import './auftrag.css' + +const LAUFEND = { senden: 'Sende …', erinnern: 'Sende …', stornieren: 'Storniere …', loeschen: 'Lösche …', neueFassung: 'Lege an …', pdf: 'Öffne …' } +const AUFRUFE = { + senden: sendAuftrag, + erinnern: erinnereAuftrag, + stornieren: storniereAuftrag, + loeschen: deleteAuftrag, + neueFassung: neueFassungAuftrag, +} + /** - * Platzhalter von W0: Strang W2 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben). - * Die fertige Komponente rendert selbst und ruft nach jeder Aktion onChanged(). - * - * Props (V8): auftraege (AuftragTeam[], V3), konten (Projektname der Zeile und Auswahl für „Projekt zuordnen“, einer Aktion für - * ABs ohne Projekt über ordneAuftragProjektZu(id, projektId) aus financeApi.js), onChanged(), onBearbeiten(auftrag) - * („Entwurf bearbeiten“; nach „Neue Fassung“ mit dem neuen Entwurf) und onShowOverview (Weg „Zur Übersicht“ aus Meldungen). + * Karte „Aufträge“ im Kundenreiter: Auftragsbestätigungen (AB) mit Status, Zusatzzeilen und den Aktionen je Status. + * Die Regeln stecken in src/lib/auftragView.js. Nach jeder Aktion ruft die Liste onChanged() (auch nach einem Fehler: + * der Stand kann ein anderer sein). „Bearbeiten“ und „Neue Fassung“ geben den Entwurf über onBearbeiten an den Container, + * „Zur Übersicht“ in Meldungen läuft über onShowOverview. Eine Sperre fängt den Doppelklick ab (eine Anfrage, eine Rückfrage). + * Schmal (Wrapper .auftrag-body bis 860 px) wird jede Zeile ein Raster. */ export default function AuftragListe({ auftraege, konten, onChanged, onBearbeiten, onShowOverview }) { - return null + const zeilen = useMemo(() => auftragZeilen(auftraege, { konten }), [auftraege, konten]) + const optionen = useMemo(() => zuordnenOptionen(konten), [konten]) + const [busy, setBusy] = useState(null) // `${id}:${aktion}` der laufenden Anfrage + const [message, setMessage] = useState(null) // { tone, text, zurUebersicht? } + const [zuordnen, setZuordnen] = useState(null) // { id, projektId } der Zeile mit offener Auswahl + const sperre = useRef(neueSperre()).current + const aliveRef = useRef(true) + + useEffect(() => { + aliveRef.current = true + return () => { + aliveRef.current = false + } + }, []) + + const zeigeFehler = (err) => { + const f = auftragFehler(err) + if (aliveRef.current) setMessage({ tone: 'warn', text: f.text, zurUebersicht: f.zurUebersicht }) + } + + async function run(zeile, aktion) { + if (aktion.key === 'bearbeiten') { + onBearbeiten(zeile.auftrag) + return + } + if (aktion.key === 'projekt') { + setZuordnen({ id: zeile.id, projektId: '' }) + setMessage(null) + return + } + if (sperre.belegt) return + const frage = auftragBestaetigung(aktion.key, zeile.auftrag) + if (frage && !window.confirm(frage)) return + await sperre.run(async () => { + setBusy(`${zeile.id}:${aktion.key}`) + setMessage(null) + try { + if (aktion.key === 'pdf') { + // kein await davor: der neue Tab muss im Klick aufgehen + await oeffnePdfImTab(() => getAuftragPdf(zeile.id)) + return + } + const data = await AUFRUFE[aktion.key](zeile.id) + if (aliveRef.current) setMessage(auftragErfolg(aktion.key, data?.auftrag || zeile.auftrag, { mailFehler: data?.mailFehler })) + onChanged() + if (aktion.key === 'neueFassung' && data?.auftrag) onBearbeiten(data.auftrag) + } catch (err) { + if (aktion.key === 'pdf') { + if (aliveRef.current) setMessage({ tone: 'warn', text: auftragFehler(err).text }) + return + } + zeigeFehler(err) + onChanged() // der Stand kann ein anderer sein + } finally { + if (aliveRef.current) setBusy(null) + } + }) + } + + async function ordneZu(zeile) { + const gewaehlt = optionen.find((o) => o.value === zuordnen?.projektId) + if (!gewaehlt || sperre.belegt) return + const frage = auftragBestaetigung('projekt', zeile.auftrag, { projektName: gewaehlt.label }) + if (frage && !window.confirm(frage)) return + await sperre.run(async () => { + setBusy(`${zeile.id}:projekt`) + setMessage(null) + try { + const data = await ordneAuftragProjektZu(zeile.id, gewaehlt.value) + if (aliveRef.current) { + setMessage(auftragErfolg('projekt', data?.auftrag || zeile.auftrag, { projektName: gewaehlt.label })) + setZuordnen(null) + } + onChanged() + } catch (err) { + zeigeFehler(err) + if (aliveRef.current) setZuordnen(null) + onChanged() + } finally { + if (aliveRef.current) setBusy(null) + } + }) + } + + return ( + +
+ {message && ( +

+ {message.text} + {message.zurUebersicht && ( + <> + {' '} + + + )} +

+ )} + + {zeilen.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + {zeilen.map((zeile) => { + const offen = zuordnen?.id === zeile.id + const wahl = offen && optionen.some((o) => o.value === zuordnen.projektId) ? zuordnen.projektId : '' + const ordnet = busy === `${zeile.id}:projekt` + return ( + + + + + + + + + ) + })} + +
Auftragsbestätigungen, neueste zuerst
NummerProjekt / TitelDatumSummeStatusAktionen
+ {zeile.istEntwurf ? {zeile.nummerText} : zeile.nummerText} + + {zeile.titel} + {zeile.projektText &&
{zeile.projektText}
} +
{zeile.datumText} + {zeile.summeText} + {zeile.summeSub &&
{zeile.summeSub}
} +
+ {zeile.statusLabel} + {zeile.notizen.map((note) => ( +
+ {note.text} +
+ ))} +
+
+ {zeile.aktionen.map((aktion) => { + const label = `${aktion.label}: ${zeile.nummerText}, ${zeile.titel}` + const text = busy === `${zeile.id}:${aktion.key}` && LAUFEND[aktion.key] ? LAUFEND[aktion.key] : aktion.label + if (aktion.danger) { + return ( + + ) + } + return ( + + ) + })} +
+ {offen && ( +
+ + + +
+ )} +
+
+ )} +
+
+ ) } diff --git a/src/components/auftraege/auftrag.css b/src/components/auftraege/auftrag.css new file mode 100644 index 0000000..62dbd56 --- /dev/null +++ b/src/components/auftraege/auftrag.css @@ -0,0 +1,35 @@ +/* Auftragsbestaetigungen (W2): Liste, Dialog-Schritte, Vorschau. Nur Tokens, alle Klassen beginnen mit auftrag-. + Die Datei wird von der Komponente geladen, global.css kommt danach: darum nie eine globale Klasse ueberschreiben. */ + +/* Liste */ +.auftrag-body { container-type: inline-size; } +.auftrag-notice { margin: 0 0 8px; } +.auftrag-table-wrap { overflow-x: auto; } +.auftrag-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +.auftrag-nummer { white-space: nowrap; } +.auftrag-titel { overflow-wrap: anywhere; } +.auftrag-table th.auftrag-amount { text-align: right; } +.auftrag-amount { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +.auftrag-note { margin-top: 2px; font-size: 13px; overflow-wrap: anywhere; } +.auftrag-actions { display: flex; flex-wrap: wrap; gap: 4px 14px; } +.auftrag-zuordnen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 8px; } +.auftrag-zuordnen .select { flex: 1 1 180px; min-width: 0; max-width: 100%; } +.auftrag-liste button.link:disabled, .auftrag-liste button.link-danger:disabled { cursor: not-allowed; } + +@container (max-width: 860px) { + .auftrag-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + .auftrag-table, .auftrag-table tbody { display: block; } + .auftrag-table tr { + display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; align-items: baseline; + grid-template-areas: "nummer status" "titel titel" "datum summe" "aktionen aktionen"; + padding: 10px 0; border-bottom: 1px solid var(--line); + } + .auftrag-table td, .auftrag-table td:first-child, .auftrag-table td:last-child { display: block; padding: 0; border: 0; min-width: 0; } + .auftrag-nummer { grid-area: nummer; } + .auftrag-status { grid-area: status; text-align: right; } + .auftrag-titel { grid-area: titel; } + .auftrag-datum { grid-area: datum; } + .auftrag-table td.auftrag-amount { grid-area: summe; } + .auftrag-aktionen { grid-area: aktionen; padding-top: 4px; } + .auftrag-table td[data-label]::before { content: attr(data-label) " "; color: var(--text-2); font-weight: 500; } +} From 8ab78b7b2c62bb3dfd5786bb733515a676adede7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:35:04 +0000 Subject: [PATCH 17/33] =?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 5060d150ea655d60037b914619136929ccdc12ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:35:49 +0000 Subject: [PATCH 18/33] Auftragsbestaetigung: Dialog-Schritte 1 bis 3 (Projekt, Auftrag, Zahlung und Termine) Co-Authored-By: Claude Sonnet 5.5 --- src/components/auftraege/AuftragSchritte.jsx | 321 +++++++++++++++++++ src/components/auftraege/auftrag.css | 43 +++ 2 files changed, 364 insertions(+) create mode 100644 src/components/auftraege/AuftragSchritte.jsx diff --git a/src/components/auftraege/AuftragSchritte.jsx b/src/components/auftraege/AuftragSchritte.jsx new file mode 100644 index 0000000..41e1916 --- /dev/null +++ b/src/components/auftraege/AuftragSchritte.jsx @@ -0,0 +1,321 @@ +import { Badge } from '../ui' +import FormField from '../finance/FormField' +import { useWorkorders } from '../../hooks/useWorkorders' +import { euro } from '../../lib/financeFormat' +import { customerName, lineTotalCents } from '../../lib/financeView' +import { KUNDENART_OPTIONEN, kundenartLabel } from '../../lib/kundenFelder' +import { + AUFTRAG_LIMITS, + AUFTRAG_WEGE, + formularSummeCents, + kundenartWirksam, + neuePosition, + parseProzent, + parseTage, + schalteZeitquelle, + vorOrtHinweis, + waehleProjekt, + wechselArt, + zahlungsplanOptionen, + zahlungsplanVorschau, + zeitquelleOptionen, +} from '../../lib/auftragView' +import './auftrag.css' + +/** Meldung eines Feldes ohne FormField (Art, Positionen, Zahlungsplan, Zeitquelle). */ +function FeldFehler({ text }) { + return text ?
{text}
: null +} + +/** Schritt 1: Projekt, Titel und (falls am Kunden noch leer) die Kundenart. */ +export function SchrittProjekt({ form, onChange, fehler, kunde, konten, neueFassung = false }) { + const name = customerName(kunde) || 'Kunde' + return ( +
+

+ Kunde: {name} + {kunde?.email ? ` · ${kunde.email}` : ''} +

+ {neueFassung &&

Neue Fassung: Beim Senden ersetzt sie die bisherige Auftragsbestätigung.

} + + + + + onChange({ ...form, titel: e.target.value, titelGeaendert: true })} + /> + + {!kunde?.kundenart ? ( + + + + ) : ( +

Kundenart: {kundenartLabel(kunde.kundenart)}. Änderbar in der Übersicht des Kunden.

+ )} +
+ ) +} + +/** Schritt 2: Absprache (Datum, Weg), Art des Auftrags und Positionen bzw. Stundensatz. */ +export function SchrittAuftrag({ form, onChange, fehler, kunde }) { + const vorOrt = vorOrtHinweis(form.auftragWeg, kundenartWirksam(kunde, form.kundenart)) + const positionen = form.positionen + const setPosition = (uid, feld, wert) => + onChange({ ...form, positionen: positionen.map((p) => (p.uid === uid ? { ...p, [feld]: wert } : p)) }) + const entferne = (uid) => onChange({ ...form, positionen: positionen.filter((p) => p.uid !== uid) }) + const summe = formularSummeCents(form) + + return ( +
+
+ + onChange({ ...form, auftragVom: e.target.value })} /> + + + + +
+ {vorOrt &&

{vorOrt}

} + +
+ Art des Auftrags + + + +
+ + {form.art === 'festpreis' && ( +
+ Positionen + {positionen.map((p, i) => { + const n = i + 1 + const zeile = lineTotalCents(p) + return ( +
+
+ Beschreibung + setPosition(p.uid, 'description', e.target.value)} + /> +
+
+ Menge + setPosition(p.uid, 'quantity', e.target.value)} + /> +
+
+ Einzelpreis in € + setPosition(p.uid, 'price', e.target.value)} + /> +
+
+ Summe + {zeile === null ? '–' : euro(zeile)} +
+
+ +
+
+ ) + })} +
+ +
+
+ Summe + {euro(summe)} +
+

Gemäß § 19 UStG ohne Umsatzsteuer.

+ +
+ )} + + {form.art === 'aufwand' && ( +
+ + onChange({ ...form, stundensatz: e.target.value })} /> + + + onChange({ ...form, schaetzung: e.target.value })} + /> + +
+ )} +
+ ) +} + +/** Zeitquelle: Tickets des Kunden, deren Arbeitsblätter zum Auftrag zählen (höchstens 10). */ +function ZeitquelleAuswahl({ kundeId, form, onChange, projektTicketIds, fehler }) { + const { workorders, loading, error } = useWorkorders({ customerId: kundeId, limit: 100 }) + const optionen = zeitquelleOptionen(workorders, form.zeitquelle, projektTicketIds) + const voll = form.zeitquelle.length >= AUFTRAG_LIMITS.maxZeitquellen + return ( +
+ Zeiterfassung aus diesen Tickets +

Arbeitsblätter dieser Tickets zählen zum Auftrag (Projektkonto, Aufwandsrechnung). Höchstens 10.

+ {loading && optionen.length === 0 &&
Tickets werden geladen …
} + {error &&

Die Tickets konnten nicht geladen werden: {error}. Die bisherige Auswahl bleibt.

} + {!loading && !error && optionen.length === 0 &&

Dieser Kunde hat noch keine Tickets.

} + {optionen.length > 0 && ( +
+ {optionen.map((o) => ( + + ))} +
+ )} + +
+ ) +} + +/** Schritt 3: Zahlungsplan, Zahlungsziel, Termine und Zeitquelle. */ +export function SchrittZahlung({ form, onChange, fehler, kunde, konten }) { + const konto = (Array.isArray(konten) ? konten : []).find((k) => k.projektId && k.projektId === form.projektId) + const projektTicketIds = konto && Array.isArray(konto.tickets) ? konto.tickets.map((t) => t.id) : [] + const vorschau = zahlungsplanVorschau({ + art: form.zahlungsart, + prozent: parseProzent(form.prozent), + text: form.zahlungstext, + summeCents: formularSummeCents(form), + zielTage: parseTage(form.zahlungsziel), + }) + return ( +
+
+ Zahlungsplan + {zahlungsplanOptionen(form.art).map((o) => ( + + ))} + +
+ {form.zahlungsart === 'anzahlung' && ( + + onChange({ ...form, prozent: e.target.value })} /> + + )} + {form.zahlungsart === 'frei' && ( +
+ +