From 2aacdb2d56f453f0fe5b98f01d9d0efb203cc779 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 14:44:29 +0000 Subject: [PATCH] Finanzen: Einstellungen, Rechnungen am Kunden, InvoiceNinja aus der Oberflaeche entfernt Co-Authored-By: Claude Sonnet 5.5 --- README.md | 14 +- src/App.jsx | 2 + src/components/InvoicePanel.jsx | 262 ---------------- src/components/TicketCard.jsx | 4 - src/components/finance/BankAccessCard.jsx | 123 ++++++++ src/components/finance/CustomerInvoices.jsx | 24 ++ src/components/finance/FixedCostsCard.jsx | 257 +++++++++++++++ src/components/finance/FormField.jsx | 27 ++ src/components/finance/SettingsCard.jsx | 167 ++++++++++ src/hooks/useCustomers.js | 2 +- src/hooks/useFinanceResource.js | 36 +++ src/hooks/useFinanceSummary.js | 24 -- src/hooks/useInvoices.js | 36 --- src/lib/financeSettings.js | 327 ++++++++++++++++++++ src/lib/financeView.js | 30 +- src/lib/integrationsApi.js | 88 ------ src/lib/leads.js | 44 +-- src/pages/CustomerDetailPage.jsx | 44 +-- src/pages/CustomersPage.jsx | 5 +- src/pages/FinancePage.jsx | 14 +- src/pages/FinanceSettingsPage.jsx | 31 ++ src/styles/global.css | 35 +++ tests/unit/financeSettings.test.mjs | 286 +++++++++++++++++ tests/unit/financeView.test.mjs | 27 ++ 24 files changed, 1410 insertions(+), 499 deletions(-) delete mode 100644 src/components/InvoicePanel.jsx create mode 100644 src/components/finance/BankAccessCard.jsx create mode 100644 src/components/finance/CustomerInvoices.jsx create mode 100644 src/components/finance/FixedCostsCard.jsx create mode 100644 src/components/finance/FormField.jsx create mode 100644 src/components/finance/SettingsCard.jsx create mode 100644 src/hooks/useFinanceResource.js delete mode 100644 src/hooks/useFinanceSummary.js delete mode 100644 src/hooks/useInvoices.js create mode 100644 src/lib/financeSettings.js delete mode 100644 src/lib/integrationsApi.js create mode 100644 src/pages/FinanceSettingsPage.jsx create mode 100644 tests/unit/financeSettings.test.mjs diff --git a/README.md b/README.md index 147fab4..74e583b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # WOMS – Ticketsystem von WEBklar Internes Ticketsystem unter https://ticket.webklar.com: Tickets, Arbeitsblätter mit -Zeiterfassung, Kunden und Leads, Website-Projekte, Planboard und Rechnungen (nur -anzeigen; Rechnungen werden in InvoiceNinja geschrieben). +Zeiterfassung, Kunden und Leads, Website-Projekte, Planboard und Finanzen (Rechnungen +schreiben und verwalten, Buchungen zuordnen; nur für Admins). React/Vite im Browser, Daten in Appwrite (Projekt `6a1058610003c5a13a05`, Datenbank `woms-database`, API unter `https://ticket.webklar.com/v1`). @@ -23,7 +23,7 @@ Datum speichert WOMS als Text `dd.mm.yyyy`, Uhrzeiten als `hhmm` (z. B. `0930`). |---|---|---| | `workorders` | Tickets | `woid` (fortlaufend ab 10000), `topic`, `details`, `status`, `priority`, `type` (Webpage, Migration, Project, Akquise), `serviceType`, `customerId`/`customerName`, `assignedTo`/`assignedName`, `requestedBy`, `startDate`/`startTime`/`endTime`, `deadline` | | `worksheets` | Arbeitsblätter | `wsid` (fortlaufend ab 100000), `woid` + `workorderId`, `employeeId`/`employeeName`, `serviceType`, `oldStatus`/`newStatus`, `totalTime` (Minuten), `startDate`/`startTime`/`endDate`/`endTime`, `details`, `isComment`, `countsToPlan` (Abo-Stunden) | -| `customers` | Kunden und Leads | `code` (Kundennummer), `name`/`companyName`, `customerStatus` (z. B. lead, customer, lost), `archived`, Rechnungsadresse, `invoiceNinjaClientId`, Portal-Zugang | +| `customers` | Kunden und Leads | `code` (Kundennummer), `name`/`companyName`, `customerStatus` (z. B. lead, customer, lost), `archived`, Rechnungsadresse, Portal-Zugang, `invoiceNinjaClientId` (nicht mehr genutzt, Daten bleiben) | | `employees` | Mitarbeiter | `userId` (Appwrite-Benutzer), `displayName`, `email`, `shortcode` (Kürzel) | | `websiteProjects` | Website-Projekte und Vorschauen | `customerId` (steuert, was der Kunde im Portal sieht), `ticketId` (Ziel der Git-Push-Arbeitsblätter), Subdomain, Repo | | `leads`, `leadPipeline`, `leadSettings` | Lead-Automatik | Recherche-Ergebnisse, Pipeline-Schritte, Sucheinstellungen | @@ -37,6 +37,8 @@ Zusammenspiel mit anderen Systemen: nachgetragen, danach zählt sie als Arbeitszeit. Termine aus der Rechnungsanalyse landen am Akquise-Ticket des Kunden und erscheinen im Planboard. Kunden-, Mitarbeiter- und Projektverwaltung laufen über die Admin-API des Portals. -- **ticket-integrations** (`/api/integrations`): Proxy zu InvoiceNinja (Rechnungen - anzeigen, Kunden verknüpfen). Die Paperless-Routen dort nutzt WOMS nicht mehr; - Paperless selbst ist gestoppt. +- **Finanzen-API des Portals** (`/api/admin/finanzen`): Übersicht, Rechnungen (Stripe), + Buchungen (Bank), Einstellungen und Fixkosten. WOMS spricht sie mit dem Appwrite-JWT an + und zeigt sie unter `/finance` (Einstellungen: `/finance/einstellungen`). +- **ticket-integrations** (`/api/integrations`): wird von WOMS nicht mehr genutzt + (weder InvoiceNinja noch Paperless; Paperless selbst ist gestoppt). diff --git a/src/App.jsx b/src/App.jsx index 2ae6755..f6986d5 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,6 +10,7 @@ import AdminPage from './pages/AdminPage' import CustomersPage from './pages/CustomersPage' import CustomerDetailPage from './pages/CustomerDetailPage' import FinancePage from './pages/FinancePage' +import FinanceSettingsPage from './pages/FinanceSettingsPage' import LegalPage from './pages/LegalPage' import AssignProjectPage from './pages/AssignProjectPage' import ServicesPage from './pages/ServicesPage' @@ -52,6 +53,7 @@ function AppRoutes() { } /> {/* Finanzen: nur Admins, alle anderen gehen zu /tickets */} } /> + } /> } /> } /> } /> diff --git a/src/components/InvoicePanel.jsx b/src/components/InvoicePanel.jsx deleted file mode 100644 index 93d508f..0000000 --- a/src/components/InvoicePanel.jsx +++ /dev/null @@ -1,262 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' -import { FaFilePdf, FaPlus, FaLink, FaSpinner, FaRotate } from 'react-icons/fa6' -import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite' -import { integrationsApi, formatMoney, INVOICE_STATUS } from '../lib/integrationsApi' -import { useInvoices } from '../hooks/useInvoices' -import { customerStage, isInvoiceReady } from '../lib/leads' - -const card = { - background: 'rgba(45, 55, 72, 0.5)', - borderRadius: '12px', - padding: '16px', - border: '1px solid rgba(16, 185, 129, 0.2)', -} - -export default function InvoicePanel({ ticket }) { - const [customer, setCustomer] = useState(null) - const [customerLoading, setCustomerLoading] = useState(true) - const [customerError, setCustomerError] = useState(null) - - const loadCustomer = useCallback(async () => { - if (!ticket.customerId) { - setCustomer(null) - setCustomerLoading(false) - return - } - setCustomerLoading(true) - try { - const doc = await databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ticket.customerId) - setCustomer(doc) - setCustomerError(null) - } catch (err) { - setCustomerError(err.message) - setCustomer(null) - } finally { - setCustomerLoading(false) - } - }, [ticket.customerId]) - - useEffect(() => { - loadCustomer() - }, [loadCustomer]) - - const clientId = customer?.invoiceNinjaClientId || null - const { invoices, loading, error, refresh } = useInvoices(clientId) - - if (customerLoading) { - return ( -
- Kunde wird geladen... -
- ) - } - - if (!ticket.customerId) { - return ( -
-

Kein Kunde am Ticket verknüpft, keine Rechnungen verfügbar.

-
- ) - } - - return ( -
-
-
- Rechnungen {customer?.name ? `- ${customer.name}` : ''} -
- {clientId && ( - - )} -
- - {customerError &&
{customerError}
} - - {customer && customerStage(customer) === 'lead' ? ( -

- Dieser Kunde ist noch ein Lead. Erst zum festen Kunden machen (im Kunden-Bereich, vollständige Daten nötig), dann sind Rechnungen möglich. -

- ) : !clientId && !isInvoiceReady(customer) ? ( -

- Rechnungsdaten unvollständig. Bitte im Kunden-Bereich Unternehmen, E-Mail, Straße, PLZ und Stadt ergänzen. -

- ) : !clientId ? ( - - ) : ( - - )} -
- ) -} - -// ---------------------------------------------------- Verknuepfung ----------- -function LinkClient({ customer, onLinked }) { - const [search, setSearch] = useState(customer?.name || '') - const [results, setResults] = useState([]) - const [busy, setBusy] = useState(false) - const [error, setError] = useState(null) - const [saving, setSaving] = useState(false) - - const doSearch = async () => { - setBusy(true) - setError(null) - try { - const data = await integrationsApi.listClients(search) - setResults(data.clients || []) - } catch (err) { - setError(err.message) - } finally { - setBusy(false) - } - } - - useEffect(() => { - doSearch() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - const saveMapping = async (clientId) => { - setSaving(true) - setError(null) - try { - await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, { - invoiceNinjaClientId: clientId, - }) - onLinked() - } catch (err) { - setError(err.message) - setSaving(false) - } - } - - const createAndLink = async () => { - setSaving(true) - setError(null) - try { - const data = await integrationsApi.createClient({ - name: customer.name || customer.companyName || 'Neuer Kunde', - email: customer.email || '', - phone: customer.phone || '', - address: customer.location || '', - }) - if (data.client?.id) { - await saveMapping(data.client.id) - } else { - throw new Error('Client konnte nicht angelegt werden') - } - } catch (err) { - setError(err.message) - setSaving(false) - } - } - - return ( -
-

- Dieser Kunde ist noch nicht mit InvoiceNinja verknüpft. -

-
- setSearch(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && doSearch()} - /> - -
- - {error &&
{error}
} - -
- {results.map((c) => ( -
-
- {c.name} - {c.number ? · {c.number} : null} - {c.email ?
{c.email}
: null} -
- -
- ))} - {!busy && results.length === 0 && ( -

Keine Treffer.

- )} -
- - -
- ) -} - -// ---------------------------------------------------- Rechnungsliste --------- -// Nur anzeigen: neue Rechnungen entstehen direkt in InvoiceNinja -function InvoiceList({ invoices, loading, error }) { - const [openingId, setOpeningId] = useState(null) - - const openPdf = async (id) => { - setOpeningId(id) - try { - await integrationsApi.openInvoicePdf(id) - } catch (err) { - alert('PDF konnte nicht geöffnet werden: ' + err.message) - } finally { - setOpeningId(null) - } - } - - return ( -
- {error &&
{error}
} - - {loading ? ( -

Rechnungen werden geladen...

- ) : invoices.length === 0 ? ( -

Noch keine Rechnungen für diesen Kunden.

- ) : ( -
- - - - - - - - {invoices.map((inv) => { - const st = INVOICE_STATUS[inv.status_id] || { label: '–', color: '#a0aec0' } - const overdue = Number(inv.balance) > 0 && inv.due_date && new Date(inv.due_date) < new Date() - return ( - - - - - - - - - - ) - })} - -
Nr.DatumFälligBetragOffenStatus
{inv.number || inv.id?.slice(-6)}{inv.date || '-'}{inv.due_date || '-'}{formatMoney(inv.amount)} 0 ? '#f59e0b' : '#10b981' }}>{formatMoney(inv.balance)}{st.label}{overdue ? ' (überfällig)' : ''} - -
-
- )} -
- ) -} diff --git a/src/components/TicketCard.jsx b/src/components/TicketCard.jsx index e479095..2d7fb42 100644 --- a/src/components/TicketCard.jsx +++ b/src/components/TicketCard.jsx @@ -12,7 +12,6 @@ import OwnSupportModal from './OwnSupportModal' import WorksheetSummary from './WorksheetSummary' import TicketProjects from './TicketProjects' import ProjectContextPanel from './ProjectContextPanel' -import InvoicePanel from './InvoicePanel' import Tabs from './ui/Tabs' import Badge from './ui/Badge' import { StatusPill, UrgentPill } from './ui/StatusPill' @@ -28,7 +27,6 @@ import { ticketTypeLabel } from '../lib/ticketStatus' const TABS = [ { id: 'details', label: 'Details' }, { id: 'worksheets', label: 'Arbeitsblätter' }, - { id: 'invoices', label: 'Rechnungen' }, ] export default function TicketCard({ ticket, onUpdate }) { @@ -155,8 +153,6 @@ export default function TicketCard({ ticket, onUpdate }) { /> )} - - {activeTab === 'invoices' && } )} diff --git a/src/components/finance/BankAccessCard.jsx b/src/components/finance/BankAccessCard.jsx new file mode 100644 index 0000000..0e794e4 --- /dev/null +++ b/src/components/finance/BankAccessCard.jsx @@ -0,0 +1,123 @@ +import { useEffect, useRef, useState } from 'react' +import { FaRotate, FaSpinner } from 'react-icons/fa6' +import { Badge, Button, Card } from '../ui' +import { useFinanceResource } from '../../hooks/useFinanceResource' +import { getBank, refreshBank, startBankAuth } from '../../lib/financeApi' +import { bankCardModel, refreshResultMessage } from '../../lib/financeSettings' +import { safeUrl } from '../../lib/financeView' + +const LOAD_ERROR = 'Der Bankzugang konnte nicht geladen werden.' + +/** + * Karte „Bankzugang“: Status, IBAN (maskiert), Zugang gültig bis, letzter Abruf und der letzte Fehler. + * „Jetzt abrufen“ holt die Buchungen sofort (der Server begrenzt die Häufigkeit, die Meldung steht dann hier), + * „Neu verbinden“ führt zur Freigabe bei der Bank (nur http/https-Adressen). Ohne Zugang gibt es nur „Bank verbinden“. + */ +export default function BankAccessCard() { + const res = useFinanceResource(getBank, LOAD_ERROR) + const [busy, setBusy] = useState(null) // 'refresh' | 'connect' + const [message, setMessage] = useState(null) // { tone: 'ok' | 'info' | 'warn', text } + const lockRef = useRef(false) + const aliveRef = useRef(true) + + useEffect(() => { + aliveRef.current = true + return () => { + aliveRef.current = false + } + }, []) + + async function refresh() { + if (lockRef.current) return + lockRef.current = true + setBusy('refresh') + setMessage(null) + try { + const result = await refreshBank() + await res.reload() + if (aliveRef.current) setMessage(refreshResultMessage(result)) + } catch (err) { + // 429 „Bitte in N Minuten erneut versuchen.“ ist kein Fehler, nur ein Hinweis + if (aliveRef.current) setMessage({ tone: err?.status === 429 ? 'info' : 'warn', text: err?.message || 'Der Abruf ist fehlgeschlagen.' }) + } finally { + lockRef.current = false + if (aliveRef.current) setBusy(null) + } + } + + async function connect() { + if (lockRef.current) return + lockRef.current = true + setBusy('connect') + setMessage(null) + try { + const { url } = await startBankAuth() + const target = safeUrl(url) + if (!target) throw new Error('Die Bank hat keine Weiterleitung geliefert.') + window.location.assign(target) + } catch (err) { + if (aliveRef.current) setMessage({ tone: 'warn', text: err?.message || 'Die Verbindung konnte nicht gestartet werden.' }) + } finally { + lockRef.current = false + if (aliveRef.current) setBusy(null) + } + } + + if (!res.data) { + return ( + + {res.loading ? ( +
+
+ ) : ( +
+

{res.error || LOAD_ERROR}

+ +
+ )} +
+ ) + } + + const model = bankCardModel(res.data.verbindung) + return ( + {model.statusLabel}}> + {res.error && ( +

+ Der Stand konnte nicht neu geladen werden: {res.error} +

+ )} + {model.rows.length > 0 && ( +
+ {model.rows.map((row) => ( +
+
{row.label}
+
{row.text}
+
+ ))} +
+ )} + {model.error && ( +

Fehler: {model.error}

+ )} + {model.hint &&

{model.hint}

} + {message && ( +

+ {message.text} +

+ )} +
+ {model.canRefresh && ( + + )} + +
+
+ ) +} diff --git a/src/components/finance/CustomerInvoices.jsx b/src/components/finance/CustomerInvoices.jsx new file mode 100644 index 0000000..2101679 --- /dev/null +++ b/src/components/finance/CustomerInvoices.jsx @@ -0,0 +1,24 @@ +import { Button, Card, EmptyState } from '../ui' +import InvoiceTable from './InvoiceTable' +import { customerInvoicesGate } from '../../lib/financeView' + +/** + * Reiter „Rechnungen“ am Kunden. Kann für den Kunden keine Rechnung geschrieben werden (noch Lead, abgesagt, keine + * E-Mail), steht der Grund da, mit „Daten erfassen“ (onShowOverview wechselt zum Reiter Übersicht). Sonst die + * Rechnungen dieses Kunden mit „Rechnung schreiben“ (der Kunde steht im Dialog fest). + */ +export default function CustomerInvoices({ customer, onShowOverview }) { + const gate = customerInvoicesGate(customer) + if (!gate.ok) { + return ( + + Daten erfassen : null} + /> + + ) + } + return +} diff --git a/src/components/finance/FixedCostsCard.jsx b/src/components/finance/FixedCostsCard.jsx new file mode 100644 index 0000000..ded7b7d --- /dev/null +++ b/src/components/finance/FixedCostsCard.jsx @@ -0,0 +1,257 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { FaPlus, FaSpinner } from 'react-icons/fa6' +import { Badge, Button, Card, EmptyState } from '../ui' +import FormField from './FormField' +import { useFinanceResource } from '../../hooks/useFinanceResource' +import { createFixedCost, deleteFixedCost, getFixedCosts, updateFixedCost } from '../../lib/financeApi' +import { + FIXED_COST_DELETE_CONFIRM, + FIXED_COST_FIELD_ORDER, + INTERVAL_OPTIONS, + SETTINGS_LIMITS, + apiErrorMessage, + emptyFixedCostForm, + fixedCostRows, + fixedCostToForm, + validateFixedCostForm, +} from '../../lib/financeSettings' + +const LOAD_ERROR = 'Die Fixkosten konnten nicht geladen werden.' +const fieldId = (name) => `fin-fix-${name}` + +/** + * Karte „Fixkosten“: regelmäßige Ausgaben (Name, Betrag in Euro, Rhythmus, nächster Termin, Suchbegriff). Anlegen und + * Bearbeiten im Formular über der Liste, „Auf inaktiv setzen“ / „Aktivieren“ je Zeile, Löschen mit Rückfrage + * (Besser inaktiv: bereits zugeordnete Buchungen behalten den Verweis). Es läuft immer nur eine Anfrage; danach wird + * die Liste neu geladen. + */ +export default function FixedCostsCard() { + const res = useFinanceResource(getFixedCosts, LOAD_ERROR) + // null = Formular zu; { id: null } = neue Position; { id } = Position bearbeiten + const [editing, setEditing] = useState(null) + const [form, setForm] = useState(emptyFixedCostForm) + const [errors, setErrors] = useState({}) + const [busy, setBusy] = useState(null) // 'save' | `${id}:toggle` | `${id}:delete` + const [message, setMessage] = useState(null) // { tone: 'ok' | 'warn', text } + const lockRef = useRef(false) + const aliveRef = useRef(true) + + useEffect(() => { + aliveRef.current = true + return () => { + aliveRef.current = false + } + }, []) + + const list = res.data?.fixkosten + const rows = useMemo(() => fixedCostRows(list), [list]) + const byId = useMemo(() => Object.fromEntries((Array.isArray(list) ? list : []).map((p) => [p.$id, p])), [list]) + + // Der Fokus geht beim Öffnen des Formulars ins erste Feld + const formKey = editing ? editing.id || 'new' : '' + useEffect(() => { + if (formKey) document.getElementById(fieldId('name'))?.focus() + }, [formKey]) + + function openNew() { + if (lockRef.current) return + setEditing({ id: null }) + setForm(emptyFixedCostForm()) + setErrors({}) + setMessage(null) + } + + function openEdit(row) { + if (lockRef.current) return + setEditing({ id: row.id }) + setForm(fixedCostToForm(byId[row.id])) + setErrors({}) + setMessage(null) + } + + function closeForm() { + if (lockRef.current) return + setEditing(null) + setErrors({}) + } + + const change = (name) => (e) => { + const value = e.target.value + setForm((f) => ({ ...f, [name]: value })) + setErrors((er) => (er[name] ? { ...er, [name]: undefined } : er)) + } + + // Eine Anfrage nach der anderen; danach die Liste neu laden (der Server ist die Wahrheit) + async function run(key, call, okText, { onOk } = {}) { + if (lockRef.current) return + lockRef.current = true + setBusy(key) + setMessage(null) + try { + await call() + // Erst die neue Liste, dann die Meldung: sie soll nie einen Stand beschreiben, den die Liste noch nicht zeigt + await res.reload() + if (aliveRef.current) { + setMessage({ tone: 'ok', text: okText }) + if (onOk) onOk() + } + } catch (err) { + if (aliveRef.current) { + setMessage({ tone: 'warn', text: apiErrorMessage(err, 'Die Anfrage ist fehlgeschlagen.') }) + } + // Der Stand kann ein anderer sein (Position schon gelöscht): neu laden + await res.reload() + } finally { + lockRef.current = false + if (aliveRef.current) setBusy(null) + } + } + + function submit(e) { + e.preventDefault() + if (lockRef.current || !editing) return + const check = validateFixedCostForm(form) + if (!check.ok) { + setErrors(check.errors) + setMessage({ tone: 'warn', text: 'Bitte die markierten Felder prüfen. Es wurde nichts gespeichert.' }) + const first = FIXED_COST_FIELD_ORDER.find((name) => check.errors[name]) + if (first) document.getElementById(fieldId(first))?.focus() + return + } + const id = editing.id + run( + 'save', + () => (id ? updateFixedCost(id, check.payload) : createFixedCost(check.payload)), + id ? 'Position gespeichert.' : 'Position angelegt.', + { onOk: () => { setEditing(null); setErrors({}) } } + ) + } + + function toggle(row) { + run( + `${row.id}:toggle`, + () => updateFixedCost(row.id, { active: !row.active }), + row.active ? `„${row.name}“ ist jetzt inaktiv.` : `„${row.name}“ ist wieder aktiv.` + ) + } + + function remove(row) { + if (lockRef.current) return + if (!window.confirm(FIXED_COST_DELETE_CONFIRM)) return + run(`${row.id}:delete`, () => deleteFixedCost(row.id), `„${row.name}“ wurde gelöscht.`, { + onOk: () => { if (editing?.id === row.id) setEditing(null) }, + }) + } + + let body + if (!res.data) { + body = res.loading ? ( +
+
+ ) : ( +
+

{res.error || LOAD_ERROR}

+ +
+ ) + } else { + body = ( + <> + {res.error && ( +

+ Die Liste konnte nicht neu geladen werden: {res.error}{' '} + +

+ )} + {editing && ( +
+

{editing.id ? 'Position bearbeiten' : 'Neue Position'}

+
+
+ + + + + + + + + + + + +
+ + + +
+
+ + +
+
+ )} + {message && ( +

+ {message.text} +

+ )} + {rows.length === 0 ? ( + + ) : ( +
    + {rows.map((row) => ( +
  • +
    + {row.name} + {row.statusLabel} +
    +
    {row.amountText}
    +
    {row.metaText}
    +
    + + + +
    +
  • + ))} +
+ )} + + ) + } + + return ( + + + ) +} diff --git a/src/components/finance/FormField.jsx b/src/components/finance/FormField.jsx new file mode 100644 index 0000000..7ce10ef --- /dev/null +++ b/src/components/finance/FormField.jsx @@ -0,0 +1,27 @@ +import { cloneElement } from 'react' + +/** + * Formularfeld mit Beschriftung, Hinweis und Fehlermeldung. `children` ist genau ein Eingabeelement; es bekommt + * `id`, `aria-invalid` und `aria-describedby` (Hinweis und Fehler). Die Fehlermeldung wird vorgelesen (role="alert"). + */ +export default function FormField({ id, label, hint, error, children }) { + const hintId = hint ? `${id}-hint` : undefined + const errorId = error ? `${id}-error` : undefined + const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined + return ( +
+ + {cloneElement(children, { id, 'aria-invalid': error ? 'true' : undefined, 'aria-describedby': describedBy })} + {hint && ( +
+ {hint} +
+ )} + {error && ( + + )} +
+ ) +} diff --git a/src/components/finance/SettingsCard.jsx b/src/components/finance/SettingsCard.jsx new file mode 100644 index 0000000..5b04974 --- /dev/null +++ b/src/components/finance/SettingsCard.jsx @@ -0,0 +1,167 @@ +import { useEffect, useRef, useState } from 'react' +import { FaFloppyDisk, FaSpinner } from 'react-icons/fa6' +import { Button, Card } from '../ui' +import FormField from './FormField' +import { useFinanceResource } from '../../hooks/useFinanceResource' +import { getSettings, patchSettings } from '../../lib/financeApi' +import { + BANK_DETAILS_EXAMPLE, + SETTINGS_FIELD_ORDER, + SETTINGS_LIMITS, + apiErrorMessage, + bankDetailsLength, + settingsToForm, + validateSettingsForm, +} from '../../lib/financeSettings' + +const LOAD_ERROR = 'Die Einstellungen konnten nicht geladen werden.' +const fieldId = (name) => `fin-set-${name}` + +/** + * Karte „Einstellungen“: Mindestpuffer, Steuer-Rücklage, Vorjahresumsatz, ESt-Vorauszahlung und Bankverbindungstext + * für den Rechnungsfuß. Beträge werden in Euro eingegeben (z. B. 1.234,56; 0 ist bei Mindestpuffer und ESt erlaubt), + * ein leerer Vorjahresumsatz heißt „unbekannt“. Fehler stehen am Feld, Fehler des Servers über dem Knopf. + */ +export default function SettingsCard() { + const res = useFinanceResource(getSettings, LOAD_ERROR) + + if (!res.data) { + return ( + + {res.loading ? ( +
+
+ ) : ( +
+

{res.error || LOAD_ERROR}

+ +
+ )} +
+ ) + } + return +} + +function SettingsForm({ initial }) { + const [form, setForm] = useState(() => settingsToForm(initial)) + const [errors, setErrors] = useState({}) + const [busy, setBusy] = useState(false) + const [notice, setNotice] = useState(null) // { tone: 'ok' | 'warn', text } + const lockRef = useRef(false) + const aliveRef = useRef(true) + + useEffect(() => { + aliveRef.current = true + return () => { + aliveRef.current = false + } + }, []) + + const change = (name) => (e) => { + const value = e.target.value + setForm((f) => ({ ...f, [name]: value })) + setErrors((er) => (er[name] ? { ...er, [name]: undefined } : er)) + if (notice) setNotice(null) + } + + async function submit(e) { + e.preventDefault() + if (lockRef.current) return + const check = validateSettingsForm(form) + if (!check.ok) { + setErrors(check.errors) + setNotice({ tone: 'warn', text: 'Bitte die markierten Felder prüfen. Es wurde nichts gespeichert.' }) + const first = SETTINGS_FIELD_ORDER.find((name) => check.errors[name]) + if (first) document.getElementById(fieldId(first))?.focus() + return + } + lockRef.current = true + setBusy(true) + setErrors({}) + setNotice(null) + try { + const data = await patchSettings(check.payload) + if (!aliveRef.current) return + if (data?.einstellungen) setForm(settingsToForm(data.einstellungen)) + setNotice({ tone: 'ok', text: 'Gespeichert.' }) + } catch (err) { + if (aliveRef.current) setNotice({ tone: 'warn', text: apiErrorMessage(err) }) + } finally { + lockRef.current = false + if (aliveRef.current) setBusy(false) + } + } + + const length = bankDetailsLength(form.bankDetailsText) + const tooLong = length > SETTINGS_LIMITS.maxBankDetails + + return ( + +
+ {/* Während des Speicherns gesperrt, damit die Antwort keine neuen Eingaben überschreibt */} +
+
+ + + + + + + + + + + + +
+ + +