Finanzen: Einstellungen, Rechnungen am Kunden, InvoiceNinja aus der Oberflaeche entfernt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:12:18 +00:00
1 parent 24a3cc41ef
commit 2aacdb2d56
24 files changed
+1410 -499

No files matched your search

-262
View File
@@ -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 (
<div style={card}>
<FaSpinner className="spinner" /> Kunde wird geladen...
</div>
)
}
if (!ticket.customerId) {
return (
<div style={card}>
<p style={{ color: '#a0aec0', margin: 0 }}>Kein Kunde am Ticket verknüpft, keine Rechnungen verfügbar.</p>
</div>
)
}
return (
<div style={card}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', margin: 0, fontSize: '16px' }}>
Rechnungen {customer?.name ? `- ${customer.name}` : ''}
</h5>
{clientId && (
<button className="btn" onClick={refresh} title="Aktualisieren"
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}>
<FaRotate />
</button>
)}
</div>
{customerError && <div className="text-red" style={{ marginBottom: 8 }}>{customerError}</div>}
{customer && customerStage(customer) === 'lead' ? (
<p style={{ color: '#a0aec0', margin: 0 }}>
Dieser Kunde ist noch ein Lead. Erst zum festen Kunden machen (im Kunden-Bereich, vollständige Daten nötig), dann sind Rechnungen möglich.
</p>
) : !clientId && !isInvoiceReady(customer) ? (
<p style={{ color: '#a0aec0', margin: 0 }}>
Rechnungsdaten unvollständig. Bitte im Kunden-Bereich Unternehmen, E-Mail, Straße, PLZ und Stadt ergänzen.
</p>
) : !clientId ? (
<LinkClient customer={customer} onLinked={loadCustomer} />
) : (
<InvoiceList invoices={invoices} loading={loading} error={error} />
)}
</div>
)
}
// ---------------------------------------------------- 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 (
<div>
<p style={{ color: '#a0aec0', fontSize: '13px', marginTop: 0 }}>
Dieser Kunde ist noch nicht mit InvoiceNinja verknüpft.
</p>
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
<input
className="form-control"
style={{ margin: 0 }}
placeholder="InvoiceNinja-Kunde suchen …"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && doSearch()}
/>
<button className="btn btn-green" style={{ margin: 0 }} onClick={doSearch} disabled={busy}>
{busy ? <FaSpinner className="spinner" /> : 'Suchen'}
</button>
</div>
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
<div style={{ maxHeight: '180px', overflowY: 'auto', marginBottom: '10px' }}>
{results.map((c) => (
<div key={c.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<div>
<strong style={{ color: 'var(--dark-text)' }}>{c.name}</strong>
{c.number ? <span style={{ color: '#a0aec0' }}> · {c.number}</span> : null}
{c.email ? <div style={{ color: '#a0aec0', fontSize: '12px' }}>{c.email}</div> : null}
</div>
<button className="btn btn-green" style={{ margin: 0 }} disabled={saving} onClick={() => saveMapping(c.id)}>
<FaLink /> Verknüpfen
</button>
</div>
))}
{!busy && results.length === 0 && (
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Keine Treffer.</p>
)}
</div>
<button className="btn" style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '10px 16px', borderRadius: '8px', cursor: 'pointer' }} disabled={saving} onClick={createAndLink}>
{saving ? <FaSpinner className="spinner" /> : <><FaPlus /> Neuen InvoiceNinja-Kunden anlegen &amp; verknüpfen</>}
</button>
</div>
)
}
// ---------------------------------------------------- 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 (
<div>
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
{loading ? (
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Rechnungen werden geladen...</p>
) : invoices.length === 0 ? (
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Noch keine Rechnungen für diesen Kunden.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="table" style={{ width: '100%', fontSize: '13px' }}>
<thead>
<tr style={{ color: '#a0aec0', textAlign: 'left' }}>
<th>Nr.</th><th>Datum</th><th>Fällig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>
{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 (
<tr key={inv.id}>
<td style={{ color: 'var(--dark-text)' }}>{inv.number || inv.id?.slice(-6)}</td>
<td style={{ color: '#cbd5e0' }}>{inv.date || '-'}</td>
<td style={{ color: overdue ? '#ef4444' : '#cbd5e0' }}>{inv.due_date || '-'}</td>
<td style={{ color: 'var(--dark-text)' }}>{formatMoney(inv.amount)}</td>
<td style={{ color: Number(inv.balance) > 0 ? '#f59e0b' : '#10b981' }}>{formatMoney(inv.balance)}</td>
<td><span style={{ color: st.color, fontWeight: 'bold' }}>{st.label}{overdue ? ' (überfällig)' : ''}</span></td>
<td>
<button className="btn" title="PDF öffnen" disabled={openingId === inv.id}
style={{ background: '#ef4444', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
onClick={() => openPdf(inv.id)}>
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
-4
View File
@@ -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 }) {
/>
</div>
)}
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
</div>
</div>
)}
+123
View File
@@ -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 (
<Card title="Bankzugang">
{res.loading ? (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Bankzugang wird geladen …</p>
</div>
) : (
<div className="fin-load-error" style={{ padding: 0 }}>
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
</div>
)}
</Card>
)
}
const model = bankCardModel(res.data.verbindung)
return (
<Card title="Bankzugang" actions={<Badge tone={model.tone}>{model.statusLabel}</Badge>}>
{res.error && (
<p className="fin-hint fin-hint-warn fin-form-notice" role="alert">
Der Stand konnte nicht neu geladen werden: {res.error}
</p>
)}
{model.rows.length > 0 && (
<dl className="fin-bank-facts">
{model.rows.map((row) => (
<div key={row.key}>
<dt>{row.label}</dt>
<dd>{row.text}</dd>
</div>
))}
</dl>
)}
{model.error && (
<p className="fin-hint fin-hint-warn fin-form-notice" role="alert">Fehler: {model.error}</p>
)}
{model.hint && <p className="fin-hint fin-form-notice">{model.hint}</p>}
{message && (
<p className={`fin-hint fin-form-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
{message.text}
</p>
)}
<div className="fin-form-actions">
{model.canRefresh && (
<Button variant="primary" onClick={refresh} disabled={busy !== null}>
<FaRotate className={busy === 'refresh' ? 'fin-spin' : undefined} aria-hidden="true" /> {busy === 'refresh' ? 'Rufe ab …' : 'Jetzt abrufen'}
</Button>
)}
<Button variant={model.canRefresh ? 'ghost' : 'primary'} onClick={connect} disabled={busy !== null}>
{busy === 'connect' ? 'Leite weiter …' : model.connectLabel}
</Button>
</div>
</Card>
)
}
@@ -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 (
<Card>
<EmptyState
title={gate.title}
hint={gate.hint}
action={onShowOverview ? <Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button> : null}
/>
</Card>
)
}
return <InvoiceTable customer={customer} />
}
+257
View File
@@ -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 ? (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Fixkosten werden geladen …</p>
</div>
) : (
<div className="fin-load-error" style={{ padding: 0 }}>
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
</div>
)
} else {
body = (
<>
{res.error && (
<p className="fin-hint fin-hint-warn" role="alert">
Die Liste konnte nicht neu geladen werden: {res.error}{' '}
<Button size="sm" variant="ghost" onClick={res.reload} disabled={res.loading}>Erneut versuchen</Button>
</p>
)}
{editing && (
<form className="fin-fixed-form" onSubmit={submit} noValidate aria-labelledby="fin-fix-form-title">
<h4 id="fin-fix-form-title" className="fin-fixed-form-title">{editing.id ? 'Position bearbeiten' : 'Neue Position'}</h4>
<fieldset className="fin-fieldset" disabled={busy === 'save'}>
<div className="fin-form-grid">
<FormField id={fieldId('name')} label="Name" error={errors.name}>
<input className="form-control" autoComplete="off" value={form.name} onChange={change('name')} placeholder="z. B. Hetzner" />
</FormField>
<FormField id={fieldId('amount')} label="Betrag (€)" error={errors.amount}>
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.amount} onChange={change('amount')} placeholder="z. B. 49,90" />
</FormField>
<FormField id={fieldId('interval')} label="Rhythmus" error={errors.interval}>
<select className="form-control" value={form.interval} onChange={change('interval')}>
{INTERVAL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</FormField>
<FormField id={fieldId('nextDate')} label="Nächster Termin" error={errors.nextDate}>
<input className="form-control" type="date" value={form.nextDate} onChange={change('nextDate')} />
</FormField>
</div>
<FormField
id={fieldId('counterpartyMatch')}
label="Suchbegriff in der Buchung (optional)"
hint={`Teil des Namens der Gegenpartei in der Bankbuchung, z. B. hetzner (höchstens ${SETTINGS_LIMITS.maxMatch} Zeichen). Damit schlägt die Seite diese Abbuchung nicht noch einmal als neue Fixkosten vor.`}
error={errors.counterpartyMatch}
>
<input className="form-control" autoComplete="off" value={form.counterpartyMatch} onChange={change('counterpartyMatch')} />
</FormField>
</fieldset>
<div className="fin-form-actions">
<Button type="submit" variant="primary" disabled={busy !== null}>
{busy === 'save' ? 'Speichere …' : 'Speichern'}
</Button>
<Button type="button" variant="ghost" onClick={closeForm} disabled={busy !== null}>Abbrechen</Button>
</div>
</form>
)}
{message && (
<p className={`fin-hint fin-form-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
{message.text}
</p>
)}
{rows.length === 0 ? (
<EmptyState title="Noch keine Fixkosten." hint="Lege regelmäßige Ausgaben an, damit Vorschau und „Verfügbar“ sie berücksichtigen." />
) : (
<ul className="fin-fixed-list" aria-label="Fixkosten">
{rows.map((row) => (
<li key={row.key} className={`fin-fixed${row.active ? '' : ' fin-fixed-off'}`}>
<div className="fin-fixed-main">
<span className="fin-fixed-name">{row.name}</span>
<Badge tone={row.active ? 'ok' : 'muted'}>{row.statusLabel}</Badge>
</div>
<div className="fin-amount fin-fixed-amount">{row.amountText}</div>
<div className="fin-fixed-meta muted">{row.metaText}</div>
<div className="fin-actions fin-fixed-actions">
<Button size="sm" variant="ghost" onClick={() => openEdit(row)} disabled={busy !== null} aria-label={`Bearbeiten: ${row.name}`}>
Bearbeiten
</Button>
<Button size="sm" variant="ghost" onClick={() => toggle(row)} disabled={busy !== null} aria-label={`${row.toggleLabel}: ${row.name}`}>
{busy === `${row.id}:toggle` ? 'Speichere …' : row.toggleLabel}
</Button>
<Button size="sm" variant="danger" onClick={() => remove(row)} disabled={busy !== null} aria-label={`Löschen: ${row.name}`}>
{busy === `${row.id}:delete` ? 'Lösche …' : 'Löschen'}
</Button>
</div>
</li>
))}
</ul>
)}
</>
)
}
return (
<Card
title="Fixkosten"
actions={
res.data && (
<Button size="sm" variant="primary" onClick={openNew} disabled={busy !== null || editing?.id === null}>
<FaPlus aria-hidden="true" /> Neue Position
</Button>
)
}
>
{body}
</Card>
)
}
+27
View File
@@ -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 (
<div className="field fin-form-field">
<label htmlFor={id}>{label}</label>
{cloneElement(children, { id, 'aria-invalid': error ? 'true' : undefined, 'aria-describedby': describedBy })}
{hint && (
<div id={hintId} className="faint fin-form-hint">
{hint}
</div>
)}
{error && (
<div id={errorId} className="fin-field-error" role="alert">
{error}
</div>
)}
</div>
)
}
+167
View File
@@ -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 (
<Card title="Einstellungen">
{res.loading ? (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Einstellungen werden geladen …</p>
</div>
) : (
<div className="fin-load-error" style={{ padding: 0 }}>
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
</div>
)}
</Card>
)
}
return <SettingsForm initial={res.data.einstellungen} />
}
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 (
<Card title="Einstellungen">
<form onSubmit={submit} noValidate>
{/* Während des Speicherns gesperrt, damit die Antwort keine neuen Eingaben überschreibt */}
<fieldset className="fin-fieldset" disabled={busy}>
<div className="fin-form-grid">
<FormField
id={fieldId('minBuffer')}
label="Mindestpuffer (€)"
hint="Dieser Betrag bleibt bei „Verfügbar“ immer auf dem Konto. 0 ist möglich."
error={errors.minBuffer}
>
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.minBuffer} onChange={change('minBuffer')} placeholder="z. B. 500,00" />
</FormField>
<FormField
id={fieldId('taxReservePercent')}
label="Steuer-Rücklage (%)"
hint={`Anteil des Gewinns, der als Rücklage gilt (0 bis ${SETTINGS_LIMITS.maxPercent}).`}
error={errors.taxReservePercent}
>
<input className="form-control fin-input-amount" inputMode="numeric" autoComplete="off" value={form.taxReservePercent} onChange={change('taxReservePercent')} placeholder="30" />
</FormField>
<FormField
id={fieldId('previousYearRevenue')}
label="Vorjahresumsatz (€)"
hint="Für die Kleinunternehmer-Grenze. Leer lassen, wenn unbekannt."
error={errors.previousYearRevenue}
>
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.previousYearRevenue} onChange={change('previousYearRevenue')} placeholder="leer = unbekannt" />
</FormField>
<FormField
id={fieldId('estPrepayment')}
label="ESt-Vorauszahlung (€)"
hint="Betrag je Termin (10.03., 10.06., 10.09., 10.12.). 0 = keine Vorauszahlung."
error={errors.estPrepayment}
>
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.estPrepayment} onChange={change('estPrepayment')} placeholder="0,00" />
</FormField>
</div>
<FormField
id={fieldId('bankDetailsText')}
label="Bankverbindung auf Rechnungen"
hint={`Steht im Fuß jeder Rechnung, z. B. „${BANK_DETAILS_EXAMPLE}“.`}
error={errors.bankDetailsText}
>
<textarea className="form-control" rows={3} value={form.bankDetailsText} onChange={change('bankDetailsText')} />
</FormField>
<div className={`fin-counter${tooLong ? ' fin-counter-over' : ''}`}>
{length} / {SETTINGS_LIMITS.maxBankDetails} Zeichen
</div>
</fieldset>
{notice && (
<p className={`fin-hint fin-form-notice${notice.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={notice.tone === 'warn' ? 'alert' : 'status'}>
{notice.text}
</p>
)}
<div className="fin-form-actions">
<Button type="submit" variant="primary" disabled={busy}>
{busy ? <FaSpinner className="spinner" aria-hidden="true" /> : <FaFloppyDisk aria-hidden="true" />} {busy ? 'Speichere …' : 'Speichern'}
</Button>
</div>
</form>
</Card>
)
}