Finanzen: Einstellungen, Bankzugang, Fixkosten und Bankrueckkehr ohne Karten
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b5d27d5801
commit
d1a1046969
7 files changed
+91
-126
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user