diff --git a/src/components/auftraege/RechnungDialog.jsx b/src/components/auftraege/RechnungDialog.jsx index dc8d40c..bed3f7c 100644 --- a/src/components/auftraege/RechnungDialog.jsx +++ b/src/components/auftraege/RechnungDialog.jsx @@ -41,6 +41,7 @@ import { startProjekt, ticketOptionen, wirksamesProjekt, + zahlungszielVorschlag, } from '../../lib/rechnungDialogView' import { BEZEICHNUNG_MAX, @@ -169,6 +170,12 @@ function RechnungFormular({ onClose, onCreated, projektId, auftraegeAktiv, onSho const gate = invoiceSendGate(kunde, totalCents) const darf = darfSpeichern(status) && busy === null && Boolean(kunde) && projekt !== null && art !== '' + // Das Zahlungsziel der gewählten AB (bzw. 14); der Reducer lässt eine Eingabe des Nutzers unberührt + const tageVorschlag = zahlungszielVorschlag({ art, ab, auftraege }) + useEffect(() => { + dispatch({ type: 'tage-vorschlag', wert: tageVorschlag }) + }, [tageVorschlag]) + // --- Effekte auf die Vorschau ----------------------------------------------------------------------------- const vorschauZeitraum = status.state === 'bereit' ? vorschau.daten.leistungszeitraum || '' : null useEffect(() => { diff --git a/src/lib/rechnungDialogState.js b/src/lib/rechnungDialogState.js index 5181ce8..d5ba14b 100644 --- a/src/lib/rechnungDialogState.js +++ b/src/lib/rechnungDialogState.js @@ -24,6 +24,7 @@ export function anfangszustand({ projektId = null } = {}) { zeitraum: '', zeitraumGeaendert: false, days: String(INVOICE_LIMITS.defaultDays), + tageGeaendert: false, memo: '', } } @@ -35,7 +36,7 @@ function neueUid(s) { export function reduce(s, a) { switch (a?.type) { case 'projekt': - return { ...anfangszustand({ projektId: a.projektId }), days: s.days, memo: s.memo } + return { ...anfangszustand({ projektId: a.projektId }), days: s.days, tageGeaendert: s.tageGeaendert, memo: s.memo } case 'art': return { ...s, @@ -84,6 +85,10 @@ export function reduce(s, a) { if (s.zeitraumGeaendert || s.zeitraum === a.text) return s return { ...s, zeitraum: a.text } case 'tage': + return { ...s, days: a.wert, tageGeaendert: true } + case 'tage-vorschlag': + // das Zahlungsziel der gewählten AB; eine Eingabe des Nutzers überschreibt er nie + if (s.tageGeaendert || s.days === a.wert) return s return { ...s, days: a.wert } case 'notiz': return { ...s, memo: a.wert } diff --git a/src/lib/rechnungDialogView.js b/src/lib/rechnungDialogView.js index 52e4f8a..7964cf0 100644 --- a/src/lib/rechnungDialogView.js +++ b/src/lib/rechnungDialogView.js @@ -2,6 +2,7 @@ // 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 { INVOICE_LIMITS } from './financeView' import { worksheetMinutes } from './support' import { germanDateToIso } from './ticketForm' import { ticketsText } from './projektkontoView' @@ -145,6 +146,19 @@ export function standardAuftrag(art, auftraege, rechnungen = []) { return '' } +/** + * Vorbelegung „Zahlungsziel in Tagen“: bei Anzahlung und Schlussrechnung das Ziel der gewählten AB, bei Aufwand das der + * jüngsten gültigen Aufwands-AB (`auftraege`), sonst 14. Nur ganze Zahlen von 0 bis 60 zählen. -> Text + */ +export function zahlungszielVorschlag({ art, ab = null, auftraege = [] } = {}) { + const standard = String(INVOICE_LIMITS.defaultDays) + let quelle = null + if (art === 'anzahlung' || art === 'schluss') quelle = ab + else if (art === 'aufwand') quelle = aufwandAuftrag(auftraege) + const tage = quelle?.zahlungszielTage + return Number.isSafeInteger(tage) && tage >= 0 && tage <= INVOICE_LIMITS.maxDays ? String(tage) : standard +} + /** Vorgabe der Anzahlung: Prozent aus dem Zahlungsplan, sonst 50. */ export function anzahlungStandard(auftrag) { const plan = auftrag?.zahlungsplan diff --git a/tests/unit/rechnungDialogState.test.mjs b/tests/unit/rechnungDialogState.test.mjs index 067b7e8..0016188 100644 --- a/tests/unit/rechnungDialogState.test.mjs +++ b/tests/unit/rechnungDialogState.test.mjs @@ -9,7 +9,7 @@ test('anfangszustand: nichts gewählt, eine leere Position, Zahlungsziel 14', () projektId: 'p1', art: '', auftragId: '', items: [{ uid: 1, description: '', quantity: '1', price: '' }], zusatz: [], abgewaehlt: [], weitereTickets: [], anzahlung: {}, zeilentexte: {}, - zeitraum: '', zeitraumGeaendert: false, days: '14', memo: '', + zeitraum: '', zeitraumGeaendert: false, days: '14', tageGeaendert: false, memo: '', }) assert.equal(anfangszustand().projektId, null) assert.equal(anfangszustand({ projektId: '' }).projektId, '') @@ -113,3 +113,20 @@ test('anzahlung und auftrag: Eingaben ergänzen sich, eine andere AB setzt sie z assert.deepEqual([s.auftragId, s.anzahlung], ['a2', {}]) assert.equal(reduce(s, { type: 'gibtsnicht' }), s) }) + +test('tage-vorschlag: übernimmt das Zahlungsziel der AB, solange der Nutzer es nicht selbst geändert hat', () => { + let s = anfangszustand({ projektId: 'p1' }) + s = reduce(s, { type: 'tage-vorschlag', wert: '30' }) + assert.equal(s.days, '30') + assert.equal(s.tageGeaendert, false) + assert.equal(reduce(s, { type: 'tage-vorschlag', wert: '30' }), s) // derselbe Wert: nichts zu tun + s = reduce(s, { type: 'tage-vorschlag', wert: '14' }) // andere AB gewählt: der Vorschlag folgt + assert.equal(s.days, '14') + // eine Eingabe des Nutzers wird nie überschrieben, auch nicht nach einem Projektwechsel + s = reduce(s, { type: 'tage', wert: '7' }) + assert.equal(s.tageGeaendert, true) + assert.equal(reduce(s, { type: 'tage-vorschlag', wert: '30' }), s) + s = reduce(s, { type: 'projekt', projektId: 'p2' }) + assert.equal(s.days, '7') + assert.equal(reduce(s, { type: 'tage-vorschlag', wert: '30' }).days, '7') +}) diff --git a/tests/unit/rechnungDialogView.test.mjs b/tests/unit/rechnungDialogView.test.mjs index 3a3c532..076d96e 100644 --- a/tests/unit/rechnungDialogView.test.mjs +++ b/tests/unit/rechnungDialogView.test.mjs @@ -7,7 +7,7 @@ const { startProjekt, projektOptionen, wirksamesProjekt, gueltigeFestpreisAbs, aufwandAuftrag, abLabel, berechnetFuerAuftrag, hatEntwurfZurAuftrag, artOptionen, standardArt, standardAuftrag, anzahlungStandard, - ausgewaehlteIds, blaetterSumme, blattAusAppwrite, ticketOptionen, + ausgewaehlteIds, blaetterSumme, blattAusAppwrite, ticketOptionen, zahlungszielVorschlag, } = await loadModule('src/lib/rechnungDialogView.js') const ab = (over = {}) => ({ @@ -215,3 +215,20 @@ test('ticketOptionen: Tickets des Kunden mit Nummer, ohne die schon gewählten', ]) assert.deepEqual(ticketOptionen(undefined), []) }) + +test('zahlungszielVorschlag: Anzahlung und Schluss übernehmen das Zahlungsziel der gewählten AB, sonst 14', () => { + const dreissig = ab({ zahlungszielTage: 30 }) + assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: dreissig }), '30') + assert.equal(zahlungszielVorschlag({ art: 'schluss', ab: dreissig }), '30') + assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: ab({ zahlungszielTage: 0 }) }), '0') // sofort fällig ist erlaubt + // Aufwand: die jüngste gültige Aufwands-AB + const aufwand = ab({ id: 'x2', art: 'aufwand', zahlungszielTage: 21 }) + assert.equal(zahlungszielVorschlag({ art: 'aufwand', auftraege: [ab(), aufwand] }), '21') + // ohne AB, ohne Wert oder mit unbrauchbarem Wert bleibt es bei 14; frei und Festbetrag kennen keine AB + assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: undefined }), '14') + assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: ab() }), '14') + for (const kaputt of [-1, 61, 1.5, '30', null, NaN]) assert.equal(zahlungszielVorschlag({ art: 'schluss', ab: ab({ zahlungszielTage: kaputt }) }), '14') + assert.equal(zahlungszielVorschlag({ art: 'festbetrag', ab: dreissig }), '14') + assert.equal(zahlungszielVorschlag({ art: 'frei', ab: dreissig }), '14') + assert.equal(zahlungszielVorschlag({ art: '', auftraege: [aufwand] }), '14') +})