diff --git a/src/components/auftraege/RechnungDialog.jsx b/src/components/auftraege/RechnungDialog.jsx index ee9fecb..a061f84 100644 --- a/src/components/auftraege/RechnungDialog.jsx +++ b/src/components/auftraege/RechnungDialog.jsx @@ -122,11 +122,15 @@ function RechnungFormular({ onClose, onCreated, projektId, auftraegeAktiv, onSho const aktiv = konten.data ? Boolean(konten.data.auftraegeAktiv) : Boolean(auftraegeAktiv) const detail = useProjektkontoDetail(projekt || '') - const auftraege = detail.data?.auftraege || [] - const rechnungen = detail.data?.rechnungen || [] + // „Ohne Projekt“: gültige ABs ohne Projekt (der Server nimmt Anzahlung und Schlussrechnung dazu ohne Projekt an) + const ohne = useOhneProjektAbs(kunde?.$id, projekt === '' && aktiv) + const quelleDaten = projekt === '' ? ohne.data : detail.data + const auftraege = quelleDaten?.auftraege || [] + const rechnungen = quelleDaten?.rechnungen || [] + const datenNeuLaden = projekt === '' ? ohne.reload : detail.reload let optionen = [] - if (projekt === '') optionen = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: aktiv }) + if (projekt === '') optionen = artOptionen({ projektId: '', auftraege, rechnungen, auftraegeAktiv: aktiv }) else if (projekt && detail.data) optionen = artOptionen({ projektId: projekt, auftraege, rechnungen, auftraegeAktiv: aktiv }) const stripeOk = detail.data?.stripeOk @@ -187,8 +191,8 @@ function RechnungFormular({ onClose, onCreated, projektId, auftraegeAktiv, onSho if (art === 'aufwand' && vorschauZeitraum !== null) dispatch({ type: 'zeitraum-vorschlag', text: vorschauZeitraum }) }, [art, vorschauZeitraum]) - const detailReloadRef = useRef(detail.reload) - detailReloadRef.current = detail.reload + const detailReloadRef = useRef(datenNeuLaden) + detailReloadRef.current = datenNeuLaden useEffect(() => { // 409 der Vorschau: ein Arbeitsblatt ist inzwischen abgerechnet, das Projektkonto neu laden (einmal je Fehlerobjekt) if (vorschau?.status === 'fehler' && vorschau.httpStatus === 409 && neuGeladenRef.current !== vorschau) { @@ -205,7 +209,7 @@ function RechnungFormular({ onClose, onCreated, projektId, auftraegeAktiv, onSho if (aliveRef.current) setBusy(null) if (r.status === 'fehler') { if (aliveRef.current) setError(r.message) - if (r.httpStatus === 409) detail.reload() + if (r.httpStatus === 409) datenNeuLaden() } else if (r.status === 'festgeschrieben') { if (onCreated) onCreated(null, { senden: true, notice: r.notice, tone: 'warn', finalizedFilter: r.finalizedFilter }) onClose() @@ -519,6 +523,20 @@ function RechnungFormular({ onClose, onCreated, projektId, auftraegeAktiv, onSho )} + {kunde && projekt === '' && ohne.loading && ( +

+ Aufträge werden geladen … +

+ )} + {kunde && projekt === '' && ohne.error && ( +

+ Die Aufträge ohne Projekt konnten nicht geladen werden: {ohne.error}{' '} + +

+ )} + {kunde && projektBereit && projekt !== '' && ( <> {detail.loading && !detail.data && ( diff --git a/src/hooks/useRechnungDaten.js b/src/hooks/useRechnungDaten.js index 9016071..a352af5 100644 --- a/src/hooks/useRechnungDaten.js +++ b/src/hooks/useRechnungDaten.js @@ -1,10 +1,10 @@ // 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, getSettings, previewInvoice } from '../lib/financeApi' +import { getInvoices, getProjektkonten, getProjektkonto, getSettings, listAuftraege, previewInvoice } from '../lib/financeApi' import { getAbgerechnet } from '../lib/teamApi' import { listKundenTickets, listTicketBlaetter } from '../lib/rechnungDialogApi' -import { blattAusAppwrite } from '../lib/rechnungDialogView' +import { blattAusAppwrite, ohneProjektAbDaten } from '../lib/rechnungDialogView' import { createRequestGuard } from '../lib/requestGuard' const VORSCHAU_WARTEZEIT_MS = 350 @@ -87,6 +87,21 @@ export function useProjektkontoDetail(projektId) { return useGeladen(() => getProjektkonto(projektId), projektId, 'Das Projektkonto konnte nicht geladen werden.') } +/** + * ABs ohne Projekt und ihre Rechnungen für „Ohne Projekt“ im Rechnungsdialog: { auftraege, rechnungen }. Geladen wird nur, + * wenn `aktiv` wahr ist (Schalter an und „Ohne Projekt“ gewählt). + */ +export function useOhneProjektAbs(kundeId, aktiv) { + return useGeladen( + async () => { + const [abs, rechnungen] = await Promise.all([listAuftraege(kundeId), getInvoices({ status: 'alle', kunde: kundeId })]) + return ohneProjektAbDaten(abs, rechnungen) + }, + aktiv ? kundeId : '', + 'Die Aufträge konnten nicht geladen werden.' + ) +} + /** * Vorschau der Rechnung zum Körper der Anfrage. `vorschau = { key, status: 'laedt' | 'ok' | 'fehler', daten?, message?, httpStatus? }`. * Der Körper wird über einen Ref gelesen, der Effekt hängt nur an [aktiv, schluessel]; nach 350 ms Ruhe geht die Anfrage raus. diff --git a/src/lib/rechnungDialogView.js b/src/lib/rechnungDialogView.js index 91c2618..705d18d 100644 --- a/src/lib/rechnungDialogView.js +++ b/src/lib/rechnungDialogView.js @@ -118,13 +118,30 @@ export function abSummenText({ ab, rechnungen, stripeOk }) { return `${summe} · bereits berechnet ${euro(berechnet)} · noch nicht berechnet ${euro(Math.max(0, (Number(ab?.summeCents) || 0) - berechnet))}` } +/** + * Aufträge und Rechnungen für „Ohne Projekt“ aus der Auftragsliste des Kunden ({ auftraege }) und seiner Rechnungsliste + * ({ rechnungen }): nur ABs ohne Projekt und die Rechnungen, die zu ihnen gehören. -> { auftraege, rechnungen } + */ +export function ohneProjektAbDaten(auftragsAntwort, rechnungsAntwort) { + const abs = Array.isArray(auftragsAntwort?.auftraege) ? auftragsAntwort.auftraege.filter((a) => a && !a.projektId) : [] + const ids = new Set(abs.map((a) => a.id)) + const alle = Array.isArray(rechnungsAntwort?.rechnungen) ? rechnungsAntwort.rechnungen : [] + return { auftraege: abs, rechnungen: alle.filter((r) => r && r.auftragId && ids.has(r.auftragId)) } +} + // --- Rechnungsarten ----------------------------------------------------------------------------------------- /** Wählbare Arten mit Sperrgrund: [{ key, label, enabled, hint }]. */ export function artOptionen({ projektId, auftraege, rechnungen, auftraegeAktiv }) { if (projektId === null || projektId === undefined) return [] const option = (key, enabled, hint = '') => ({ key, label: ART_OPTION_LABEL[key], enabled, hint: enabled ? '' : hint }) - if (projektId === '') return [option('frei', true)] + if (projektId === '') { + // Eine gültige Festpreis-AB ohne Projekt erlaubt Anzahlung und Schlussrechnung (der Server nimmt sie ohne Projekt an) + const ohne = auftraegeAktiv ? gueltigeFestpreisAbs(Array.isArray(auftraege) ? auftraege.filter((a) => a && !a.projektId) : []) : [] + if (ohne.length === 0) return [option('frei', true)] + const ohneEntwurf = ohne.filter((a) => !hatEntwurfZurAuftrag(rechnungen, a.id)) + return [option('frei', true), option('anzahlung', true), option('schluss', ohneEntwurf.length > 0, ENTWURF_HINWEIS)] + } const abs = auftraegeAktiv ? gueltigeFestpreisAbs(auftraege) : [] const freie = abs.filter((a) => !hatEntwurfZurAuftrag(rechnungen, a.id)) const schlussHinweis = abs.length === 0 ? AB_HINWEIS : ENTWURF_HINWEIS @@ -140,7 +157,7 @@ export function artOptionen({ projektId, auftraege, rechnungen, auftraegeAktiv } export function standardArt(optionen, { auftraege, rechnungen, offeneMinuten, stripeOk = true }) { const liste = Array.isArray(optionen) ? optionen : [] if (liste.length === 0) return '' - if (liste.length === 1 && liste[0].key === 'frei') return 'frei' + if (liste.some((o) => o.key === 'frei' && o.enabled)) return 'frei' // ohne Projekt // Ist Stripe nicht erreichbar, sind die berechneten Beträge unbekannt: keine Art vorschlagen if (stripeOk === false) return '' const waehlbar = (key) => liste.some((o) => o.key === key && o.enabled) diff --git a/tests/unit/rechnungDialogView.test.mjs b/tests/unit/rechnungDialogView.test.mjs index 1bece4e..1fce816 100644 --- a/tests/unit/rechnungDialogView.test.mjs +++ b/tests/unit/rechnungDialogView.test.mjs @@ -9,7 +9,7 @@ const { artOptionen, standardArt, standardAuftrag, anzahlungStandard, ausgewaehlteIds, blaetterSumme, blattAusAppwrite, ticketOptionen, zahlungszielVorschlag, STEUERNUMMER_RECHNUNG_HINWEIS, steuernummerHinweis, - STRIPE_UNBEKANNT, abSummenText, + STRIPE_UNBEKANNT, abSummenText, ohneProjektAbDaten, } = await loadModule('src/lib/rechnungDialogView.js') const ab = (over = {}) => ({ @@ -128,7 +128,7 @@ test('artOptionen: ein offener Entwurf zur AB sperrt die Schlussrechnung, nicht 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 }) + const frei = artOptionen({ projektId: '', auftraege: [ab({ projektId: 'p1' })], rechnungen: [], auftraegeAktiv: true }) assert.deepEqual(zustand(frei), [['frei', true]]) }) @@ -272,3 +272,29 @@ test('standardArt: bei Stripe-Ausfall wird keine Art vorgeschlagen (Beträge unb const frei = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: true }) assert.equal(standardArt(frei, { ...lage, stripeOk: false }), 'frei') }) + +test('artOptionen ohne Projekt: eine gültige AB ohne Projekt macht Anzahlung und Schlussrechnung wählbar', () => { + const ohne = (args = {}) => artOptionen({ projektId: '', auftraege: [ab()], rechnungen: [], auftraegeAktiv: true, ...args }) + assert.deepEqual(zustand(ohne()), [['frei', true], ['anzahlung', true], ['schluss', true]]) + assert.deepEqual(ohne().map((x) => x.label), ['Freie Positionen', 'Anzahlung', 'Schlussrechnung']) + // eine AB mit Projekt, ein Entwurf, ein falscher Status oder der ausgeschaltete Schalter: wie bisher nur frei bzw. gesperrt + assert.deepEqual(zustand(ohne({ auftraege: [ab({ projektId: 'p1' })] })), [['frei', true]]) + assert.deepEqual(zustand(ohne({ auftraege: [ab({ status: 'entwurf' })] })), [['frei', true]]) + assert.deepEqual(zustand(ohne({ auftraegeAktiv: false })), [['frei', true]]) + const entwurf = ohne({ rechnungen: [rk({ status: 'entwurf' })] }) + assert.deepEqual(zustand(entwurf), [['frei', true], ['anzahlung', true], ['schluss', false]]) + assert.equal(entwurf[2].hint, ENTWURF_HINWEIS) + // Vorgabe bleibt die freie Rechnung + assert.equal(standardArt(ohne(), { auftraege: [ab()], rechnungen: [], offeneMinuten: 0 }), 'frei') +}) + +test('ohneProjektAbDaten: nur ABs ohne Projekt und deren Rechnungen aus Auftrags- und Rechnungsliste', () => { + const daten = ohneProjektAbDaten( + { auftraege: [ab({ id: 'a1', projektId: '' }), ab({ id: 'a2', projektId: 'p1' }), ab({ id: 'a3' })] }, + { rechnungen: [rk({ auftragId: 'a1' }), rk({ id: 'in_2', auftragId: 'a2' }), rk({ id: 'in_3', auftragId: '' }), rk({ id: 'in_4', auftragId: 'a3', status: 'entwurf' })] } + ) + assert.deepEqual(daten.auftraege.map((a) => a.id), ['a1', 'a3']) + assert.deepEqual(daten.rechnungen.map((r) => r.id), ['in_1', 'in_4']) + assert.deepEqual(ohneProjektAbDaten(undefined, null), { auftraege: [], rechnungen: [] }) + assert.deepEqual(ohneProjektAbDaten({ auftraege: 'x' }, { rechnungen: {} }), { auftraege: [], rechnungen: [] }) +})