Finanzen: Einstellungen, Bankzugang, Fixkosten und Bankrueckkehr ohne Karten

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-06 14:38:00 +00:00
1 parent b5d27d5801
commit d1a1046969
7 files changed
+91 -126

No files matched your search

+16 -27
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { FaRotate, FaSpinner } from 'react-icons/fa6'
import { Fragment, useEffect, useRef, useState } from 'react'
import { Badge, Button, Card } from '../ui'
import { useFinanceResource } from '../../hooks/useFinanceResource'
import { noticeProps } from '../../lib/financeUi'
import { getBank, refreshBank, startBankAuth } from '../../lib/financeApi'
import { bankCardModel, refreshResultMessage } from '../../lib/financeSettings'
import { safeUrl } from '../../lib/financeView'
@@ -67,14 +67,11 @@ export default function BankAccessCard() {
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="loading" role="status">Bankzugang wird geladen …</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 className="fin-load-error">
<p {...noticeProps('warn')}>{res.error || LOAD_ERROR}</p>
<Button onClick={res.reload}>Erneut versuchen</Button>
</div>
)}
</Card>
@@ -85,36 +82,28 @@ export default function BankAccessCard() {
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>
<p {...noticeProps('warn', 'fin-notice')}>Der Stand konnte nicht neu geladen werden: {res.error}</p>
)}
{model.rows.length > 0 && (
<dl className="fin-bank-facts">
<dl className="kv fin-kv">
{model.rows.map((row) => (
<div key={row.key}>
<Fragment key={row.key}>
<dt>{row.label}</dt>
<dd>{row.text}</dd>
</div>
<dd className="num">{row.text}</dd>
</Fragment>
))}
</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>
)}
{model.error && <p {...noticeProps('warn', 'fin-notice')}>Fehler: {model.error}</p>}
{model.hint && <p {...noticeProps('info', 'fin-notice')}>{model.hint}</p>}
{message && <p {...noticeProps(message.tone, 'fin-notice')}>{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'}
{busy === 'refresh' ? 'Rufe ab …' : 'Jetzt abrufen'}
</Button>
)}
<Button variant={model.canRefresh ? 'ghost' : 'primary'} onClick={connect} disabled={busy !== null}>
<Button variant={model.canRefresh ? 'default' : 'primary'} onClick={connect} disabled={busy !== null}>
{busy === 'connect' ? 'Leite weiter …' : model.connectLabel}
</Button>
</div>
+25 -30
View File
@@ -1,8 +1,8 @@
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 { noticeProps } from '../../lib/financeUi'
import { createFixedCost, deleteFixedCost, getFixedCosts, updateFixedCost } from '../../lib/financeApi'
import {
FIXED_COST_DELETE_CONFIRM,
@@ -146,45 +146,44 @@ export default function FixedCostsCard() {
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="loading" role="status">Fixkosten werden geladen …</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 className="fin-load-error">
<p {...noticeProps('warn')}>{res.error || LOAD_ERROR}</p>
<Button onClick={res.reload}>Erneut versuchen</Button>
</div>
)
} else {
body = (
<>
{res.error && (
<p className="fin-hint fin-hint-warn" role="alert">
<p {...noticeProps('warn', 'fin-notice')}>
Die Liste konnte nicht neu geladen werden: {res.error}{' '}
<Button size="sm" variant="ghost" onClick={res.reload} disabled={res.loading}>Erneut versuchen</Button>
<button type="button" className="link" 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>
<h3 id="fin-fix-form-title" className="fin-form-title">{editing.id ? 'Position bearbeiten' : 'Neue Position'}</h3>
<fieldset className="fin-fieldset" disabled={busy === 'save'}>
<div className="fin-form-grid">
<div className="field-row">
<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" />
<input className="input" 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" />
<input className="input fin-input-amount" inputMode="decimal" autoComplete="off" value={form.amount} onChange={change('amount')} placeholder="z. B. 49,90" />
</FormField>
</div>
<div className="field-row">
<FormField id={fieldId('interval')} label="Rhythmus" error={errors.interval}>
<select className="form-control" value={form.interval} onChange={change('interval')}>
<select className="select" 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')} />
<input className="input" type="date" value={form.nextDate} onChange={change('nextDate')} />
</FormField>
</div>
<FormField
@@ -193,22 +192,18 @@ export default function FixedCostsCard() {
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')} />
<input className="input" 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>
<Button type="button" 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>
)}
{message && <p {...noticeProps(message.tone, 'fin-notice')}>{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." />
) : (
@@ -222,13 +217,13 @@ export default function FixedCostsCard() {
<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}`}>
<button type="button" className="link" 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}`}>
</button>
<button type="button" className="link" 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}`}>
</button>
<Button type="button" variant="danger" onClick={() => remove(row)} disabled={busy !== null} aria-label={`Löschen: ${row.name}`}>
{busy === `${row.id}:delete` ? 'Lösche …' : 'Löschen'}
</Button>
</div>
@@ -245,8 +240,8 @@ export default function FixedCostsCard() {
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 onClick={openNew} disabled={busy !== null || editing?.id === null}>
Neue Position
</Button>
)
}
+5 -4
View File
@@ -1,8 +1,9 @@
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").
* Formularfeld mit Beschriftung, Hinweis und Fehlermeldung (Klassen des Bausteins `Field`: `.field`, `.hint`, `.msg.err`).
* `children` ist genau ein Eingabeelement; es bekommt `id`, `aria-invalid` und `aria-describedby` (Hinweis und Fehler).
* Den roten Ring am Feld liefert allein `aria-invalid`. Die Fehlermeldung wird vorgelesen (role="alert").
*/
export default function FormField({ id, label, hint, error, children }) {
const hintId = hint ? `${id}-hint` : undefined
@@ -13,12 +14,12 @@ export default function FormField({ id, label, hint, error, children }) {
<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">
<div id={hintId} className="hint">
{hint}
</div>
)}
{error && (
<div id={errorId} className="fin-field-error" role="alert">
<div id={errorId} className="msg err" role="alert">
{error}
</div>
)}
+15 -20
View File
@@ -1,8 +1,8 @@
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 { noticeProps } from '../../lib/financeUi'
import { getSettings, patchSettings } from '../../lib/financeApi'
import {
BANK_DETAILS_EXAMPLE,
@@ -29,14 +29,11 @@ export default function SettingsCard() {
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="loading" role="status">Einstellungen werden geladen …</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 className="fin-load-error">
<p {...noticeProps('warn')}>{res.error || LOAD_ERROR}</p>
<Button onClick={res.reload}>Erneut versuchen</Button>
</div>
)}
</Card>
@@ -103,14 +100,14 @@ function SettingsForm({ initial }) {
<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">
<div className="field-row">
<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" />
<input className="input fin-input-amount" inputMode="decimal" autoComplete="off" value={form.minBuffer} onChange={change('minBuffer')} placeholder="z. B. 500,00" />
</FormField>
<FormField
id={fieldId('taxReservePercent')}
@@ -118,15 +115,17 @@ function SettingsForm({ initial }) {
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" />
<input className="input fin-input-amount" inputMode="numeric" autoComplete="off" value={form.taxReservePercent} onChange={change('taxReservePercent')} placeholder="30" />
</FormField>
</div>
<div className="field-row">
<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" />
<input className="input fin-input-amount" inputMode="decimal" autoComplete="off" value={form.previousYearRevenue} onChange={change('previousYearRevenue')} placeholder="leer = unbekannt" />
</FormField>
<FormField
id={fieldId('estPrepayment')}
@@ -134,7 +133,7 @@ function SettingsForm({ initial }) {
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" />
<input className="input fin-input-amount" inputMode="decimal" autoComplete="off" value={form.estPrepayment} onChange={change('estPrepayment')} placeholder="0,00" />
</FormField>
</div>
@@ -144,21 +143,17 @@ function SettingsForm({ initial }) {
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')} />
<textarea className="textarea" 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>
)}
{notice && <p {...noticeProps(notice.tone, 'fin-notice')}>{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'}
{busy ? 'Speichere …' : 'Speichern'}
</Button>
</div>
</form>