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

+8 -6
View File
@@ -1,8 +1,8 @@
# WOMS – Ticketsystem von WEBklar # WOMS – Ticketsystem von WEBklar
Internes Ticketsystem unter https://ticket.webklar.com: Tickets, Arbeitsblätter mit Internes Ticketsystem unter https://ticket.webklar.com: Tickets, Arbeitsblätter mit
Zeiterfassung, Kunden und Leads, Website-Projekte, Planboard und Rechnungen (nur Zeiterfassung, Kunden und Leads, Website-Projekte, Planboard und Finanzen (Rechnungen
anzeigen; Rechnungen werden in InvoiceNinja geschrieben). schreiben und verwalten, Buchungen zuordnen; nur für Admins).
React/Vite im Browser, Daten in Appwrite (Projekt `6a1058610003c5a13a05`, React/Vite im Browser, Daten in Appwrite (Projekt `6a1058610003c5a13a05`,
Datenbank `woms-database`, API unter `https://ticket.webklar.com/v1`). Datenbank `woms-database`, API unter `https://ticket.webklar.com/v1`).
@@ -23,7 +23,7 @@ Datum speichert WOMS als Text `dd.mm.yyyy`, Uhrzeiten als `hhmm` (z. B. `0930`).
|---|---|---| |---|---|---|
| `workorders` | Tickets | `woid` (fortlaufend ab 10000), `topic`, `details`, `status`, `priority`, `type` (Webpage, Migration, Project, Akquise), `serviceType`, `customerId`/`customerName`, `assignedTo`/`assignedName`, `requestedBy`, `startDate`/`startTime`/`endTime`, `deadline` | | `workorders` | Tickets | `woid` (fortlaufend ab 10000), `topic`, `details`, `status`, `priority`, `type` (Webpage, Migration, Project, Akquise), `serviceType`, `customerId`/`customerName`, `assignedTo`/`assignedName`, `requestedBy`, `startDate`/`startTime`/`endTime`, `deadline` |
| `worksheets` | Arbeitsblätter | `wsid` (fortlaufend ab 100000), `woid` + `workorderId`, `employeeId`/`employeeName`, `serviceType`, `oldStatus`/`newStatus`, `totalTime` (Minuten), `startDate`/`startTime`/`endDate`/`endTime`, `details`, `isComment`, `countsToPlan` (Abo-Stunden) | | `worksheets` | Arbeitsblätter | `wsid` (fortlaufend ab 100000), `woid` + `workorderId`, `employeeId`/`employeeName`, `serviceType`, `oldStatus`/`newStatus`, `totalTime` (Minuten), `startDate`/`startTime`/`endDate`/`endTime`, `details`, `isComment`, `countsToPlan` (Abo-Stunden) |
| `customers` | Kunden und Leads | `code` (Kundennummer), `name`/`companyName`, `customerStatus` (z. B. lead, customer, lost), `archived`, Rechnungsadresse, `invoiceNinjaClientId`, Portal-Zugang | | `customers` | Kunden und Leads | `code` (Kundennummer), `name`/`companyName`, `customerStatus` (z. B. lead, customer, lost), `archived`, Rechnungsadresse, Portal-Zugang, `invoiceNinjaClientId` (nicht mehr genutzt, Daten bleiben) |
| `employees` | Mitarbeiter | `userId` (Appwrite-Benutzer), `displayName`, `email`, `shortcode` (Kürzel) | | `employees` | Mitarbeiter | `userId` (Appwrite-Benutzer), `displayName`, `email`, `shortcode` (Kürzel) |
| `websiteProjects` | Website-Projekte und Vorschauen | `customerId` (steuert, was der Kunde im Portal sieht), `ticketId` (Ziel der Git-Push-Arbeitsblätter), Subdomain, Repo | | `websiteProjects` | Website-Projekte und Vorschauen | `customerId` (steuert, was der Kunde im Portal sieht), `ticketId` (Ziel der Git-Push-Arbeitsblätter), Subdomain, Repo |
| `leads`, `leadPipeline`, `leadSettings` | Lead-Automatik | Recherche-Ergebnisse, Pipeline-Schritte, Sucheinstellungen | | `leads`, `leadPipeline`, `leadSettings` | Lead-Automatik | Recherche-Ergebnisse, Pipeline-Schritte, Sucheinstellungen |
@@ -37,6 +37,8 @@ Zusammenspiel mit anderen Systemen:
nachgetragen, danach zählt sie als Arbeitszeit. Termine aus der Rechnungsanalyse nachgetragen, danach zählt sie als Arbeitszeit. Termine aus der Rechnungsanalyse
landen am Akquise-Ticket des Kunden und erscheinen im Planboard. Kunden-, Mitarbeiter- landen am Akquise-Ticket des Kunden und erscheinen im Planboard. Kunden-, Mitarbeiter-
und Projektverwaltung laufen über die Admin-API des Portals. und Projektverwaltung laufen über die Admin-API des Portals.
- **ticket-integrations** (`/api/integrations`): Proxy zu InvoiceNinja (Rechnungen - **Finanzen-API des Portals** (`/api/admin/finanzen`): Übersicht, Rechnungen (Stripe),
anzeigen, Kunden verknüpfen). Die Paperless-Routen dort nutzt WOMS nicht mehr; Buchungen (Bank), Einstellungen und Fixkosten. WOMS spricht sie mit dem Appwrite-JWT an
Paperless selbst ist gestoppt. und zeigt sie unter `/finance` (Einstellungen: `/finance/einstellungen`).
- **ticket-integrations** (`/api/integrations`): wird von WOMS nicht mehr genutzt
(weder InvoiceNinja noch Paperless; Paperless selbst ist gestoppt).
+2
View File
@@ -10,6 +10,7 @@ import AdminPage from './pages/AdminPage'
import CustomersPage from './pages/CustomersPage' import CustomersPage from './pages/CustomersPage'
import CustomerDetailPage from './pages/CustomerDetailPage' import CustomerDetailPage from './pages/CustomerDetailPage'
import FinancePage from './pages/FinancePage' import FinancePage from './pages/FinancePage'
import FinanceSettingsPage from './pages/FinanceSettingsPage'
import LegalPage from './pages/LegalPage' import LegalPage from './pages/LegalPage'
import AssignProjectPage from './pages/AssignProjectPage' import AssignProjectPage from './pages/AssignProjectPage'
import ServicesPage from './pages/ServicesPage' import ServicesPage from './pages/ServicesPage'
@@ -52,6 +53,7 @@ function AppRoutes() {
<Route path="/leads" element={<Navigate to="/tickets" replace />} /> <Route path="/leads" element={<Navigate to="/tickets" replace />} />
{/* Finanzen: nur Admins, alle anderen gehen zu /tickets */} {/* Finanzen: nur Admins, alle anderen gehen zu /tickets */}
<Route path="/finance" element={<ProtectedRoute><AdminRoute><FinancePage /></AdminRoute></ProtectedRoute>} /> <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="/finanzen/bank-callback" element={<ProtectedRoute><AdminRoute><BankCallbackPage /></AdminRoute></ProtectedRoute>} />
<Route path="/rechtliches" element={<ProtectedRoute><LegalPage /></ProtectedRoute>} /> <Route path="/rechtliches" element={<ProtectedRoute><LegalPage /></ProtectedRoute>} />
<Route path="/serverpuls" element={<ProtectedRoute><ServerpulsPage /></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 WorksheetSummary from './WorksheetSummary'
import TicketProjects from './TicketProjects' import TicketProjects from './TicketProjects'
import ProjectContextPanel from './ProjectContextPanel' import ProjectContextPanel from './ProjectContextPanel'
import InvoicePanel from './InvoicePanel'
import Tabs from './ui/Tabs' import Tabs from './ui/Tabs'
import Badge from './ui/Badge' import Badge from './ui/Badge'
import { StatusPill, UrgentPill } from './ui/StatusPill' import { StatusPill, UrgentPill } from './ui/StatusPill'
@@ -28,7 +27,6 @@ import { ticketTypeLabel } from '../lib/ticketStatus'
const TABS = [ const TABS = [
{ id: 'details', label: 'Details' }, { id: 'details', label: 'Details' },
{ id: 'worksheets', label: 'Arbeitsblätter' }, { id: 'worksheets', label: 'Arbeitsblätter' },
{ id: 'invoices', label: 'Rechnungen' },
] ]
export default function TicketCard({ ticket, onUpdate }) { export default function TicketCard({ ticket, onUpdate }) {
@@ -155,8 +153,6 @@ export default function TicketCard({ ticket, onUpdate }) {
/> />
</div> </div>
)} )}
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
</div> </div>
</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 () => { const fetchCustomers = useCallback(async () => {
try { try {
// Direkt aus Appwrite: die Admin-API des Portals liefert gekuerzte Daten // 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( const response = await databases.listDocuments(
DATABASE_ID, DATABASE_ID,
COLLECTIONS.CUSTOMERS, 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 } 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) ----------------------------------------------------------------------------------- // --- 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) const dayOf = (tx) => text(tx.bookingDate) || text(tx.valueDate)
/** Buchungen neueste zuerst (Buchungsdatum, sonst Wertstellung); gleiche Tage behalten die Reihenfolge des Servers. */ /** 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 { 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) { export function isInvoiceReady(c) {
return Boolean(c && c.name && c.email && c.street && c.city && c.postalCode) 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() { async function nextWoid() {
try { try {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [ const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
@@ -113,18 +82,11 @@ export async function createLead({ company, email }, user) {
return { customer, ticket } 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) { 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, ...fullData,
customerStatus: 'customer', customerStatus: 'customer',
updatedAt: new Date().toISOString(), 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 { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui' import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
import { StatusPill, UrgentPill } from '../components/ui/StatusPill' import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
import InvoicePanel from '../components/InvoicePanel' import CustomerInvoices from '../components/finance/CustomerInvoices'
import AssignedProjectCard from '../components/AssignedProjectCard' import AssignedProjectCard from '../components/AssignedProjectCard'
import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential' import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '../lib/customerAdminApi' import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '../lib/customerAdminApi'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useWebsiteProjects } from '../hooks/useWebsiteProjects' 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' const PORTAL_URL = 'https://project.webklar.com'
@@ -49,7 +49,6 @@ export default function CustomerDetailPage() {
const stage = customerStage(customer) const stage = customerStage(customer)
const archived = isArchived(customer) const archived = isArchived(customer)
const syntheticTicket = { customerId: customer.$id, customerName: customer.name, woid: '' }
const toggleArchived = async () => { const toggleArchived = async () => {
await setCustomerArchived(customer, !archived) await setCustomerArchived(customer, !archived)
@@ -96,15 +95,7 @@ export default function CustomerDetailPage() {
{tab === 'overview' && <OverviewTab customer={customer} stage={stage} onSaved={load} />} {tab === 'overview' && <OverviewTab customer={customer} stage={stage} onSaved={load} />}
{tab === 'tickets' && <CustomerTickets customerId={customer.$id} />} {tab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
{tab === 'invoices' && ( {tab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />}
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 === 'projects' && <CustomerProjects customerId={customer.$id} />} {tab === 'projects' && <CustomerProjects customerId={customer.$id} />}
</div> </div>
) )
@@ -135,13 +126,8 @@ function OverviewTab({ customer, stage, onSaved }) {
const save = async () => { const save = async () => {
setBusy(true); setMsg(null) setBusy(true); setMsg(null)
try { try {
const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form) await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
let note = 'Gespeichert' setMsg('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)
onSaved() onSaved()
} catch (err) { } catch (err) {
setMsg('Fehler: ' + err.message) setMsg('Fehler: ' + err.message)
@@ -151,11 +137,13 @@ function OverviewTab({ customer, stage, onSaved }) {
const upgrade = async () => { const upgrade = async () => {
if (!ready) { setMsg('Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.'); return } if (!ready) { setMsg('Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.'); return }
setBusy(true); setMsg(null) setBusy(true); setMsg(null)
const r = await upgradeToCustomer(customer, form) try {
if (r.clientId) setMsg('Zum festen Kunden gemacht und mit InvoiceNinja verknuepft.') await upgradeToCustomer(customer, form)
else setMsg('Status gesetzt, aber InvoiceNinja-Verknuepfung fehlgeschlagen: ' + (r.syncError || 'unbekannt')) setMsg('Zum festen Kunden gemacht.')
setBusy(false) onSaved()
onSaved() } catch (err) {
setMsg('Fehler: ' + err.message)
} finally { setBusy(false) }
} }
return ( return (
@@ -183,8 +171,8 @@ function OverviewTab({ customer, stage, onSaved }) {
</div> </div>
</Card> </Card>
<Card title="Rechnungsdaten (fuer InvoiceNinja)"> <Card title="Rechnungsdaten">
{!ready && <div className="badge badge-warn" style={{ marginBottom: 12 }}>Unvollstaendig - fuer Rechnungen erforderlich</div>} {!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> <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"> <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> <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="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> <Field label="USt-IdNr."><input className="form-control" value={form.vatNumber} onChange={(e) => setForm({ ...form, vatNumber: e.target.value })} /></Field>
</div> </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> </Card>
<PortalAccessCard customer={customer} onSaved={onSaved} /> <PortalAccessCard customer={customer} onSaved={onSaved} />
+2 -3
View File
@@ -1,6 +1,6 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { Link } from 'react-router-dom' 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 { useCustomers } from '../hooks/useCustomers'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { createLead, customerStage, isArchived } from '../lib/leads' import { createLead, customerStage, isArchived } from '../lib/leads'
@@ -37,7 +37,7 @@ export default function CustomersPage() {
<div className="page"> <div className="page">
<PageHeader <PageHeader
title="Kunden" title="Kunden"
subtitle="Potenzielle Kunden (Leads) und feste Kunden - eine Pflege, Sync mit InvoiceNinja" subtitle="Potenzielle Kunden (Leads) und feste Kunden – eine Pflege"
actions={ actions={
<> <>
{isAdmin && ( {isAdmin && (
@@ -116,7 +116,6 @@ export default function CustomersPage() {
{st === 'lead' && <Badge tone="warn" dot>Lead</Badge>} {st === 'lead' && <Badge tone="warn" dot>Lead</Badge>}
{st === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>} {st === 'customer' && <Badge tone="ok" dot>Fester Kunde</Badge>}
{st === 'lost' && <Badge tone="muted">Abgesagt</Badge>} {st === 'lost' && <Badge tone="muted">Abgesagt</Badge>}
{c.invoiceNinjaClientId && <Badge tone="info"><FaFileInvoiceDollar /> Ninja</Badge>}
</div> </div>
</Link> </Link>
) )
+11 -3
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { FaSpinner } from 'react-icons/fa6' import { FaGear, FaSpinner } from 'react-icons/fa6'
import { PageHeader, Card, Button } from '../components/ui' import { PageHeader, Card, Button } from '../components/ui'
import StatusLine from '../components/finance/StatusLine' import StatusLine from '../components/finance/StatusLine'
import LiquidityHead from '../components/finance/LiquidityHead' import LiquidityHead from '../components/finance/LiquidityHead'
@@ -191,7 +191,15 @@ export default function FinancePage() {
return ( return (
<div className="page"> <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 && ( {assignNotice && (
<p className="fin-hint fin-hint-warn fin-page-notice" role="alert"> <p className="fin-hint fin-hint-warn fin-page-notice" role="alert">
{assignNotice}{' '} {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-dialog-actions .ui-btn { flex: 1 1 auto; }
.fin-toolbar .seg button { padding: 6px 9px; } .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; }
}
+286
View File
@@ -0,0 +1,286 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const {
SETTINGS_LIMITS,
BANK_DETAILS_EXAMPLE,
FIXED_COST_DELETE_CONFIRM,
SETTINGS_FIELD_ORDER,
FIXED_COST_FIELD_ORDER,
INTERVAL_OPTIONS,
apiErrorMessage,
euroField,
bankDetailsLength,
settingsToForm,
validateSettingsForm,
emptyFixedCostForm,
fixedCostToForm,
validateFixedCostForm,
fixedCostRows,
dateTimeDe,
bankCardModel,
refreshResultMessage,
} = await loadModule('src/lib/financeSettings.js')
const NOW = Date.parse('2026-10-01T13:55:21.691Z') // 15:55 Berliner Zeit
test('Konstanten: Texte und Grenzen wie im Brief', () => {
assert.equal(FIXED_COST_DELETE_CONFIRM, 'Position wirklich löschen? Bereits zugeordnete Buchungen behalten den Verweis. Besser auf inaktiv setzen.')
assert.equal(BANK_DETAILS_EXAMPLE, 'Bankverbindung: WEBklar GbR · IBAN DE.. · BIC ..')
assert.equal(SETTINGS_LIMITS.maxPercent, 60)
assert.equal(SETTINGS_LIMITS.maxBankDetails, 500)
assert.deepEqual(INTERVAL_OPTIONS, [
{ value: 'monat', label: 'monatlich' },
{ value: 'quartal', label: 'vierteljährlich' },
{ value: 'jahr', label: 'jährlich' },
])
})
test('euroField: Cent -> Eingabetext ohne Euro-Zeichen, rundläufig mit parseEuro', () => {
assert.equal(euroField(123456), '1.234,56')
assert.equal(euroField(0), '0,00')
assert.equal(euroField(2000), '20,00')
assert.equal(euroField(100000000), '1.000.000,00')
assert.equal(euroField(null), '')
assert.equal(euroField(undefined), '')
assert.equal(euroField('5'), '')
})
test('apiErrorMessage: entfernt den technischen Feldnamen, sonst unverändert', () => {
assert.equal(
apiErrorMessage(new Error('minBufferCents: Der Mindestpuffer muss eine ganze Zahl von 0 bis 100.000.000 Cent sein.')),
'Der Mindestpuffer muss eine ganze Zahl von 0 bis 100.000.000 Cent sein.'
)
assert.equal(apiErrorMessage(new Error('counterpartyMatch: Der Suchbegriff muss ein Text sein.')), 'Der Suchbegriff muss ein Text sein.')
assert.equal(apiErrorMessage(new Error('Fixkosten-Position nicht gefunden.')), 'Fixkosten-Position nicht gefunden.')
assert.equal(apiErrorMessage(new Error('Bitte in 5 Minuten erneut versuchen.')), 'Bitte in 5 Minuten erneut versuchen.')
assert.equal(apiErrorMessage(new Error('Das Kundenportal ist gerade nicht erreichbar.')), 'Das Kundenportal ist gerade nicht erreichbar.')
assert.equal(apiErrorMessage(null), 'Das Speichern ist fehlgeschlagen.')
assert.equal(apiErrorMessage(new Error(' ')), 'Das Speichern ist fehlgeschlagen.')
assert.equal(apiErrorMessage({}, 'Anders.'), 'Anders.')
})
test('settingsToForm: Cent -> Euro-Texte, null Vorjahresumsatz -> leer', () => {
assert.deepEqual(
settingsToForm({ minBufferCents: 2000, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: 'IBAN DE00' }),
{ minBuffer: '20,00', taxReservePercent: '30', previousYearRevenue: '', estPrepayment: '0,00', bankDetailsText: 'IBAN DE00' }
)
assert.equal(settingsToForm({ previousYearRevenueCents: 1500000 }).previousYearRevenue, '15.000,00')
assert.deepEqual(settingsToForm(null), { minBuffer: '', taxReservePercent: '', previousYearRevenue: '', estPrepayment: '', bankDetailsText: '' })
})
test('validateSettingsForm: gültiges Formular -> PATCH-Körper in Cent', () => {
const r = validateSettingsForm({ minBuffer: '1.234,56', taxReservePercent: '30', previousYearRevenue: '', estPrepayment: '0', bankDetailsText: ' Bankverbindung: X ' })
assert.deepEqual(r, {
ok: true,
payload: { minBufferCents: 123456, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: 'Bankverbindung: X' },
})
// Vorjahresumsatz gesetzt; 0 für Mindestpuffer; Rücklage 0 und 60 sind erlaubt; "30 %" ebenfalls
const edge = validateSettingsForm({ minBuffer: '0', taxReservePercent: '60', previousYearRevenue: '15.000,00 €', estPrepayment: '1.250,50', bankDetailsText: '' })
assert.deepEqual(edge.payload, { minBufferCents: 0, taxReservePercent: 60, previousYearRevenueCents: 1500000, estPrepaymentCents: 125050, bankDetailsText: '' })
assert.equal(validateSettingsForm({ minBuffer: '0', taxReservePercent: '0', previousYearRevenue: '0', estPrepayment: '0', bankDetailsText: '' }).payload.taxReservePercent, 0)
assert.equal(validateSettingsForm({ minBuffer: '0', taxReservePercent: '30 %', previousYearRevenue: '', estPrepayment: '0', bankDetailsText: '' }).payload.taxReservePercent, 30)
// Vorjahresumsatz 0 ist ein Wert, nicht „leer“
assert.equal(validateSettingsForm({ minBuffer: '0', taxReservePercent: '30', previousYearRevenue: '0,00', estPrepayment: '0', bankDetailsText: '' }).payload.previousYearRevenueCents, 0)
})
test('validateSettingsForm: Fehler je Feld, alle auf einmal, nichts wird gesendet', () => {
const r = validateSettingsForm({ minBuffer: '', taxReservePercent: '61', previousYearRevenue: '12.50', estPrepayment: 'abc', bankDetailsText: 'x'.repeat(501) })
assert.equal(r.ok, false)
assert.equal(r.payload, undefined)
assert.equal(r.errors.minBuffer, 'Bitte einen Betrag eingeben (0 ist möglich).')
assert.equal(r.errors.taxReservePercent, 'Die Rücklage muss eine ganze Zahl von 0 bis 60 sein.')
assert.equal(r.errors.previousYearRevenue, 'Ungültiger Betrag. Bitte einen Betrag wie 1.234,56 eingeben.')
assert.equal(r.errors.estPrepayment, 'Ungültiger Betrag. Bitte einen Betrag wie 1.234,56 eingeben.')
assert.equal(r.errors.bankDetailsText, 'Der Text darf höchstens 500 Zeichen lang sein (aktuell 501).')
assert.deepEqual(Object.keys(r.errors).sort(), [...SETTINGS_FIELD_ORDER].sort())
})
test('validateSettingsForm: Prozent nur ganze Zahlen 0 bis 60; Grenzen für Mindestpuffer und Text', () => {
const base = { minBuffer: '0', previousYearRevenue: '', estPrepayment: '0', bankDetailsText: '' }
for (const bad of ['', ' ', '-1', '30,5', '30.5', '6a', '061x', '1000', 'dreißig']) {
assert.equal(validateSettingsForm({ ...base, taxReservePercent: bad }).ok, false, `Prozent „${bad}“`)
}
for (const good of ['0', '1', '59', '60', '030']) {
assert.equal(validateSettingsForm({ ...base, taxReservePercent: good }).ok, true, `Prozent „${good}“`)
}
assert.equal(validateSettingsForm({ ...base, taxReservePercent: '30', minBuffer: '1.000.000,00' }).ok, true)
const over = validateSettingsForm({ ...base, taxReservePercent: '30', minBuffer: '1.000.000,01' })
assert.equal(over.errors.minBuffer, 'Der Mindestpuffer darf höchstens 1.000.000,00 € betragen.')
assert.equal(validateSettingsForm({ ...base, taxReservePercent: '30', minBuffer: '-5' }).ok, false)
// Text: genau 500 gespeicherte Zeichen sind erlaubt, Leerraum am Rand zählt nicht
assert.equal(validateSettingsForm({ ...base, taxReservePercent: '30', bankDetailsText: ` ${'x'.repeat(500)} ` }).ok, true)
assert.equal(validateSettingsForm({ ...base, taxReservePercent: '30', bankDetailsText: 'x'.repeat(501) }).ok, false)
assert.equal(validateSettingsForm(null).ok, false)
})
test('bankDetailsLength: zählt ohne Leerraum am Rand', () => {
assert.equal(bankDetailsLength(' abc '), 3)
assert.equal(bankDetailsLength(''), 0)
assert.equal(bankDetailsLength(null), 0)
assert.equal(bankDetailsLength('a\nb'), 3)
})
test('emptyFixedCostForm und fixedCostToForm', () => {
assert.deepEqual(emptyFixedCostForm(), { name: '', amount: '', interval: 'monat', nextDate: '', counterpartyMatch: '' })
assert.deepEqual(
fixedCostToForm({ $id: 'f1', name: 'Hetzner', amountCents: 1000, interval: 'quartal', nextDate: '2026-11-01', counterpartyMatch: 'hetzner', active: true }),
{ name: 'Hetzner', amount: '10,00', interval: 'quartal', nextDate: '2026-11-01', counterpartyMatch: 'hetzner' }
)
// unbekannter Rhythmus fällt auf monatlich
assert.equal(fixedCostToForm({ interval: 'woche' }).interval, 'monat')
assert.deepEqual(fixedCostToForm(null), emptyFixedCostForm())
})
test('validateFixedCostForm: gültig -> Körper in Cent, Suchbegriff getrimmt', () => {
const r = validateFixedCostForm({ name: ' Hetzner ', amount: '49,90', interval: 'monat', nextDate: '2026-11-01', counterpartyMatch: ' Hetzner ' })
assert.deepEqual(r, { ok: true, payload: { name: 'Hetzner', amountCents: 4990, interval: 'monat', nextDate: '2026-11-01', counterpartyMatch: 'Hetzner' } })
assert.equal(validateFixedCostForm({ name: 'X', amount: '1.200', interval: 'jahr', nextDate: '2028-02-29', counterpartyMatch: '' }).payload.amountCents, 120000)
assert.equal(validateFixedCostForm({ name: 'X', amount: '0,01', interval: 'jahr', nextDate: '2028-02-29', counterpartyMatch: '' }).ok, true)
})
test('validateFixedCostForm: Fehler je Feld', () => {
const r = validateFixedCostForm({ name: ' ', amount: '0', interval: 'woche', nextDate: '2026-02-30', counterpartyMatch: 'x'.repeat(121) })
assert.equal(r.ok, false)
assert.equal(r.errors.name, 'Bitte einen Namen eingeben (höchstens 120 Zeichen).')
assert.equal(r.errors.amount, 'Der Betrag muss größer als 0 sein (z. B. 49,90).')
assert.equal(r.errors.interval, 'Bitte einen Rhythmus wählen.')
assert.equal(r.errors.nextDate, 'Bitte ein gültiges Datum wählen.')
assert.equal(r.errors.counterpartyMatch, 'Der Suchbegriff darf höchstens 120 Zeichen lang sein.')
assert.deepEqual(Object.keys(r.errors).sort(), [...FIXED_COST_FIELD_ORDER].sort())
const empty = validateFixedCostForm(emptyFixedCostForm())
assert.equal(empty.errors.amount, 'Bitte einen Betrag eingeben.')
assert.equal(empty.errors.nextDate, 'Bitte ein gültiges Datum wählen.')
assert.equal(validateFixedCostForm({ name: 'X', amount: '12.50', interval: 'monat', nextDate: '2026-11-01', counterpartyMatch: '' }).errors.amount, 'Ungültiger Betrag. Bitte einen Betrag wie 1.234,56 eingeben.')
assert.equal(validateFixedCostForm({ name: 'X'.repeat(121), amount: '1', interval: 'monat', nextDate: '2026-11-01', counterpartyMatch: '' }).ok, false)
// ein Datum mit Uhrzeit oder anderem Format ist kein Termin
for (const bad of ['01.11.2026', '2026-11-01T00:00:00Z', '2026-1-1', '']) {
assert.equal(validateFixedCostForm({ name: 'X', amount: '1', interval: 'monat', nextDate: bad, counterpartyMatch: '' }).ok, false, bad)
}
assert.equal(validateFixedCostForm(null).ok, false)
})
test('fixedCostRows: aktive zuerst, Texte de-DE, Schalter-Beschriftung', () => {
const rows = fixedCostRows([
{ $id: 'f2', name: 'Altlast', amountCents: 500, interval: 'jahr', nextDate: '2027-01-01', counterpartyMatch: 'x', active: false },
{ $id: 'f1', name: 'Hetzner', amountCents: 123456, interval: 'monat', nextDate: '2026-11-01', counterpartyMatch: '', active: true },
{ $id: 'f3', name: 'Domain', amountCents: 1200, interval: 'quartal', nextDate: '2026-12-15', active: true },
null,
])
assert.deepEqual(rows.map((r) => r.id), ['f1', 'f3', 'f2'])
assert.deepEqual(rows[0], {
key: 'f1',
id: 'f1',
name: 'Hetzner',
amountText: '1.234,56 €',
intervalText: 'monatlich',
nextDateText: '01.11.2026',
matchText: '',
metaText: 'monatlich · nächster Termin 01.11.2026',
active: true,
statusLabel: 'Aktiv',
toggleLabel: 'Auf inaktiv setzen',
})
assert.equal(rows[1].intervalText, 'vierteljährlich')
assert.equal(rows[2].statusLabel, 'Inaktiv')
assert.equal(rows[2].toggleLabel, 'Aktivieren')
assert.equal(rows[2].matchText, 'x')
assert.equal(rows[2].metaText, 'jährlich · nächster Termin 01.01.2027 · Suchbegriff „x“')
assert.equal(rows[1].metaText, 'vierteljährlich · nächster Termin 15.12.2026')
assert.deepEqual(fixedCostRows(null), [])
assert.deepEqual(fixedCostRows('x'), [])
// kaputte Werte werden zu Platzhaltern, nicht zu Fehlern
const odd = fixedCostRows([{ $id: 'f9' }])[0]
assert.equal(odd.name, 'Ohne Namen')
assert.equal(odd.amountText, '–')
assert.equal(odd.intervalText, '–')
assert.equal(odd.nextDateText, '–')
assert.equal(odd.metaText, '– · nächster Termin –')
})
test('dateTimeDe: Berliner Zeit, 24 Stunden', () => {
assert.equal(dateTimeDe('2026-10-01T10:55:21.691Z'), '01.10.2026, 12:55 Uhr')
assert.equal(dateTimeDe('2026-01-15T23:30:00.000Z'), '16.01.2026, 00:30 Uhr')
assert.equal(dateTimeDe(''), '–')
assert.equal(dateTimeDe('kaputt'), '–')
assert.equal(dateTimeDe(null), '–')
})
const BANK = (over = {}) => ({
status: 'aktiv',
ibanMasked: 'DE00 •••• 9313',
validUntil: '2099-03-29T00:00:00.000Z',
lastSyncAt: '2026-10-01T10:55:21.691Z',
lastSyncOk: true,
lastError: '',
...over,
})
test('bankCardModel: ohne Verbindung nur „Bank verbinden“', () => {
for (const none of [null, undefined, 'x']) {
const m = bankCardModel(none, NOW)
assert.equal(m.connected, false)
assert.equal(m.statusLabel, 'Nicht verbunden')
assert.equal(m.canRefresh, false)
assert.equal(m.connectLabel, 'Bank verbinden')
assert.deepEqual(m.rows, [])
assert.equal(m.error, '')
}
})
test('bankCardModel: verbunden zeigt IBAN, Gültigkeit und letzten Abruf', () => {
const m = bankCardModel(BANK(), NOW)
assert.equal(m.connected, true)
assert.equal(m.statusLabel, 'Verbunden')
assert.equal(m.tone, 'ok')
assert.deepEqual(m.rows, [
{ key: 'iban', label: 'IBAN', text: 'DE00 •••• 9313' },
{ key: 'valid', label: 'Zugang gültig bis', text: '29.03.2099' },
{ key: 'sync', label: 'Letzter Abruf', text: '01.10.2026, 12:55 Uhr · vor 3 Std.' },
])
assert.equal(m.error, '')
assert.equal(m.canRefresh, true)
assert.equal(m.connectLabel, 'Neu verbinden')
assert.equal(m.hint, '')
})
test('bankCardModel: Fehler, abgelaufen, noch kein Abruf', () => {
const failed = bankCardModel(BANK({ lastSyncOk: false, lastError: 'Bank antwortet nicht' }), NOW)
assert.equal(failed.statusLabel, 'Abruf fehlgeschlagen')
assert.equal(failed.tone, 'warn')
assert.equal(failed.error, 'Bank antwortet nicht')
assert.equal(failed.canRefresh, true)
// Abruf gescheitert, aber der Server nennt keinen Text
assert.equal(bankCardModel(BANK({ lastSyncOk: false, lastError: '' }), NOW).error, 'Der letzte Abruf ist fehlgeschlagen.')
assert.equal(bankCardModel(BANK({ status: 'fehler', lastSyncOk: true }), NOW).statusLabel, 'Abruf fehlgeschlagen')
// abgelaufen (Datum oder Status): Abruf gesperrt, nur neu verbinden
const expired = bankCardModel(BANK({ validUntil: '2026-09-30T00:00:00.000Z' }), NOW)
assert.equal(expired.statusLabel, 'Zugang abgelaufen')
assert.equal(expired.tone, 'danger')
assert.equal(expired.canRefresh, false)
assert.equal(expired.hint, 'Der Zugang ist abgelaufen. Bitte neu verbinden.')
assert.equal(bankCardModel(BANK({ status: 'abgelaufen' }), NOW).canRefresh, false)
// am letzten Gültigkeitstag selbst noch nutzbar
assert.equal(bankCardModel(BANK({ validUntil: '2026-10-01T00:00:00.000Z' }), NOW).statusLabel, 'Verbunden')
// abgelaufen hat Vorrang vor Fehler
assert.equal(bankCardModel(BANK({ validUntil: '2026-01-01T00:00:00.000Z', lastSyncOk: false }), NOW).statusLabel, 'Zugang abgelaufen')
const fresh = bankCardModel(BANK({ lastSyncAt: '' }), NOW)
assert.equal(fresh.rows[2].text, 'noch kein Abruf')
const sparse = bankCardModel({ status: 'wartet' }, NOW)
assert.equal(sparse.statusLabel, 'wartet')
assert.equal(sparse.rows[0].text, '–')
assert.equal(sparse.rows[1].text, '–')
})
test('refreshResultMessage: Ergebnis des Abrufs', () => {
assert.deepEqual(refreshResultMessage({ ok: true, neu: 3, aktualisiert: 1, geloescht: 0 }), { tone: 'ok', text: 'Abruf abgeschlossen: 3 neu, 1 aktualisiert.' })
assert.deepEqual(refreshResultMessage({ ok: true, neu: 0, aktualisiert: 0, geloescht: 2 }), { tone: 'ok', text: 'Abruf abgeschlossen: 2 entfernt.' })
assert.deepEqual(refreshResultMessage({ ok: true, neu: 0, aktualisiert: 0, geloescht: 0 }), { tone: 'ok', text: 'Abruf abgeschlossen, keine neuen Buchungen.' })
assert.deepEqual(refreshResultMessage({ ok: false, error: 'Bank antwortet nicht' }), { tone: 'warn', text: 'Abruf fehlgeschlagen: Bank antwortet nicht' })
assert.deepEqual(refreshResultMessage({ ok: false }), { tone: 'warn', text: 'Abruf fehlgeschlagen.' })
assert.equal(refreshResultMessage(null).tone, 'ok')
})
+27
View File
@@ -46,6 +46,7 @@ const {
customerPickerOptions, customerPickerOptions,
customerSuggestions, customerSuggestions,
resolveCustomerText, resolveCustomerText,
customerInvoicesGate,
sortTransactions, sortTransactions,
strongestSuggestion, strongestSuggestion,
suggestionText, suggestionText,
@@ -1184,3 +1185,29 @@ test('assignBody und invoiceAssignConfirmText', () => {
assert.deepEqual(assignBody(null, 'x'), { note: 'x' }) assert.deepEqual(assignBody(null, 'x'), { note: 'x' })
assert.equal(invoiceAssignConfirmText('WK-0005'), 'Die Rechnung WK-0005 wird in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.') assert.equal(invoiceAssignConfirmText('WK-0005'), 'Die Rechnung WK-0005 wird in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.')
}) })
test('customerInvoicesGate: fester Kunde mit E-Mail darf, alle anderen bekommen Grund und Hinweis', () => {
assert.deepEqual(customerInvoicesGate({ $id: 'c1', name: 'A', email: 'a@example.invalid', customerStatus: 'customer' }), { ok: true })
// Bestandsdaten ohne Status zählen als fester Kunde
assert.deepEqual(customerInvoicesGate({ $id: 'c1', name: 'A', email: 'a@example.invalid' }), { ok: true })
const lead = customerInvoicesGate({ name: 'A', email: 'a@example.invalid', customerStatus: 'lead' })
assert.equal(lead.ok, false)
assert.equal(lead.title, 'Noch ein Lead')
assert.equal(lead.hint, 'Erst zum festen Kunden machen (vollständige Daten nötig), dann sind Rechnungen möglich.')
// Lead ohne E-Mail: der Lead-Hinweis hat Vorrang
assert.equal(customerInvoicesGate({ name: 'A', email: '', customerStatus: 'LEAD' }).title, 'Noch ein Lead')
const lost = customerInvoicesGate({ name: 'A', email: 'a@example.invalid', customerStatus: 'lost' })
assert.deepEqual(lost, { ok: false, title: 'Noch kein fester Kunde', hint: 'Rechnungen gibt es nur für feste Kunden.' })
const noMail = customerInvoicesGate({ name: 'A', email: ' ', customerStatus: 'customer' })
assert.equal(noMail.ok, false)
assert.equal(noMail.title, 'E-Mail fehlt')
assert.match(noMail.hint, /E-Mail-Adresse in den Stammdaten/)
const none = customerInvoicesGate(null)
assert.equal(none.ok, false)
assert.equal(none.title, 'Kein Kunde ausgewählt')
assert.equal(none.hint, '')
})