Kontrolle: AB ohne Projekt im Rechnungsdialog für Anzahlung und Schluss wählbar

Der Server (rechnungsbau.js, baueAbRechnung) nimmt Anzahlung und Schlussrechnung ohne
Projekt an. Unter 'Ohne Projekt' lädt der Dialog die ABs des Kunden ohne Projekt und
ihre Rechnungen; frei bleibt die Vorgabe.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 15:19:35 +00:00
1 parent 79f230ea2c
commit 4b184f744f
4 files changed
+88 -12

No files matched your search

+24 -6
View File
@@ -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 && (
<p className="msg muted" role="status">
Aufträge werden geladen …
</p>
)}
{kunde && projekt === '' && ohne.error && (
<p className="msg warn" role="alert">
Die Aufträge ohne Projekt konnten nicht geladen werden: {ohne.error}{' '}
<button type="button" className="link" onClick={ohne.reload}>
Erneut versuchen
</button>
</p>
)}
{kunde && projektBereit && projekt !== '' && (
<>
{detail.loading && !detail.data && (
+17 -2
View File
@@ -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.
+19 -2
View File
@@ -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)