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

+2
View File
@@ -10,6 +10,7 @@ import AdminPage from './pages/AdminPage'
import CustomersPage from './pages/CustomersPage'
import CustomerDetailPage from './pages/CustomerDetailPage'
import FinancePage from './pages/FinancePage'
import FinanceSettingsPage from './pages/FinanceSettingsPage'
import LegalPage from './pages/LegalPage'
import AssignProjectPage from './pages/AssignProjectPage'
import ServicesPage from './pages/ServicesPage'
@@ -52,6 +53,7 @@ function AppRoutes() {
<Route path="/leads" element={<Navigate to="/tickets" replace />} />
{/* Finanzen: nur Admins, alle anderen gehen zu /tickets */}
<Route path="/finance" element={<ProtectedRoute><AdminRoute><FinancePage /></AdminRoute></ProtectedRoute>} />
<Route path="/finance/einstellungen" element={<ProtectedRoute><AdminRoute><FinanceSettingsPage /></AdminRoute></ProtectedRoute>} />
<Route path="/finanzen/bank-callback" element={<ProtectedRoute><AdminRoute><BankCallbackPage /></AdminRoute></ProtectedRoute>} />
<Route path="/rechtliches" element={<ProtectedRoute><LegalPage /></ProtectedRoute>} />
<Route path="/serverpuls" element={<ProtectedRoute><ServerpulsPage /></ProtectedRoute>} />
-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>
)
}
+1 -1
View File
@@ -19,7 +19,7 @@ export function useCustomers() {
const fetchCustomers = useCallback(async () => {
try {
// Direkt aus Appwrite: die Admin-API des Portals liefert gekuerzte Daten
// (ohne Stern und InvoiceNinja-Verknuepfung)
// (zum Beispiel ohne Stern)
const response = await databases.listDocuments(
DATABASE_ID,
COLLECTIONS.CUSTOMERS,
+36
View File
@@ -0,0 +1,36 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Lädt eine Ressource der Finanzen-API (z. B. getSettings) und hält sie.
* data bleibt beim erneuten Laden stehen; error ist die Meldung des letzten fehlgeschlagenen Versuchs (leer, solange
* alles in Ordnung ist). reload() liefert ein Promise, das nie wirft; nur der jüngste Aufruf zählt.
*/
export function useFinanceResource(load, fallbackError) {
const [state, setState] = useState({ data: null, loading: true, error: '' })
const aliveRef = useRef(true)
const seqRef = useRef(0)
const loadRef = useRef(load)
loadRef.current = load
const reload = useCallback(async () => {
const mine = ++seqRef.current
const current = () => aliveRef.current && mine === seqRef.current
setState((s) => ({ ...s, loading: true, error: '' }))
try {
const data = await loadRef.current()
if (current()) setState({ data: data && typeof data === 'object' ? data : {}, loading: false, error: '' })
} catch (err) {
if (current()) setState((s) => ({ ...s, loading: false, error: err?.message || fallbackError }))
}
}, [fallbackError])
useEffect(() => {
aliveRef.current = true
reload()
return () => {
aliveRef.current = false
}
}, [reload])
return { ...state, reload }
}
-24
View File
@@ -1,24 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { integrationsApi } from '../lib/integrationsApi'
export function useFinanceSummary() {
const [summary, setSummary] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
setSummary(await integrationsApi.invoicesSummary())
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
return { summary, loading, error, refresh: load }
}
-36
View File
@@ -1,36 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { integrationsApi } from '../lib/integrationsApi'
/**
* Laedt die InvoiceNinja-Rechnungen eines verknuepften Clients.
* clientId = customers.invoiceNinjaClientId (oder null -> nichts laden).
*/
export function useInvoices(clientId) {
const [invoices, setInvoices] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const fetchInvoices = useCallback(async () => {
if (!clientId) {
setInvoices([])
return
}
setLoading(true)
setError(null)
try {
const data = await integrationsApi.listInvoices(clientId)
setInvoices(data.invoices || [])
} catch (err) {
setError(err.message)
setInvoices([])
} finally {
setLoading(false)
}
}, [clientId])
useEffect(() => {
fetchInvoices()
}, [fetchInvoices])
return { invoices, loading, error, refresh: fetchInvoices }
}
+327
View File
@@ -0,0 +1,327 @@
// Reine Helfer fuer die Einstellungsseite der Finanzen (Einstellungen, Fixkosten, Bankzugang); kein React, kein
// Appwrite, unter Node testbar. Betraege sind Cent, in Formularen Euro-Texte (de-DE); Texte deutsch.
import { INTERVAL_LABEL, dateDe, euro, parseEuro, sinceLabel, ymdBerlin } from './financeFormat'
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
const text = (v) => (typeof v === 'string' ? v.trim() : '')
/** Grenzen der Portal-API (settings.js); die Oberfläche prüft vorab, der Server entscheidet. */
export const SETTINGS_LIMITS = {
maxPercent: 60,
maxBufferCents: 100_000_000,
maxBankDetails: 500,
maxName: 120,
maxMatch: 120,
}
/** Beispiel für den Bankverbindungstext (steht im Fuß jeder Rechnung). */
export const BANK_DETAILS_EXAMPLE = 'Bankverbindung: WEBklar GbR · IBAN DE.. · BIC ..'
/** Rückfrage vor dem Löschen einer Fixkosten-Position. */
export const FIXED_COST_DELETE_CONFIRM =
'Position wirklich löschen? Bereits zugeordnete Buchungen behalten den Verweis. Besser auf inaktiv setzen.'
const AMOUNT_HELP = 'Bitte einen Betrag wie 1.234,56 eingeben.'
// Felder, deren Namen der Server der Fehlermeldung voranstellt („minBufferCents: Der Mindestpuffer …“)
const API_FIELDS = [
'minBufferCents',
'taxReservePercent',
'previousYearRevenueCents',
'estPrepaymentCents',
'bankDetailsText',
'name',
'amountCents',
'interval',
'nextDate',
'counterpartyMatch',
'active',
]
const FIELD_PREFIX = new RegExp(`^(?:${API_FIELDS.join('|')}): `)
/** Meldung eines API-Fehlers ohne den technischen Feldnamen am Anfang. */
export function apiErrorMessage(err, fallback = 'Das Speichern ist fehlgeschlagen.') {
const message = text(err?.message)
return message ? message.replace(FIELD_PREFIX, '') : fallback
}
/** 123456 -> '1.234,56' (ohne Euro-Zeichen, für Eingabefelder); null/ungültig -> ''. */
export function euroField(cents) {
return isNum(cents) ? euro(cents).replace(/ €$/, '') : ''
}
/** Text -> Cent oder { error }; leer ist Sache des Aufrufers. */
function amountCents(value) {
try {
return { cents: parseEuro(String(value ?? '')) }
} catch {
return { error: `Ungültiger Betrag. ${AMOUNT_HELP}` }
}
}
// --- Einstellungen -------------------------------------------------------------------------------------------
/** Zeichen des Bankverbindungstexts, wie sie gespeichert werden (ohne Leerraum am Rand). */
export function bankDetailsLength(value) {
return text(value).length
}
/** Antwort des Servers (Cent) -> Formular (Texte). */
export function settingsToForm(settings) {
const s = settings && typeof settings === 'object' ? settings : {}
return {
minBuffer: euroField(s.minBufferCents),
taxReservePercent: isNum(s.taxReservePercent) ? String(s.taxReservePercent) : '',
previousYearRevenue: euroField(s.previousYearRevenueCents),
estPrepayment: euroField(s.estPrepaymentCents),
bankDetailsText: typeof s.bankDetailsText === 'string' ? s.bankDetailsText : '',
}
}
/**
* Formular -> PATCH-Körper. { ok: true, payload } oder { ok: false, errors: { feld: 'Meldung' } } (alle Fehler).
* Mindestpuffer und ESt-Vorauszahlung: Betrag ab 0 (0 ist erlaubt), Pflicht. Vorjahresumsatz: leer = null.
* Rücklage: ganze Zahl 0 bis 60. Bankverbindungstext: höchstens 500 Zeichen.
*/
export function validateSettingsForm(form) {
const f = form && typeof form === 'object' ? form : {}
const errors = {}
const payload = {}
const buffer = text(f.minBuffer)
if (!buffer) {
errors.minBuffer = 'Bitte einen Betrag eingeben (0 ist möglich).'
} else {
const r = amountCents(buffer)
if (r.error) errors.minBuffer = r.error
else if (r.cents > SETTINGS_LIMITS.maxBufferCents) errors.minBuffer = `Der Mindestpuffer darf höchstens ${euro(SETTINGS_LIMITS.maxBufferCents)} betragen.`
else payload.minBufferCents = r.cents
}
const percent = text(f.taxReservePercent).replace(/\s*%$/, '')
if (/^[0-9]{1,3}$/.test(percent) && Number(percent) <= SETTINGS_LIMITS.maxPercent) {
payload.taxReservePercent = Number(percent)
} else {
errors.taxReservePercent = `Die Rücklage muss eine ganze Zahl von 0 bis ${SETTINGS_LIMITS.maxPercent} sein.`
}
const revenue = text(f.previousYearRevenue)
if (!revenue) {
payload.previousYearRevenueCents = null
} else {
const r = amountCents(revenue)
if (r.error) errors.previousYearRevenue = r.error
else payload.previousYearRevenueCents = r.cents
}
const prepayment = text(f.estPrepayment)
if (!prepayment) {
errors.estPrepayment = 'Bitte einen Betrag eingeben (0 ist möglich).'
} else {
const r = amountCents(prepayment)
if (r.error) errors.estPrepayment = r.error
else payload.estPrepaymentCents = r.cents
}
const bank = text(f.bankDetailsText)
if (bank.length > SETTINGS_LIMITS.maxBankDetails) {
errors.bankDetailsText = `Der Text darf höchstens ${SETTINGS_LIMITS.maxBankDetails} Zeichen lang sein (aktuell ${bank.length}).`
} else {
payload.bankDetailsText = bank
}
return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, payload }
}
/** Reihenfolge der Felder im Formular (für den Fokus auf das erste fehlerhafte). */
export const SETTINGS_FIELD_ORDER = ['minBuffer', 'taxReservePercent', 'previousYearRevenue', 'estPrepayment', 'bankDetailsText']
// --- Fixkosten -----------------------------------------------------------------------------------------------
/** Auswahl für den Rhythmus: [{ value: 'monat', label: 'monatlich' }, …]. */
export const INTERVAL_OPTIONS = Object.entries(INTERVAL_LABEL).map(([value, label]) => ({ value, label }))
export function emptyFixedCostForm() {
return { name: '', amount: '', interval: 'monat', nextDate: '', counterpartyMatch: '' }
}
/** Position des Servers -> Formular (Texte). */
export function fixedCostToForm(position) {
const p = position && typeof position === 'object' ? position : {}
return {
name: typeof p.name === 'string' ? p.name : '',
amount: euroField(p.amountCents),
interval: Object.prototype.hasOwnProperty.call(INTERVAL_LABEL, p.interval) ? p.interval : 'monat',
nextDate: typeof p.nextDate === 'string' ? p.nextDate : '',
counterpartyMatch: typeof p.counterpartyMatch === 'string' ? p.counterpartyMatch : '',
}
}
function isRealDay(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(value) && dateDe(value) !== '–'
}
/**
* Formular -> Körper für POST/PATCH /fixkosten: { ok: true, payload: { name, amountCents, interval, nextDate,
* counterpartyMatch } } oder { ok: false, errors: { feld: 'Meldung' } }. Betrag größer als 0.
*/
export function validateFixedCostForm(form) {
const f = form && typeof form === 'object' ? form : {}
const errors = {}
const name = text(f.name)
if (name.length < 1 || name.length > SETTINGS_LIMITS.maxName) {
errors.name = `Bitte einen Namen eingeben (höchstens ${SETTINGS_LIMITS.maxName} Zeichen).`
}
let cents = 0
if (!text(f.amount)) {
errors.amount = 'Bitte einen Betrag eingeben.'
} else {
const r = amountCents(f.amount)
if (r.error) errors.amount = r.error
else if (r.cents < 1) errors.amount = 'Der Betrag muss größer als 0 sein (z. B. 49,90).'
else cents = r.cents
}
if (!Object.prototype.hasOwnProperty.call(INTERVAL_LABEL, f.interval)) errors.interval = 'Bitte einen Rhythmus wählen.'
const nextDate = text(f.nextDate)
if (!isRealDay(nextDate)) errors.nextDate = 'Bitte ein gültiges Datum wählen.'
const match = text(f.counterpartyMatch)
if (match.length > SETTINGS_LIMITS.maxMatch) {
errors.counterpartyMatch = `Der Suchbegriff darf höchstens ${SETTINGS_LIMITS.maxMatch} Zeichen lang sein.`
}
if (Object.keys(errors).length > 0) return { ok: false, errors }
return { ok: true, payload: { name, amountCents: cents, interval: f.interval, nextDate, counterpartyMatch: match } }
}
/** Reihenfolge der Felder im Fixkosten-Formular. */
export const FIXED_COST_FIELD_ORDER = ['name', 'amount', 'interval', 'nextDate', 'counterpartyMatch']
/**
* Zeilen der Fixkostenliste: aktive zuerst, dann inaktive (Reihenfolge des Servers bleibt je Gruppe).
* Je Zeile: { key, id, name, amountText, intervalText, nextDateText, matchText, metaText, active, statusLabel,
* toggleLabel }; metaText = „monatlich · nächster Termin 01.11.2026 · Suchbegriff „hetzner““.
*/
export function fixedCostRows(list) {
const items = (Array.isArray(list) ? list : []).filter((p) => p && typeof p === 'object')
const rows = items.map((p, index) => {
const active = p.active !== false
const name = text(p.name) || 'Ohne Namen'
const intervalText = INTERVAL_LABEL[p.interval] || '–'
const nextDateText = dateDe(p.nextDate)
const matchText = text(p.counterpartyMatch)
return {
key: text(p.$id) || `fixed-${index}`,
id: text(p.$id),
name,
amountText: euro(p.amountCents),
intervalText,
nextDateText,
matchText,
metaText: [intervalText, `nächster Termin ${nextDateText}`, matchText && `Suchbegriff „${matchText}“`].filter(Boolean).join(' · '),
active,
statusLabel: active ? 'Aktiv' : 'Inaktiv',
toggleLabel: active ? 'Auf inaktiv setzen' : 'Aktivieren',
}
})
return [...rows.filter((r) => r.active), ...rows.filter((r) => !r.active)]
}
// --- Bankzugang ----------------------------------------------------------------------------------------------
const berlinTime = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
})
/** ISO-Zeitpunkt -> '01.10.2026, 12:55 Uhr' (Berliner Zeit); ungültig -> '–'. */
export function dateTimeDe(value) {
const t = typeof value === 'string' && value.trim() ? Date.parse(value) : NaN
if (Number.isNaN(t)) return '–'
const p = Object.fromEntries(berlinTime.formatToParts(new Date(t)).map((x) => [x.type, x.value]))
return `${p.day}.${p.month}.${p.year}, ${p.hour}:${p.minute} Uhr`
}
/**
* Karte „Bankzugang“: { connected, statusLabel, tone, rows: [{ key, label, text }], error, canRefresh, connectLabel,
* hint }. Ohne Verbindung (null) nur „Bank verbinden“. Abgelaufener Zugang (Status oder Datum): kein Abruf, nur
* „Neu verbinden“. error ist der Text des letzten Fehlers (auch wenn der Server keinen nennt, der Abruf aber scheiterte).
*/
export function bankCardModel(verbindung, now = Date.now()) {
if (!verbindung || typeof verbindung !== 'object') {
return {
connected: false,
statusLabel: 'Nicht verbunden',
tone: 'muted',
rows: [],
error: '',
canRefresh: false,
connectLabel: 'Bank verbinden',
hint: 'Ohne Bankzugang fehlen Kontostand und Buchungen.',
}
}
const validDay = ymdBerlin(verbindung.validUntil)
const today = ymdBerlin(now)
const expired = verbindung.status === 'abgelaufen' || (validDay !== null && today !== null && validDay < today)
const failed = verbindung.lastSyncOk === false || verbindung.status === 'fehler'
let statusLabel = 'Unbekannt'
let tone = 'muted'
if (expired) {
statusLabel = 'Zugang abgelaufen'
tone = 'danger'
} else if (failed) {
statusLabel = 'Abruf fehlgeschlagen'
tone = 'warn'
} else if (verbindung.status === 'aktiv') {
statusLabel = 'Verbunden'
tone = 'ok'
} else if (text(verbindung.status)) {
statusLabel = text(verbindung.status)
}
const synced = text(verbindung.lastSyncAt)
const lastError = text(verbindung.lastError)
return {
connected: true,
statusLabel,
tone,
rows: [
{ key: 'iban', label: 'IBAN', text: text(verbindung.ibanMasked) || '–' },
{ key: 'valid', label: 'Zugang gültig bis', text: dateDe(verbindung.validUntil) },
{
key: 'sync',
label: 'Letzter Abruf',
text: synced ? `${dateTimeDe(synced)} · ${sinceLabel(synced, now)}` : 'noch kein Abruf',
},
],
error: lastError || (failed ? 'Der letzte Abruf ist fehlgeschlagen.' : ''),
canRefresh: !expired,
connectLabel: 'Neu verbinden',
hint: expired ? 'Der Zugang ist abgelaufen. Bitte neu verbinden.' : '',
}
}
/** Ergebnis von POST /bank/aktualisieren ({ ok, neu, aktualisiert, geloescht, error? }) -> { tone, text }. */
export function refreshResultMessage(result) {
const r = result && typeof result === 'object' ? result : {}
if (r.ok === false) {
const error = text(r.error)
return { tone: 'warn', text: error ? `Abruf fehlgeschlagen: ${error}` : 'Abruf fehlgeschlagen.' }
}
const parts = []
if (r.neu > 0) parts.push(`${r.neu} neu`)
if (r.aktualisiert > 0) parts.push(`${r.aktualisiert} aktualisiert`)
if (r.geloescht > 0) parts.push(`${r.geloescht} entfernt`)
return { tone: 'ok', text: parts.length > 0 ? `Abruf abgeschlossen: ${parts.join(', ')}.` : 'Abruf abgeschlossen, keine neuen Buchungen.' }
}
+29 -1
View File
@@ -746,9 +746,37 @@ export function resolveCustomerText(options, input) {
return { state: option.ok ? 'ok' : 'blocked', option }
}
/**
* Reiter „Rechnungen“ am Kunden: { ok: true } oder { ok: false, title, hint } (Grund nach canInvoice plus ein Satz,
* was zu tun ist). Der Reiter ist für jeden Kunden sichtbar; ohne ok zeigt er statt der Liste diesen Hinweis.
*/
export function customerInvoicesGate(customer) {
const check = canInvoice(customer)
if (check.ok) return { ok: true }
const status = text(customer?.customerStatus).toLowerCase()
if (status === 'lead') {
return {
ok: false,
title: 'Noch ein Lead',
hint: 'Erst zum festen Kunden machen (vollständige Daten nötig), dann sind Rechnungen möglich.',
}
}
if (status === 'lost') {
return { ok: false, title: check.reason, hint: 'Rechnungen gibt es nur für feste Kunden.' }
}
if (customer && !text(customer.email)) {
return {
ok: false,
title: check.reason,
hint: 'Rechnungen gehen per E-Mail an den Kunden. Bitte zuerst die E-Mail-Adresse in den Stammdaten eintragen.',
}
}
return { ok: false, title: check.reason, hint: '' }
}
// --- Buchungen (Block 7) -----------------------------------------------------------------------------------
const RANK = { hoch: 0, mittel: 1, niedrig: 2 }
const RANK ={ hoch: 0, mittel: 1, niedrig: 2 }
const dayOf = (tx) => text(tx.bookingDate) || text(tx.valueDate)
/** Buchungen neueste zuerst (Buchungsdatum, sonst Wertstellung); gleiche Tage behalten die Reihenfolge des Servers. */
-88
View File
@@ -1,88 +0,0 @@
import { account } from './appwrite'
/**
* Client fuer das Integrations-Backend (InvoiceNinja).
* Same-Origin unter /api/integrations, authentifiziert per Appwrite-JWT.
*/
const BASE = '/api/integrations'
async function authHeaders(extra = {}) {
const { jwt } = await account.createJWT()
return { 'X-Appwrite-JWT': jwt, ...extra }
}
async function call(path, { method = 'GET', body } = {}) {
const headers = await authHeaders(body ? { 'Content-Type': 'application/json' } : {})
const res = await fetch(`${BASE}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(data.error || `Integrations-Fehler ${res.status}`)
}
return data
}
async function callBlob(path) {
const headers = await authHeaders()
const res = await fetch(`${BASE}${path}`, { headers })
if (!res.ok) throw new Error(`Download fehlgeschlagen (${res.status})`)
return res.blob()
}
function qs(params = {}) {
const sp = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') sp.set(k, v)
})
const s = sp.toString()
return s ? `?${s}` : ''
}
async function openBlobInNewTab(blob) {
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener')
setTimeout(() => URL.revokeObjectURL(url), 60_000)
}
export const integrationsApi = {
// ---- health ----
health: () => call('/health'),
// ---- InvoiceNinja: clients ----
listClients: (search = '') => call(`/clients${qs({ search })}`),
createClient: (payload) => call('/clients', { method: 'POST', body: payload }),
updateClient: (id, payload) => call(`/clients/${encodeURIComponent(id)}`, { method: 'PUT', body: payload }),
// ---- InvoiceNinja: invoices ----
listInvoices: (clientId, perPage) => call(`/invoices${qs({ clientId, per_page: perPage })}`),
invoicesSummary: () => call('/invoices/summary'),
async openInvoicePdf(id) {
const blob = await callBlob(`/invoices/${encodeURIComponent(id)}/pdf`)
await openBlobInNewTab(blob)
},
}
// Oeffentliche InvoiceNinja-URL (fuer Deeplinks ins Portal)
export const INVOICE_NINJA_PUBLIC_URL = 'https://invoice.webklar.com'
// InvoiceNinja status_id -> deutsche Bezeichnung + Farbe
export const INVOICE_STATUS = {
1: { label: 'Entwurf', color: '#a0aec0' },
2: { label: 'Versendet', color: '#3b82f6' },
3: { label: 'Teilzahlung', color: '#f59e0b' },
4: { label: 'Bezahlt', color: '#10b981' },
5: { label: 'Storniert', color: '#ef4444' },
'-1': { label: 'Storniert', color: '#ef4444' },
}
export function formatMoney(amount, currency = 'EUR') {
const n = Number(amount || 0)
try {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency }).format(n)
} catch {
return `${n.toFixed(2)} ${currency}`
}
}
+3 -41
View File
@@ -1,7 +1,6 @@
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from './appwrite'
import { integrationsApi } from './integrationsApi'
/** Pflichtfelder, damit eine Rechnung erstellt werden kann (wie InvoiceNinja). */
/** Pflichtfelder eines festen Kunden: Name, E-Mail und vollständige Anschrift (Voraussetzung für „Zum festen Kunden machen“). */
export function isInvoiceReady(c) {
return Boolean(c && c.name && c.email && c.street && c.city && c.postalCode)
}
@@ -26,36 +25,6 @@ export async function setCustomerArchived(customer, archived) {
})
}
function ninjaPayload(c) {
return {
name: c.companyName || c.name,
email: c.email || '',
phone: c.phone || '',
street: c.street || '',
city: c.city || '',
postalCode: c.postalCode || '',
country: c.country || '',
vatNumber: c.vatNumber || '',
contactName: c.contactName || '',
}
}
/** Erstellt/aktualisiert den InvoiceNinja-Client und speichert die ID am Kunden. */
export async function syncCustomerToNinja(customer) {
if (customer.invoiceNinjaClientId) {
await integrationsApi.updateClient(customer.invoiceNinjaClientId, ninjaPayload(customer))
return customer.invoiceNinjaClientId
}
const r = await integrationsApi.createClient(ninjaPayload(customer))
const id = r.client?.id
if (id) {
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
invoiceNinjaClientId: id,
})
}
return id
}
async function nextWoid() {
try {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
@@ -113,18 +82,11 @@ export async function createLead({ company, email }, user) {
return { customer, ticket }
}
/** Macht aus einem Lead einen festen Kunden (mit vollstaendigen Daten) und legt den InvoiceNinja-Client an. */
/** Macht aus einem Lead einen festen Kunden (mit vollständigen Daten). Liefert das gespeicherte Kunden-Dokument. */
export async function upgradeToCustomer(customer, fullData) {
const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
return databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
...fullData,
customerStatus: 'customer',
updatedAt: new Date().toISOString(),
})
let clientId = updated.invoiceNinjaClientId
try {
clientId = await syncCustomerToNinja(updated)
} catch (e) {
return { customer: updated, clientId: null, syncError: e.message }
}
return { customer: { ...updated, invoiceNinjaClientId: clientId }, clientId }
}
+14 -30
View File
@@ -4,13 +4,13 @@ import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaArrow
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
import InvoicePanel from '../components/InvoicePanel'
import CustomerInvoices from '../components/finance/CustomerInvoices'
import AssignedProjectCard from '../components/AssignedProjectCard'
import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '../lib/customerAdminApi'
import { useAuth } from '../context/AuthContext'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer, syncCustomerToNinja } from '../lib/leads'
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
const PORTAL_URL = 'https://project.webklar.com'
@@ -49,7 +49,6 @@ export default function CustomerDetailPage() {
const stage = customerStage(customer)
const archived = isArchived(customer)
const syntheticTicket = { customerId: customer.$id, customerName: customer.name, woid: '' }
const toggleArchived = async () => {
await setCustomerArchived(customer, !archived)
@@ -96,15 +95,7 @@ export default function CustomerDetailPage() {
{tab === 'overview' && <OverviewTab customer={customer} stage={stage} onSaved={load} />}
{tab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
{tab === 'invoices' && (
stage === 'lead' ? (
<Card><EmptyState title="Noch ein Lead" hint="Erst zum festen Kunden machen (vollstaendige Daten noetig), dann sind Rechnungen moeglich." action={<Button variant="primary" onClick={() => setTab('overview')}>Daten erfassen</Button>} /></Card>
) : !isInvoiceReady(customer) ? (
<Card><EmptyState title="Daten unvollstaendig" hint="Fuer Rechnungen werden Unternehmen, E-Mail, Strasse, Stadt und PLZ benoetigt." action={<Button variant="primary" onClick={() => setTab('overview')}>Daten vervollstaendigen</Button>} /></Card>
) : (
<InvoicePanel ticket={syntheticTicket} />
)
)}
{tab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />}
{tab === 'projects' && <CustomerProjects customerId={customer.$id} />}
</div>
)
@@ -135,13 +126,8 @@ function OverviewTab({ customer, stage, onSaved }) {
const save = async () => {
setBusy(true); setMsg(null)
try {
const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
let note = 'Gespeichert'
// Wenn fester Kunde: Aenderungen nach InvoiceNinja syncen (sofern Daten reichen oder bereits verknuepft)
if (stage === 'customer' && (updated.invoiceNinjaClientId || isInvoiceReady(updated))) {
try { await syncCustomerToNinja(updated) } catch (e) { note = 'Gespeichert, InvoiceNinja-Sync fehlgeschlagen: ' + e.message }
}
setMsg(note)
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
setMsg('Gespeichert')
onSaved()
} catch (err) {
setMsg('Fehler: ' + err.message)
@@ -151,11 +137,13 @@ function OverviewTab({ customer, stage, onSaved }) {
const upgrade = async () => {
if (!ready) { setMsg('Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.'); return }
setBusy(true); setMsg(null)
const r = await upgradeToCustomer(customer, form)
if (r.clientId) setMsg('Zum festen Kunden gemacht und mit InvoiceNinja verknuepft.')
else setMsg('Status gesetzt, aber InvoiceNinja-Verknuepfung fehlgeschlagen: ' + (r.syncError || 'unbekannt'))
setBusy(false)
onSaved()
try {
await upgradeToCustomer(customer, form)
setMsg('Zum festen Kunden gemacht.')
onSaved()
} catch (err) {
setMsg('Fehler: ' + err.message)
} finally { setBusy(false) }
}
return (
@@ -183,8 +171,8 @@ function OverviewTab({ customer, stage, onSaved }) {
</div>
</Card>
<Card title="Rechnungsdaten (fuer InvoiceNinja)">
{!ready && <div className="badge badge-warn" style={{ marginBottom: 12 }}>Unvollstaendig - fuer Rechnungen erforderlich</div>}
<Card title="Rechnungsdaten">
{!ready && <div className="badge badge-warn" style={{ marginBottom: 12 }}>Unvollständig – für Rechnungen erforderlich</div>}
<Field label="Strasse + Nr."><input className="form-control" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></Field>
<div className="flex gap-3 wrap">
<Field label="PLZ"><input className="form-control" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} /></Field>
@@ -194,10 +182,6 @@ function OverviewTab({ customer, stage, onSaved }) {
<Field label="Land"><input className="form-control" value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} /></Field>
<Field label="USt-IdNr."><input className="form-control" value={form.vatNumber} onChange={(e) => setForm({ ...form, vatNumber: e.target.value })} /></Field>
</div>
<div className="flex items-center justify-between" style={{ marginTop: 8 }}>
<span className="muted">InvoiceNinja</span>
{customer.invoiceNinjaClientId ? <Badge tone="ok" dot>verknuepft</Badge> : <Badge tone="muted">nicht verknuepft</Badge>}
</div>
</Card>
<PortalAccessCard customer={customer} onSaved={onSaved} />
+2 -3
View File
@@ -1,6 +1,6 @@
import { useState, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { FaSpinner, FaPlus, FaStar, FaFileInvoiceDollar } from 'react-icons/fa6'
import { FaSpinner, FaPlus, FaStar } from 'react-icons/fa6'
import { useCustomers } from '../hooks/useCustomers'
import { useAuth } from '../context/AuthContext'
import { createLead, customerStage, isArchived } from '../lib/leads'
@@ -37,7 +37,7 @@ export default function CustomersPage() {
<div className="page">
<PageHeader
title="Kunden"
subtitle="Potenzielle Kunden (Leads) und feste Kunden - eine Pflege, Sync mit InvoiceNinja"
subtitle="Potenzielle Kunden (Leads) und feste Kunden – eine Pflege"
actions={
<>
{isAdmin && (
@@ -116,7 +116,6 @@ export default function CustomersPage() {
{st === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
{st === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
{st === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
{c.invoiceNinjaClientId && <Badge tone="info"><FaFileInvoiceDollar /> Ninja</Badge>}
</div>
</Link>
)
+11 -3
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FaSpinner } from 'react-icons/fa6'
import { Link, useNavigate } from 'react-router-dom'
import { FaGear, FaSpinner } from 'react-icons/fa6'
import { PageHeader, Card, Button } from '../components/ui'
import StatusLine from '../components/finance/StatusLine'
import LiquidityHead from '../components/finance/LiquidityHead'
@@ -191,7 +191,15 @@ export default function FinancePage() {
return (
<div className="page">
<PageHeader title="Finanzen" subtitle="Kontostand, Aufgaben und Zahlungen im Blick" />
<PageHeader
title="Finanzen"
subtitle="Kontostand, Aufgaben und Zahlungen im Blick"
actions={
<Button as={Link} variant="ghost" to="/finance/einstellungen">
<FaGear aria-hidden="true" /> Einstellungen
</Button>
}
/>
{assignNotice && (
<p className="fin-hint fin-hint-warn fin-page-notice" role="alert">
{assignNotice}{' '}
+31
View File
@@ -0,0 +1,31 @@
import { Link } from 'react-router-dom'
import { FaArrowLeft } from 'react-icons/fa6'
import { PageHeader, Button } from '../components/ui'
import SettingsCard from '../components/finance/SettingsCard'
import BankAccessCard from '../components/finance/BankAccessCard'
import FixedCostsCard from '../components/finance/FixedCostsCard'
/**
* Finanzen / Einstellungen (/finance/einstellungen, nur Admins): Rücklagen und Bankverbindungstext, Bankzugang
* (Status, Abruf, neu verbinden) und die Fixkostenliste. Jede Karte lädt und speichert für sich.
*/
export default function FinanceSettingsPage() {
return (
<div className="page">
<PageHeader
title="Finanzen: Einstellungen"
subtitle="Rücklagen, Bankzugang und Fixkosten"
actions={
<Button as={Link} variant="ghost" to="/finance">
<FaArrowLeft aria-hidden="true" /> Zurück zu den Finanzen
</Button>
}
/>
<div className="fin-stack">
<SettingsCard />
<BankAccessCard />
<FixedCostsCard />
</div>
</div>
)
}
+35
View File
@@ -1431,3 +1431,38 @@ textarea.form-control {
.fin-dialog-actions .ui-btn { flex: 1 1 auto; }
.fin-toolbar .seg button { padding: 6px 9px; }
}
/* Finanzen: Einstellungen (Formular, Bankzugang, Fixkosten) */
.fin-fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.fin-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--sp-4); }
.fin-form-field { min-width: 0; }
.fin-form-hint { font-size: 12px; overflow-wrap: anywhere; }
.fin-field-error { font-size: 13px; font-weight: 600; color: var(--danger); overflow-wrap: anywhere; }
.fin-form-field .form-control[aria-invalid="true"] { border-color: var(--danger) !important; }
.fin-input-amount { text-align: right; font-variant-numeric: tabular-nums; }
.fin-counter { margin: -6px 0 var(--sp-3); text-align: right; font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.fin-counter-over { color: var(--danger); font-weight: 700; }
.fin-form-notice { margin: 0 0 var(--sp-3); }
.fin-form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); margin-top: var(--sp-3); }
.fin-bank-facts { display: grid; gap: 6px; margin: 0 0 var(--sp-3); }
.fin-bank-facts > div { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: var(--sp-3); align-items: baseline; }
.fin-bank-facts dt { color: var(--text-muted); font-size: 13px; }
.fin-bank-facts dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.fin-fixed-form { margin-bottom: var(--sp-4); padding: var(--sp-4); border: 1px solid var(--border-strong); border-radius: var(--r-sm); }
.fin-fixed-form-title { margin: 0 0 var(--sp-3); font-size: 15px; font-weight: 700; }
.fin-fixed-list { list-style: none; margin: 0; padding: 0; }
.fin-fixed {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-4); align-items: baseline;
padding: var(--sp-3) 0; border-bottom: 1px solid var(--border);
}
.fin-fixed:last-child { border-bottom: none; }
.fin-fixed-off .fin-fixed-name, .fin-fixed-off .fin-fixed-amount { color: var(--text-muted); }
.fin-fixed-main { display: flex; flex-wrap: wrap; align-items: center; gap: 6px var(--sp-2); min-width: 0; }
.fin-fixed-name { font-weight: 700; overflow-wrap: anywhere; }
.fin-fixed-amount { font-weight: 700; }
.fin-fixed-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 2px var(--sp-4); font-size: 13px; overflow-wrap: anywhere; }
.fin-fixed-actions { grid-column: 1 / -1; padding-top: 4px; }
@media (max-width: 640px) {
.fin-form-grid { grid-template-columns: minmax(0, 1fr); }
.fin-bank-facts > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
}