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)) +})