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:
1 parent
79f230ea2c
commit
4b184f744f
4 files changed
+88
-12
No files matched your search
@@ -122,11 +122,15 @@ function RechnungFormular({ onClose, onCreated, projektId, auftraegeAktiv, onSho
|
|||||||
const aktiv = konten.data ? Boolean(konten.data.auftraegeAktiv) : Boolean(auftraegeAktiv)
|
const aktiv = konten.data ? Boolean(konten.data.auftraegeAktiv) : Boolean(auftraegeAktiv)
|
||||||
|
|
||||||
const detail = useProjektkontoDetail(projekt || '')
|
const detail = useProjektkontoDetail(projekt || '')
|
||||||
const auftraege = detail.data?.auftraege || []
|
// „Ohne Projekt“: gültige ABs ohne Projekt (der Server nimmt Anzahlung und Schlussrechnung dazu ohne Projekt an)
|
||||||
const rechnungen = detail.data?.rechnungen || []
|
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 = []
|
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 })
|
else if (projekt && detail.data) optionen = artOptionen({ projektId: projekt, auftraege, rechnungen, auftraegeAktiv: aktiv })
|
||||||
|
|
||||||
const stripeOk = detail.data?.stripeOk
|
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 })
|
if (art === 'aufwand' && vorschauZeitraum !== null) dispatch({ type: 'zeitraum-vorschlag', text: vorschauZeitraum })
|
||||||
}, [art, vorschauZeitraum])
|
}, [art, vorschauZeitraum])
|
||||||
|
|
||||||
const detailReloadRef = useRef(detail.reload)
|
const detailReloadRef = useRef(datenNeuLaden)
|
||||||
detailReloadRef.current = detail.reload
|
detailReloadRef.current = datenNeuLaden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 409 der Vorschau: ein Arbeitsblatt ist inzwischen abgerechnet, das Projektkonto neu laden (einmal je Fehlerobjekt)
|
// 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) {
|
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 (aliveRef.current) setBusy(null)
|
||||||
if (r.status === 'fehler') {
|
if (r.status === 'fehler') {
|
||||||
if (aliveRef.current) setError(r.message)
|
if (aliveRef.current) setError(r.message)
|
||||||
if (r.httpStatus === 409) detail.reload()
|
if (r.httpStatus === 409) datenNeuLaden()
|
||||||
} else if (r.status === 'festgeschrieben') {
|
} else if (r.status === 'festgeschrieben') {
|
||||||
if (onCreated) onCreated(null, { senden: true, notice: r.notice, tone: 'warn', finalizedFilter: r.finalizedFilter })
|
if (onCreated) onCreated(null, { senden: true, notice: r.notice, tone: 'warn', finalizedFilter: r.finalizedFilter })
|
||||||
onClose()
|
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 !== '' && (
|
{kunde && projektBereit && projekt !== '' && (
|
||||||
<>
|
<>
|
||||||
{detail.loading && !detail.data && (
|
{detail.loading && !detail.data && (
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// Hooks des Rechnungsdialogs: Projektliste, Projektkonto, Vorschau, Tickets des Kunden und Arbeitsblätter weiterer Tickets.
|
// 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.
|
// Ü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 { 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 { getAbgerechnet } from '../lib/teamApi'
|
||||||
import { listKundenTickets, listTicketBlaetter } from '../lib/rechnungDialogApi'
|
import { listKundenTickets, listTicketBlaetter } from '../lib/rechnungDialogApi'
|
||||||
import { blattAusAppwrite } from '../lib/rechnungDialogView'
|
import { blattAusAppwrite, ohneProjektAbDaten } from '../lib/rechnungDialogView'
|
||||||
import { createRequestGuard } from '../lib/requestGuard'
|
import { createRequestGuard } from '../lib/requestGuard'
|
||||||
|
|
||||||
const VORSCHAU_WARTEZEIT_MS = 350
|
const VORSCHAU_WARTEZEIT_MS = 350
|
||||||
@@ -87,6 +87,21 @@ export function useProjektkontoDetail(projektId) {
|
|||||||
return useGeladen(() => getProjektkonto(projektId), projektId, 'Das Projektkonto konnte nicht geladen werden.')
|
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? }`.
|
* 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.
|
* Der Körper wird über einen Ref gelesen, der Effekt hängt nur an [aktiv, schluessel]; nach 350 ms Ruhe geht die Anfrage raus.
|
||||||
|
|||||||
@@ -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))}`
|
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 -----------------------------------------------------------------------------------------
|
// --- Rechnungsarten -----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Wählbare Arten mit Sperrgrund: [{ key, label, enabled, hint }]. */
|
/** Wählbare Arten mit Sperrgrund: [{ key, label, enabled, hint }]. */
|
||||||
export function artOptionen({ projektId, auftraege, rechnungen, auftraegeAktiv }) {
|
export function artOptionen({ projektId, auftraege, rechnungen, auftraegeAktiv }) {
|
||||||
if (projektId === null || projektId === undefined) return []
|
if (projektId === null || projektId === undefined) return []
|
||||||
const option = (key, enabled, hint = '') => ({ key, label: ART_OPTION_LABEL[key], enabled, hint: enabled ? '' : hint })
|
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 abs = auftraegeAktiv ? gueltigeFestpreisAbs(auftraege) : []
|
||||||
const freie = abs.filter((a) => !hatEntwurfZurAuftrag(rechnungen, a.id))
|
const freie = abs.filter((a) => !hatEntwurfZurAuftrag(rechnungen, a.id))
|
||||||
const schlussHinweis = abs.length === 0 ? AB_HINWEIS : ENTWURF_HINWEIS
|
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 }) {
|
export function standardArt(optionen, { auftraege, rechnungen, offeneMinuten, stripeOk = true }) {
|
||||||
const liste = Array.isArray(optionen) ? optionen : []
|
const liste = Array.isArray(optionen) ? optionen : []
|
||||||
if (liste.length === 0) return ''
|
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
|
// Ist Stripe nicht erreichbar, sind die berechneten Beträge unbekannt: keine Art vorschlagen
|
||||||
if (stripeOk === false) return ''
|
if (stripeOk === false) return ''
|
||||||
const waehlbar = (key) => liste.some((o) => o.key === key && o.enabled)
|
const waehlbar = (key) => liste.some((o) => o.key === key && o.enabled)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const {
|
|||||||
artOptionen, standardArt, standardAuftrag, anzahlungStandard,
|
artOptionen, standardArt, standardAuftrag, anzahlungStandard,
|
||||||
ausgewaehlteIds, blaetterSumme, blattAusAppwrite, ticketOptionen, zahlungszielVorschlag,
|
ausgewaehlteIds, blaetterSumme, blattAusAppwrite, ticketOptionen, zahlungszielVorschlag,
|
||||||
STEUERNUMMER_RECHNUNG_HINWEIS, steuernummerHinweis,
|
STEUERNUMMER_RECHNUNG_HINWEIS, steuernummerHinweis,
|
||||||
STRIPE_UNBEKANNT, abSummenText,
|
STRIPE_UNBEKANNT, abSummenText, ohneProjektAbDaten,
|
||||||
} = await loadModule('src/lib/rechnungDialogView.js')
|
} = await loadModule('src/lib/rechnungDialogView.js')
|
||||||
|
|
||||||
const ab = (over = {}) => ({
|
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', () => {
|
test('artOptionen: ohne Wahl keine Arten, ohne Projekt nur die freie Rechnung', () => {
|
||||||
assert.deepEqual(artOptionen({ projektId: null, auftraege: [ab()], rechnungen: [], auftraegeAktiv: true }), [])
|
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]])
|
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 })
|
const frei = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: true })
|
||||||
assert.equal(standardArt(frei, { ...lage, stripeOk: false }), 'frei')
|
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: [] })
|
||||||
|
})
|
||||||
Reference in new issue
Block a user