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:
1 parent
24a3cc41ef
commit
2aacdb2d56
24 files changed
+1410
-499
No files matched your search
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user