} />
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.
- ) : (
-
-
-
-
- | Nr. | Datum | Fällig | Betrag | Offen | Status | |
-
-
-
- {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 (
-
- | {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 ? (
+
+
+
Bankzugang wird geladen …
+
+ ) : (
+
+
{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 ? (
+
+
+
Fixkosten werden geladen …
+
+ ) : (
+
+
{res.error || LOAD_ERROR}
+
+
+ )
+ } else {
+ body = (
+ <>
+ {res.error && (
+
+ Die Liste konnte nicht neu geladen werden: {res.error}{' '}
+
+
+ )}
+ {editing && (
+
+ )}
+ {message && (
+
+ {message.text}
+
+ )}
+ {rows.length === 0 ? (
+
+ ) : (
+
+ {rows.map((row) => (
+ -
+
+ {row.name}
+ {row.statusLabel}
+
+ {row.amountText}
+ {row.metaText}
+
+
+
+
+
+
+ ))}
+
+ )}
+ >
+ )
+ }
+
+ return (
+
+ Neue Position
+
+ )
+ }
+ >
+ {body}
+
+ )
+}
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 && (
+
+ {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 ? (
+
+
+
Einstellungen werden geladen …
+
+ ) : (
+
+
{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 (
+
+
+
+ )
+}
diff --git a/src/hooks/useCustomers.js b/src/hooks/useCustomers.js
index 7298d72..9015162 100644
--- a/src/hooks/useCustomers.js
+++ b/src/hooks/useCustomers.js
@@ -19,7 +19,7 @@ export function useCustomers() {
const fetchCustomers = useCallback(async () => {
try {
// Direkt aus Appwrite: die Admin-API des Portals liefert gekuerzte Daten
- // (ohne Stern und InvoiceNinja-Verknuepfung)
+ // (zum Beispiel ohne Stern)
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
diff --git a/src/hooks/useFinanceResource.js b/src/hooks/useFinanceResource.js
new file mode 100644
index 0000000..5810069
--- /dev/null
+++ b/src/hooks/useFinanceResource.js
@@ -0,0 +1,36 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+/**
+ * Lädt eine Ressource der Finanzen-API (z. B. getSettings) und hält sie.
+ * data bleibt beim erneuten Laden stehen; error ist die Meldung des letzten fehlgeschlagenen Versuchs (leer, solange
+ * alles in Ordnung ist). reload() liefert ein Promise, das nie wirft; nur der jüngste Aufruf zählt.
+ */
+export function useFinanceResource(load, fallbackError) {
+ const [state, setState] = useState({ data: null, loading: true, error: '' })
+ const aliveRef = useRef(true)
+ const seqRef = useRef(0)
+ const loadRef = useRef(load)
+ loadRef.current = load
+
+ const reload = useCallback(async () => {
+ const mine = ++seqRef.current
+ const current = () => aliveRef.current && mine === seqRef.current
+ setState((s) => ({ ...s, loading: true, error: '' }))
+ try {
+ const data = await loadRef.current()
+ if (current()) setState({ data: data && typeof data === 'object' ? data : {}, loading: false, error: '' })
+ } catch (err) {
+ if (current()) setState((s) => ({ ...s, loading: false, error: err?.message || fallbackError }))
+ }
+ }, [fallbackError])
+
+ useEffect(() => {
+ aliveRef.current = true
+ reload()
+ return () => {
+ aliveRef.current = false
+ }
+ }, [reload])
+
+ return { ...state, reload }
+}
diff --git a/src/hooks/useFinanceSummary.js b/src/hooks/useFinanceSummary.js
deleted file mode 100644
index f53f6cf..0000000
--- a/src/hooks/useFinanceSummary.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { useState, useEffect, useCallback } from 'react'
-import { integrationsApi } from '../lib/integrationsApi'
-
-export function useFinanceSummary() {
- const [summary, setSummary] = useState(null)
- const [loading, setLoading] = useState(true)
- const [error, setError] = useState(null)
-
- const load = useCallback(async () => {
- setLoading(true)
- setError(null)
- try {
- setSummary(await integrationsApi.invoicesSummary())
- } catch (err) {
- setError(err.message)
- } finally {
- setLoading(false)
- }
- }, [])
-
- useEffect(() => { load() }, [load])
-
- return { summary, loading, error, refresh: load }
-}
diff --git a/src/hooks/useInvoices.js b/src/hooks/useInvoices.js
deleted file mode 100644
index b5c1035..0000000
--- a/src/hooks/useInvoices.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { useState, useEffect, useCallback } from 'react'
-import { integrationsApi } from '../lib/integrationsApi'
-
-/**
- * Laedt die InvoiceNinja-Rechnungen eines verknuepften Clients.
- * clientId = customers.invoiceNinjaClientId (oder null -> nichts laden).
- */
-export function useInvoices(clientId) {
- const [invoices, setInvoices] = useState([])
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
-
- const fetchInvoices = useCallback(async () => {
- if (!clientId) {
- setInvoices([])
- return
- }
- setLoading(true)
- setError(null)
- try {
- const data = await integrationsApi.listInvoices(clientId)
- setInvoices(data.invoices || [])
- } catch (err) {
- setError(err.message)
- setInvoices([])
- } finally {
- setLoading(false)
- }
- }, [clientId])
-
- useEffect(() => {
- fetchInvoices()
- }, [fetchInvoices])
-
- return { invoices, loading, error, refresh: fetchInvoices }
-}
diff --git a/src/lib/financeSettings.js b/src/lib/financeSettings.js
new file mode 100644
index 0000000..8e59a9a
--- /dev/null
+++ b/src/lib/financeSettings.js
@@ -0,0 +1,327 @@
+// Reine Helfer fuer die Einstellungsseite der Finanzen (Einstellungen, Fixkosten, Bankzugang); kein React, kein
+// Appwrite, unter Node testbar. Betraege sind Cent, in Formularen Euro-Texte (de-DE); Texte deutsch.
+import { INTERVAL_LABEL, dateDe, euro, parseEuro, sinceLabel, ymdBerlin } from './financeFormat'
+
+const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
+const text = (v) => (typeof v === 'string' ? v.trim() : '')
+
+/** Grenzen der Portal-API (settings.js); die Oberfläche prüft vorab, der Server entscheidet. */
+export const SETTINGS_LIMITS = {
+ maxPercent: 60,
+ maxBufferCents: 100_000_000,
+ maxBankDetails: 500,
+ maxName: 120,
+ maxMatch: 120,
+}
+
+/** Beispiel für den Bankverbindungstext (steht im Fuß jeder Rechnung). */
+export const BANK_DETAILS_EXAMPLE = 'Bankverbindung: WEBklar GbR · IBAN DE.. · BIC ..'
+
+/** Rückfrage vor dem Löschen einer Fixkosten-Position. */
+export const FIXED_COST_DELETE_CONFIRM =
+ 'Position wirklich löschen? Bereits zugeordnete Buchungen behalten den Verweis. Besser auf inaktiv setzen.'
+
+const AMOUNT_HELP = 'Bitte einen Betrag wie 1.234,56 eingeben.'
+
+// Felder, deren Namen der Server der Fehlermeldung voranstellt („minBufferCents: Der Mindestpuffer …“)
+const API_FIELDS = [
+ 'minBufferCents',
+ 'taxReservePercent',
+ 'previousYearRevenueCents',
+ 'estPrepaymentCents',
+ 'bankDetailsText',
+ 'name',
+ 'amountCents',
+ 'interval',
+ 'nextDate',
+ 'counterpartyMatch',
+ 'active',
+]
+const FIELD_PREFIX = new RegExp(`^(?:${API_FIELDS.join('|')}): `)
+
+/** Meldung eines API-Fehlers ohne den technischen Feldnamen am Anfang. */
+export function apiErrorMessage(err, fallback = 'Das Speichern ist fehlgeschlagen.') {
+ const message = text(err?.message)
+ return message ? message.replace(FIELD_PREFIX, '') : fallback
+}
+
+/** 123456 -> '1.234,56' (ohne Euro-Zeichen, für Eingabefelder); null/ungültig -> ''. */
+export function euroField(cents) {
+ return isNum(cents) ? euro(cents).replace(/ €$/, '') : ''
+}
+
+/** Text -> Cent oder { error }; leer ist Sache des Aufrufers. */
+function amountCents(value) {
+ try {
+ return { cents: parseEuro(String(value ?? '')) }
+ } catch {
+ return { error: `Ungültiger Betrag. ${AMOUNT_HELP}` }
+ }
+}
+
+// --- Einstellungen -------------------------------------------------------------------------------------------
+
+/** Zeichen des Bankverbindungstexts, wie sie gespeichert werden (ohne Leerraum am Rand). */
+export function bankDetailsLength(value) {
+ return text(value).length
+}
+
+/** Antwort des Servers (Cent) -> Formular (Texte). */
+export function settingsToForm(settings) {
+ const s = settings && typeof settings === 'object' ? settings : {}
+ return {
+ minBuffer: euroField(s.minBufferCents),
+ taxReservePercent: isNum(s.taxReservePercent) ? String(s.taxReservePercent) : '',
+ previousYearRevenue: euroField(s.previousYearRevenueCents),
+ estPrepayment: euroField(s.estPrepaymentCents),
+ bankDetailsText: typeof s.bankDetailsText === 'string' ? s.bankDetailsText : '',
+ }
+}
+
+/**
+ * Formular -> PATCH-Körper. { ok: true, payload } oder { ok: false, errors: { feld: 'Meldung' } } (alle Fehler).
+ * Mindestpuffer und ESt-Vorauszahlung: Betrag ab 0 (0 ist erlaubt), Pflicht. Vorjahresumsatz: leer = null.
+ * Rücklage: ganze Zahl 0 bis 60. Bankverbindungstext: höchstens 500 Zeichen.
+ */
+export function validateSettingsForm(form) {
+ const f = form && typeof form === 'object' ? form : {}
+ const errors = {}
+ const payload = {}
+
+ const buffer = text(f.minBuffer)
+ if (!buffer) {
+ errors.minBuffer = 'Bitte einen Betrag eingeben (0 ist möglich).'
+ } else {
+ const r = amountCents(buffer)
+ if (r.error) errors.minBuffer = r.error
+ else if (r.cents > SETTINGS_LIMITS.maxBufferCents) errors.minBuffer = `Der Mindestpuffer darf höchstens ${euro(SETTINGS_LIMITS.maxBufferCents)} betragen.`
+ else payload.minBufferCents = r.cents
+ }
+
+ const percent = text(f.taxReservePercent).replace(/\s*%$/, '')
+ if (/^[0-9]{1,3}$/.test(percent) && Number(percent) <= SETTINGS_LIMITS.maxPercent) {
+ payload.taxReservePercent = Number(percent)
+ } else {
+ errors.taxReservePercent = `Die Rücklage muss eine ganze Zahl von 0 bis ${SETTINGS_LIMITS.maxPercent} sein.`
+ }
+
+ const revenue = text(f.previousYearRevenue)
+ if (!revenue) {
+ payload.previousYearRevenueCents = null
+ } else {
+ const r = amountCents(revenue)
+ if (r.error) errors.previousYearRevenue = r.error
+ else payload.previousYearRevenueCents = r.cents
+ }
+
+ const prepayment = text(f.estPrepayment)
+ if (!prepayment) {
+ errors.estPrepayment = 'Bitte einen Betrag eingeben (0 ist möglich).'
+ } else {
+ const r = amountCents(prepayment)
+ if (r.error) errors.estPrepayment = r.error
+ else payload.estPrepaymentCents = r.cents
+ }
+
+ const bank = text(f.bankDetailsText)
+ if (bank.length > SETTINGS_LIMITS.maxBankDetails) {
+ errors.bankDetailsText = `Der Text darf höchstens ${SETTINGS_LIMITS.maxBankDetails} Zeichen lang sein (aktuell ${bank.length}).`
+ } else {
+ payload.bankDetailsText = bank
+ }
+
+ return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, payload }
+}
+
+/** Reihenfolge der Felder im Formular (für den Fokus auf das erste fehlerhafte). */
+export const SETTINGS_FIELD_ORDER = ['minBuffer', 'taxReservePercent', 'previousYearRevenue', 'estPrepayment', 'bankDetailsText']
+
+// --- Fixkosten -----------------------------------------------------------------------------------------------
+
+/** Auswahl für den Rhythmus: [{ value: 'monat', label: 'monatlich' }, …]. */
+export const INTERVAL_OPTIONS = Object.entries(INTERVAL_LABEL).map(([value, label]) => ({ value, label }))
+
+export function emptyFixedCostForm() {
+ return { name: '', amount: '', interval: 'monat', nextDate: '', counterpartyMatch: '' }
+}
+
+/** Position des Servers -> Formular (Texte). */
+export function fixedCostToForm(position) {
+ const p = position && typeof position === 'object' ? position : {}
+ return {
+ name: typeof p.name === 'string' ? p.name : '',
+ amount: euroField(p.amountCents),
+ interval: Object.prototype.hasOwnProperty.call(INTERVAL_LABEL, p.interval) ? p.interval : 'monat',
+ nextDate: typeof p.nextDate === 'string' ? p.nextDate : '',
+ counterpartyMatch: typeof p.counterpartyMatch === 'string' ? p.counterpartyMatch : '',
+ }
+}
+
+function isRealDay(value) {
+ return /^\d{4}-\d{2}-\d{2}$/.test(value) && dateDe(value) !== '–'
+}
+
+/**
+ * Formular -> Körper für POST/PATCH /fixkosten: { ok: true, payload: { name, amountCents, interval, nextDate,
+ * counterpartyMatch } } oder { ok: false, errors: { feld: 'Meldung' } }. Betrag größer als 0.
+ */
+export function validateFixedCostForm(form) {
+ const f = form && typeof form === 'object' ? form : {}
+ const errors = {}
+
+ const name = text(f.name)
+ if (name.length < 1 || name.length > SETTINGS_LIMITS.maxName) {
+ errors.name = `Bitte einen Namen eingeben (höchstens ${SETTINGS_LIMITS.maxName} Zeichen).`
+ }
+
+ let cents = 0
+ if (!text(f.amount)) {
+ errors.amount = 'Bitte einen Betrag eingeben.'
+ } else {
+ const r = amountCents(f.amount)
+ if (r.error) errors.amount = r.error
+ else if (r.cents < 1) errors.amount = 'Der Betrag muss größer als 0 sein (z. B. 49,90).'
+ else cents = r.cents
+ }
+
+ if (!Object.prototype.hasOwnProperty.call(INTERVAL_LABEL, f.interval)) errors.interval = 'Bitte einen Rhythmus wählen.'
+
+ const nextDate = text(f.nextDate)
+ if (!isRealDay(nextDate)) errors.nextDate = 'Bitte ein gültiges Datum wählen.'
+
+ const match = text(f.counterpartyMatch)
+ if (match.length > SETTINGS_LIMITS.maxMatch) {
+ errors.counterpartyMatch = `Der Suchbegriff darf höchstens ${SETTINGS_LIMITS.maxMatch} Zeichen lang sein.`
+ }
+
+ if (Object.keys(errors).length > 0) return { ok: false, errors }
+ return { ok: true, payload: { name, amountCents: cents, interval: f.interval, nextDate, counterpartyMatch: match } }
+}
+
+/** Reihenfolge der Felder im Fixkosten-Formular. */
+export const FIXED_COST_FIELD_ORDER = ['name', 'amount', 'interval', 'nextDate', 'counterpartyMatch']
+
+/**
+ * Zeilen der Fixkostenliste: aktive zuerst, dann inaktive (Reihenfolge des Servers bleibt je Gruppe).
+ * Je Zeile: { key, id, name, amountText, intervalText, nextDateText, matchText, metaText, active, statusLabel,
+ * toggleLabel }; metaText = „monatlich · nächster Termin 01.11.2026 · Suchbegriff „hetzner““.
+ */
+export function fixedCostRows(list) {
+ const items = (Array.isArray(list) ? list : []).filter((p) => p && typeof p === 'object')
+ const rows = items.map((p, index) => {
+ const active = p.active !== false
+ const name = text(p.name) || 'Ohne Namen'
+ const intervalText = INTERVAL_LABEL[p.interval] || '–'
+ const nextDateText = dateDe(p.nextDate)
+ const matchText = text(p.counterpartyMatch)
+ return {
+ key: text(p.$id) || `fixed-${index}`,
+ id: text(p.$id),
+ name,
+ amountText: euro(p.amountCents),
+ intervalText,
+ nextDateText,
+ matchText,
+ metaText: [intervalText, `nächster Termin ${nextDateText}`, matchText && `Suchbegriff „${matchText}“`].filter(Boolean).join(' · '),
+ active,
+ statusLabel: active ? 'Aktiv' : 'Inaktiv',
+ toggleLabel: active ? 'Auf inaktiv setzen' : 'Aktivieren',
+ }
+ })
+ return [...rows.filter((r) => r.active), ...rows.filter((r) => !r.active)]
+}
+
+// --- Bankzugang ----------------------------------------------------------------------------------------------
+
+const berlinTime = new Intl.DateTimeFormat('de-DE', {
+ timeZone: 'Europe/Berlin',
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hourCycle: 'h23',
+})
+
+/** ISO-Zeitpunkt -> '01.10.2026, 12:55 Uhr' (Berliner Zeit); ungültig -> '–'. */
+export function dateTimeDe(value) {
+ const t = typeof value === 'string' && value.trim() ? Date.parse(value) : NaN
+ if (Number.isNaN(t)) return '–'
+ const p = Object.fromEntries(berlinTime.formatToParts(new Date(t)).map((x) => [x.type, x.value]))
+ return `${p.day}.${p.month}.${p.year}, ${p.hour}:${p.minute} Uhr`
+}
+
+/**
+ * Karte „Bankzugang“: { connected, statusLabel, tone, rows: [{ key, label, text }], error, canRefresh, connectLabel,
+ * hint }. Ohne Verbindung (null) nur „Bank verbinden“. Abgelaufener Zugang (Status oder Datum): kein Abruf, nur
+ * „Neu verbinden“. error ist der Text des letzten Fehlers (auch wenn der Server keinen nennt, der Abruf aber scheiterte).
+ */
+export function bankCardModel(verbindung, now = Date.now()) {
+ if (!verbindung || typeof verbindung !== 'object') {
+ return {
+ connected: false,
+ statusLabel: 'Nicht verbunden',
+ tone: 'muted',
+ rows: [],
+ error: '',
+ canRefresh: false,
+ connectLabel: 'Bank verbinden',
+ hint: 'Ohne Bankzugang fehlen Kontostand und Buchungen.',
+ }
+ }
+
+ const validDay = ymdBerlin(verbindung.validUntil)
+ const today = ymdBerlin(now)
+ const expired = verbindung.status === 'abgelaufen' || (validDay !== null && today !== null && validDay < today)
+ const failed = verbindung.lastSyncOk === false || verbindung.status === 'fehler'
+
+ let statusLabel = 'Unbekannt'
+ let tone = 'muted'
+ if (expired) {
+ statusLabel = 'Zugang abgelaufen'
+ tone = 'danger'
+ } else if (failed) {
+ statusLabel = 'Abruf fehlgeschlagen'
+ tone = 'warn'
+ } else if (verbindung.status === 'aktiv') {
+ statusLabel = 'Verbunden'
+ tone = 'ok'
+ } else if (text(verbindung.status)) {
+ statusLabel = text(verbindung.status)
+ }
+
+ const synced = text(verbindung.lastSyncAt)
+ const lastError = text(verbindung.lastError)
+ return {
+ connected: true,
+ statusLabel,
+ tone,
+ rows: [
+ { key: 'iban', label: 'IBAN', text: text(verbindung.ibanMasked) || '–' },
+ { key: 'valid', label: 'Zugang gültig bis', text: dateDe(verbindung.validUntil) },
+ {
+ key: 'sync',
+ label: 'Letzter Abruf',
+ text: synced ? `${dateTimeDe(synced)} · ${sinceLabel(synced, now)}` : 'noch kein Abruf',
+ },
+ ],
+ error: lastError || (failed ? 'Der letzte Abruf ist fehlgeschlagen.' : ''),
+ canRefresh: !expired,
+ connectLabel: 'Neu verbinden',
+ hint: expired ? 'Der Zugang ist abgelaufen. Bitte neu verbinden.' : '',
+ }
+}
+
+/** Ergebnis von POST /bank/aktualisieren ({ ok, neu, aktualisiert, geloescht, error? }) -> { tone, text }. */
+export function refreshResultMessage(result) {
+ const r = result && typeof result === 'object' ? result : {}
+ if (r.ok === false) {
+ const error = text(r.error)
+ return { tone: 'warn', text: error ? `Abruf fehlgeschlagen: ${error}` : 'Abruf fehlgeschlagen.' }
+ }
+ const parts = []
+ if (r.neu > 0) parts.push(`${r.neu} neu`)
+ if (r.aktualisiert > 0) parts.push(`${r.aktualisiert} aktualisiert`)
+ if (r.geloescht > 0) parts.push(`${r.geloescht} entfernt`)
+ return { tone: 'ok', text: parts.length > 0 ? `Abruf abgeschlossen: ${parts.join(', ')}.` : 'Abruf abgeschlossen, keine neuen Buchungen.' }
+}
diff --git a/src/lib/financeView.js b/src/lib/financeView.js
index 346ecdb..5b2f722 100644
--- a/src/lib/financeView.js
+++ b/src/lib/financeView.js
@@ -746,9 +746,37 @@ export function resolveCustomerText(options, input) {
return { state: option.ok ? 'ok' : 'blocked', option }
}
+/**
+ * Reiter „Rechnungen“ am Kunden: { ok: true } oder { ok: false, title, hint } (Grund nach canInvoice plus ein Satz,
+ * was zu tun ist). Der Reiter ist für jeden Kunden sichtbar; ohne ok zeigt er statt der Liste diesen Hinweis.
+ */
+export function customerInvoicesGate(customer) {
+ const check = canInvoice(customer)
+ if (check.ok) return { ok: true }
+ const status = text(customer?.customerStatus).toLowerCase()
+ if (status === 'lead') {
+ return {
+ ok: false,
+ title: 'Noch ein Lead',
+ hint: 'Erst zum festen Kunden machen (vollständige Daten nötig), dann sind Rechnungen möglich.',
+ }
+ }
+ if (status === 'lost') {
+ return { ok: false, title: check.reason, hint: 'Rechnungen gibt es nur für feste Kunden.' }
+ }
+ if (customer && !text(customer.email)) {
+ return {
+ ok: false,
+ title: check.reason,
+ hint: 'Rechnungen gehen per E-Mail an den Kunden. Bitte zuerst die E-Mail-Adresse in den Stammdaten eintragen.',
+ }
+ }
+ return { ok: false, title: check.reason, hint: '' }
+}
+
// --- Buchungen (Block 7) -----------------------------------------------------------------------------------
-const RANK = { hoch: 0, mittel: 1, niedrig: 2 }
+const RANK ={ hoch: 0, mittel: 1, niedrig: 2 }
const dayOf = (tx) => text(tx.bookingDate) || text(tx.valueDate)
/** Buchungen neueste zuerst (Buchungsdatum, sonst Wertstellung); gleiche Tage behalten die Reihenfolge des Servers. */
diff --git a/src/lib/integrationsApi.js b/src/lib/integrationsApi.js
deleted file mode 100644
index bc8ae2b..0000000
--- a/src/lib/integrationsApi.js
+++ /dev/null
@@ -1,88 +0,0 @@
-import { account } from './appwrite'
-
-/**
- * Client fuer das Integrations-Backend (InvoiceNinja).
- * Same-Origin unter /api/integrations, authentifiziert per Appwrite-JWT.
- */
-const BASE = '/api/integrations'
-
-async function authHeaders(extra = {}) {
- const { jwt } = await account.createJWT()
- return { 'X-Appwrite-JWT': jwt, ...extra }
-}
-
-async function call(path, { method = 'GET', body } = {}) {
- const headers = await authHeaders(body ? { 'Content-Type': 'application/json' } : {})
- const res = await fetch(`${BASE}${path}`, {
- method,
- headers,
- body: body ? JSON.stringify(body) : undefined,
- })
- const data = await res.json().catch(() => ({}))
- if (!res.ok) {
- throw new Error(data.error || `Integrations-Fehler ${res.status}`)
- }
- return data
-}
-
-async function callBlob(path) {
- const headers = await authHeaders()
- const res = await fetch(`${BASE}${path}`, { headers })
- if (!res.ok) throw new Error(`Download fehlgeschlagen (${res.status})`)
- return res.blob()
-}
-
-function qs(params = {}) {
- const sp = new URLSearchParams()
- Object.entries(params).forEach(([k, v]) => {
- if (v !== undefined && v !== null && v !== '') sp.set(k, v)
- })
- const s = sp.toString()
- return s ? `?${s}` : ''
-}
-
-async function openBlobInNewTab(blob) {
- const url = URL.createObjectURL(blob)
- window.open(url, '_blank', 'noopener')
- setTimeout(() => URL.revokeObjectURL(url), 60_000)
-}
-
-export const integrationsApi = {
- // ---- health ----
- health: () => call('/health'),
-
- // ---- InvoiceNinja: clients ----
- listClients: (search = '') => call(`/clients${qs({ search })}`),
- createClient: (payload) => call('/clients', { method: 'POST', body: payload }),
- updateClient: (id, payload) => call(`/clients/${encodeURIComponent(id)}`, { method: 'PUT', body: payload }),
-
- // ---- InvoiceNinja: invoices ----
- listInvoices: (clientId, perPage) => call(`/invoices${qs({ clientId, per_page: perPage })}`),
- invoicesSummary: () => call('/invoices/summary'),
- async openInvoicePdf(id) {
- const blob = await callBlob(`/invoices/${encodeURIComponent(id)}/pdf`)
- await openBlobInNewTab(blob)
- },
-}
-
-// Oeffentliche InvoiceNinja-URL (fuer Deeplinks ins Portal)
-export const INVOICE_NINJA_PUBLIC_URL = 'https://invoice.webklar.com'
-
-// InvoiceNinja status_id -> deutsche Bezeichnung + Farbe
-export const INVOICE_STATUS = {
- 1: { label: 'Entwurf', color: '#a0aec0' },
- 2: { label: 'Versendet', color: '#3b82f6' },
- 3: { label: 'Teilzahlung', color: '#f59e0b' },
- 4: { label: 'Bezahlt', color: '#10b981' },
- 5: { label: 'Storniert', color: '#ef4444' },
- '-1': { label: 'Storniert', color: '#ef4444' },
-}
-
-export function formatMoney(amount, currency = 'EUR') {
- const n = Number(amount || 0)
- try {
- return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(n)
- } catch {
- return `${n.toFixed(2)} ${currency}`
- }
-}
diff --git a/src/lib/leads.js b/src/lib/leads.js
index 0625bd3..7393873 100644
--- a/src/lib/leads.js
+++ b/src/lib/leads.js
@@ -1,7 +1,6 @@
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from './appwrite'
-import { integrationsApi } from './integrationsApi'
-/** Pflichtfelder, damit eine Rechnung erstellt werden kann (wie InvoiceNinja). */
+/** Pflichtfelder eines festen Kunden: Name, E-Mail und vollständige Anschrift (Voraussetzung für „Zum festen Kunden machen“). */
export function isInvoiceReady(c) {
return Boolean(c && c.name && c.email && c.street && c.city && c.postalCode)
}
@@ -26,36 +25,6 @@ export async function setCustomerArchived(customer, archived) {
})
}
-function ninjaPayload(c) {
- return {
- name: c.companyName || c.name,
- email: c.email || '',
- phone: c.phone || '',
- street: c.street || '',
- city: c.city || '',
- postalCode: c.postalCode || '',
- country: c.country || '',
- vatNumber: c.vatNumber || '',
- contactName: c.contactName || '',
- }
-}
-
-/** Erstellt/aktualisiert den InvoiceNinja-Client und speichert die ID am Kunden. */
-export async function syncCustomerToNinja(customer) {
- if (customer.invoiceNinjaClientId) {
- await integrationsApi.updateClient(customer.invoiceNinjaClientId, ninjaPayload(customer))
- return customer.invoiceNinjaClientId
- }
- const r = await integrationsApi.createClient(ninjaPayload(customer))
- const id = r.client?.id
- if (id) {
- await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
- invoiceNinjaClientId: id,
- })
- }
- return id
-}
-
async function nextWoid() {
try {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
@@ -113,18 +82,11 @@ export async function createLead({ company, email }, user) {
return { customer, ticket }
}
-/** Macht aus einem Lead einen festen Kunden (mit vollstaendigen Daten) und legt den InvoiceNinja-Client an. */
+/** Macht aus einem Lead einen festen Kunden (mit vollständigen Daten). Liefert das gespeicherte Kunden-Dokument. */
export async function upgradeToCustomer(customer, fullData) {
- const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
+ return databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
...fullData,
customerStatus: 'customer',
updatedAt: new Date().toISOString(),
})
- let clientId = updated.invoiceNinjaClientId
- try {
- clientId = await syncCustomerToNinja(updated)
- } catch (e) {
- return { customer: updated, clientId: null, syncError: e.message }
- }
- return { customer: { ...updated, invoiceNinjaClientId: clientId }, clientId }
}
diff --git a/src/pages/CustomerDetailPage.jsx b/src/pages/CustomerDetailPage.jsx
index e857a67..58be87b 100644
--- a/src/pages/CustomerDetailPage.jsx
+++ b/src/pages/CustomerDetailPage.jsx
@@ -4,13 +4,13 @@ import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaArrow
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
-import InvoicePanel from '../components/InvoicePanel'
+import CustomerInvoices from '../components/finance/CustomerInvoices'
import AssignedProjectCard from '../components/AssignedProjectCard'
import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '../lib/customerAdminApi'
import { useAuth } from '../context/AuthContext'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
-import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer, syncCustomerToNinja } from '../lib/leads'
+import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
const PORTAL_URL = 'https://project.webklar.com'
@@ -49,7 +49,6 @@ export default function CustomerDetailPage() {
const stage = customerStage(customer)
const archived = isArchived(customer)
- const syntheticTicket = { customerId: customer.$id, customerName: customer.name, woid: '' }
const toggleArchived = async () => {
await setCustomerArchived(customer, !archived)
@@ -96,15 +95,7 @@ export default function CustomerDetailPage() {
{tab === 'overview' && }
{tab === 'tickets' && }
- {tab === 'invoices' && (
- stage === 'lead' ? (
- setTab('overview')}>Daten erfassen} />
- ) : !isInvoiceReady(customer) ? (
- setTab('overview')}>Daten vervollstaendigen} />
- ) : (
-
- )
- )}
+ {tab === 'invoices' && setTab('overview')} />}
{tab === 'projects' && }
)
@@ -135,13 +126,8 @@ function OverviewTab({ customer, stage, onSaved }) {
const save = async () => {
setBusy(true); setMsg(null)
try {
- const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
- let note = 'Gespeichert'
- // Wenn fester Kunde: Aenderungen nach InvoiceNinja syncen (sofern Daten reichen oder bereits verknuepft)
- if (stage === 'customer' && (updated.invoiceNinjaClientId || isInvoiceReady(updated))) {
- try { await syncCustomerToNinja(updated) } catch (e) { note = 'Gespeichert, InvoiceNinja-Sync fehlgeschlagen: ' + e.message }
- }
- setMsg(note)
+ await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
+ setMsg('Gespeichert')
onSaved()
} catch (err) {
setMsg('Fehler: ' + err.message)
@@ -151,11 +137,13 @@ function OverviewTab({ customer, stage, onSaved }) {
const upgrade = async () => {
if (!ready) { setMsg('Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.'); return }
setBusy(true); setMsg(null)
- const r = await upgradeToCustomer(customer, form)
- if (r.clientId) setMsg('Zum festen Kunden gemacht und mit InvoiceNinja verknuepft.')
- else setMsg('Status gesetzt, aber InvoiceNinja-Verknuepfung fehlgeschlagen: ' + (r.syncError || 'unbekannt'))
- setBusy(false)
- onSaved()
+ try {
+ await upgradeToCustomer(customer, form)
+ setMsg('Zum festen Kunden gemacht.')
+ onSaved()
+ } catch (err) {
+ setMsg('Fehler: ' + err.message)
+ } finally { setBusy(false) }
}
return (
@@ -183,8 +171,8 @@ function OverviewTab({ customer, stage, onSaved }) {
-
- {!ready && Unvollstaendig - fuer Rechnungen erforderlich
}
+
+ {!ready && Unvollständig – für Rechnungen erforderlich
}
setForm({ ...form, street: e.target.value })} />
setForm({ ...form, postalCode: e.target.value })} />
@@ -194,10 +182,6 @@ function OverviewTab({ customer, stage, onSaved }) {
setForm({ ...form, country: e.target.value })} />
setForm({ ...form, vatNumber: e.target.value })} />
-
- InvoiceNinja
- {customer.invoiceNinjaClientId ? verknuepft : nicht verknuepft}
-
diff --git a/src/pages/CustomersPage.jsx b/src/pages/CustomersPage.jsx
index affbbfb..d7f65f6 100644
--- a/src/pages/CustomersPage.jsx
+++ b/src/pages/CustomersPage.jsx
@@ -1,6 +1,6 @@
import { useState, useMemo } from 'react'
import { Link } from 'react-router-dom'
-import { FaSpinner, FaPlus, FaStar, FaFileInvoiceDollar } from 'react-icons/fa6'
+import { FaSpinner, FaPlus, FaStar } from 'react-icons/fa6'
import { useCustomers } from '../hooks/useCustomers'
import { useAuth } from '../context/AuthContext'
import { createLead, customerStage, isArchived } from '../lib/leads'
@@ -37,7 +37,7 @@ export default function CustomersPage() {
{isAdmin && (
@@ -116,7 +116,6 @@ export default function CustomersPage() {
{st === 'lead' && Lead}
{st === 'customer' && Fester Kunde}
{st === 'lost' && Abgesagt}
- {c.invoiceNinjaClientId && Ninja}
)
diff --git a/src/pages/FinancePage.jsx b/src/pages/FinancePage.jsx
index 687407f..7aa4004 100644
--- a/src/pages/FinancePage.jsx
+++ b/src/pages/FinancePage.jsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { FaSpinner } from 'react-icons/fa6'
+import { Link, useNavigate } from 'react-router-dom'
+import { FaGear, FaSpinner } from 'react-icons/fa6'
import { PageHeader, Card, Button } from '../components/ui'
import StatusLine from '../components/finance/StatusLine'
import LiquidityHead from '../components/finance/LiquidityHead'
@@ -191,7 +191,15 @@ export default function FinancePage() {
return (
-
+
+ Einstellungen
+
+ }
+ />
{assignNotice && (
{assignNotice}{' '}
diff --git a/src/pages/FinanceSettingsPage.jsx b/src/pages/FinanceSettingsPage.jsx
new file mode 100644
index 0000000..8d8d536
--- /dev/null
+++ b/src/pages/FinanceSettingsPage.jsx
@@ -0,0 +1,31 @@
+import { Link } from 'react-router-dom'
+import { FaArrowLeft } from 'react-icons/fa6'
+import { PageHeader, Button } from '../components/ui'
+import SettingsCard from '../components/finance/SettingsCard'
+import BankAccessCard from '../components/finance/BankAccessCard'
+import FixedCostsCard from '../components/finance/FixedCostsCard'
+
+/**
+ * Finanzen / Einstellungen (/finance/einstellungen, nur Admins): Rücklagen und Bankverbindungstext, Bankzugang
+ * (Status, Abruf, neu verbinden) und die Fixkostenliste. Jede Karte lädt und speichert für sich.
+ */
+export default function FinanceSettingsPage() {
+ return (
+
+
+ Zurück zu den Finanzen
+
+ }
+ />
+
+
+
+
+
+
+ )
+}
diff --git a/src/styles/global.css b/src/styles/global.css
index f8c964f..ec485e0 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -1431,3 +1431,38 @@ textarea.form-control {
.fin-dialog-actions .ui-btn { flex: 1 1 auto; }
.fin-toolbar .seg button { padding: 6px 9px; }
}
+
+/* Finanzen: Einstellungen (Formular, Bankzugang, Fixkosten) */
+.fin-fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
+.fin-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--sp-4); }
+.fin-form-field { min-width: 0; }
+.fin-form-hint { font-size: 12px; overflow-wrap: anywhere; }
+.fin-field-error { font-size: 13px; font-weight: 600; color: var(--danger); overflow-wrap: anywhere; }
+.fin-form-field .form-control[aria-invalid="true"] { border-color: var(--danger) !important; }
+.fin-input-amount { text-align: right; font-variant-numeric: tabular-nums; }
+.fin-counter { margin: -6px 0 var(--sp-3); text-align: right; font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
+.fin-counter-over { color: var(--danger); font-weight: 700; }
+.fin-form-notice { margin: 0 0 var(--sp-3); }
+.fin-form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); margin-top: var(--sp-3); }
+.fin-bank-facts { display: grid; gap: 6px; margin: 0 0 var(--sp-3); }
+.fin-bank-facts > div { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: var(--sp-3); align-items: baseline; }
+.fin-bank-facts dt { color: var(--text-muted); font-size: 13px; }
+.fin-bank-facts dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
+.fin-fixed-form { margin-bottom: var(--sp-4); padding: var(--sp-4); border: 1px solid var(--border-strong); border-radius: var(--r-sm); }
+.fin-fixed-form-title { margin: 0 0 var(--sp-3); font-size: 15px; font-weight: 700; }
+.fin-fixed-list { list-style: none; margin: 0; padding: 0; }
+.fin-fixed {
+ display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-4); align-items: baseline;
+ padding: var(--sp-3) 0; border-bottom: 1px solid var(--border);
+}
+.fin-fixed:last-child { border-bottom: none; }
+.fin-fixed-off .fin-fixed-name, .fin-fixed-off .fin-fixed-amount { color: var(--text-muted); }
+.fin-fixed-main { display: flex; flex-wrap: wrap; align-items: center; gap: 6px var(--sp-2); min-width: 0; }
+.fin-fixed-name { font-weight: 700; overflow-wrap: anywhere; }
+.fin-fixed-amount { font-weight: 700; }
+.fin-fixed-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 2px var(--sp-4); font-size: 13px; overflow-wrap: anywhere; }
+.fin-fixed-actions { grid-column: 1 / -1; padding-top: 4px; }
+@media (max-width: 640px) {
+ .fin-form-grid { grid-template-columns: minmax(0, 1fr); }
+ .fin-bank-facts > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
+}
diff --git a/tests/unit/financeSettings.test.mjs b/tests/unit/financeSettings.test.mjs
new file mode 100644
index 0000000..9fec33c
--- /dev/null
+++ b/tests/unit/financeSettings.test.mjs
@@ -0,0 +1,286 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+const {
+ SETTINGS_LIMITS,
+ BANK_DETAILS_EXAMPLE,
+ FIXED_COST_DELETE_CONFIRM,
+ SETTINGS_FIELD_ORDER,
+ FIXED_COST_FIELD_ORDER,
+ INTERVAL_OPTIONS,
+ apiErrorMessage,
+ euroField,
+ bankDetailsLength,
+ settingsToForm,
+ validateSettingsForm,
+ emptyFixedCostForm,
+ fixedCostToForm,
+ validateFixedCostForm,
+ fixedCostRows,
+ dateTimeDe,
+ bankCardModel,
+ refreshResultMessage,
+} = await loadModule('src/lib/financeSettings.js')
+
+const NOW = Date.parse('2026-10-01T13:55:21.691Z') // 15:55 Berliner Zeit
+
+test('Konstanten: Texte und Grenzen wie im Brief', () => {
+ assert.equal(FIXED_COST_DELETE_CONFIRM, 'Position wirklich löschen? Bereits zugeordnete Buchungen behalten den Verweis. Besser auf inaktiv setzen.')
+ assert.equal(BANK_DETAILS_EXAMPLE, 'Bankverbindung: WEBklar GbR · IBAN DE.. · BIC ..')
+ assert.equal(SETTINGS_LIMITS.maxPercent, 60)
+ assert.equal(SETTINGS_LIMITS.maxBankDetails, 500)
+ assert.deepEqual(INTERVAL_OPTIONS, [
+ { value: 'monat', label: 'monatlich' },
+ { value: 'quartal', label: 'vierteljährlich' },
+ { value: 'jahr', label: 'jährlich' },
+ ])
+})
+
+test('euroField: Cent -> Eingabetext ohne Euro-Zeichen, rundläufig mit parseEuro', () => {
+ assert.equal(euroField(123456), '1.234,56')
+ assert.equal(euroField(0), '0,00')
+ assert.equal(euroField(2000), '20,00')
+ assert.equal(euroField(100000000), '1.000.000,00')
+ assert.equal(euroField(null), '')
+ assert.equal(euroField(undefined), '')
+ assert.equal(euroField('5'), '')
+})
+
+test('apiErrorMessage: entfernt den technischen Feldnamen, sonst unverändert', () => {
+ assert.equal(
+ apiErrorMessage(new Error('minBufferCents: Der Mindestpuffer muss eine ganze Zahl von 0 bis 100.000.000 Cent sein.')),
+ 'Der Mindestpuffer muss eine ganze Zahl von 0 bis 100.000.000 Cent sein.'
+ )
+ assert.equal(apiErrorMessage(new Error('counterpartyMatch: Der Suchbegriff muss ein Text sein.')), 'Der Suchbegriff muss ein Text sein.')
+ assert.equal(apiErrorMessage(new Error('Fixkosten-Position nicht gefunden.')), 'Fixkosten-Position nicht gefunden.')
+ assert.equal(apiErrorMessage(new Error('Bitte in 5 Minuten erneut versuchen.')), 'Bitte in 5 Minuten erneut versuchen.')
+ assert.equal(apiErrorMessage(new Error('Das Kundenportal ist gerade nicht erreichbar.')), 'Das Kundenportal ist gerade nicht erreichbar.')
+ assert.equal(apiErrorMessage(null), 'Das Speichern ist fehlgeschlagen.')
+ assert.equal(apiErrorMessage(new Error(' ')), 'Das Speichern ist fehlgeschlagen.')
+ assert.equal(apiErrorMessage({}, 'Anders.'), 'Anders.')
+})
+
+test('settingsToForm: Cent -> Euro-Texte, null Vorjahresumsatz -> leer', () => {
+ assert.deepEqual(
+ settingsToForm({ minBufferCents: 2000, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: 'IBAN DE00' }),
+ { minBuffer: '20,00', taxReservePercent: '30', previousYearRevenue: '', estPrepayment: '0,00', bankDetailsText: 'IBAN DE00' }
+ )
+ assert.equal(settingsToForm({ previousYearRevenueCents: 1500000 }).previousYearRevenue, '15.000,00')
+ assert.deepEqual(settingsToForm(null), { minBuffer: '', taxReservePercent: '', previousYearRevenue: '', estPrepayment: '', bankDetailsText: '' })
+})
+
+test('validateSettingsForm: gültiges Formular -> PATCH-Körper in Cent', () => {
+ const r = validateSettingsForm({ minBuffer: '1.234,56', taxReservePercent: '30', previousYearRevenue: '', estPrepayment: '0', bankDetailsText: ' Bankverbindung: X ' })
+ assert.deepEqual(r, {
+ ok: true,
+ payload: { minBufferCents: 123456, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: 'Bankverbindung: X' },
+ })
+ // Vorjahresumsatz gesetzt; 0 für Mindestpuffer; Rücklage 0 und 60 sind erlaubt; "30 %" ebenfalls
+ const edge = validateSettingsForm({ minBuffer: '0', taxReservePercent: '60', previousYearRevenue: '15.000,00 €', estPrepayment: '1.250,50', bankDetailsText: '' })
+ assert.deepEqual(edge.payload, { minBufferCents: 0, taxReservePercent: 60, previousYearRevenueCents: 1500000, estPrepaymentCents: 125050, bankDetailsText: '' })
+ assert.equal(validateSettingsForm({ minBuffer: '0', taxReservePercent: '0', previousYearRevenue: '0', estPrepayment: '0', bankDetailsText: '' }).payload.taxReservePercent, 0)
+ assert.equal(validateSettingsForm({ minBuffer: '0', taxReservePercent: '30 %', previousYearRevenue: '', estPrepayment: '0', bankDetailsText: '' }).payload.taxReservePercent, 30)
+ // Vorjahresumsatz 0 ist ein Wert, nicht „leer“
+ assert.equal(validateSettingsForm({ minBuffer: '0', taxReservePercent: '30', previousYearRevenue: '0,00', estPrepayment: '0', bankDetailsText: '' }).payload.previousYearRevenueCents, 0)
+})
+
+test('validateSettingsForm: Fehler je Feld, alle auf einmal, nichts wird gesendet', () => {
+ const r = validateSettingsForm({ minBuffer: '', taxReservePercent: '61', previousYearRevenue: '12.50', estPrepayment: 'abc', bankDetailsText: 'x'.repeat(501) })
+ assert.equal(r.ok, false)
+ assert.equal(r.payload, undefined)
+ assert.equal(r.errors.minBuffer, 'Bitte einen Betrag eingeben (0 ist möglich).')
+ assert.equal(r.errors.taxReservePercent, 'Die Rücklage muss eine ganze Zahl von 0 bis 60 sein.')
+ assert.equal(r.errors.previousYearRevenue, 'Ungültiger Betrag. Bitte einen Betrag wie 1.234,56 eingeben.')
+ assert.equal(r.errors.estPrepayment, 'Ungültiger Betrag. Bitte einen Betrag wie 1.234,56 eingeben.')
+ assert.equal(r.errors.bankDetailsText, 'Der Text darf höchstens 500 Zeichen lang sein (aktuell 501).')
+ assert.deepEqual(Object.keys(r.errors).sort(), [...SETTINGS_FIELD_ORDER].sort())
+})
+
+test('validateSettingsForm: Prozent nur ganze Zahlen 0 bis 60; Grenzen für Mindestpuffer und Text', () => {
+ const base = { minBuffer: '0', previousYearRevenue: '', estPrepayment: '0', bankDetailsText: '' }
+ for (const bad of ['', ' ', '-1', '30,5', '30.5', '6a', '061x', '1000', 'dreißig']) {
+ assert.equal(validateSettingsForm({ ...base, taxReservePercent: bad }).ok, false, `Prozent „${bad}“`)
+ }
+ for (const good of ['0', '1', '59', '60', '030']) {
+ assert.equal(validateSettingsForm({ ...base, taxReservePercent: good }).ok, true, `Prozent „${good}“`)
+ }
+ assert.equal(validateSettingsForm({ ...base, taxReservePercent: '30', minBuffer: '1.000.000,00' }).ok, true)
+ const over = validateSettingsForm({ ...base, taxReservePercent: '30', minBuffer: '1.000.000,01' })
+ assert.equal(over.errors.minBuffer, 'Der Mindestpuffer darf höchstens 1.000.000,00 € betragen.')
+ assert.equal(validateSettingsForm({ ...base, taxReservePercent: '30', minBuffer: '-5' }).ok, false)
+ // Text: genau 500 gespeicherte Zeichen sind erlaubt, Leerraum am Rand zählt nicht
+ assert.equal(validateSettingsForm({ ...base, taxReservePercent: '30', bankDetailsText: ` ${'x'.repeat(500)} ` }).ok, true)
+ assert.equal(validateSettingsForm({ ...base, taxReservePercent: '30', bankDetailsText: 'x'.repeat(501) }).ok, false)
+ assert.equal(validateSettingsForm(null).ok, false)
+})
+
+test('bankDetailsLength: zählt ohne Leerraum am Rand', () => {
+ assert.equal(bankDetailsLength(' abc '), 3)
+ assert.equal(bankDetailsLength(''), 0)
+ assert.equal(bankDetailsLength(null), 0)
+ assert.equal(bankDetailsLength('a\nb'), 3)
+})
+
+test('emptyFixedCostForm und fixedCostToForm', () => {
+ assert.deepEqual(emptyFixedCostForm(), { name: '', amount: '', interval: 'monat', nextDate: '', counterpartyMatch: '' })
+ assert.deepEqual(
+ fixedCostToForm({ $id: 'f1', name: 'Hetzner', amountCents: 1000, interval: 'quartal', nextDate: '2026-11-01', counterpartyMatch: 'hetzner', active: true }),
+ { name: 'Hetzner', amount: '10,00', interval: 'quartal', nextDate: '2026-11-01', counterpartyMatch: 'hetzner' }
+ )
+ // unbekannter Rhythmus fällt auf monatlich
+ assert.equal(fixedCostToForm({ interval: 'woche' }).interval, 'monat')
+ assert.deepEqual(fixedCostToForm(null), emptyFixedCostForm())
+})
+
+test('validateFixedCostForm: gültig -> Körper in Cent, Suchbegriff getrimmt', () => {
+ const r = validateFixedCostForm({ name: ' Hetzner ', amount: '49,90', interval: 'monat', nextDate: '2026-11-01', counterpartyMatch: ' Hetzner ' })
+ assert.deepEqual(r, { ok: true, payload: { name: 'Hetzner', amountCents: 4990, interval: 'monat', nextDate: '2026-11-01', counterpartyMatch: 'Hetzner' } })
+ assert.equal(validateFixedCostForm({ name: 'X', amount: '1.200', interval: 'jahr', nextDate: '2028-02-29', counterpartyMatch: '' }).payload.amountCents, 120000)
+ assert.equal(validateFixedCostForm({ name: 'X', amount: '0,01', interval: 'jahr', nextDate: '2028-02-29', counterpartyMatch: '' }).ok, true)
+})
+
+test('validateFixedCostForm: Fehler je Feld', () => {
+ const r = validateFixedCostForm({ name: ' ', amount: '0', interval: 'woche', nextDate: '2026-02-30', counterpartyMatch: 'x'.repeat(121) })
+ assert.equal(r.ok, false)
+ assert.equal(r.errors.name, 'Bitte einen Namen eingeben (höchstens 120 Zeichen).')
+ assert.equal(r.errors.amount, 'Der Betrag muss größer als 0 sein (z. B. 49,90).')
+ assert.equal(r.errors.interval, 'Bitte einen Rhythmus wählen.')
+ assert.equal(r.errors.nextDate, 'Bitte ein gültiges Datum wählen.')
+ assert.equal(r.errors.counterpartyMatch, 'Der Suchbegriff darf höchstens 120 Zeichen lang sein.')
+ assert.deepEqual(Object.keys(r.errors).sort(), [...FIXED_COST_FIELD_ORDER].sort())
+
+ const empty = validateFixedCostForm(emptyFixedCostForm())
+ assert.equal(empty.errors.amount, 'Bitte einen Betrag eingeben.')
+ assert.equal(empty.errors.nextDate, 'Bitte ein gültiges Datum wählen.')
+ assert.equal(validateFixedCostForm({ name: 'X', amount: '12.50', interval: 'monat', nextDate: '2026-11-01', counterpartyMatch: '' }).errors.amount, 'Ungültiger Betrag. Bitte einen Betrag wie 1.234,56 eingeben.')
+ assert.equal(validateFixedCostForm({ name: 'X'.repeat(121), amount: '1', interval: 'monat', nextDate: '2026-11-01', counterpartyMatch: '' }).ok, false)
+ // ein Datum mit Uhrzeit oder anderem Format ist kein Termin
+ for (const bad of ['01.11.2026', '2026-11-01T00:00:00Z', '2026-1-1', '']) {
+ assert.equal(validateFixedCostForm({ name: 'X', amount: '1', interval: 'monat', nextDate: bad, counterpartyMatch: '' }).ok, false, bad)
+ }
+ assert.equal(validateFixedCostForm(null).ok, false)
+})
+
+test('fixedCostRows: aktive zuerst, Texte de-DE, Schalter-Beschriftung', () => {
+ const rows = fixedCostRows([
+ { $id: 'f2', name: 'Altlast', amountCents: 500, interval: 'jahr', nextDate: '2027-01-01', counterpartyMatch: 'x', active: false },
+ { $id: 'f1', name: 'Hetzner', amountCents: 123456, interval: 'monat', nextDate: '2026-11-01', counterpartyMatch: '', active: true },
+ { $id: 'f3', name: 'Domain', amountCents: 1200, interval: 'quartal', nextDate: '2026-12-15', active: true },
+ null,
+ ])
+ assert.deepEqual(rows.map((r) => r.id), ['f1', 'f3', 'f2'])
+ assert.deepEqual(rows[0], {
+ key: 'f1',
+ id: 'f1',
+ name: 'Hetzner',
+ amountText: '1.234,56 €',
+ intervalText: 'monatlich',
+ nextDateText: '01.11.2026',
+ matchText: '',
+ metaText: 'monatlich · nächster Termin 01.11.2026',
+ active: true,
+ statusLabel: 'Aktiv',
+ toggleLabel: 'Auf inaktiv setzen',
+ })
+ assert.equal(rows[1].intervalText, 'vierteljährlich')
+ assert.equal(rows[2].statusLabel, 'Inaktiv')
+ assert.equal(rows[2].toggleLabel, 'Aktivieren')
+ assert.equal(rows[2].matchText, 'x')
+ assert.equal(rows[2].metaText, 'jährlich · nächster Termin 01.01.2027 · Suchbegriff „x“')
+ assert.equal(rows[1].metaText, 'vierteljährlich · nächster Termin 15.12.2026')
+ assert.deepEqual(fixedCostRows(null), [])
+ assert.deepEqual(fixedCostRows('x'), [])
+ // kaputte Werte werden zu Platzhaltern, nicht zu Fehlern
+ const odd = fixedCostRows([{ $id: 'f9' }])[0]
+ assert.equal(odd.name, 'Ohne Namen')
+ assert.equal(odd.amountText, '–')
+ assert.equal(odd.intervalText, '–')
+ assert.equal(odd.nextDateText, '–')
+ assert.equal(odd.metaText, '– · nächster Termin –')
+})
+
+test('dateTimeDe: Berliner Zeit, 24 Stunden', () => {
+ assert.equal(dateTimeDe('2026-10-01T10:55:21.691Z'), '01.10.2026, 12:55 Uhr')
+ assert.equal(dateTimeDe('2026-01-15T23:30:00.000Z'), '16.01.2026, 00:30 Uhr')
+ assert.equal(dateTimeDe(''), '–')
+ assert.equal(dateTimeDe('kaputt'), '–')
+ assert.equal(dateTimeDe(null), '–')
+})
+
+const BANK = (over = {}) => ({
+ status: 'aktiv',
+ ibanMasked: 'DE00 •••• 9313',
+ validUntil: '2099-03-29T00:00:00.000Z',
+ lastSyncAt: '2026-10-01T10:55:21.691Z',
+ lastSyncOk: true,
+ lastError: '',
+ ...over,
+})
+
+test('bankCardModel: ohne Verbindung nur „Bank verbinden“', () => {
+ for (const none of [null, undefined, 'x']) {
+ const m = bankCardModel(none, NOW)
+ assert.equal(m.connected, false)
+ assert.equal(m.statusLabel, 'Nicht verbunden')
+ assert.equal(m.canRefresh, false)
+ assert.equal(m.connectLabel, 'Bank verbinden')
+ assert.deepEqual(m.rows, [])
+ assert.equal(m.error, '')
+ }
+})
+
+test('bankCardModel: verbunden zeigt IBAN, Gültigkeit und letzten Abruf', () => {
+ const m = bankCardModel(BANK(), NOW)
+ assert.equal(m.connected, true)
+ assert.equal(m.statusLabel, 'Verbunden')
+ assert.equal(m.tone, 'ok')
+ assert.deepEqual(m.rows, [
+ { key: 'iban', label: 'IBAN', text: 'DE00 •••• 9313' },
+ { key: 'valid', label: 'Zugang gültig bis', text: '29.03.2099' },
+ { key: 'sync', label: 'Letzter Abruf', text: '01.10.2026, 12:55 Uhr · vor 3 Std.' },
+ ])
+ assert.equal(m.error, '')
+ assert.equal(m.canRefresh, true)
+ assert.equal(m.connectLabel, 'Neu verbinden')
+ assert.equal(m.hint, '')
+})
+
+test('bankCardModel: Fehler, abgelaufen, noch kein Abruf', () => {
+ const failed = bankCardModel(BANK({ lastSyncOk: false, lastError: 'Bank antwortet nicht' }), NOW)
+ assert.equal(failed.statusLabel, 'Abruf fehlgeschlagen')
+ assert.equal(failed.tone, 'warn')
+ assert.equal(failed.error, 'Bank antwortet nicht')
+ assert.equal(failed.canRefresh, true)
+ // Abruf gescheitert, aber der Server nennt keinen Text
+ assert.equal(bankCardModel(BANK({ lastSyncOk: false, lastError: '' }), NOW).error, 'Der letzte Abruf ist fehlgeschlagen.')
+ assert.equal(bankCardModel(BANK({ status: 'fehler', lastSyncOk: true }), NOW).statusLabel, 'Abruf fehlgeschlagen')
+
+ // abgelaufen (Datum oder Status): Abruf gesperrt, nur neu verbinden
+ const expired = bankCardModel(BANK({ validUntil: '2026-09-30T00:00:00.000Z' }), NOW)
+ assert.equal(expired.statusLabel, 'Zugang abgelaufen')
+ assert.equal(expired.tone, 'danger')
+ assert.equal(expired.canRefresh, false)
+ assert.equal(expired.hint, 'Der Zugang ist abgelaufen. Bitte neu verbinden.')
+ assert.equal(bankCardModel(BANK({ status: 'abgelaufen' }), NOW).canRefresh, false)
+ // am letzten Gültigkeitstag selbst noch nutzbar
+ assert.equal(bankCardModel(BANK({ validUntil: '2026-10-01T00:00:00.000Z' }), NOW).statusLabel, 'Verbunden')
+ // abgelaufen hat Vorrang vor Fehler
+ assert.equal(bankCardModel(BANK({ validUntil: '2026-01-01T00:00:00.000Z', lastSyncOk: false }), NOW).statusLabel, 'Zugang abgelaufen')
+
+ const fresh = bankCardModel(BANK({ lastSyncAt: '' }), NOW)
+ assert.equal(fresh.rows[2].text, 'noch kein Abruf')
+ const sparse = bankCardModel({ status: 'wartet' }, NOW)
+ assert.equal(sparse.statusLabel, 'wartet')
+ assert.equal(sparse.rows[0].text, '–')
+ assert.equal(sparse.rows[1].text, '–')
+})
+
+test('refreshResultMessage: Ergebnis des Abrufs', () => {
+ assert.deepEqual(refreshResultMessage({ ok: true, neu: 3, aktualisiert: 1, geloescht: 0 }), { tone: 'ok', text: 'Abruf abgeschlossen: 3 neu, 1 aktualisiert.' })
+ assert.deepEqual(refreshResultMessage({ ok: true, neu: 0, aktualisiert: 0, geloescht: 2 }), { tone: 'ok', text: 'Abruf abgeschlossen: 2 entfernt.' })
+ assert.deepEqual(refreshResultMessage({ ok: true, neu: 0, aktualisiert: 0, geloescht: 0 }), { tone: 'ok', text: 'Abruf abgeschlossen, keine neuen Buchungen.' })
+ assert.deepEqual(refreshResultMessage({ ok: false, error: 'Bank antwortet nicht' }), { tone: 'warn', text: 'Abruf fehlgeschlagen: Bank antwortet nicht' })
+ assert.deepEqual(refreshResultMessage({ ok: false }), { tone: 'warn', text: 'Abruf fehlgeschlagen.' })
+ assert.equal(refreshResultMessage(null).tone, 'ok')
+})
diff --git a/tests/unit/financeView.test.mjs b/tests/unit/financeView.test.mjs
index 57d26b0..6d03fa2 100644
--- a/tests/unit/financeView.test.mjs
+++ b/tests/unit/financeView.test.mjs
@@ -46,6 +46,7 @@ const {
customerPickerOptions,
customerSuggestions,
resolveCustomerText,
+ customerInvoicesGate,
sortTransactions,
strongestSuggestion,
suggestionText,
@@ -1184,3 +1185,29 @@ test('assignBody und invoiceAssignConfirmText', () => {
assert.deepEqual(assignBody(null, 'x'), { note: 'x' })
assert.equal(invoiceAssignConfirmText('WK-0005'), 'Die Rechnung WK-0005 wird in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.')
})
+
+test('customerInvoicesGate: fester Kunde mit E-Mail darf, alle anderen bekommen Grund und Hinweis', () => {
+ assert.deepEqual(customerInvoicesGate({ $id: 'c1', name: 'A', email: 'a@example.invalid', customerStatus: 'customer' }), { ok: true })
+ // Bestandsdaten ohne Status zählen als fester Kunde
+ assert.deepEqual(customerInvoicesGate({ $id: 'c1', name: 'A', email: 'a@example.invalid' }), { ok: true })
+
+ const lead = customerInvoicesGate({ name: 'A', email: 'a@example.invalid', customerStatus: 'lead' })
+ assert.equal(lead.ok, false)
+ assert.equal(lead.title, 'Noch ein Lead')
+ assert.equal(lead.hint, 'Erst zum festen Kunden machen (vollständige Daten nötig), dann sind Rechnungen möglich.')
+ // Lead ohne E-Mail: der Lead-Hinweis hat Vorrang
+ assert.equal(customerInvoicesGate({ name: 'A', email: '', customerStatus: 'LEAD' }).title, 'Noch ein Lead')
+
+ const lost = customerInvoicesGate({ name: 'A', email: 'a@example.invalid', customerStatus: 'lost' })
+ assert.deepEqual(lost, { ok: false, title: 'Noch kein fester Kunde', hint: 'Rechnungen gibt es nur für feste Kunden.' })
+
+ const noMail = customerInvoicesGate({ name: 'A', email: ' ', customerStatus: 'customer' })
+ assert.equal(noMail.ok, false)
+ assert.equal(noMail.title, 'E-Mail fehlt')
+ assert.match(noMail.hint, /E-Mail-Adresse in den Stammdaten/)
+
+ const none = customerInvoicesGate(null)
+ assert.equal(none.ok, false)
+ assert.equal(none.title, 'Kein Kunde ausgewählt')
+ assert.equal(none.hint, '')
+})