Compare commits

...
13 Commits
Author SHA1 Message Date
ClaudeandClaude Sonnet 5.5 72f07410fe Finanzen: Rueckfrage vor dem Senden, Anschrift des Kunden pruefen, Fehlermeldungen im Bild
Senden einer Rechnung (Dialog und Entwurf in der Tabelle) fragt vorher: "Rechnung fuer
<Kunde> ueber <Betrag> jetzt festschreiben und an <E-Mail> senden? Danach laesst sie sich
nur noch stornieren."

Fehlt dem Kunden Strasse, PLZ oder Ort, zeigen Rechnungsdialog und Kundenansicht die
Warnung "Adresse des Kunden unvollstaendig ..." mit Weg zur Uebersicht, und Senden ist
ueber 250 € gesperrt (§ 14 Abs. 4 Nr. 1 UStG, Kleinbetragsrechnung); Entwuerfe bleiben
moeglich. Reine Helfer addressComplete und invoiceSendGate samt Tests.

Fehlermeldungen in Rechnungs- und Zuordnen-Dialog werden per scrollIntoView
({ block: 'nearest' }) in den sichtbaren Bereich geholt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:22:00 +00:00
ClaudeandClaude Sonnet 5.5 1e72faf97c Finanzen: Portal-Client mit deutschen Fehlertexten fuer Proxy-Fehler und abgelaufene Anmeldung
Der Anfrage-Kern liegt jetzt in financeRequest.js (rein, fetch und createJWT werden
injiziert, unter Node testbar). 502/503/504 ohne JSON-Antwort und Netzfehler melden
"Das Kundenportal ist gerade nicht erreichbar."; scheitert createJWT, kommt
"Anmeldung abgelaufen. Bitte neu anmelden." (status 401). Meldungen des Portals bleiben.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:21:53 +00:00
ClaudeandClaude Sonnet 5.5 e9cfae28ee Finanzen: Steuerkarte mit Orientierungshinweis und neuer Anteilsformulierung, Ladetext auf Deutsch
TaxCard zeigt am Ende immer "Orientierungswert, ersetzt keine Steuerberatung."; der
Anteil heisst "<x> % der Grenze von 25.000 €" statt "<x> % der 25.000-€-Grenze".
AdminRoute zeigt "Wird geladen …" statt "Loading...".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:21:53 +00:00
ClaudeandClaude Sonnet 5.5 0b1101d384 Finanzen: Korrekturen aus dem Oberflaechentest
Seitenmenue startet auf schmalen Bildschirmen (bis 720 px) eingeklappt. Bei 390 px liess
das feste 248-px-Menue nur ~140 px Inhalt, jede Seite (auch /finance) scrollte waagerecht;
das fiel in den Einzeltests ohne App-Rahmen nicht auf.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 edf118a044 Finanzen: Reiter Rechnungen am Kunden nur fuer Admins, Badge Adresse unvollstaendig
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 2aacdb2d56 Finanzen: Einstellungen, Rechnungen am Kunden, InvoiceNinja aus der Oberflaeche entfernt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 24a3cc41ef Finanzen: Offen zeigt auch Ueberfaellige, Rechnungsliste laedt nach Zuordnen neu
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 1feeb18c08 Finanzen: Rechnungen schreiben und verwalten, Buchungen zuordnen
Block 6 (Rechnungen: Filter, Altersgruppen, Aktionen, Dialog "Rechnung schreiben")
und Block 7 (Buchungen mit Paging, Zuordnen-Dialog, bei Rechnungen zweistufig).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 18bc3012e0 Finanzen: Steuer-Texte zu den Grenzen genauer, Test fuer gleichen Tag
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 3826f1ab72 Finanzen: Vorschau, Steuer und Abos
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 88a98b858f Finanzen: Statuszeile, Liquiditaetskopf und Aufgabenliste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 0069a62a09 Finanzen: nur fuer Admins, Rueckkehrseite fuer die Bankfreigabe
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
ClaudeandClaude Sonnet 5.5 c910bedcec Finanzen: Formatierung, Betragsparser und Portal-API-Client
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:12:18 +00:00
56 changed files with 7015 additions and 616 deletions

No files matched your search

+8 -6
View File
@@ -1,8 +1,8 @@
# WOMS – Ticketsystem von WEBklar
Internes Ticketsystem unter https://ticket.webklar.com: Tickets, Arbeitsblätter mit
Zeiterfassung, Kunden und Leads, Website-Projekte, Planboard und Rechnungen (nur
anzeigen; Rechnungen werden in InvoiceNinja geschrieben).
Zeiterfassung, Kunden und Leads, Website-Projekte, Planboard und Finanzen (Rechnungen
schreiben und verwalten, Buchungen zuordnen; nur für Admins).
React/Vite im Browser, Daten in Appwrite (Projekt `6a1058610003c5a13a05`,
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` |
| `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) |
| `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 |
@@ -37,6 +37,8 @@ Zusammenspiel mit anderen Systemen:
nachgetragen, danach zählt sie als Arbeitszeit. Termine aus der Rechnungsanalyse
landen am Akquise-Ticket des Kunden und erscheinen im Planboard. Kunden-, Mitarbeiter-
und Projektverwaltung laufen über die Admin-API des Portals.
- **ticket-integrations** (`/api/integrations`): Proxy zu InvoiceNinja (Rechnungen
anzeigen, Kunden verknüpfen). Die Paperless-Routen dort nutzt WOMS nicht mehr;
Paperless selbst ist gestoppt.
- **Finanzen-API des Portals** (`/api/admin/finanzen`): Übersicht, Rechnungen (Stripe),
Buchungen (Bank), Einstellungen und Fixkosten. WOMS spricht sie mit dem Appwrite-JWT an
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).
+7 -1
View File
@@ -10,10 +10,13 @@ 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'
import ServerpulsPage from './pages/ServerpulsPage'
import BankCallbackPage from './pages/BankCallbackPage'
import AdminRoute from './components/AdminRoute'
import { safeReturnPath } from './lib/returnPath'
function ProtectedRoute({ children }) {
@@ -48,7 +51,10 @@ function AppRoutes() {
<Route path="/customers" element={<ProtectedRoute><CustomersPage /></ProtectedRoute>} />
<Route path="/customers/:id" element={<ProtectedRoute><CustomerDetailPage /></ProtectedRoute>} />
<Route path="/leads" element={<Navigate to="/tickets" replace />} />
<Route path="/finance" element={<ProtectedRoute><FinancePage /></ProtectedRoute>} />
{/* 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>} />
<Route path="/planboard" element={<ProtectedRoute><PlanboardPage /></ProtectedRoute>} />
+20
View File
@@ -0,0 +1,20 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
// Nur fuer Admins; wird innerhalb von ProtectedRoute verwendet. Alle anderen landen bei den Tickets.
export default function AdminRoute({ children }) {
const { loading, isAdmin } = useAuth()
if (loading) {
return (
<div className="text-center p-4" style={{ marginTop: 80 }}>
<div className="spinner"></div>
<p>Wird geladen …</p>
</div>
)
}
if (!isAdmin) return <Navigate to="/tickets" replace />
return children
}
-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>
)
}
+6 -3
View File
@@ -19,12 +19,13 @@ import {
} from '@tabler/icons-react'
import { openProjectAdmin } from '../lib/employeeAdminApi'
import { serverpulsApi } from '../lib/serverpulsApi'
import { startsCollapsed } from '../lib/sidebar'
export default function Sidebar() {
const { user, logout, isAdmin } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
const [collapsed, setCollapsed] = useState(() => startsCollapsed())
const [switching, setSwitching] = useState(false)
const [pulseLevel, setPulseLevel] = useState('ok')
@@ -61,7 +62,7 @@ export default function Sidebar() {
{ label: 'Kunden', href: '/customers', icon: <IconUsers /> },
// Leads liegen auf Eis (Lead-Automatik gestoppt 2026-09-30): grau, nicht klickbar
{ label: 'Leads', href: '/leads', icon: <IconTargetArrow />, paused: true },
{ label: 'Finanzen', href: '/finance', icon: <IconReportMoney /> },
...(isAdmin ? [{ label: 'Finanzen', href: '/finance', also: '/finanzen', icon: <IconReportMoney /> }] : []),
{ label: 'Rechtliches', href: '/rechtliches', icon: <IconScale /> },
...(isAdmin ? [{ label: 'Serverpuls', href: '/serverpuls', icon: <IconActivityHeartbeat />, dot: pulseLevel }] : []),
...(isAdmin ? [{ label: 'Admin', href: '/admin', icon: <IconSettings /> }] : []),
@@ -69,6 +70,8 @@ export default function Sidebar() {
const isActive = (href) =>
location.pathname === href || location.pathname.startsWith(href + '/')
// Finanzen bleibt auch auf der Rueckkehrseite der Bankfreigabe (/finanzen/...) markiert
const isLinkActive = (link) => isActive(link.href) || (link.also ? isActive(link.also) : false)
return (
<aside className={`side ${collapsed ? 'collapsed' : ''}`}>
@@ -96,7 +99,7 @@ export default function Sidebar() {
<Link
key={link.href}
to={link.href}
className={`side-link ${isActive(link.href) ? 'active' : ''}`}
className={`side-link ${isLinkActive(link) ? 'active' : ''}`}
title={link.label}
>
{link.icon}
-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>
)}
+61
View File
@@ -0,0 +1,61 @@
import { FaTriangleExclamation } from 'react-icons/fa6'
import { Badge, Card, EmptyState } from '../ui'
import { aboView } from '../../lib/financeView'
/**
* Block 5: „Abos“. Einnahmen pro Monat (aktiv und Zahlung fehlgeschlagen), zahlende Kunden, gefährdete Einnahmen
* (nur wenn > 0) und die Liste Kunde · Produkt · Status · Betrag · nächste Abbuchung. Konnten die Abos nicht
* geladen werden, steht stattdessen die Meldung des Servers da (keine Zahlen).
*/
export default function AboCard({ abos }) {
const v = aboView(abos)
if (v.error) {
return (
<Card title="Abos" className="fin-abo-card">
<p className="fin-hint fin-hint-warn" role="alert">
<FaTriangleExclamation aria-hidden="true" /> {v.error}
</p>
</Card>
)
}
return (
<Card title="Abos" className="fin-abo-card" bodyStyle={{ padding: 0 }}>
<div className="fin-stats">
<span className="fin-amount fin-stat-value">{v.monthlyText}</span>
<span className="fin-stat-label">Abo-Einnahmen pro Monat</span>
<span className="fin-amount fin-stat-value">{v.payingText}</span>
<span className="fin-stat-label">zahlende Kunden</span>
{v.atRiskText && (
<>
<span className="fin-amount fin-stat-value fin-flag">{v.atRiskText}</span>
<span className="fin-stat-label fin-flag">
<FaTriangleExclamation aria-hidden="true" /> gefährdet
</span>
</>
)}
</div>
{v.rows.length === 0 ? (
<EmptyState title="Noch keine Abos." />
) : (
<ul className="fin-abos">
{v.rows.map((row) => (
<li key={row.key} className="fin-abo">
<div className="fin-abo-main">
<span className="fin-abo-customer">{row.customer}</span>
<span className="fin-abo-product muted"> · {row.product}</span>
</div>
<div className="fin-amount fin-abo-amount">{row.amountText}</div>
<div className="fin-abo-meta">
<Badge tone={row.tone}>{row.statusLabel}</Badge>
<span className="fin-abo-next muted">nächste Abbuchung: {row.nextText}</span>
</div>
</li>
))}
</ul>
)}
</Card>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { Link } from 'react-router-dom'
import { ADDRESS_INCOMPLETE_TEXT, addressComplete } from '../../lib/financeView'
/**
* Hinweis „Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.“ mit Weg zur
* Übersicht des Kunden (dort stehen die Stammdaten). Rendert nichts, wenn die Anschrift vollständig ist. Mit
* `onShowOverview` (Reiter am Kunden) wechselt ein Knopf zum Reiter Übersicht, sonst führt ein Link zur Kundenseite.
* `variant`: 'dialog' (im Rechnungsdialog) oder 'page' (über der Rechnungsliste des Kunden).
*/
export default function AddressWarning({ customer, onShowOverview, variant = 'page' }) {
if (!customer || addressComplete(customer)) return null
return (
<div className={`fin-address-warn fin-address-warn-${variant}`} role="alert">
<span>{ADDRESS_INCOMPLETE_TEXT}</span>
{onShowOverview ? (
<button type="button" className="fin-link" onClick={onShowOverview}>
Zur Übersicht
</button>
) : customer.$id ? (
<Link className="fin-link" to={`/customers/${customer.$id}`}>
Zur Übersicht
</Link>
) : null}
</div>
)
}
+321
View File
@@ -0,0 +1,321 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { FaXmark } from 'react-icons/fa6'
import { Badge, Button } from '../ui'
import { useDialogFocus } from '../../hooks/useDialogFocus'
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
import { assignTransaction, getFixedCosts, unassignTransaction } from '../../lib/financeApi'
import {
assignBody,
assignModel,
invoiceAssignConfirmText,
transactionRows,
} from '../../lib/financeView'
const NOTE_MAX = 500
/**
* Dialog „Buchung zuordnen“. Auswahl je nach Buchung (siehe assignModel): Vorschläge, andere offene Rechnung,
* Stripe-Auszahlung (aus den Vorschlägen), Fixkosten (nur Ausgänge, aus der Fixkostenliste), Kategorie, dazu eine
* Notiz. Vorgemerkte Buchungen (PDNG) bekommen nur Kategorien und Fixkosten. Eine Rechnung wird in Stripe als
* bezahlt markiert und das lässt sich nicht rückgängig machen, deshalb zweistufig: erst „Zuordnen“, dann der
* Bestätigungstext mit „Ja, als bezahlt markieren“; vorher geht keine Anfrage raus. Fehler des Servers (409 Betrag
* weicht ab, 502 nicht gespeichert …) stehen im Dialog, der offen bleibt. Eine bestehende Zuordnung (außer Rechnung)
* lässt sich mit „Zuordnung lösen“ aufheben.
*
* invoices = offene und überfällige Rechnungen (invoicesLoading / invoicesError für den Ladezustand; invoicesLoaded =
* frisch und ohne Fehler geladen, dann sind Vorschläge zu nicht mehr offenen Rechnungen gesperrt).
* onDone(buchung) nach Erfolg (Aufrufer lädt neu und schließt); onClose({ refresh }) bei Abbruch, refresh = ein
* Fehler trat auf, der Stand kann sich geändert haben.
*/
export default function AssignDialog({ transaction, invoices = [], invoicesLoading = false, invoicesError = '', invoicesLoaded = false, onClose, onDone }) {
const tx = transaction || {}
const row = useMemo(() => transactionRows([tx])[0], [tx])
const [fixed, setFixed] = useState({ list: [], loading: false, error: '' })
const [selected, setSelected] = useState('')
const [note, setNote] = useState('')
const [confirming, setConfirming] = useState(false)
const [busy, setBusy] = useState(null) // 'assign' | 'unassign'
const [error, setError] = useState('')
const lockRef = useRef(false)
const aliveRef = useRef(true)
const dirtyRef = useRef(false)
const dialogRef = useRef(null)
const confirmRef = useRef(null)
const errorRef = useRef(null)
const outgoing = Number.isFinite(tx.amountCents) && tx.amountCents < 0
const unassigned = !tx.category
const onOverlayKeyDown = useDialogFocus(dialogRef)
// Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen
useScrollIntoView(errorRef, error)
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
// Fixkosten nur für Ausgänge ohne Zuordnung
useEffect(() => {
if (!outgoing || !unassigned) return undefined
let cancelled = false
setFixed({ list: [], loading: true, error: '' })
getFixedCosts()
.then((data) => {
if (!cancelled) setFixed({ list: Array.isArray(data?.fixkosten) ? data.fixkosten : [], loading: false, error: '' })
})
.catch((err) => {
if (!cancelled) setFixed({ list: [], loading: false, error: err?.message || 'Die Fixkosten konnten nicht geladen werden.' })
})
return () => {
cancelled = true
}
}, [outgoing, unassigned])
const model = useMemo(() => assignModel(tx, { invoices, fixedCosts: fixed.list, invoicesLoaded }), [tx, invoices, fixed.list, invoicesLoaded])
const option = model.byKey[selected] || null
function close() {
if (lockRef.current) return
onClose({ refresh: dirtyRef.current })
}
useEffect(() => {
const onKey = (e) => {
// Escape schließt zuerst nur die offene Auswahl (z. B. ein Aufklappmenü): dann nicht den Dialog
if (e.key === 'Escape' && !e.defaultPrevented) close()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
})
function choose(key) {
setSelected(key)
setConfirming(false)
if (error) setError('')
}
useEffect(() => {
if (confirming) confirmRef.current?.focus()
}, [confirming])
async function doAssign() {
if (lockRef.current || !option || option.disabled) return
lockRef.current = true
setBusy('assign')
setError('')
try {
const data = await assignTransaction(tx.$id, assignBody(option, note))
onDone(data?.buchung || null)
} catch (err) {
dirtyRef.current = true
if (aliveRef.current) {
setError(err?.message || 'Die Zuordnung ist fehlgeschlagen.')
setConfirming(false)
}
} finally {
lockRef.current = false
if (aliveRef.current) setBusy(null)
}
}
// Erster Schritt: bei einer Rechnung nur den Bestätigungstext zeigen, noch keine Anfrage
function primary() {
if (!option || option.disabled || lockRef.current) return
if (option.type === 'invoice' && !confirming) {
setConfirming(true)
return
}
doAssign()
}
async function doUnassign() {
if (lockRef.current) return
lockRef.current = true
setBusy('unassign')
setError('')
try {
const data = await unassignTransaction(tx.$id)
onDone(data?.buchung || null)
} catch (err) {
dirtyRef.current = true
if (aliveRef.current) setError(err?.message || 'Die Zuordnung konnte nicht gelöst werden.')
} finally {
lockRef.current = false
if (aliveRef.current) setBusy(null)
}
}
const disabled = busy !== null
const showInvoiceState = model.incoming && !model.pending && !model.assigned
const invoiceSelectValue = model.invoices.some((o) => o.key === selected) ? selected : ''
const fixedSelectValue = model.fixedCosts.some((o) => o.key === selected) ? selected : ''
const radio = (o) => (
<label key={o.key} className={`fin-choice${o.disabled ? ' fin-choice-off' : ''}`}>
<input
type="radio"
name="fin-assign-target"
value={o.key}
checked={selected === o.key}
disabled={o.disabled || disabled}
onChange={() => choose(o.key)}
/>
<span className="fin-choice-text">
<span className="fin-choice-label">{o.label}</span>
{(o.confidence || o.detail) && (
<span className="fin-choice-detail muted">
{o.confidence ? `Übereinstimmung: ${o.confidence}` : ''}
{o.confidence && o.detail ? ' · ' : ''}
{o.detail}
</span>
)}
{o.disabled && o.disabledReason && <span className="fin-choice-detail fin-hint-warn">{o.disabledReason}</span>}
</span>
</label>
)
return (
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
<button type="button" className="overlay-close fin-close" onClick={close} disabled={disabled} aria-label="Schließen">
<FaXmark aria-hidden="true" />
</button>
<div
className="overlay-content fin-dialog fin-assign"
role="dialog"
aria-modal="true"
aria-labelledby="fin-assign-title"
tabIndex={-1}
ref={dialogRef}
>
<h2 id="fin-assign-title" className="mb-2">Buchung zuordnen</h2>
<dl className="fin-tx-facts">
<div><dt>Datum</dt><dd>{row?.dateText}{row?.pending && <> <Badge tone="muted">vorgemerkt</Badge></>}</dd></div>
<div><dt>Gegenpartei</dt><dd>{row?.counterparty}</dd></div>
{row?.remittance && <div><dt>Verwendungszweck</dt><dd className="fin-wrap">{row.remittance}</dd></div>}
<div><dt>Betrag</dt><dd className="fin-amount fin-tx-facts-amount">{row?.amountText}</dd></div>
</dl>
{error && (
<div className="fin-dialog-error" role="alert" ref={errorRef}>
{error}
</div>
)}
{model.assigned ? (
<>
<p className="fin-assigned">
Zugeordnet: <strong>{model.currentText}</strong>
</p>
{!model.canUnassign && (
<p className="fin-dialog-sub">Die Rechnung ist in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.</p>
)}
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={close} disabled={disabled}>Schließen</Button>
{model.canUnassign && (
<Button type="button" onClick={doUnassign} disabled={disabled}>
{busy === 'unassign' ? 'Löse …' : 'Zuordnung lösen'}
</Button>
)}
</div>
</>
) : (
<>
{model.hint && <p className="fin-dialog-sub" role="note">{model.hint}</p>}
{showInvoiceState && invoicesLoading && <p className="fin-dialog-sub" role="status">Offene Rechnungen werden geladen …</p>}
{showInvoiceState && invoicesError && (
<p className="fin-dialog-warn" role="alert">Die offenen Rechnungen konnten nicht geladen werden: {invoicesError}</p>
)}
{outgoing && fixed.loading && <p className="fin-dialog-sub" role="status">Fixkosten werden geladen …</p>}
{outgoing && fixed.error && <p className="fin-dialog-warn" role="alert">Die Fixkosten konnten nicht geladen werden: {fixed.error}</p>}
{model.suggestions.length > 0 && (
<fieldset className="fin-choices">
<legend className="form-label">Vorschläge</legend>
{model.suggestions.map(radio)}
</fieldset>
)}
{model.invoices.length > 0 && (
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-invoice">Andere offene Rechnung</label>
<select
id="fin-assign-invoice"
className="form-control"
value={invoiceSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Rechnung wählen …</option>
{model.invoices.map((o) => (
<option key={o.key} value={o.key} disabled={o.disabled}>
{o.label}{o.disabled ? ` (${o.disabledReason})` : ''}
</option>
))}
</select>
</div>
)}
{model.fixedCosts.length > 0 && (
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-fixed">Fixkosten</label>
<select
id="fin-assign-fixed"
className="form-control"
value={fixedSelectValue}
disabled={disabled}
onChange={(e) => choose(e.target.value)}
>
<option value="">Fixkosten wählen …</option>
{model.fixedCosts.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</div>
)}
<fieldset className="fin-choices">
<legend className="form-label">Kategorie</legend>
{model.categories.map(radio)}
</fieldset>
<div className="form-group">
<label className="form-label" htmlFor="fin-assign-note">Notiz (optional)</label>
<textarea
id="fin-assign-note"
className="form-control"
rows={2}
maxLength={NOTE_MAX}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
{confirming && option ? (
<div className="fin-confirm" role="group" aria-label="Bestätigung" tabIndex={-1} ref={confirmRef}>
<p>{invoiceAssignConfirmText(option.number || option.label)}</p>
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={() => setConfirming(false)} disabled={disabled}>Zurück</Button>
<Button type="button" variant="primary" onClick={doAssign} disabled={disabled}>
{busy === 'assign' ? 'Markiere …' : 'Ja, als bezahlt markieren'}
</Button>
</div>
</div>
) : (
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={close} disabled={disabled}>Abbrechen</Button>
<Button type="button" variant="primary" onClick={primary} disabled={disabled || !option || option.disabled}>
{busy === 'assign' ? 'Ordne zu …' : 'Zuordnen'}
</Button>
</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,31 @@
import { Button, Card, EmptyState } from '../ui'
import AddressWarning from './AddressWarning'
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). Fehlt dem Kunden die Anschrift
* (Straße, PLZ, Ort), steht darüber eine Warnung mit Knopf zur Übersicht; „Senden“ ist dann über 250 € gesperrt.
*/
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 (
<>
<AddressWarning customer={customer} onShowOverview={onShowOverview} variant="page" />
<InvoiceTable customer={customer} onShowOverview={onShowOverview} />
</>
)
}
+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>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { FaTriangleExclamation } from 'react-icons/fa6'
import { Badge, Card, EmptyState } from '../ui'
import { forecastNotes, forecastRows } from '../../lib/financeView'
/**
* Block 3: „Vorschau 30 Tage“. Je Posten Datum, Bezeichnung, „sicher“/„erwartet“ (als Text), Betrag mit Vorzeichen
* und der laufende Saldo beider Linien ab dem Kontostand. Ohne Kontostand entfallen die Saldo-Spalten (mit Hinweis).
* Darunter die Annahme des Servers und Hinweise, wenn Stripe oder die Abos fehlen. Schmal (Karte bis 560 px)
* wird jede Zeile dreizeilig: Datum, Sicherheit und Betrag / Posten / beide Salden; ohne Salden sind es zwei Zeilen.
*/
export default function ForecastTable({ vorschau, kontostandCents, stripeDown = false, abosFehler = false }) {
const v = vorschau && typeof vorschau === 'object' ? vorschau : {}
const { rows, hasBalance } = forecastRows(v.posten, kontostandCents)
const notes = forecastNotes({ kontostandCents, stripeDown, abosFehler, hasRows: rows.length > 0 })
const assumption = typeof v.annahme === 'string' ? v.annahme.trim() : ''
return (
<Card title="Vorschau 30 Tage" className="fin-forecast-card" bodyStyle={{ padding: 0 }}>
{rows.length === 0 ? (
<EmptyState title="Keine Zahlungen in den nächsten 30 Tagen bekannt." />
) : (
<div className="fin-table-wrap">
<table className={`ui-table fin-forecast${hasBalance ? '' : ' fin-forecast-nobal'}`} role="table">
<caption className="fin-sr">Erwartete Zahlungen der nächsten 30 Tage</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col">Posten</th>
<th role="columnheader" scope="col">Sicherheit</th>
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
{hasBalance && (
<>
<th role="columnheader" scope="col" className="fin-amount">Saldo sicher</th>
<th role="columnheader" scope="col" className="fin-amount">Saldo erwartet</th>
</>
)}
</tr>
</thead>
<tbody role="rowgroup">
{rows.map((row) => (
<tr key={row.key} role="row">
<td role="cell" className="fin-forecast-date">{row.dateText}</td>
<td role="cell" className="fin-forecast-label">{row.label}</td>
<td role="cell" className="fin-forecast-kind">
<Badge tone={row.sicherheit === 'sicher' ? 'ok' : 'info'}>{row.sicherheit}</Badge>
</td>
<td role="cell" className="fin-amount fin-forecast-amount">{row.amountText}</td>
{hasBalance && (
<>
<td
role="cell"
data-label="Saldo sicher"
className={`fin-amount fin-forecast-sicher${row.saldoSicherNegative ? ' fin-neg' : ''}`}
>
<span className="fin-saldo">{row.saldoSicherText}</span>
</td>
<td
role="cell"
data-label="Saldo erwartet"
className={`fin-amount fin-forecast-erwartet${row.saldoErwartetNegative ? ' fin-neg' : ''}`}
>
<span className="fin-saldo">{row.saldoErwartetText}</span>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{(notes.length > 0 || assumption) && (
<div className="fin-forecast-foot">
{notes.map((note) => (
<p key={note.key} className="fin-hint fin-hint-warn" role="status">
<FaTriangleExclamation aria-hidden="true" /> {note.text}
</p>
))}
{assumption && <p className="fin-hint">{assumption}</p>}
</div>
)}
</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>
)
}
@@ -0,0 +1,346 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { FaPlus, FaTrashCan, FaXmark } from 'react-icons/fa6'
import { AutocompleteInput, Button } from '../ui'
import AddressWarning from './AddressWarning'
import { useCustomers } from '../../hooks/useCustomers'
import { useDialogFocus } from '../../hooks/useDialogFocus'
import { useScrollIntoView } from '../../hooks/useScrollIntoView'
import { createInvoice } from '../../lib/financeApi'
import { canInvoice, euro } from '../../lib/financeFormat'
import {
ADDRESS_INCOMPLETE_TEXT,
INVOICE_LIMITS,
customerName,
customerPickerOptions,
customerSuggestions,
finalizedFilter,
invoiceFailureMessage,
invoiceSendConfirmText,
invoiceSendGate,
invoiceTotalCents,
isFinalizedNotSent,
lineTotalCents,
resolveCustomerText,
validateInvoiceForm,
} from '../../lib/financeView'
let itemCounter = 0
const newItem = () => ({ uid: ++itemCounter, description: '', quantity: '1', price: '' })
/**
* Dialog „Rechnung schreiben“. Kunde (nur feste Kunden mit E-Mail wählbar; mit `fixedCustomer` steht er fest),
* Positionen (Beschreibung, Menge, Einzelpreis in Euro), Zahlungsziel (Standard 14 Tage), Notiz, Summe und der
* Hinweis nach § 19 UStG. „Als Entwurf speichern“ und „Senden“ sperren sich gegenseitig, solange die Anfrage läuft;
* Fehler des Servers stehen im Dialog (und werden in den sichtbaren Bereich geholt). „Senden“ fragt vorher nach
* (die Rechnung wird festgeschrieben und per Mail an den Kunden geschickt). Fehlt dem Kunden die Anschrift (Straße,
* PLZ, Ort), steht eine Warnung mit Weg zur Übersicht da, und „Senden“ ist über 250 € gesperrt (§ 14 Abs. 4 Nr. 1
* UStG; Entwürfe bleiben möglich). `onShowOverview` (Reiter am Kunden) wechselt zur Übersicht, sonst führt ein Link
* zur Kundenseite.
*
* onCreated(rechnung, { senden }) nach Erfolg; wurde die Rechnung festgeschrieben, aber nicht versendet (502 mit
* invoiceId), kommt onCreated(null, { senden: true, notice, tone: 'warn', finalizedFilter }) und der Dialog schließt
* (die Rechnung steht dann „offen“ in der Liste und lässt sich dort erneut senden). Danach ruft der Dialog onClose().
*/
export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCustomer = null, onShowOverview = null }) {
if (!open) return null
// Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht
return fixedCustomer ? (
<InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={fixedCustomer} source={null} onShowOverview={onShowOverview} />
) : (
<PickerForm onClose={onClose} onCreated={onCreated} />
)
}
function PickerForm({ onClose, onCreated }) {
const { customers, loading, error } = useCustomers()
return <InvoiceForm onClose={onClose} onCreated={onCreated} fixedCustomer={null} source={{ customers, loading, error }} onShowOverview={null} />
}
function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview }) {
const [customerText, setCustomerText] = useState('')
const [items, setItems] = useState(() => [newItem()])
const [days, setDays] = useState(String(INVOICE_LIMITS.defaultDays))
const [memo, setMemo] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(null) // 'draft' | 'send'
const lockRef = useRef(false)
const aliveRef = useRef(true)
const dialogRef = useRef(null)
const errorRef = useRef(null)
const onOverlayKeyDown = useDialogFocus(dialogRef)
// Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen
useScrollIntoView(errorRef, error)
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
useEffect(() => {
const onKey = (e) => {
// Escape schließt auch die Kundenliste (Autovervollständigung): dann nicht den ganzen Dialog
if (e.key === 'Escape' && !e.defaultPrevented && !lockRef.current) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers])
const resolved = fixedCustomer
? { state: canInvoice(fixedCustomer).ok ? 'ok' : 'blocked', option: null }
: resolveCustomerText(options, customerText)
const customer = fixedCustomer || resolved.option?.customer || null
const customerBlocked = fixedCustomer ? canInvoice(fixedCustomer) : resolved.option ? { ok: resolved.option.ok, reason: resolved.option.reason } : { ok: true }
const total = invoiceTotalCents(items)
// Anschrift des Empfängers (§ 14 Abs. 4 Nr. 1 UStG): ohne sie ist „Senden“ nur bis 250 € möglich
const gate = invoiceSendGate(customerBlocked.ok ? customer : null, total)
const updateItem = (uid, field, value) => {
setItems((list) => list.map((it) => (it.uid === uid ? { ...it, [field]: value } : it)))
if (error) setError('')
}
async function submit(senden) {
if (lockRef.current) return
const check = validateInvoiceForm({ customer, items, days, memo })
if (!check.ok) {
setError(check.error)
return
}
if (senden) {
if (!invoiceSendGate(customer, check.totalCents).sendAllowed) {
setError(ADDRESS_INCOMPLETE_TEXT)
return
}
// Senden schreibt die Rechnung fest und schickt sie an den Kunden: erst nach Rückfrage
const sure = window.confirm(
invoiceSendConfirmText({ name: customerName(customer), email: customer.email, totalCents: check.totalCents })
)
if (!sure) return
}
lockRef.current = true
setBusy(senden ? 'send' : 'draft')
setError('')
try {
const data = await createInvoice({ ...check.payload, senden })
if (onCreated) onCreated(data?.rechnung || null, { senden })
onClose()
} catch (err) {
if (isFinalizedNotSent(err)) {
// Die Rechnung steht bei Stripe: keine zweite anlegen, die Liste zeigt sie
if (onCreated) onCreated(null, { senden: true, notice: invoiceFailureMessage(err), tone: 'warn', finalizedFilter: finalizedFilter(err) })
onClose()
return
}
if (aliveRef.current) setError(invoiceFailureMessage(err))
} finally {
lockRef.current = false
if (aliveRef.current) setBusy(null)
}
}
const disabled = busy !== null
return (
<div className="overlay fin-overlay" onKeyDown={onOverlayKeyDown}>
<button type="button" className="overlay-close fin-close" onClick={onClose} disabled={disabled} aria-label="Schließen">
<FaXmark aria-hidden="true" />
</button>
<div
className="overlay-content fin-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="fin-invoice-title"
tabIndex={-1}
ref={dialogRef}
>
<h2 id="fin-invoice-title" className="mb-2">Rechnung schreiben</h2>
{error && (
<div className="fin-dialog-error" role="alert" ref={errorRef}>
{error}
</div>
)}
{/* Enter sendet nie (das wäre eine Mail an den Kunden): nur die beiden Knöpfe unten lösen aus */}
<form onSubmit={(e) => e.preventDefault()}>
<div className="form-group">
<label className="form-label" htmlFor="fin-invoice-customer">Kunde</label>
{fixedCustomer ? (
<p className="fin-fixed-customer">
<strong>{customerName(fixedCustomer) || 'Kunde'}</strong>
{fixedCustomer.email ? <span className="fin-dialog-sub"> · {fixedCustomer.email}</span> : null}
</p>
) : source?.loading ? (
<p className="fin-dialog-sub" role="status">Kunden werden geladen …</p>
) : source?.error ? (
<p className="fin-dialog-warn" role="alert">Die Kunden konnten nicht geladen werden: {source.error}</p>
) : (
<AutocompleteInput
id="fin-invoice-customer"
value={customerText}
onChange={(v) => {
setCustomerText(v)
if (error) setError('')
}}
getSuggestions={(t) => customerSuggestions(options, t)}
placeholder="Kunde suchen …"
/>
)}
{!customerBlocked.ok && (
<p className="fin-dialog-warn" role="alert">
{customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
</p>
)}
{!fixedCustomer && resolved.state === 'unknown' && (
<p className="fin-dialog-sub">Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.</p>
)}
{customer && customerBlocked.ok && customer.email && !fixedCustomer && (
<p className="fin-dialog-sub">Die Rechnung geht an {customer.email}.</p>
)}
{gate.warn && (
<AddressWarning
customer={customer}
variant="dialog"
onShowOverview={
onShowOverview
? () => {
if (lockRef.current) return
onClose()
onShowOverview()
}
: undefined
}
/>
)}
</div>
<fieldset className="fin-items">
<legend className="form-label">Positionen</legend>
{items.map((item, index) => {
const line = lineTotalCents(item)
const n = index + 1
return (
<div key={item.uid} className="fin-item">
<div className="fin-item-field fin-item-desc">
<label className="fin-item-label" htmlFor={`fin-item-desc-${item.uid}`}>Beschreibung</label>
<input
id={`fin-item-desc-${item.uid}`}
className="form-control"
maxLength={INVOICE_LIMITS.maxDescription}
value={item.description}
onChange={(e) => updateItem(item.uid, 'description', e.target.value)}
aria-label={`Position ${n}: Beschreibung`}
placeholder="z. B. Webdesign Startseite"
/>
</div>
<div className="fin-item-field fin-item-qty">
<label className="fin-item-label" htmlFor={`fin-item-qty-${item.uid}`}>Menge</label>
<input
id={`fin-item-qty-${item.uid}`}
className="form-control fin-amount"
inputMode="numeric"
value={item.quantity}
onChange={(e) => updateItem(item.uid, 'quantity', e.target.value)}
aria-label={`Position ${n}: Menge`}
/>
</div>
<div className="fin-item-field fin-item-price">
<label className="fin-item-label" htmlFor={`fin-item-price-${item.uid}`}>Einzelpreis in €</label>
<input
id={`fin-item-price-${item.uid}`}
className="form-control fin-amount"
inputMode="decimal"
value={item.price}
onChange={(e) => updateItem(item.uid, 'price', e.target.value)}
aria-label={`Position ${n}: Einzelpreis in Euro`}
placeholder="0,00"
/>
</div>
<div className="fin-item-field fin-item-sum">
<span className="fin-item-label">Summe</span>
<span className="fin-amount fin-item-total">{line === null ? '–' : euro(line)}</span>
</div>
<div className="fin-item-field fin-item-remove">
<button
type="button"
className="fin-icon-btn"
onClick={() => setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
disabled={items.length <= 1}
aria-label={`Position ${n} entfernen`}
title="Position entfernen"
>
<FaTrashCan aria-hidden="true" />
</button>
</div>
</div>
)
})}
<div className="fin-items-foot">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
disabled={items.length >= INVOICE_LIMITS.maxItems}
>
<FaPlus aria-hidden="true" /> Position
</Button>
<div className="fin-total" aria-live="polite">
<span>Summe</span>
<span className="fin-amount fin-total-value">{euro(total)}</span>
</div>
</div>
<p className="fin-dialog-sub">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
</fieldset>
<div className="fin-dialog-row">
<div className="form-group fin-days">
<label className="form-label" htmlFor="fin-invoice-days">Zahlungsziel in Tagen</label>
<input
id="fin-invoice-days"
className="form-control fin-amount"
inputMode="numeric"
value={days}
onChange={(e) => {
setDays(e.target.value)
if (error) setError('')
}}
/>
</div>
<div className="form-group fin-memo">
<label className="form-label" htmlFor="fin-invoice-memo">Notiz (erscheint auf der Rechnung)</label>
<textarea
id="fin-invoice-memo"
className="form-control"
rows={2}
maxLength={INVOICE_LIMITS.maxMemo}
value={memo}
onChange={(e) => setMemo(e.target.value)}
/>
</div>
</div>
<div className="fin-dialog-actions">
<Button type="button" variant="ghost" onClick={onClose} disabled={disabled}>Abbrechen</Button>
<Button type="button" onClick={() => submit(false)} disabled={disabled}>
{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
</Button>
<Button
type="button"
variant="primary"
onClick={() => submit(true)}
disabled={disabled || !gate.sendAllowed}
title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
>
{busy === 'send' ? 'Sende …' : 'Senden'}
</Button>
</div>
</form>
</div>
</div>
)
}
+292
View File
@@ -0,0 +1,292 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FaPlus, FaSpinner } from 'react-icons/fa6'
import { Badge, Button, Card, EmptyState } from '../ui'
import InvoiceCreateModal from './InvoiceCreateModal'
import Pager from './Pager'
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
import { pageOf } from '../../lib/financeFormat'
import {
ADDRESS_INCOMPLETE_TEXT,
DEFAULT_INVOICE_FILTER,
INVOICES_ANCHOR,
INVOICE_FILTERS,
ageGroupCells,
finalizedFilter,
invoiceConfirmText,
invoiceFilterStatuses,
invoiceSendGate,
invoiceDoneMessage,
invoiceFailureMessage,
invoiceRows,
isFinalizedNotSent,
mergeInvoiceLists,
} from '../../lib/financeView'
const PAGE_SIZE = 50
const RUNNING_LABEL = { send: 'Sende …', resend: 'Sende …', void: 'Storniere …', delete: 'Lösche …' }
const CALLS = { send: sendInvoice, resend: resendInvoice, void: voidInvoice, delete: deleteInvoice }
/**
* Block 6: Rechnungen. Filter Offen / Überfällig / Bezahlt / Entwürfe / Alle (Standard Offen), Altersgruppen der
* offenen Rechnungen (nur mit `altersgruppen`, also nicht in der Kundenansicht), Tabelle mit 50 Zeilen je Seite
* und die Aktionen je Status (siehe invoiceActions). Löschen, Stornieren und Erinnerung fragen vorher nach; solange
* eine Anfrage läuft, sind alle Aktionen gesperrt. „Rechnung schreiben“ öffnet den Dialog.
*
* customerId filtert auf einen Kunden; `customer` (Appwrite-Dokument) legt zusätzlich den Kunden im Dialog fest
* (Kundenansicht). onChanged() meldet jede Änderung (Übersicht neu laden). Schmal (Karte bis 700 px) wird jede
* Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. „Senden“ eines Entwurfs
* fragt vorher nach. In der Kundenansicht ist „Senden“ gesperrt, solange dem Kunden die Anschrift fehlt und der
* Entwurf über 250 € liegt (§ 14 Abs. 4 Nr. 1 UStG); `onShowOverview` führt dann im Dialog zur Übersicht.
*
* „Offen“ zeigt offene und überfällige Rechnungen (zwei Abrufe, zusammengeführt, neueste zuerst). Ändert sich
* `refreshToken` (die Seite lädt nach Zuordnen, Bankabruf usw. alles neu), lädt auch die Liste neu.
*/
export default function InvoiceTable({ altersgruppen = null, customerId = null, customer = null, refreshToken = 0, onChanged, onShowOverview = null }) {
const filterCustomer = customerId || customer?.$id || null
const [status, setStatus] = useState(DEFAULT_INVOICE_FILTER)
const [reloadKey, setReloadKey] = useState(0)
const [result, setResult] = useState({ key: '', list: [] }) // Antwort samt Filter, zu dem sie gehört
const [error, setError] = useState(null) // { key, message }
const [page, setPage] = useState(1)
const [busy, setBusy] = useState(null) // `${id}:${aktion}` der laufenden Anfrage
const [message, setMessage] = useState(null) // { tone: 'ok' | 'warn', text }
const [createOpen, setCreateOpen] = useState(false)
const lockRef = useRef(false)
const aliveRef = useRef(true)
const seqRef = useRef(0)
const onChangedRef = useRef(onChanged)
onChangedRef.current = onChanged
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
const key = `${status}|${filterCustomer || ''}`
useEffect(() => {
// Nur die jüngste Anfrage zählt (Filterwechsel während des Ladens)
const mine = ++seqRef.current
Promise.all(invoiceFilterStatuses(status).map((s) => getInvoices({ status: s, kunde: filterCustomer })))
.then((results) => {
if (!aliveRef.current || mine !== seqRef.current) return
setResult({ key, list: mergeInvoiceLists(results.map((r) => r?.rechnungen)) })
setError(null)
})
.catch((err) => {
if (!aliveRef.current || mine !== seqRef.current) return
setError({ key, message: err?.message || 'Die Rechnungen konnten nicht geladen werden.' })
})
}, [status, filterCustomer, key, reloadKey, refreshToken])
// Nur mit bekanntem Kunden (Kundenansicht) lässt sich die Anschrift prüfen
const sendBlocked = (invoice) => Boolean(customer) && !invoiceSendGate(customer, invoice?.totalCents).sendAllowed
const reload = () => setReloadKey((k) => k + 1)
const changed = () => {
reload()
if (onChangedRef.current) onChangedRef.current()
}
const current = result.key === key
const invoices = current ? result.list : []
const rows = useMemo(() => invoiceRows(invoices), [invoices])
const byId = useMemo(() => Object.fromEntries(invoices.map((inv) => [inv.id, inv])), [invoices])
const paged = pageOf(rows, page, PAGE_SIZE)
const loadError = error && error.key === key ? error.message : ''
const loading = !current && !loadError
const ages = ageGroupCells(filterCustomer ? null : altersgruppen)
function pickFilter(next) {
if (next === status) return
setStatus(next)
setPage(1)
setMessage(null)
}
async function run(row, action) {
if (lockRef.current) return
const invoice = byId[row.id]
if (action.key === 'send' && sendBlocked(invoice)) return
const confirmText = invoiceConfirmText(action.key, invoice)
if (confirmText && !window.confirm(confirmText)) return
lockRef.current = true
setBusy(`${row.id}:${action.key}`)
setMessage(null)
try {
const data = await CALLS[action.key](row.id)
if (aliveRef.current) setMessage({ tone: 'ok', text: invoiceDoneMessage(action.key, data?.rechnung) })
if (aliveRef.current) changed()
} catch (err) {
if (!aliveRef.current) return
setMessage({ tone: 'warn', text: invoiceFailureMessage(err) })
if (isFinalizedNotSent(err)) {
// Festgeschrieben: die Rechnung ist jetzt offen und steht im Filter „Offen“
setStatus(finalizedFilter(err))
setPage(1)
}
// Der Stand bei Stripe kann ein anderer sein (schon gesendet, schon bezahlt, gelöscht): neu laden
changed()
} finally {
lockRef.current = false
if (aliveRef.current) setBusy(null)
}
}
function handleCreated(rechnung, info = {}) {
if (info.notice) {
setMessage({ tone: info.tone || 'ok', text: info.notice })
if (info.finalizedFilter) setStatus(info.finalizedFilter)
} else {
setMessage({ tone: 'ok', text: invoiceDoneMessage(info.senden ? 'create-send' : 'create-draft', rechnung) })
// Der neue Eintrag soll sichtbar sein: Entwurf unter „Entwürfe“, gesendete Rechnung unter „Offen“
setStatus(rechnung?.status === 'entwurf' ? 'entwurf' : 'offen')
}
setPage(1)
changed()
}
return (
<section id={filterCustomer ? undefined : INVOICES_ANCHOR} className="fin-block">
<Card
title="Rechnungen"
className="fin-invoices-card"
bodyStyle={{ padding: 0 }}
actions={
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
<FaPlus aria-hidden="true" /> Rechnung schreiben
</Button>
}
>
{ages.length > 0 && (
<ul className="fin-ages" aria-label="Offene Rechnungen nach Alter">
{ages.map((cell) => (
<li key={cell.key} className="fin-age">
<span className="fin-age-label">{cell.label}</span>
<span className="fin-age-count">{cell.count} {cell.count === 1 ? 'Rechnung' : 'Rechnungen'}</span>
<span className="fin-amount fin-age-sum">{cell.sumText}</span>
</li>
))}
</ul>
)}
<div className="fin-toolbar">
<div className="seg" role="group" aria-label="Rechnungen filtern">
{INVOICE_FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={status === f.key ? 'active' : ''}
aria-pressed={status === f.key}
onClick={() => pickFilter(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
{message && (
<p className={`fin-hint fin-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
{message.text}
</p>
)}
{loading ? (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Rechnungen werden geladen …</p>
</div>
) : loadError ? (
<div className="fin-load-error">
<p className="fin-hint fin-hint-warn" role="alert">{loadError}</p>
<Button size="sm" onClick={reload}>Erneut versuchen</Button>
</div>
) : rows.length === 0 ? (
<EmptyState title="Keine Rechnungen in dieser Ansicht." />
) : (
<div className="fin-table-wrap">
<table className="ui-table fin-invoices" role="table">
<caption className="fin-sr">Rechnungen, neueste zuerst</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Nr.</th>
<th role="columnheader" scope="col">Kunde</th>
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col">Fällig</th>
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
<th role="columnheader" scope="col" className="fin-amount">Offen</th>
<th role="columnheader" scope="col">Status</th>
<th role="columnheader" scope="col">Aktionen</th>
</tr>
</thead>
<tbody role="rowgroup">
{paged.items.map((row) => (
<tr key={row.key} role="row">
<td role="cell" className="fin-inv-number">
{row.numberText}
{row.isAbo && <span className="fin-inv-abo muted"> · Abo</span>}
</td>
<td role="cell" className="fin-inv-customer">{row.customerText}</td>
<td role="cell" className="fin-inv-created" data-label="Datum">{row.createdText}</td>
<td role="cell" className="fin-inv-due" data-label="Fällig">{row.dueText}</td>
<td role="cell" className="fin-amount fin-inv-total" data-label="Betrag">{row.totalText}</td>
<td role="cell" className="fin-amount fin-inv-remaining" data-label="Offen">{row.remainingText}</td>
<td role="cell" className="fin-inv-status">
<div className="fin-inv-statusbox">
<Badge tone={row.statusTone}>{row.statusLabel}</Badge>
{row.statusNote && <span className="fin-inv-note muted">{row.statusNote}</span>}
</div>
</td>
<td role="cell" className="fin-inv-actions">
<div className="fin-actions">
{row.actions.map((action) =>
action.url ? (
<Button
key={action.key}
as="a"
size="sm"
variant="ghost"
href={action.url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${action.label} öffnen: ${row.numberText}, ${row.customerText}`}
>
{action.label}
</Button>
) : (
<Button
key={action.key}
size="sm"
variant={action.key === 'delete' || action.key === 'void' ? 'danger' : 'ghost'}
onClick={() => run(row, action)}
disabled={busy !== null || (action.key === 'send' && sendBlocked(byId[row.id]))}
title={action.key === 'send' && sendBlocked(byId[row.id]) ? ADDRESS_INCOMPLETE_TEXT : undefined}
aria-label={`${action.label}: ${row.numberText}, ${row.customerText}`}
>
{busy === `${row.id}:${action.key}` ? RUNNING_LABEL[action.key] : action.label}
</Button>
)
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Rechnungen" onPage={setPage} />
</Card>
<InvoiceCreateModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={handleCreated}
fixedCustomer={customer}
onShowOverview={onShowOverview}
/>
</section>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { useId, useState } from 'react'
import { FaChevronDown, FaChevronUp } from 'react-icons/fa6'
import { Kpi } from '../ui'
import { euroWhole } from '../../lib/financeFormat'
import { breakdownRows, lowPointLines } from '../../lib/financeView'
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
/** Block 1: Kontostand, „Verfügbar nach Rücklagen“ (mit Aufschlüsselung) und „Tiefster Stand in 30 Tagen“. */
export default function LiquidityHead({ kopf }) {
const k = kopf && typeof kopf === 'object' ? kopf : {}
const [open, setOpen] = useState(false)
const listId = useId()
const hasBalance = isNum(k.kontostandCents)
const shortfall = isNum(k.verfuegbarCents) && k.verfuegbarCents < 0
const lows = lowPointLines(k)
const anyLowNegative = lows.some((l) => l && l.negative)
return (
<div className="kpi-grid fin-head">
<Kpi
label="Kontostand"
value={euroWhole(k.kontostandCents)}
sub={hasBalance ? 'gebucht' : 'Noch kein Kontostand abgerufen'}
/>
<Kpi
label="Verfügbar nach Rücklagen"
value={euroWhole(k.verfuegbarCents)}
accent={shortfall ? 'var(--danger)' : 'var(--accent)'}
sub={
<>
{shortfall && <div className="fin-flag">Rücklagen nicht gedeckt</div>}
{isNum(k.verfuegbarCents) && (
<>
<button
type="button"
className="fin-link"
aria-expanded={open}
aria-controls={listId}
onClick={() => setOpen((v) => !v)}
>
Aufschlüsselung {open ? <FaChevronUp aria-hidden="true" /> : <FaChevronDown aria-hidden="true" />}
</button>
{open && (
<ul id={listId} className="fin-breakdown">
{breakdownRows(k.aufschluesselung, k.verfuegbarCents).map((row) => (
<li key={row.key} className={row.total ? 'fin-breakdown-total' : undefined}>
<span>{row.label}</span>
<span className="fin-amount">{row.text}</span>
</li>
))}
</ul>
)}
</>
)}
{!isNum(k.verfuegbarCents) && <div>Ohne Kontostand nicht berechenbar</div>}
</>
}
/>
<Kpi
label="Tiefster Stand in 30 Tagen"
accent={anyLowNegative ? 'var(--danger)' : 'var(--info)'}
value={
lows.some(Boolean) ? (
<div className="fin-lows">
{lows.map(
(low) =>
low && (
<div key={low.key} className="fin-low">
<span className="fin-low-label">{low.label}:</span>
<span className="fin-amount">{low.amountText}</span>
<span className="fin-low-when">
am {low.dateText}
{low.negative && <span className="fin-flag"> · im Minus</span>}
</span>
</div>
)
)}
</div>
) : (
'–'
)
}
sub={lows.some(Boolean) ? '„erwartet“ zählt offene Rechnungen und Abos dazu' : 'Ohne Kontostand nicht berechenbar'}
/>
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { Button } from '../ui'
/**
* Blätterleiste „Zurück · Seite 2 von 5 · 230 Buchungen · Weiter“; bei nur einer Seite erscheint sie nicht.
* `noun` ist die Mehrzahl, z. B. „Buchungen“.
*/
export default function Pager({ page, pages, total, noun = 'Einträge', onPage }) {
if (pages <= 1) return null
return (
<nav className="fin-pager" aria-label={`Seiten der ${noun}`}>
<Button size="sm" variant="ghost" onClick={() => onPage(page - 1)} disabled={page <= 1}>
Zurück
</Button>
<span className="fin-pager-info" aria-live="polite">
Seite {page} von {pages}
{typeof total === 'number' ? ` · ${total} ${noun}` : ''}
</span>
<Button size="sm" variant="ghost" onClick={() => onPage(page + 1)} disabled={page >= pages}>
Weiter
</Button>
</nav>
)
}
+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>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useRef, useState } from 'react'
import { FaRotate, FaTriangleExclamation } from 'react-icons/fa6'
import { Button } from '../ui'
import { statusItems } from '../../lib/financeView'
/**
* Block 0: Stand von Bank und Stripe in einer Zeile und der Knopf „Aktualisieren“.
* onRefresh() ruft den Bankabruf und lädt die Übersicht neu; wirft er (z. B. 429 „Bitte in N Minuten erneut
* versuchen.“), erscheint die Meldung des Servers als Hinweis unter der Zeile.
*/
export default function StatusLine({ stand, onRefresh, refreshing = false }) {
const [hint, setHint] = useState(null) // { text, limited }
const aliveRef = useRef(true)
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
const items = statusItems(stand)
async function handleRefresh() {
setHint(null)
try {
await onRefresh()
} catch (err) {
if (aliveRef.current) {
setHint({ text: err?.message || 'Der Abruf ist fehlgeschlagen.', limited: err?.status === 429 })
}
}
}
return (
<div className="fin-status-wrap">
<div className="fin-status">
<div className="fin-status-items">
{items.map((item) => (
<span key={item.key} className={`fin-status-item${item.ok ? '' : ' fin-status-warn'}`}>
{!item.ok && <FaTriangleExclamation aria-hidden="true" />}
{item.text}
</span>
))}
</div>
<Button size="sm" variant="ghost" onClick={handleRefresh} disabled={refreshing}>
<FaRotate className={refreshing ? 'fin-spin' : undefined} aria-hidden="true" />{' '}
{refreshing ? 'Aktualisiere …' : 'Aktualisieren'}
</Button>
</div>
{hint && (
<p className={`fin-hint${hint.limited ? '' : ' fin-hint-warn'}`} role={hint.limited ? 'status' : 'alert'}>
{hint.text}
</p>
)}
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { Card } from '../ui'
import { taxView } from '../../lib/financeView'
/**
* Block 4: „Steuer“. Rücklage (Betrag und woraus sie sich ergibt), Kleinunternehmer-Zähler gegen die Grenzen,
* Vorjahr (nur wenn eingetragen), Gewerbesteuer-Hinweis (nur wenn nötig) und die nächste ESt-Vorauszahlung
* (nur wenn eingestellt). Ist der Zähler unbekannt (Stripe nicht erreichbar), steht das als Text da, nie eine 0.
* Am Ende steht immer der Hinweis „Orientierungswert, ersetzt keine Steuerberatung.“
*/
export default function TaxCard({ steuer }) {
const v = taxView(steuer)
return (
<Card title="Steuer" className="fin-tax-card">
<div className="fin-facts">
<div className="fin-fact">
<div className="fin-fact-row">
<span className="fin-fact-label">Steuer-Rücklage</span>
<span className="fin-amount fin-fact-big">{v.reserve.amountText}</span>
</div>
{v.reserve.basisText && <div className="fin-fact-sub">{v.reserve.basisText}</div>}
</div>
<div className="fin-fact">
<div className="fin-fact-label">Kleinunternehmer (§ 19 UStG)</div>
<div className={`fin-fact-value${v.small.known ? '' : ' fin-fact-unknown'}`}>{v.small.counterText}</div>
{v.small.shareText && <div className="fin-fact-sub">{v.small.shareText}</div>}
<div className="fin-fact-sub">{v.small.limitText}</div>
<div className="fin-fact-sub">{v.small.hardText}</div>
{v.small.previousText && <div className="fin-fact-sub">{v.small.previousText}</div>}
</div>
{v.gewerbeText && (
<div className="fin-fact">
<div className="fin-fact-label">Gewerbesteuer</div>
<div className="fin-fact-sub fin-fact-sub-strong">{v.gewerbeText}</div>
</div>
)}
{v.prepayment && (
<div className="fin-fact">
<div className="fin-fact-row">
<span className="fin-fact-label">Nächste ESt-Vorauszahlung</span>
<span className="fin-amount fin-fact-strong">{v.prepayment.amountText}</span>
</div>
<div className="fin-fact-sub">am {v.prepayment.dateText}</div>
</div>
)}
<p className="fin-hint fin-tax-note">{v.noteText}</p>
</div>
</Card>
)
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useRef, useState } from 'react'
import { Badge, Button, Card, EmptyState } from '../ui'
import { TODO_LIMIT, todoButtonLabel, todoKey, urgencyOf, visibleTodos } from '../../lib/financeView'
/**
* Block 2: „Zu erledigen“, höchstens 7 Zeilen, danach „Alle N anzeigen“. Je Zeile Dringlichkeit (Text), Titel,
* Text und genau ein Knopf. onAction(aktion, aufgabe) darf ein Promise liefern; wirft es, steht die Meldung
* unter der Zeile. Solange eine Aktion läuft, sind alle Knöpfe gesperrt (kein Doppelklick).
*/
export default function TodoList({ aufgaben, onAction }) {
const [expanded, setExpanded] = useState(false)
const [running, setRunning] = useState(null) // Schlüssel der laufenden Aufgabe
const [failure, setFailure] = useState(null) // { key, message }
const aliveRef = useRef(true)
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
const { shown, total } = visibleTodos(aufgaben, expanded)
async function run(todo, key) {
if (running) return
setFailure(null)
setRunning(key)
try {
await onAction(todo.aktion, todo)
} catch (err) {
if (aliveRef.current) setFailure({ key, message: err?.message || 'Die Aktion ist fehlgeschlagen.' })
} finally {
if (aliveRef.current) setRunning(null)
}
}
return (
<Card
title="Zu erledigen"
actions={total > 0 ? <Badge tone="muted">{total} offen</Badge> : null}
className="fin-todo-card"
bodyStyle={{ padding: 0 }}
>
{total === 0 ? (
<EmptyState title="Nichts offen" />
) : (
<>
<ul className="fin-todos">
{shown.map((todo, index) => {
const key = todoKey(todo, index)
const urgency = urgencyOf(todo)
const label = todoButtonLabel(todo)
return (
<li key={key} className={`fin-todo fin-todo-${todo.dringlichkeit || 'info'}`}>
<Badge tone={urgency.tone}>{urgency.label}</Badge>
<div className="fin-todo-main">
<div className="fin-todo-title">{todo.titel}</div>
{todo.text && <div className="fin-todo-text muted">{todo.text}</div>}
{failure?.key === key && (
<div className="fin-hint fin-hint-warn" role="alert">
{failure.message}
</div>
)}
</div>
{label && (
<Button size="sm" onClick={() => run(todo, key)} disabled={running !== null}>
{running === key ? 'Moment …' : label}
</Button>
)}
</li>
)
})}
</ul>
{total > TODO_LIMIT && (
<div className="fin-todos-more">
<Button size="sm" variant="ghost" onClick={() => setExpanded((v) => !v)}>
{expanded ? 'Weniger anzeigen' : `Alle ${total} anzeigen`}
</Button>
</div>
)}
</>
)}
</Card>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useMemo, useState } from 'react'
import { FaSpinner, FaTriangleExclamation } from 'react-icons/fa6'
import { Badge, Button, Card, EmptyState } from '../ui'
import Pager from './Pager'
import { pageOf } from '../../lib/financeFormat'
import { transactionRows } from '../../lib/financeView'
const PAGE_SIZE = 50
export const TRANSACTIONS_ANCHOR = 'finanzen-buchungen'
/**
* Block 7: Buchungen. Alle gespeicherten Buchungen, neueste zuerst, 50 je Seite: Datum, Gegenpartei mit
* Verwendungszweck (zweizeilig), Betrag mit Vorzeichen, Zuordnung (bei offenen Buchungen der stärkste Vorschlag als
* Text) und der Knopf „Zuordnen“ bzw. „Ändern“. Vorgemerkte Buchungen (PDNG) tragen „vorgemerkt“. Die Daten kommen
* vom Aufrufer (useFinanceTransactions), damit Seite und Zuordnen-Dialog dieselbe Liste benutzen.
* onAssign(buchung) öffnet den Dialog. Schmal (Karte bis 640 px) wird jede Zeile ein Raster: Datum und Betrag /
* Gegenpartei und Verwendungszweck / Zuordnung und Knopf.
*/
export default function TransactionTable({ transactions, stripeFehler = '', loaded = false, loading = false, error = '', onReload, onAssign }) {
const [page, setPage] = useState(1)
const rows = useMemo(() => transactionRows(transactions), [transactions])
const paged = pageOf(rows, page, PAGE_SIZE)
let body
if (!loaded && loading) {
body = (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Buchungen werden geladen …</p>
</div>
)
} else if (!loaded) {
body = (
<div className="fin-load-error">
<p className="fin-hint fin-hint-warn" role="alert">{error || 'Die Buchungen konnten nicht geladen werden.'}</p>
<Button size="sm" onClick={onReload}>Erneut versuchen</Button>
</div>
)
} else if (rows.length === 0) {
body = <EmptyState title="Noch keine Buchungen gespeichert." hint="Sobald die Bank verbunden und abgerufen ist, erscheinen sie hier." />
} else {
body = (
<div className="fin-table-wrap">
<table className="ui-table fin-tx" role="table">
<caption className="fin-sr">Buchungen, neueste zuerst</caption>
<thead role="rowgroup">
<tr role="row">
<th role="columnheader" scope="col">Datum</th>
<th role="columnheader" scope="col">Gegenpartei und Verwendungszweck</th>
<th role="columnheader" scope="col" className="fin-amount">Betrag</th>
<th role="columnheader" scope="col">Zuordnung</th>
<th role="columnheader" scope="col"><span className="fin-sr">Aktion</span></th>
</tr>
</thead>
<tbody role="rowgroup">
{paged.items.map((row) => (
<tr key={row.key} role="row">
<td role="cell" className="fin-tx-date">{row.dateText}</td>
<td role="cell" className="fin-tx-what">
<div className="fin-tx-counterparty">{row.counterparty}</div>
{row.remittance && (
<div className="fin-tx-remittance muted" title={row.remittance}>{row.remittance}</div>
)}
</td>
<td role="cell" className={`fin-amount fin-tx-amount${row.incoming ? '' : ' fin-tx-out'}`}>{row.amountText}</td>
<td role="cell" className="fin-tx-assign">
<div className="fin-tx-badges">
<Badge tone={row.assigned ? 'ok' : 'warn'}>{row.assignmentText}</Badge>
{row.pending && <Badge tone="muted">vorgemerkt</Badge>}
</div>
{row.suggestionText && <div className="fin-tx-suggestion muted">{row.suggestionText}</div>}
{row.note && <div className="fin-tx-note muted">Notiz: {row.note}</div>}
</td>
<td role="cell" className="fin-tx-action">
{row.action && (
<Button
size="sm"
variant={row.action === 'assign' ? 'default' : 'ghost'}
onClick={() => onAssign(row.tx)}
aria-label={`${row.actionLabel}: ${row.amountText} ${row.counterparty}`}
>
{row.actionLabel}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
return (
<section id={TRANSACTIONS_ANCHOR} className="fin-block">
<Card title="Buchungen" className="fin-tx-card" bodyStyle={{ padding: 0 }}>
{loaded && error && (
<p className="fin-hint fin-hint-warn fin-notice" role="alert">
Die Buchungen konnten nicht neu geladen werden: {error}{' '}
<Button size="sm" variant="ghost" onClick={onReload} disabled={loading}>Erneut versuchen</Button>
</p>
)}
{stripeFehler && (
<p className="fin-hint fin-hint-warn fin-notice" role="status">
<FaTriangleExclamation aria-hidden="true" /> {stripeFehler}
</p>
)}
{body}
<Pager page={paged.page} pages={paged.pages} total={rows.length} noun="Buchungen" onPage={setPage} />
</Card>
</section>
)
}
+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,
+39
View File
@@ -0,0 +1,39 @@
import { useCallback, useEffect } from 'react'
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
/**
* Fokus für ein Dialog-Overlay: beim Öffnen springt der Fokus in den Dialog (`dialogRef`), beim Schließen zurück zum
* Element, das ihn vorher hatte (falls es noch da ist). Der zurückgegebene onKeyDown gehört an das Overlay: Tab und
* Umschalt+Tab bleiben im Dialog (sonst landet man hinter dem Overlay in der Seite).
*/
export function useDialogFocus(dialogRef) {
useEffect(() => {
const opener = document.activeElement
dialogRef.current?.focus()
return () => {
if (opener && opener.isConnected && typeof opener.focus === 'function') opener.focus()
}
}, [dialogRef])
return useCallback((e) => {
if (e.key !== 'Tab') return
const overlay = e.currentTarget
const items = [...overlay.querySelectorAll(FOCUSABLE)].filter((el) => el.getClientRects().length > 0)
if (items.length === 0) {
e.preventDefault()
return
}
const first = items[0]
const last = items[items.length - 1]
const active = document.activeElement
const onDialogBox = active === dialogRef.current // der Dialog selbst hat den Fokus (tabIndex -1)
if (e.shiftKey && (active === first || onDialogBox || !overlay.contains(active))) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && (active === last || !overlay.contains(active))) {
e.preventDefault()
first.focus()
}
}, [dialogRef])
}
+43
View File
@@ -0,0 +1,43 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { getOverview } from '../lib/financeApi'
const FALLBACK_ERROR = 'Die Übersicht konnte nicht geladen werden.'
/**
* Lädt die Finanzübersicht (GET /uebersicht).
* data bleibt beim erneuten Laden stehen, damit die Seite nicht aufblitzt; error ist die Meldung des letzten
* fehlgeschlagenen Versuchs (leer, solange alles in Ordnung ist). reload() liefert ein Promise, das nie wirft.
*/
export function useFinanceOverview() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const aliveRef = useRef(true)
const seqRef = useRef(0)
const reload = useCallback(async () => {
// Nur die jüngste Anfrage zählt: eine langsame ältere darf neuere Daten nicht überschreiben
const mine = ++seqRef.current
const current = () => aliveRef.current && mine === seqRef.current
setLoading(true)
setError('')
try {
const next = await getOverview()
if (current()) setData(next && typeof next === 'object' ? next : {})
} catch (err) {
if (current()) setError(err?.message || FALLBACK_ERROR)
} finally {
if (current()) setLoading(false)
}
}, [])
useEffect(() => {
aliveRef.current = true
reload()
return () => {
aliveRef.current = false
}
}, [reload])
return { data, loading, error, reload }
}
+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 }
}
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { getTransactions } from '../lib/financeApi'
const FALLBACK_ERROR = 'Die Buchungen konnten nicht geladen werden.'
/**
* Lädt die gespeicherten Buchungen samt Zuordnungsvorschlägen (GET /buchungen, höchstens 1000, neueste zuerst).
* `transactions` bleibt beim erneuten Laden stehen; `loaded` ist true, sobald einmal geladen wurde (auch leer);
* `stripeFehler` ist der Hinweis des Servers, wenn Stripe die Vorschläge nicht liefern konnte. reload() wirft nie.
*/
export function useFinanceTransactions() {
const [state, setState] = useState({ transactions: [], stripeFehler: '', loaded: false })
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const aliveRef = useRef(true)
const seqRef = useRef(0)
const reload = useCallback(async () => {
// Nur die jüngste Anfrage zählt: eine langsame ältere darf neuere Daten nicht überschreiben
const mine = ++seqRef.current
const current = () => aliveRef.current && mine === seqRef.current
setLoading(true)
setError('')
try {
const data = await getTransactions()
if (current()) {
setState({
transactions: Array.isArray(data?.buchungen) ? data.buchungen : [],
stripeFehler: typeof data?.stripeFehler === 'string' ? data.stripeFehler : '',
loaded: true,
})
}
} catch (err) {
if (current()) setError(err?.message || FALLBACK_ERROR)
} finally {
if (current()) setLoading(false)
}
}, [])
useEffect(() => {
aliveRef.current = true
reload()
return () => {
aliveRef.current = false
}
}, [reload])
return { ...state, loading, error, reload }
}
-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 }
}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react'
import { getInvoices } from '../lib/financeApi'
import { invoiceFilterStatuses, mergeInvoiceLists } from '../lib/financeView'
const FALLBACK_ERROR = 'Die offenen Rechnungen konnten nicht geladen werden.'
/**
* Offene und überfällige Rechnungen für den Zuordnen-Dialog. Lädt jedes Mal frisch, wenn `active` true wird
* (damit der Dialog nie auf einem alten Stand arbeitet), und leert die Liste, wenn es wieder false ist.
* `loaded` ist true, sobald die Liste dieser Öffnung frisch und ohne Fehler da ist (erst dann darf der Dialog eine
* Rechnung als „nicht mehr offen“ erkennen). Scheitert Stripe, bleibt `invoices` leer und `error` nennt den Grund
* (Kategorien lassen sich trotzdem wählen).
*/
export function useOpenInvoices(active) {
const [state, setState] = useState({ invoices: [], loading: false, error: '', loaded: false })
useEffect(() => {
if (!active) {
setState({ invoices: [], loading: false, error: '', loaded: false })
return undefined
}
let cancelled = false
setState({ invoices: [], loading: true, error: '', loaded: false })
Promise.all(invoiceFilterStatuses('offen').map((status) => getInvoices({ status })))
.then((results) => {
if (cancelled) return
setState({ invoices: mergeInvoiceLists(results.map((r) => r?.rechnungen)), loading: false, error: '', loaded: true })
})
.catch((err) => {
if (!cancelled) setState({ invoices: [], loading: false, error: err?.message || FALLBACK_ERROR, loaded: false })
})
return () => {
cancelled = true
}
}, [active])
return state
}
+14
View File
@@ -0,0 +1,14 @@
import { useEffect } from 'react'
/**
* Holt das Element in `ref` in den sichtbaren Bereich, sobald sich `trigger` ändert und nicht leer ist (zum Beispiel eine
* Fehlermeldung ganz oben in einem langen Dialog, die sonst außerhalb des Fensters stehen würde). `block: 'nearest'`
* bewegt nur so weit wie nötig, ein schon sichtbares Element bleibt ruhig.
*/
export function useScrollIntoView(ref, trigger) {
useEffect(() => {
if (!trigger) return
const el = ref.current
if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' })
}, [ref, trigger])
}
+38
View File
@@ -0,0 +1,38 @@
// Rueckkehr von der Bankfreigabe (Finom): Adresse auswerten und Ergebnis fuer die Seite aufbereiten.
const FALLBACK_ERROR = 'Die Bankfreigabe konnte nicht abgeschlossen werden.'
/**
* '?state=a&code=b' -> { code, state, error }; null, wenn weder code noch error vorhanden sind.
* error_description wird an error angehaengt ('access_denied: abgebrochen').
*/
export function parseCallback(search) {
if (typeof search !== 'string') return null
const params = new URLSearchParams(search)
const code = params.get('code') || ''
const state = params.get('state') || ''
let error = params.get('error') || ''
const description = params.get('error_description') || ''
if (!code && !error) return null
if (error && description) error = `${error}: ${description}`
return { code, state, error }
}
/** Antwort von completeBankAuth -> { ok: true, validUntil } (alles andere gilt als Fehler). */
export function successResult(response) {
if (response && response.ok === true) {
return { ok: true, validUntil: typeof response.validUntil === 'string' ? response.validUntil : '' }
}
return { ok: false, message: FALLBACK_ERROR }
}
/** Fehler von completeBankAuth -> { ok: false, message } mit der deutschen Meldung des Servers. */
export function failureResult(err) {
const message = typeof err?.message === 'string' && err.message.trim() ? err.message : FALLBACK_ERROR
return { ok: false, message }
}
/** Prueft ein Ergebnis, das aus dem Verlaufszustand des Browsers gelesen wurde. */
export function isResult(value) {
return Boolean(value) && typeof value === 'object' && typeof value.ok === 'boolean'
}
+19
View File
@@ -0,0 +1,19 @@
// Reiter der Kundenansicht (rein, ohne React; unter Node testbar). „Rechnungen“ ruft die Finanzen-API des Portals auf,
// die nur Admins freigibt: für alle anderen gibt es den Reiter nicht.
const BASE_TABS = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'tickets', label: 'Tickets' },
{ id: 'invoices', label: 'Rechnungen', adminOnly: true },
{ id: 'projects', label: 'Projekte' },
]
/** Reiter für Tabs: [{ id, label }]; „Rechnungen“ nur für Admins. */
export function customerTabs(isAdmin) {
return BASE_TABS.filter((t) => !t.adminOnly || isAdmin === true).map(({ id, label }) => ({ id, label }))
}
/** Der Reiter, der tatsächlich gezeigt wird: ein nicht (mehr) erlaubter oder unbekannter Reiter fällt auf „Übersicht“ zurück. */
export function resolveCustomerTab(tab, isAdmin) {
return customerTabs(isAdmin).some((t) => t.id === tab) ? tab : 'overview'
}
+59
View File
@@ -0,0 +1,59 @@
import { account } from './appwrite'
import { createFinanceRequest } from './financeRequest'
/**
* Client fuer die Finanzen-API des Kundenportals (/api/admin/finanzen).
* Authentifiziert per Appwrite-JWT (Bearer); alle Betraege in Cent. Anfrage-Kern und Fehlertexte: financeRequest.js.
*/
const PROJECT_ADMIN_URL =
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
const BASE = `${PROJECT_ADMIN_URL}/api/admin/finanzen`
const request = createFinanceRequest({ baseUrl: BASE, createJWT: () => account.createJWT() })
const enc = encodeURIComponent
// --- Uebersicht ---
export const getOverview = () => request('/uebersicht')
// --- Rechnungen ---
export function getInvoices({ status, kunde } = {}) {
const params = new URLSearchParams()
if (status) params.set('status', status)
if (kunde) params.set('kunde', kunde)
const qs = params.toString()
return request(`/rechnungen${qs ? `?${qs}` : ''}`)
}
// Jeder Aufruf bekommt einen neuen Schluessel, damit ein Doppelklick keine zweite Rechnung erzeugt.
export const createInvoice = (body) =>
request('/rechnungen', { method: 'POST', body: { ...body, idempotencyKey: crypto.randomUUID() } })
export const sendInvoice = (id) => request(`/rechnungen/${enc(id)}/senden`, { method: 'POST' })
// "Erinnerung senden": das Portal versendet eine offene/ueberfaellige Rechnung erneut
export const resendInvoice = sendInvoice
export const voidInvoice = (id) => request(`/rechnungen/${enc(id)}/stornieren`, { method: 'POST' })
export const deleteInvoice = (id) => request(`/rechnungen/${enc(id)}`, { method: 'DELETE' })
// --- Buchungen ---
export const getTransactions = () => request('/buchungen')
// target: genau eins von { invoiceId } | { payoutId } | { fixedCostId } | { category }, optional note
export const assignTransaction = (id, target) =>
request(`/buchungen/${enc(id)}/zuordnen`, { method: 'POST', body: target })
export const unassignTransaction = (id) => request(`/buchungen/${enc(id)}/zuordnung`, { method: 'DELETE' })
// --- Bank ---
export const getBank = () => request('/bank')
export const refreshBank = () => request('/bank/aktualisieren', { method: 'POST' })
export const startBankAuth = () => request('/bank/verbinden', { method: 'POST' })
export const completeBankAuth = ({ code, state, error } = {}) =>
request('/bank/rueckkehr', { method: 'POST', body: { code, state, error } })
// --- Einstellungen ---
export const getSettings = () => request('/einstellungen')
export const patchSettings = (patch) => request('/einstellungen', { method: 'PATCH', body: patch })
// --- Fixkosten ---
export const getFixedCosts = () => request('/fixkosten')
export const createFixedCost = (body) => request('/fixkosten', { method: 'POST', body })
export const updateFixedCost = (id, patch) => request(`/fixkosten/${enc(id)}`, { method: 'PATCH', body: patch })
export const deleteFixedCost = (id) => request(`/fixkosten/${enc(id)}`, { method: 'DELETE' })
+212
View File
@@ -0,0 +1,212 @@
// Reine Format- und Parser-Helfer fuer die Finanzen-Seiten (kein React, kein Appwrite, unter Node testbar).
// Alle Betraege sind Cent (Integer). Minus ist immer U+2212, nicht der Bindestrich.
const MINUS = '−'
const PLACEHOLDER = '–'
function isNum(v) {
return typeof v === 'number' && Number.isFinite(v)
}
// 1234567 -> '1.234.567'
function groupThousands(intString) {
return intString.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
}
// Cent -> { neg, euros, rest } mit auf ganze Cent gerundetem Betrag; neg nur, wenn der Betrag nicht 0 ist.
function splitCents(cents) {
const abs = Math.round(Math.abs(cents))
return { neg: cents < 0 && abs !== 0, euros: Math.floor(abs / 100), rest: abs % 100 }
}
/** 123456 -> '1.234,56 €'; null/ungueltig -> '–'; negativ mit U+2212. */
export function euro(cents) {
if (!isNum(cents)) return PLACEHOLDER
const { neg, euros, rest } = splitCents(cents)
return `${neg ? MINUS : ''}${groupThousands(String(euros))},${String(rest).padStart(2, '0')} €`
}
/** 10893 -> '109 €' (auf ganze Euro gerundet, ab 50 Cent auf). */
export function euroWhole(cents) {
if (!isNum(cents)) return PLACEHOLDER
const abs = Math.round(Math.abs(cents) / 100)
const neg = cents < 0 && abs !== 0
return `${neg ? MINUS : ''}${groupThousands(String(abs))} €`
}
/** 16879 -> '+168,79 €', -449 -> '−4,49 €', 0 -> '0,00 €'. */
export function signedEuro(cents) {
if (!isNum(cents)) return PLACEHOLDER
const { neg } = splitCents(cents)
const text = euro(Math.abs(cents))
if (neg) return `${MINUS}${text}`
return cents > 0 && text !== '0,00 €' ? `+${text}` : text
}
/** 30 -> '30', 27.5 -> '27,5', 0.32 (1 Stelle) -> '0,3'; höchstens `maxDigits` Nachkommastellen, ohne überflüssige Nullen; ungültig -> '–'. */
export function percentDe(value, maxDigits = 2) {
if (!isNum(value)) return PLACEHOLDER
const rounded = Number(value.toFixed(maxDigits))
return String(rounded).replace('-', MINUS).replace('.', ',')
}
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/
const berlinDate = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
/** '2027-03-29' -> '29.03.2027'; ISO-Zeitpunkte in Berliner Zeit; ungueltig -> '–'. */
export function dateDe(value) {
if (typeof value !== 'string' || !value.trim()) return PLACEHOLDER
const s = value.trim()
const m = DATE_ONLY.exec(s)
if (m) {
const [, y, mo, d] = m
const probe = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)))
const valid =
probe.getUTCFullYear() === Number(y) &&
probe.getUTCMonth() === Number(mo) - 1 &&
probe.getUTCDate() === Number(d)
return valid ? `${d}.${mo}.${y}` : PLACEHOLDER
}
const t = Date.parse(s)
return Number.isNaN(t) ? PLACEHOLDER : berlinDate.format(new Date(t))
}
/** ISO-Zeitpunkt -> 'gerade eben' | 'vor 12 Min.' | 'vor 3 Std.' | 'vor 1 Tag' | 'vor 2 Tagen'. */
export function sinceLabel(iso, now = Date.now()) {
if (typeof iso !== 'string' && !(iso instanceof Date)) return PLACEHOLDER
const then = iso instanceof Date ? iso.getTime() : Date.parse(iso)
const nowMs = now instanceof Date ? now.getTime() : Number(now)
if (Number.isNaN(then) || Number.isNaN(nowMs)) return PLACEHOLDER
const min = Math.floor(Math.max(0, nowMs - then) / 60000)
if (min < 1) return 'gerade eben'
if (min < 60) return `vor ${min} Min.`
const hours = Math.floor(min / 60)
if (hours < 24) return `vor ${hours} Std.`
const days = Math.floor(hours / 24)
return days === 1 ? 'vor 1 Tag' : `vor ${days} Tagen`
}
/** Berliner Kalendertag ('YYYY-MM-DD') eines ISO-Zeitpunkts, Date oder Millisekundenwerts; ungueltig -> null. */
export function ymdBerlin(value) {
const t =
value instanceof Date ? value.getTime() : typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : NaN
if (!Number.isFinite(t)) return null
const parts = Object.fromEntries(berlinDate.formatToParts(new Date(t)).map((p) => [p.type, p.value]))
return `${parts.year}-${parts.month}-${parts.day}`
}
// Ganze Euro mit Tausenderpunkten (1.234.567) oder ohne Punkte, optional ,1-2 Nachkommastellen.
// Eine fuehrende 0 ist nur ohne Tausenderpunkte erlaubt, '12.50' (englisch) ist absichtlich ungueltig.
const AMOUNT = /^([1-9][0-9]{0,2}(?:\.[0-9]{3})+|[0-9]+)(?:,([0-9]{1,2}))?$/
/**
* '1.234,56' -> 123456 (Cent). Erlaubt Tausenderpunkte, Dezimalkomma und ein
* einzelnes Euro-Zeichen davor oder dahinter. Keine negativen Werte, kein Dezimalpunkt.
* Wirft Error('Ungültiger Betrag').
*/
export function parseEuro(text) {
const invalid = () => new Error('Ungültiger Betrag')
if (typeof text !== 'string') throw invalid()
let s = text.trim()
if (s.startsWith('€')) s = s.slice(1).trim()
else if (s.endsWith('€')) s = s.slice(0, -1).trim()
const m = AMOUNT.exec(s)
if (!m) throw invalid()
const euros = Number(m[1].replace(/\./g, ''))
const cents = Number((m[2] || '').padEnd(2, '0'))
const total = euros * 100 + cents
if (!Number.isSafeInteger(total)) throw invalid()
return total
}
export const INVOICE_STATUS = {
entwurf: { label: 'Entwurf', tone: 'muted' },
offen: { label: 'Offen', tone: 'info' },
ueberfaellig: { label: 'Überfällig', tone: 'warn' },
bezahlt: { label: 'Bezahlt', tone: 'ok' },
storniert: { label: 'Storniert', tone: 'muted' },
uneinbringlich: { label: 'Uneinbringlich', tone: 'danger' },
}
export const CATEGORY_LABEL = {
rechnung: 'Rechnung',
'rechnung-extern': 'Einnahme ohne Stripe-Rechnung',
'stripe-auszahlung': 'Stripe-Auszahlung',
fixkosten: 'Fixkosten',
privat: 'Privat/Umbuchung',
sonstiges: 'Sonstiges',
'': 'Offen',
}
export const URGENCY = {
hoch: { label: 'Dringend', tone: 'danger' },
mittel: { label: 'Bald', tone: 'warn' },
info: { label: 'Hinweis', tone: 'muted' },
}
export const AGE_GROUP_LABEL = {
'nicht-faellig': 'Nicht fällig',
'1-30': '1–30 Tage',
'31-60': '31–60 Tage',
'ueber-60': 'Über 60 Tage',
}
export const INTERVAL_LABEL = {
monat: 'monatlich',
quartal: 'vierteljährlich',
jahr: 'jährlich',
}
const INTERVAL_MONTHS = { monat: 1, quartal: 3, jahr: 12 }
function daysInMonth(year, month) {
if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28
return [4, 6, 9, 11].includes(month) ? 30 : 31
}
/**
* Naechster Termin einer Folge: '2026-01-31' + 'monat' -> '2026-02-28' (Monatsende wird begrenzt, nie in den
* Folgemonat uebergelaufen). Rhythmen: monat, quartal, jahr. Ungueltiges Datum oder unbekannter Rhythmus -> null.
*/
export function nextIntervalDate(ymd, interval) {
const months = Object.prototype.hasOwnProperty.call(INTERVAL_MONTHS, interval) ? INTERVAL_MONTHS[interval] : 0
const m = typeof ymd === 'string' ? DATE_ONLY.exec(ymd.trim()) : null
if (!months || !m) return null
const year = Number(m[1])
const month = Number(m[2])
const day = Number(m[3])
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) return null
const total = year * 12 + (month - 1) + months
const nextYear = Math.floor(total / 12)
const nextMonth = (total % 12) + 1
const nextDay = Math.min(day, daysInMonth(nextYear, nextMonth))
return `${String(nextYear).padStart(4, '0')}-${String(nextMonth).padStart(2, '0')}-${String(nextDay).padStart(2, '0')}`
}
/** Seite einer Liste (1-basiert); die Seitenzahl wird auf 1..pages begrenzt. */
export function pageOf(list, page, size = 50) {
const items = Array.isArray(list) ? list : []
const perPage = Number.isInteger(size) && size > 0 ? size : 50
const pages = Math.max(1, Math.ceil(items.length / perPage))
const wanted = Math.floor(Number(page))
const current = Number.isNaN(wanted) ? 1 : Math.min(Math.max(wanted, 1), pages)
return { items: items.slice((current - 1) * perPage, current * perPage), page: current, pages }
}
/**
* Darf fuer diesen Kunden eine Rechnung erstellt werden?
* Leads und Abgesagte sind keine festen Kunden; Bestandsdaten ohne Status zaehlen als Kunde (wie customerStage).
*/
export function canInvoice(customer) {
if (!customer) return { ok: false, reason: 'Kein Kunde ausgewählt' }
const status = String(customer.customerStatus || '').trim().toLowerCase()
if (status === 'lead' || status === 'lost') return { ok: false, reason: 'Noch kein fester Kunde' }
const email = typeof customer.email === 'string' ? customer.email.trim() : ''
if (!email) return { ok: false, reason: 'E-Mail fehlt' }
return { ok: true }
}
+67
View File
@@ -0,0 +1,67 @@
import { createJwtCache, fetchWithRetry } from './serverpulsAuth'
// Anfrage-Kern der Finanzen-API (Portal). Rein (ohne Appwrite-Import, fetch und createJWT werden injiziert), damit die
// Fehlertexte unter Node testbar sind; financeApi.js verdrahtet ihn mit Appwrite.
export const PORTAL_DOWN_TEXT = 'Das Kundenportal ist gerade nicht erreichbar.'
export const SESSION_EXPIRED_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
const statusError = (message, status) => Object.assign(new Error(message), { status })
/**
* request(path, { method, body }) -> geparstes JSON oder ein Error mit `status` (und bei 502 nach dem Festschreiben
* `invoiceId` / `invoiceNumber`). Meldungen:
* - fetch wirft (Netz, Portal aus): „Das Kundenportal ist gerade nicht erreichbar.“ (status 0)
* - 502/503/504 ohne JSON-Antwort (Proxy-Fehlerseite): dieselbe Meldung (status = HTTP-Status)
* - createJWT scheitert (Sitzung abgelaufen): „Anmeldung abgelaufen. Bitte neu anmelden.“ (status 401)
* - sonst die Meldung des Portals (`error`), ohne sie „API-Fehler <Status>“
* Das JWT wird 10 Minuten gecacht; bei 401 einmal mit frischem JWT wiederholt.
*/
export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args) => globalThis.fetch(...args), now }) {
// JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer)
const jwtCache = createJwtCache({
createJWT: async () => {
try {
return await createJWT()
} catch {
throw statusError(SESSION_EXPIRED_TEXT, 401)
}
},
...(now ? { now } : {}),
})
// Netzwerkfehler (fetch wirft) bekommen eine deutsche Meldung mit status 0
async function portalFetch(url, init) {
try {
return await fetchImpl(url, init)
} catch {
throw statusError(PORTAL_DOWN_TEXT, 0)
}
}
return async function request(path, { method = 'GET', body } = {}) {
const res = await fetchWithRetry(jwtCache, (jwt) =>
portalFetch(`${baseUrl}${path}`, {
method,
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
)
let data = null
try {
data = await res.json()
} catch {
data = null // keine JSON-Antwort (Fehlerseite des Proxys, leerer Body)
}
if (!res.ok) {
const gatewayDown = data === null && (res.status === 502 || res.status === 503 || res.status === 504)
const info = data && typeof data === 'object' ? data : {}
const err = statusError(gatewayDown ? PORTAL_DOWN_TEXT : info.error || `API-Fehler ${res.status}`, res.status)
// Nach dem Festschreiben (502) liegt die Rechnung schon als "offen" vor
if (info.invoiceId) err.invoiceId = info.invoiceId
if (info.invoiceNumber) err.invoiceNumber = info.invoiceNumber
throw err
}
return data ?? {}
}
}
+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.' }
}
File diff suppressed because it is too large. Load diff
-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 }
}
+12
View File
@@ -0,0 +1,12 @@
// Seitenmenue: auf schmalen Bildschirmen (Handy) startet es eingeklappt, sonst bleiben vom Inhalt bei 390 px nur
// etwa 140 px und jede Seite scrollt waagerecht. Ausklappen geht weiterhin per Knopf.
export const AUTO_COLLAPSE_MAX_WIDTH = 720
/** true, wenn das Menue eingeklappt starten soll (Fensterbreite bis 720 px); ohne window (Test, Server) false. */
export function startsCollapsed(win = typeof window !== 'undefined' ? window : null) {
try {
return Boolean(win?.matchMedia?.(`(max-width: ${AUTO_COLLAPSE_MAX_WIDTH}px)`)?.matches)
} catch {
return false
}
}
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { PageHeader, Card, Button } from '../components/ui'
import { completeBankAuth } from '../lib/financeApi'
import { dateDe } from '../lib/financeFormat'
import { parseCallback, successResult, failureResult, isResult } from '../lib/bankCallback'
const PATH = '/finanzen/bank-callback'
// Rueckkehrseite der Bankfreigabe: code/state aus der Adresse gehen einmalig ans Portal,
// danach verschwinden sie aus der Adresszeile; angezeigt werden nur Ergebnis und Meldung.
export default function BankCallbackPage() {
const location = useLocation()
const navigate = useNavigate()
// Beim ersten Rendern einlesen: danach ist der Suchteil aus der Adresse entfernt
const [params] = useState(() => parseCallback(location.search))
const [result, setResult] = useState(() => (isResult(location.state) ? location.state : null))
const startedRef = useRef(false)
const aliveRef = useRef(true)
useEffect(() => {
aliveRef.current = true
// useRef-Schutz: auch bei doppelten Effekten (StrictMode) genau ein Aufruf
if (params && !result && !startedRef.current) {
startedRef.current = true
completeBankAuth(params)
.then(successResult, failureResult)
.then((outcome) => {
if (!aliveRef.current) return
setResult(outcome)
navigate(PATH, { replace: true, state: outcome })
})
}
return () => { aliveRef.current = false }
}, []) // eslint-disable-line react-hooks/exhaustive-deps
let body
if (result) {
body = (
<>
<p role={result.ok ? 'status' : 'alert'} style={{ fontWeight: 600 }}>
{result.ok
? `Bankzugang erneuert, gültig bis ${dateDe(result.validUntil)}`
: result.message}
</p>
<div style={{ marginTop: 16 }}>
<Button as={Link} variant="primary" to="/finance">Zu den Finanzen</Button>
</div>
</>
)
} else if (params) {
body = (
<div className="text-center" role="status">
<div className="spinner" />
<p className="muted">Bankzugang wird eingerichtet …</p>
</div>
)
} else {
body = (
<>
<p className="muted">Zu dieser Seite liegt keine Rückmeldung der Bank vor.</p>
<div style={{ marginTop: 16 }}>
<Button as={Link} variant="primary" to="/finance">Zu den Finanzen</Button>
</div>
</>
)
}
return (
<div className="page">
<PageHeader title="Bankzugang" />
<Card>{body}</Card>
</div>
)
}
+21 -41
View File
@@ -4,23 +4,17 @@ 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 { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
const PORTAL_URL = 'https://project.webklar.com'
const TABS = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'tickets', label: 'Tickets' },
{ id: 'invoices', label: 'Rechnungen' },
{ id: 'projects', label: 'Projekte' },
]
export default function CustomerDetailPage() {
const { id } = useParams()
const [customer, setCustomer] = useState(null)
@@ -48,8 +42,9 @@ export default function CustomerDetailPage() {
if (error || !customer) return <div className="page"><EmptyState title="Kunde nicht gefunden" hint={error} action={<Button as={Link} to="/customers"><FaArrowLeft /> Zurück</Button>} /></div>
const stage = customerStage(customer)
// „Rechnungen“ nur für Admins (die Finanzen-API gibt sonst 403); ein nicht erlaubter Reiter fällt auf die Übersicht zurück
const activeTab = resolveCustomerTab(tab, isAdmin)
const archived = isArchived(customer)
const syntheticTicket = { customerId: customer.$id, customerName: customer.name, woid: '' }
const toggleArchived = async () => {
await setCustomerArchived(customer, !archived)
@@ -92,20 +87,12 @@ export default function CustomerDetailPage() {
}
/>
<Tabs tabs={TABS} active={tab} onChange={setTab} />
<Tabs tabs={customerTabs(isAdmin)} active={activeTab} onChange={setTab} />
{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 === 'projects' && <CustomerProjects customerId={customer.$id} />}
{activeTab === 'overview' && <OverviewTab customer={customer} stage={stage} onSaved={load} />}
{activeTab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
{activeTab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />}
{activeTab === 'projects' && <CustomerProjects customerId={customer.$id} />}
</div>
)
}
@@ -135,13 +122,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 +133,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 +167,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 }}>Adresse unvollständig</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 +178,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>
)
+203 -106
View File
@@ -1,127 +1,224 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate } from 'react-icons/fa6'
import { integrationsApi, formatMoney, INVOICE_STATUS, INVOICE_NINJA_PUBLIC_URL } from '../lib/integrationsApi'
import { useFinanceSummary } from '../hooks/useFinanceSummary'
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
import { useCallback, useEffect, useRef, useState } from 'react'
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'
import TodoList from '../components/finance/TodoList'
import ForecastTable from '../components/finance/ForecastTable'
import TaxCard from '../components/finance/TaxCard'
import AboCard from '../components/finance/AboCard'
import InvoiceTable from '../components/finance/InvoiceTable'
import TransactionTable from '../components/finance/TransactionTable'
import AssignDialog from '../components/finance/AssignDialog'
import { useFinanceOverview } from '../hooks/useFinanceOverview'
import { useFinanceTransactions } from '../hooks/useFinanceTransactions'
import { useOpenInvoices } from '../hooks/useOpenInvoices'
import { createFixedCost, refreshBank, startBankAuth } from '../lib/financeApi'
import { planTodoAction, safeUrl } from '../lib/financeView'
export default function FinancePage() {
const { summary, loading: sumLoading, refresh: refreshSummary } = useFinanceSummary()
const [invoices, setInvoices] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [filter, setFilter] = useState('all') // all | open | overdue | paid
const [search, setSearch] = useState('')
const [openingId, setOpeningId] = useState(null)
const navigate = useNavigate()
const { data, loading, error, reload } = useFinanceOverview()
const txs = useFinanceTransactions()
const reloadTransactions = txs.reload
const [refreshing, setRefreshing] = useState(false)
// Buchung, die gerade zugeordnet werden soll (Knopf in Block 7 oder Aufgabe „Zahlungseingang zuordnen“)
const [assignTxId, setAssignTxId] = useState(null)
const [assignNotice, setAssignNotice] = useState('')
// Zähler, der die Rechnungsliste (Block 6) zum Neuladen bringt: sie hält ihre Liste selbst
const [invoicesRefresh, setInvoicesRefresh] = useState(0)
const refreshLock = useRef(false)
const retriedRef = useRef(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await integrationsApi.listInvoices(undefined, 400)
setInvoices(data.invoices || [])
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
const assignTx = assignTxId ? txs.transactions.find((t) => t.$id === assignTxId) || null : null
// Offene Rechnungen braucht nur ein gebuchter, noch nicht zugeordneter Eingang
const needsInvoices = Boolean(assignTx && !assignTx.category && assignTx.status !== 'PDNG' && assignTx.amountCents > 0)
const openInvoices = useOpenInvoices(needsInvoices)
// Übersicht, Buchungen und Rechnungsliste zusammen neu laden (nach Zuordnen, Rechnungsänderungen und Bankabruf):
// eine eben bezahlte Rechnung soll sofort aus „Offen“ verschwinden
const reloadAll = useCallback(() => {
setInvoicesRefresh((n) => n + 1)
return Promise.all([reload(), reloadTransactions()])
}, [reload, reloadTransactions])
// Eine Aufgabe kann eine Buchung nennen, die in der geladenen Liste fehlt (neu abgerufen): einmal nachladen,
// dann melden statt still nichts zu tun
useEffect(() => {
if (!assignTxId || assignTx || txs.loading) return
if (retriedRef.current !== assignTxId) {
retriedRef.current = assignTxId
reloadTransactions()
return
}
setAssignNotice(
txs.error
? `Die Buchungen konnten nicht geladen werden: ${txs.error}`
: 'Die Buchung wurde nicht gefunden. Möglicherweise ist sie schon zugeordnet.'
)
setAssignTxId(null)
}, [assignTxId, assignTx, txs.loading, txs.error, reloadTransactions])
const openAssign = useCallback((id) => {
setAssignNotice('')
retriedRef.current = null
setAssignTxId(id)
}, [])
useEffect(() => { load() }, [load])
const handleAssignDone = useCallback(() => {
setAssignTxId(null)
return reloadAll()
}, [reloadAll])
const today = new Date().toISOString().slice(0, 10)
const filtered = useMemo(() => {
return invoices.filter((inv) => {
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
if (filter === 'open' && !(Number(inv.balance) > 0)) return false
if (filter === 'overdue' && !overdue) return false
// InvoiceNinja liefert status_id als Text ("4" = bezahlt)
if (filter === 'paid' && String(inv.status_id) !== '4') return false
if (search) {
const s = search.toLowerCase()
if (!`${inv.number} ${inv.client_name}`.toLowerCase().includes(s)) return false
const handleAssignClose = useCallback(
(info) => {
setAssignTxId(null)
// Nach einem Fehler kann sich der Stand geändert haben (z. B. „Bereits zugeordnet“)
if (info?.refresh) reloadAll()
},
[reloadAll]
)
// Bankabruf anstoßen, danach die Übersicht neu laden. Fehler (z. B. 429) gehen an den Aufrufer.
// Ein Abruf, der bei der Bank scheitert, antwortet mit ok:false; das steht danach in der Statuszeile.
const refresh = useCallback(async () => {
if (refreshLock.current) return
refreshLock.current = true
setRefreshing(true)
try {
await refreshBank()
await reloadAll()
} finally {
refreshLock.current = false
setRefreshing(false)
}
}, [reloadAll])
const handleAction = useCallback(
async (aktion) => {
const plan = planTodoAction(aktion)
switch (plan.kind) {
case 'bank-connect': {
const { url } = await startBankAuth()
const target = safeUrl(url)
if (!target) throw new Error('Die Bank hat keine Weiterleitung geliefert.')
window.location.assign(target)
return
}
case 'bank-refresh':
return refresh()
case 'open-url':
window.open(plan.url, '_blank', 'noopener,noreferrer')
return
case 'scroll':
// Der Rechnungsblock (Task 5) trägt diese Id; ohne ihn passiert nichts. Erst nach dem Neuzeichnen
// starten: ein Scroll mitten im Klick wird sonst vom Neuzeichnen der Aufgabenzeile abgebrochen.
requestAnimationFrame(() => {
document.getElementById(plan.id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
return
case 'assign':
openAssign(plan.transactionId)
return
case 'navigate':
navigate(plan.to)
return
case 'fixed-cost':
if (!window.confirm(plan.confirmText)) return
await createFixedCost(plan.payload)
await reload()
return
default:
throw new Error(plan.message)
}
return true
})
}, [invoices, filter, search, today])
},
[navigate, openAssign, refresh, reload]
)
const openPdf = async (id) => {
setOpeningId(id)
try { await integrationsApi.openInvoicePdf(id) }
catch (err) { alert('PDF-Fehler: ' + err.message) }
finally { setOpeningId(null) }
let body
if (!data && loading) {
body = (
<div className="empty" role="status">
<FaSpinner className="spinner" aria-hidden="true" />
<p className="muted">Finanzen werden geladen …</p>
</div>
)
} else if (!data) {
body = (
<Card>
<p role="alert" style={{ fontWeight: 600, marginTop: 0 }}>{error || 'Die Übersicht konnte nicht geladen werden.'}</p>
<Button variant="primary" onClick={reload}>Erneut versuchen</Button>
</Card>
)
} else {
body = (
<div className="fin-stack">
{error && (
<p className="fin-hint fin-hint-warn" role="alert">
Die Übersicht konnte nicht neu geladen werden: {error}{' '}
<Button size="sm" variant="ghost" onClick={reload} disabled={loading}>Erneut versuchen</Button>
</p>
)}
<StatusLine stand={data.stand} onRefresh={refresh} refreshing={refreshing} />
<LiquidityHead kopf={data.kopf} />
<TodoList aufgaben={data.aufgaben} onAction={handleAction} />
<div className="fin-cols">
<ForecastTable
vorschau={data.vorschau}
kontostandCents={data.kopf?.kontostandCents}
stripeDown={data.stand?.stripe?.ok === false}
abosFehler={Boolean(data.abos?.fehler)}
/>
<TaxCard steuer={data.steuer} />
</div>
<div className="fin-cols fin-cols-half">
<AboCard abos={data.abos} />
</div>
<InvoiceTable altersgruppen={data.rechnungen?.altersgruppen} refreshToken={invoicesRefresh} onChanged={reloadAll} />
<TransactionTable
transactions={txs.transactions}
stripeFehler={txs.stripeFehler}
loaded={txs.loaded}
loading={txs.loading}
error={txs.error}
onReload={reloadTransactions}
onAssign={(tx) => openAssign(tx.$id)}
/>
</div>
)
}
return (
<div className="page">
<PageHeader
title="Finanzen"
subtitle="Rechnungen aus InvoiceNinja: Übersicht und Status. Neue Rechnungen direkt in InvoiceNinja schreiben."
subtitle="Kontostand, Aufgaben und Zahlungen im Blick"
actions={
<>
<Button variant="ghost" as="a" href={INVOICE_NINJA_PUBLIC_URL} target="_blank" rel="noopener noreferrer">
<FaArrowUpRightFromSquare /> InvoiceNinja
</Button>
<Button variant="ghost" onClick={() => { load(); refreshSummary() }}><FaRotate /> Aktualisieren</Button>
</>
<Button as={Link} variant="ghost" to="/finance/einstellungen">
<FaGear aria-hidden="true" /> Einstellungen
</Button>
}
/>
<div className="kpi-grid">
<Kpi label="Offener Betrag" value={sumLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
<Kpi label="Überfällig" value={sumLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} überfällig`} accent="var(--danger)" />
<Kpi label="Bezahlt (Monat)" value={sumLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
<Kpi label="Rechnungen gesamt" value={sumLoading ? '...' : (summary?.total ?? 0)} accent="var(--info)" />
</div>
<Card pad={false}>
<div className="ui-card-head">
<div className="seg">
{[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Überfällig'], ['paid', 'Bezahlt']].map(([id, label]) => (
<button key={id} className={filter === id ? 'active' : ''} onClick={() => setFilter(id)}>{label}</button>
))}
</div>
<input className="form-control search-input" placeholder="Nummer oder Kunde suchen …" value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 280 }} />
</div>
<div className="ui-card-body" style={{ padding: 0 }}>
{loading ? (
<div className="empty"><FaSpinner className="spinner" /></div>
) : error ? (
<div className="empty text-red">{error}</div>
) : filtered.length === 0 ? (
<EmptyState title="Keine Rechnungen" hint="Keine Rechnungen für diesen Filter." />
) : (
<div style={{ overflowX: 'auto' }}>
<table className="ui-table">
<thead>
<tr><th>Nr.</th><th>Kunde</th><th>Datum</th><th>Fällig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{filtered.map((inv) => {
const st = INVOICE_STATUS[inv.status_id] || { label: '–', color: 'var(--text-muted)' }
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
return (
<tr key={inv.id}>
<td className="mono">{inv.number || inv.id?.slice(-6)}</td>
<td>{inv.client_name || '-'}</td>
<td className="muted">{inv.date || '-'}</td>
<td style={{ color: overdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date || '-'}</td>
<td>{formatMoney(inv.amount)}</td>
<td style={{ color: Number(inv.balance) > 0 ? 'var(--warn)' : 'var(--ok)' }}>{formatMoney(inv.balance)}</td>
<td><span style={{ color: st.color, fontWeight: 700 }}>{st.label}{overdue ? ' (überfällig)' : ''}</span></td>
<td>
<Button size="sm" variant="ghost" disabled={openingId === inv.id} onClick={() => openPdf(inv.id)} title="PDF">
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
</Button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</Card>
{assignNotice && (
<p className="fin-hint fin-hint-warn fin-page-notice" role="alert">
{assignNotice}{' '}
<Button size="sm" variant="ghost" onClick={() => setAssignNotice('')}>Schließen</Button>
</p>
)}
{body}
{assignTx && (
<AssignDialog
key={assignTx.$id}
transaction={assignTx}
invoices={openInvoices.invoices}
invoicesLoading={openInvoices.loading}
invoicesError={openInvoices.error}
invoicesLoaded={openInvoices.loaded}
onClose={handleAssignClose}
onDone={handleAssignDone}
/>
)}
</div>
)
}
+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>
)
}
+323
View File
@@ -1147,3 +1147,326 @@ textarea.form-control {
.legal-form-row { grid-template-columns: 1fr; }
.legal-contracts { display: block; overflow-x: auto; }
}
/* --- Finanzen --- */
.fin-amount { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.fin-stack { display: flex; flex-direction: column; gap: var(--sp-5); }
.fin-stack .kpi-grid { margin-bottom: 0; }
.fin-spin { animation: spin 1s linear infinite; }
.fin-hint { margin: 0; font-size: 13px; color: var(--text-muted); }
.fin-hint-warn { color: var(--warn); }
.fin-flag { color: var(--danger); font-weight: 700; }
.fin-link {
background: none; border: 0; padding: 0; font: inherit; cursor: pointer;
color: var(--accent); display: inline-flex; align-items: center; gap: 6px;
text-decoration: underline; text-underline-offset: 2px;
}
.fin-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
/* Block 0: Statuszeile */
.fin-status-wrap { display: flex; flex-direction: column; gap: var(--sp-2); }
.fin-status {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--sp-3);
padding: 10px 14px; font-size: 13px; color: var(--text-muted);
background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--r-md);
}
.fin-status-items { display: flex; flex-wrap: wrap; gap: 4px var(--sp-4); min-width: 0; }
.fin-status-item { display: inline-flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; }
.fin-status-warn { color: var(--warn); font-weight: 600; }
/* Block 1: Liquiditaetskopf */
.fin-head .kpi-value { font-variant-numeric: tabular-nums; }
.fin-breakdown { list-style: none; margin: var(--sp-2) 0 0; padding: 0; display: grid; gap: 4px; color: var(--text-muted); }
.fin-breakdown li { display: flex; justify-content: space-between; gap: var(--sp-3); }
.fin-breakdown .fin-amount { color: var(--text); }
.fin-breakdown-total { border-top: 1px solid var(--border-strong); padding-top: 4px; font-weight: 700; color: var(--text); }
.fin-lows { display: grid; grid-template-columns: auto auto minmax(0, 1fr); gap: 6px 10px; align-items: baseline; font-size: 14px; font-weight: 600; }
.fin-low { display: contents; }
.fin-low-label { color: var(--text-muted); }
.fin-low .fin-amount { font-size: 20px; font-weight: 800; }
.fin-low-when { color: var(--text-muted); font-size: 13px; font-weight: 500; }
/* Block 2: Zu erledigen */
.fin-todo-card { overflow: hidden; }
.fin-todos { list-style: none; margin: 0; padding: 0; }
.fin-todo {
display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: var(--sp-3); align-items: start;
padding: 12px var(--sp-4); border-bottom: 1px solid var(--border); border-left: 3px solid var(--border);
}
.fin-todo:last-child { border-bottom: none; }
.fin-todo-hoch { border-left-color: var(--danger); }
.fin-todo-mittel { border-left-color: var(--warn); }
.fin-todo .badge { justify-self: start; }
.fin-todo-title { font-weight: 700; }
.fin-todo-text { font-size: 13px; margin-top: 2px; overflow-wrap: anywhere; }
.fin-todo .fin-hint { margin-top: 6px; }
.fin-todos-more { padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
@media (max-width: 720px) {
.fin-todo { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); }
.fin-todo .ui-btn { justify-self: start; }
}
/* Bloecke 3-5: Vorschau, Steuer, Abos */
.fin-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.fin-neg { color: var(--danger); }
.fin-cols { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-5); align-items: start; }
@media (min-width: 1100px) {
.fin-cols { grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr); }
.fin-cols-half { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* Block 3: Vorschau (Tabelle; in einer schmalen Karte wird jede Zeile zweizeilig) */
.fin-forecast-card { container-type: inline-size; overflow: hidden; }
.fin-table-wrap { overflow-x: auto; }
.fin-forecast th, .fin-forecast td { padding: 9px 10px; vertical-align: baseline; }
.fin-forecast th.fin-amount { text-align: right; }
.fin-forecast th:first-child, .fin-forecast td:first-child { padding-left: var(--sp-4); }
.fin-forecast th:last-child, .fin-forecast td:last-child { padding-right: var(--sp-4); }
.fin-forecast tbody tr:last-child td { border-bottom: none; }
.fin-forecast-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
.fin-forecast-label { overflow-wrap: anywhere; }
.fin-forecast-foot { display: grid; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
.fin-forecast-foot .fin-hint svg { vertical-align: -2px; }
@container (max-width: 560px) {
.fin-forecast thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fin-forecast, .fin-forecast tbody { display: block; }
.fin-forecast tr {
display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px 0; align-items: baseline;
grid-template-areas: "date date kind kind amount amount" "label label label label label label" "sicher sicher sicher erwartet erwartet erwartet";
padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
}
.fin-forecast-nobal tr { grid-template-areas: "date date kind kind amount amount" "label label label label label label"; }
.fin-forecast tbody tr:last-child { border-bottom: none; }
.fin-forecast td, .fin-forecast td:first-child, .fin-forecast td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
.fin-forecast tbody tr:hover td { background: transparent; }
.fin-forecast-date { grid-area: date; }
.fin-forecast-kind { grid-area: kind; }
.fin-forecast-amount { grid-area: amount; font-weight: 700; }
.fin-forecast-label { grid-area: label; }
.fin-forecast td.fin-forecast-sicher, .fin-forecast td.fin-forecast-erwartet {
display: flex; flex-wrap: wrap; align-items: baseline; column-gap: var(--sp-2); font-size: 13px;
}
.fin-forecast-sicher { grid-area: sicher; }
.fin-forecast-erwartet { grid-area: erwartet; }
.fin-forecast td.fin-forecast-sicher { padding-right: var(--sp-3); }
.fin-forecast td.fin-forecast-erwartet { padding-left: var(--sp-3); }
.fin-saldo { margin-left: auto; }
.fin-forecast td[data-label]::before { content: attr(data-label); color: var(--text-muted); font-size: 12px; font-weight: 500; white-space: nowrap; }
}
/* Block 4: Steuer */
.fin-facts { display: grid; gap: var(--sp-4); }
.fin-fact { display: grid; gap: 3px; padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border); }
.fin-fact:last-child { padding-bottom: 0; border-bottom: none; }
.fin-fact-row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-3); }
.fin-fact-label { color: var(--text-muted); font-size: 13px; }
.fin-fact-big { font-size: 24px; font-weight: 800; }
.fin-fact-strong { font-size: 16px; font-weight: 700; }
.fin-fact-value { font-weight: 700; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.fin-fact-unknown { color: var(--warn); }
.fin-fact-sub { font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
.fin-fact-sub-strong { color: var(--text); }
/* Block 5: Abos */
.fin-abo-card { overflow: hidden; }
.fin-stats { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px var(--sp-3); align-items: baseline; padding: var(--sp-4); border-bottom: 1px solid var(--border); }
.fin-stat-value { font-size: 20px; font-weight: 800; }
.fin-stat-label { color: var(--text-muted); }
.fin-stat-label.fin-flag { color: var(--danger); }
.fin-stat-label svg { vertical-align: -2px; }
.fin-abos { list-style: none; margin: 0; padding: 0; }
.fin-abo {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-3); align-items: baseline;
padding: 11px var(--sp-4); border-bottom: 1px solid var(--border);
}
.fin-abo:last-child { border-bottom: none; }
.fin-abo-customer { font-weight: 700; overflow-wrap: anywhere; }
.fin-abo-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 4px var(--sp-3); font-size: 13px; }
/* Block 6 und 7: Rechnungen und Buchungen (Tabellen; in einer schmalen Karte wird jede Zeile ein Raster) */
.fin-block { scroll-margin-top: var(--sp-4); }
.fin-invoices-card, .fin-tx-card { container-type: inline-size; overflow: hidden; }
.fin-toolbar { padding: var(--sp-3) var(--sp-4); }
.fin-toolbar .seg { flex-wrap: wrap; max-width: 100%; }
.fin-notice { padding: var(--sp-3) var(--sp-4) var(--sp-2); }
.fin-load-error { display: grid; gap: var(--sp-3); justify-items: start; padding: var(--sp-4); }
.fin-page-notice { margin-bottom: var(--sp-4); }
.fin-ages {
list-style: none; margin: 0; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border);
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3);
}
.fin-age { display: grid; gap: 2px; min-width: 0; }
.fin-age-label { color: var(--text-muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; }
.fin-age-count { font-size: 13px; color: var(--text-muted); }
.fin-age-sum { text-align: left; font-size: 18px; font-weight: 800; }
.fin-pager { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border); }
.fin-pager-info { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.fin-invoices th, .fin-invoices td, .fin-tx th, .fin-tx td { padding: 9px 10px; vertical-align: top; }
.fin-invoices th.fin-amount, .fin-tx th.fin-amount { text-align: right; }
.fin-invoices th:first-child, .fin-invoices td:first-child, .fin-tx th:first-child, .fin-tx td:first-child { padding-left: var(--sp-4); }
.fin-invoices th:last-child, .fin-invoices td:last-child, .fin-tx th:last-child, .fin-tx td:last-child { padding-right: var(--sp-4); }
.fin-invoices tbody tr:last-child td, .fin-tx tbody tr:last-child td { border-bottom: none; }
.fin-inv-number { font-weight: 700; white-space: nowrap; }
.fin-inv-customer { overflow-wrap: anywhere; }
.fin-inv-created, .fin-inv-due, .fin-tx-date { white-space: nowrap; font-variant-numeric: tabular-nums; }
.fin-inv-statusbox { display: grid; gap: 3px; justify-items: start; }
.fin-inv-note { font-size: 12px; }
.fin-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.fin-tx-counterparty { font-weight: 600; overflow-wrap: anywhere; }
.fin-tx-remittance {
font-size: 13px; overflow-wrap: anywhere;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.fin-tx-amount { font-weight: 700; }
.fin-tx-badges { display: flex; flex-wrap: wrap; gap: 4px; }
.fin-tx-assign .badge { white-space: normal; }
.fin-tx-suggestion, .fin-tx-note { font-size: 12px; margin-top: 4px; overflow-wrap: anywhere; }
.fin-tx-action { white-space: nowrap; text-align: right; }
@container (max-width: 860px) {
.fin-ages { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.fin-invoices thead, .fin-tx thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fin-invoices, .fin-invoices tbody, .fin-tx, .fin-tx tbody { display: block; }
.fin-invoices tr {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px var(--sp-4); align-items: baseline;
grid-template-areas: "number status" "customer customer" "created due" "total remaining" "actions actions";
padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
}
.fin-invoices tbody tr:last-child { border-bottom: none; }
.fin-invoices td, .fin-invoices td:first-child, .fin-invoices td:last-child, .fin-tx td, .fin-tx td:first-child, .fin-tx td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
.fin-invoices tbody tr:hover td, .fin-tx tbody tr:hover td { background: transparent; }
.fin-inv-number { grid-area: number; }
.fin-inv-status { grid-area: status; }
.fin-inv-statusbox { justify-items: end; text-align: right; }
.fin-inv-customer { grid-area: customer; }
.fin-inv-created { grid-area: created; }
.fin-inv-due { grid-area: due; }
.fin-inv-total { grid-area: total; }
.fin-inv-remaining { grid-area: remaining; }
.fin-inv-actions { grid-area: actions; padding-top: 4px; }
.fin-invoices td[data-label] { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0 var(--sp-2); font-size: 13px; }
.fin-invoices td[data-label]::before { content: attr(data-label); color: var(--text-muted); font-weight: 500; }
}
@container (max-width: 640px) {
.fin-tx tr {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-4); align-items: baseline;
grid-template-areas: "date amount" "what what" "assign action";
padding: 10px var(--sp-4); border-bottom: 1px solid var(--border);
}
.fin-tx tbody tr:last-child { border-bottom: none; }
.fin-tx-date { grid-area: date; }
.fin-tx-amount { grid-area: amount; }
.fin-tx-what { grid-area: what; }
.fin-tx-assign { grid-area: assign; }
.fin-tx-action { grid-area: action; align-self: start; }
}
/* Dialoge: Rechnung schreiben, Buchung zuordnen (auf dem dunklen Overlay) */
.fin-close { background: none; border: 0; padding: 0; line-height: 1; font-size: 32px; top: 16px; right: 24px; z-index: 1; }
.fin-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 4px; }
.fin-close:disabled { opacity: 0.4; cursor: not-allowed; }
.fin-overlay { background: rgba(6, 9, 14, 0.98); }
.fin-dialog { max-width: 760px; }
.fin-dialog:focus { outline: none; }
.fin-dialog-error {
margin-bottom: var(--sp-4); padding: var(--sp-3) var(--sp-4); border-radius: var(--r-sm);
border: 1px solid var(--danger); background: rgba(239, 68, 68, 0.16); color: #fecaca; overflow-wrap: anywhere;
}
.fin-dialog-warn { margin: 6px 0 0; font-size: 13px; color: #fbbf24; overflow-wrap: anywhere; }
.fin-dialog-sub { margin: 6px 0 0; font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; }
.fin-fixed-customer { margin: 0; overflow-wrap: anywhere; }
.fin-address-warn { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px var(--sp-3); font-size: 13px; overflow-wrap: anywhere; }
.fin-address-warn-dialog { margin: 6px 0 0; color: #fbbf24; }
.fin-address-warn-page { margin: 0 0 var(--sp-4); padding: var(--sp-3) var(--sp-4); border: 1px solid var(--warn); border-radius: var(--r-sm); color: var(--warn); }
.fin-address-warn .fin-link { font-size: 13px; }
.fin-items { border: 0; padding: 0; margin: 0 0 var(--sp-4); min-width: 0; }
.fin-items legend { padding: 0; }
.fin-item {
display: grid; grid-template-columns: minmax(0, 1fr) 90px 130px 120px 38px; gap: 8px; align-items: end; margin-bottom: 8px;
}
.fin-item-field { min-width: 0; }
.fin-item-label { display: block; margin-bottom: 4px; font-size: 12px; color: var(--text-muted); }
.fin-item ~ .fin-item .fin-item-label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fin-item .form-control.fin-amount { text-align: right; }
.fin-item-total { display: block; padding: 10px 0; font-weight: 600; }
.fin-icon-btn {
width: 38px; height: 40px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
background: transparent; border: 1px solid var(--border-strong); border-radius: 6px; color: var(--text-muted);
}
.fin-icon-btn:hover:not(:disabled) { color: #f87171; border-color: #f87171; }
.fin-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.fin-icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.fin-items-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-3); margin-top: var(--sp-3); }
.fin-total { display: flex; align-items: baseline; gap: var(--sp-4); font-size: 18px; font-weight: 700; }
.fin-dialog-row { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--sp-4); }
.fin-dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--sp-3); margin-top: var(--sp-4); }
.fin-tx-facts { display: grid; gap: 6px; margin: 0 0 var(--sp-4); padding: var(--sp-3) var(--sp-4); border: 1px solid var(--border-strong); border-radius: var(--r-sm); }
.fin-tx-facts > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: var(--sp-3); align-items: baseline; }
.fin-tx-facts dt { color: var(--text-muted); font-size: 13px; }
.fin-tx-facts dd { margin: 0; overflow-wrap: anywhere; }
.fin-tx-facts .fin-tx-facts-amount { text-align: left; font-weight: 700; }
.fin-assigned { margin: 0 0 var(--sp-2); }
.fin-choices { border: 0; padding: 0; margin: 0 0 var(--sp-4); display: grid; gap: 8px; min-width: 0; }
.fin-choices legend { padding: 0; margin-bottom: 4px; }
.fin-choice {
display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; cursor: pointer;
border: 1px solid var(--border-strong); border-radius: var(--r-sm);
}
.fin-choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
.fin-choice:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.fin-choice input { margin-top: 4px; accent-color: var(--accent); }
.fin-choice-off { opacity: 0.65; cursor: not-allowed; }
.fin-choice-text { display: grid; gap: 2px; min-width: 0; }
.fin-choice-label { font-weight: 600; overflow-wrap: anywhere; }
.fin-choice-detail { font-size: 13px; overflow-wrap: anywhere; }
.fin-confirm { margin-top: var(--sp-4); padding: var(--sp-4); border: 1px solid var(--warn); border-radius: var(--r-sm); background: rgba(245, 158, 11, 0.1); }
.fin-confirm:focus { outline: 2px solid var(--warn); outline-offset: 2px; }
.fin-confirm p { margin: 0; font-weight: 600; overflow-wrap: anywhere; }
@media (max-width: 640px) {
.fin-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) 38px; grid-template-areas: "desc desc desc desc" "qty price sum remove"; }
.fin-item-desc { grid-area: desc; }
.fin-item-qty { grid-area: qty; }
.fin-item-price { grid-area: price; }
.fin-item-sum { grid-area: sum; text-align: right; }
.fin-item-remove { grid-area: remove; }
.fin-item ~ .fin-item .fin-item-label { position: static; width: auto; height: auto; margin: 0 0 4px; overflow: visible; clip: auto; white-space: normal; }
.fin-dialog-row { grid-template-columns: minmax(0, 1fr); }
.fin-tx-facts > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
.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; }
}
+14
View File
@@ -0,0 +1,14 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const source = readFileSync(path.join(root, 'src/components/AdminRoute.jsx'), 'utf8')
// AdminRoute braucht React und den Auth-Kontext (nicht unter Node ladbar): der Ladetext wird am Quelltext geprüft
test('AdminRoute: Ladetext auf Deutsch, kein englisches „Loading...“', () => {
assert.match(source, /<p>Wird geladen …<\/p>/)
assert.doesNotMatch(source, /Loading/)
})
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { parseCallback, successResult, failureResult, isResult } = await loadModule('src/lib/bankCallback.js')
test('parseCallback: code und state', () => {
assert.deepEqual(parseCallback('?state=a&code=b'), { code: 'b', state: 'a', error: '' })
})
test('parseCallback: Abbruch bei der Bank, error_description wird angehaengt', () => {
assert.deepEqual(
parseCallback('?state=a&error=access_denied&error_description=abgebrochen'),
{ code: '', state: 'a', error: 'access_denied: abgebrochen' },
)
assert.equal(parseCallback('?error=access_denied').error, 'access_denied')
})
test('parseCallback: ohne code und error -> null', () => {
assert.equal(parseCallback(''), null)
assert.equal(parseCallback('?'), null)
assert.equal(parseCallback('?state=a'), null)
assert.equal(parseCallback('?error_description=nur-text'), null)
assert.equal(parseCallback(undefined), null)
assert.equal(parseCallback(null), null)
assert.equal(parseCallback(42), null)
})
test('parseCallback: URL-Kodierung, erster Wert gewinnt, Suchteil auch ohne ?', () => {
assert.deepEqual(parseCallback('?code=a%2Bb%3D&state=x%20y'), { code: 'a+b=', state: 'x y', error: '' })
assert.equal(parseCallback('?code=erst&code=zweit').code, 'erst')
assert.deepEqual(parseCallback('code=b&state=a'), { code: 'b', state: 'a', error: '' })
})
test('successResult: gueltig bis uebernehmen', () => {
assert.deepEqual(successResult({ ok: true, validUntil: '2099-03-29T00:00:00.000Z' }), { ok: true, validUntil: '2099-03-29T00:00:00.000Z' })
assert.deepEqual(successResult({ ok: true }), { ok: true, validUntil: '' })
})
test('successResult: Antwort ohne ok -> Fehlerergebnis mit deutschem Text', () => {
const r = successResult({ ok: false })
assert.equal(r.ok, false)
assert.match(r.message, /Bankfreigabe/)
assert.equal(successResult(null).ok, false)
})
test('failureResult: Meldung des Servers, sonst Standardtext', () => {
assert.deepEqual(failureResult({ message: 'Die Freigabe ist abgelaufen.' }), { ok: false, message: 'Die Freigabe ist abgelaufen.' })
assert.match(failureResult(new Error('')).message, /Bankfreigabe/)
assert.match(failureResult(undefined).message, /Bankfreigabe/)
})
test('isResult: nur gueltige Ergebnisse aus dem Verlaufszustand', () => {
assert.equal(isResult({ ok: true, validUntil: 'x' }), true)
assert.equal(isResult({ ok: false, message: 'm' }), true)
assert.equal(isResult(null), false)
assert.equal(isResult({}), false)
assert.equal(isResult({ ok: 'ja' }), false)
})
+33
View File
@@ -0,0 +1,33 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { customerTabs, resolveCustomerTab } = await loadModule('src/lib/customerTabs.js')
test('customerTabs: Rechnungen nur für Admins, Reihenfolge bleibt', () => {
assert.deepEqual(customerTabs(true), [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'tickets', label: 'Tickets' },
{ id: 'invoices', label: 'Rechnungen' },
{ id: 'projects', label: 'Projekte' },
])
const staff = customerTabs(false)
assert.deepEqual(staff.map((t) => t.id), ['overview', 'tickets', 'projects'])
// unbekannt oder nicht eindeutig true gilt als kein Admin
for (const v of [undefined, null, 0, '', 'true']) {
assert.deepEqual(customerTabs(v).map((t) => t.id), ['overview', 'tickets', 'projects'], String(v))
}
// die Einträge tragen nur id und label (kein internes Feld)
assert.deepEqual(Object.keys(customerTabs(true)[2]), ['id', 'label'])
})
test('resolveCustomerTab: erlaubter Reiter bleibt, sonst Übersicht', () => {
assert.equal(resolveCustomerTab('invoices', true), 'invoices')
assert.equal(resolveCustomerTab('invoices', false), 'overview')
assert.equal(resolveCustomerTab('invoices', undefined), 'overview')
assert.equal(resolveCustomerTab('tickets', false), 'tickets')
assert.equal(resolveCustomerTab('projects', false), 'projects')
assert.equal(resolveCustomerTab('overview', false), 'overview')
assert.equal(resolveCustomerTab('gibtsnicht', true), 'overview')
assert.equal(resolveCustomerTab(undefined, true), 'overview')
})
+318
View File
@@ -0,0 +1,318 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const {
euro,
euroWhole,
signedEuro,
dateDe,
sinceLabel,
parseEuro,
INVOICE_STATUS,
CATEGORY_LABEL,
URGENCY,
AGE_GROUP_LABEL,
pageOf,
canInvoice,
nextIntervalDate,
ymdBerlin,
INTERVAL_LABEL,
percentDe,
} = await loadModule('src/lib/financeFormat.js')
const MINUS = '−'
test('euro: de-DE mit Tausenderpunkt und Komma, Leerzeichen vor dem Euro-Zeichen', () => {
assert.equal(euro(123456), '1.234,56 €')
assert.equal(euro(10893), '108,93 €')
assert.equal(euro(0), '0,00 €')
assert.equal(euro(5), '0,05 €')
assert.equal(euro(100), '1,00 €')
assert.equal(euro(100000000), '1.000.000,00 €')
})
test('euro: Platzhalter für fehlende Werte', () => {
assert.equal(euro(null), '–')
assert.equal(euro(undefined), '–')
assert.equal(euro(NaN), '–')
assert.equal(euro('abc'), '–')
})
test('euro: negative Beträge mit Minuszeichen U+2212', () => {
assert.equal(euro(-449), `${MINUS}4,49 €`)
assert.equal(euro(-123456), `${MINUS}1.234,56 €`)
assert.equal(euro(-5), `${MINUS}0,05 €`)
assert.equal(euro(-0), '0,00 €')
})
test('euroWhole: auf ganze Euro gerundet', () => {
assert.equal(euroWhole(10893), '109 €')
assert.equal(euroWhole(10849), '108 €')
assert.equal(euroWhole(10850), '109 €')
assert.equal(euroWhole(0), '0 €')
assert.equal(euroWhole(123456), '1.235 €')
assert.equal(euroWhole(null), '–')
assert.equal(euroWhole(undefined), '–')
})
test('euroWhole: negative Werte, keine „−0 €“', () => {
assert.equal(euroWhole(-10893), `${MINUS}109 €`)
assert.equal(euroWhole(-49), '0 €')
assert.equal(euroWhole(-50), `${MINUS}1 €`)
})
test('signedEuro: immer mit Vorzeichen, Minus ist U+2212', () => {
assert.equal(signedEuro(16879), '+168,79 €')
assert.equal(signedEuro(-449), `${MINUS}4,49 €`)
assert.equal(signedEuro(-449).charAt(0), '−')
assert.equal(signedEuro(123456), '+1.234,56 €')
assert.equal(signedEuro(-123456), `${MINUS}1.234,56 €`)
assert.equal(signedEuro(0), '0,00 €')
assert.equal(signedEuro(null), '–')
assert.equal(signedEuro(undefined), '–')
})
test('dateDe: ISO-Datum zu TT.MM.JJJJ', () => {
assert.equal(dateDe('2027-03-29'), '29.03.2027')
assert.equal(dateDe('2026-01-05'), '05.01.2026')
})
test('dateDe: ISO-Zeitpunkt in Berliner Zeit, Platzhalter für Ungültiges', () => {
assert.equal(dateDe('2099-03-29T00:00:00.000Z'), '29.03.2099')
// 23:30 UTC im Sommer = 01:30 am Folgetag in Berlin
assert.equal(dateDe('2026-07-01T23:30:00.000Z'), '02.07.2026')
assert.equal(dateDe(''), '–')
assert.equal(dateDe(null), '–')
assert.equal(dateDe(undefined), '–')
assert.equal(dateDe('kein Datum'), '–')
})
test('sinceLabel: gerade eben / Minuten / Stunden / Tage', () => {
const now = Date.parse('2026-10-01T12:00:00.000Z')
const ago = (ms) => new Date(now - ms).toISOString()
assert.equal(sinceLabel(ago(0), now), 'gerade eben')
assert.equal(sinceLabel(ago(59 * 1000), now), 'gerade eben')
assert.equal(sinceLabel(ago(60 * 1000), now), 'vor 1 Min.')
assert.equal(sinceLabel(ago(12 * 60000), now), 'vor 12 Min.')
assert.equal(sinceLabel(ago(59 * 60000), now), 'vor 59 Min.')
assert.equal(sinceLabel(ago(60 * 60000), now), 'vor 1 Std.')
assert.equal(sinceLabel(ago(3 * 3600000), now), 'vor 3 Std.')
assert.equal(sinceLabel(ago(23 * 3600000), now), 'vor 23 Std.')
assert.equal(sinceLabel(ago(24 * 3600000), now), 'vor 1 Tag')
assert.equal(sinceLabel(ago(2 * 86400000), now), 'vor 2 Tagen')
assert.equal(sinceLabel(ago(40 * 86400000), now), 'vor 40 Tagen')
})
test('sinceLabel: Zukunft, Date als now, fehlende Werte', () => {
const now = Date.parse('2026-10-01T12:00:00.000Z')
assert.equal(sinceLabel(new Date(now + 5 * 60000).toISOString(), now), 'gerade eben')
assert.equal(sinceLabel('2026-10-01T11:48:00.000Z', new Date(now)), 'vor 12 Min.')
assert.equal(sinceLabel(null, now), '–')
assert.equal(sinceLabel('Quatsch', now), '–')
})
test('parseEuro: Tausenderpunkte und Dezimalkomma', () => {
assert.equal(parseEuro('1.234,56'), 123456)
assert.equal(parseEuro('12,50'), 1250)
assert.equal(parseEuro('12,5'), 1250)
assert.equal(parseEuro('1234'), 123400)
assert.equal(parseEuro('1234,5'), 123450)
assert.equal(parseEuro('0,05'), 5)
assert.equal(parseEuro('0'), 0)
assert.equal(parseEuro('0,00'), 0)
assert.equal(parseEuro('1.234'), 123400)
assert.equal(parseEuro('1.234.567,89'), 123456789)
assert.equal(parseEuro('19,99'), 1999)
})
test('parseEuro: Leerraum und Euro-Zeichen werden toleriert', () => {
assert.equal(parseEuro(' 12,50 '), 1250)
assert.equal(parseEuro('12,50 €'), 1250)
assert.equal(parseEuro('12,50€'), 1250)
assert.equal(parseEuro('€ 12,50'), 1250)
})
test('parseEuro: ergibt exakte Cent (keine Gleitkomma-Fehler)', () => {
assert.equal(parseEuro('19,99'), 1999)
assert.equal(parseEuro('0,29'), 29)
assert.equal(parseEuro('1,15'), 115)
assert.equal(parseEuro('4,35'), 435)
assert.ok(Number.isInteger(parseEuro('1.234,56')))
})
test('parseEuro: ungültige Eingaben werfen „Ungültiger Betrag“', () => {
const bad = [
'', ' ', 'abc', '12abc', '1,2,3', '12,345', '1,234', '12.50', '1.23', '1.2345',
'1..234', '1.234,5,6', ',5', '.5', '12,', '1.', '-5', '-0', '+5', '−5', '1e3',
'12 34', '1 234,56', '0.500', '€', '12,50 € 3', '١٢', '12',
]
for (const text of bad) {
assert.throws(() => parseEuro(text), { message: 'Ungültiger Betrag' }, `sollte werfen: ${JSON.stringify(text)}`)
}
})
test('parseEuro: Nicht-Strings und zu große Werte werfen', () => {
for (const v of [null, undefined, 12.5, {}, [], true]) {
assert.throws(() => parseEuro(v), { message: 'Ungültiger Betrag' })
}
assert.throws(() => parseEuro('99999999999999999999'), { message: 'Ungültiger Betrag' })
})
test('Status- und Beschriftungstabellen (verbatim)', () => {
assert.deepEqual(INVOICE_STATUS, {
entwurf: { label: 'Entwurf', tone: 'muted' },
offen: { label: 'Offen', tone: 'info' },
ueberfaellig: { label: 'Überfällig', tone: 'warn' },
bezahlt: { label: 'Bezahlt', tone: 'ok' },
storniert: { label: 'Storniert', tone: 'muted' },
uneinbringlich: { label: 'Uneinbringlich', tone: 'danger' },
})
assert.deepEqual(CATEGORY_LABEL, {
rechnung: 'Rechnung',
'rechnung-extern': 'Einnahme ohne Stripe-Rechnung',
'stripe-auszahlung': 'Stripe-Auszahlung',
fixkosten: 'Fixkosten',
privat: 'Privat/Umbuchung',
sonstiges: 'Sonstiges',
'': 'Offen',
})
assert.deepEqual(URGENCY, {
hoch: { label: 'Dringend', tone: 'danger' },
mittel: { label: 'Bald', tone: 'warn' },
info: { label: 'Hinweis', tone: 'muted' },
})
assert.deepEqual(AGE_GROUP_LABEL, {
'nicht-faellig': 'Nicht fällig',
'1-30': '1–30 Tage',
'31-60': '31–60 Tage',
'ueber-60': 'Über 60 Tage',
})
})
test('pageOf: 1-basiert, 50 pro Seite, Standardgröße', () => {
const list = Array.from({ length: 120 }, (_, i) => i + 1)
const p1 = pageOf(list, 1)
assert.equal(p1.items.length, 50)
assert.equal(p1.items[0], 1)
assert.equal(p1.page, 1)
assert.equal(p1.pages, 3)
const p3 = pageOf(list, 3)
assert.deepEqual(p3.items, list.slice(100))
assert.equal(p3.items.length, 20)
assert.equal(p3.page, 3)
assert.equal(p3.pages, 3)
})
test('pageOf: Seitenzahl wird begrenzt, eigene Größe, leere Liste', () => {
const list = [1, 2, 3, 4, 5]
assert.deepEqual(pageOf(list, 99, 2), { items: [5], page: 3, pages: 3 })
assert.deepEqual(pageOf(list, 0, 2), { items: [1, 2], page: 1, pages: 3 })
assert.deepEqual(pageOf(list, -4, 2), { items: [1, 2], page: 1, pages: 3 })
assert.deepEqual(pageOf(list, 'x', 2), { items: [1, 2], page: 1, pages: 3 })
assert.deepEqual(pageOf(list, 2.7, 2), { items: [3, 4], page: 2, pages: 3 })
assert.deepEqual(pageOf([], 1), { items: [], page: 1, pages: 1 })
assert.deepEqual(pageOf(null, 3), { items: [], page: 1, pages: 1 })
assert.deepEqual(pageOf(undefined), { items: [], page: 1, pages: 1 })
assert.equal(pageOf(Array.from({ length: 50 }, (_, i) => i), 1).pages, 1)
assert.equal(pageOf(Array.from({ length: 51 }, (_, i) => i), 1).pages, 2)
})
test('canInvoice: fester Kunde mit E-Mail ist ok', () => {
assert.deepEqual(canInvoice({ customerStatus: 'customer', email: 'k@example.invalid' }), { ok: true })
// Bestandsdaten ohne Status gelten als fester Kunde (wie customerStage)
assert.deepEqual(canInvoice({ email: 'k@example.invalid' }), { ok: true })
assert.deepEqual(canInvoice({ customerStatus: '', email: 'k@example.invalid' }), { ok: true })
})
test('canInvoice: Lead und Abgesagt sind noch keine festen Kunden', () => {
const reason = 'Noch kein fester Kunde'
assert.deepEqual(canInvoice({ customerStatus: 'lead', email: 'k@example.invalid' }), { ok: false, reason })
assert.deepEqual(canInvoice({ customerStatus: 'lost', email: 'k@example.invalid' }), { ok: false, reason })
assert.deepEqual(canInvoice({ customerStatus: 'Lead', email: 'k@example.invalid' }), { ok: false, reason })
assert.deepEqual(canInvoice({ customerStatus: ' LOST ', email: 'k@example.invalid' }), { ok: false, reason })
// Status hat Vorrang vor fehlender E-Mail
assert.deepEqual(canInvoice({ customerStatus: 'lead', email: '' }), { ok: false, reason })
})
test('canInvoice: ohne E-Mail nicht möglich', () => {
const reason = 'E-Mail fehlt'
assert.deepEqual(canInvoice({ customerStatus: 'customer' }), { ok: false, reason })
assert.deepEqual(canInvoice({ customerStatus: 'customer', email: '' }), { ok: false, reason })
assert.deepEqual(canInvoice({ customerStatus: 'customer', email: ' ' }), { ok: false, reason })
assert.deepEqual(canInvoice({ customerStatus: 'customer', email: null }), { ok: false, reason })
assert.deepEqual(canInvoice({}), { ok: false, reason })
})
test('canInvoice: kein Kunde', () => {
assert.equal(canInvoice(null).ok, false)
assert.equal(canInvoice(undefined).ok, false)
assert.equal(typeof canInvoice(null).reason, 'string')
})
test('nextIntervalDate: ein Rhythmus nach dem Datum', () => {
assert.equal(nextIntervalDate('2026-09-27', 'monat'), '2026-10-27')
assert.equal(nextIntervalDate('2026-09-27', 'quartal'), '2026-12-27')
assert.equal(nextIntervalDate('2026-09-27', 'jahr'), '2027-09-27')
assert.equal(nextIntervalDate('2026-12-15', 'monat'), '2027-01-15')
assert.equal(nextIntervalDate('2026-11-15', 'quartal'), '2027-02-15')
})
test('nextIntervalDate: Monatsende wird begrenzt, nicht in den Folgemonat übergelaufen', () => {
assert.equal(nextIntervalDate('2026-01-31', 'monat'), '2026-02-28')
assert.equal(nextIntervalDate('2024-01-31', 'monat'), '2024-02-29')
assert.equal(nextIntervalDate('2026-03-31', 'monat'), '2026-04-30')
assert.equal(nextIntervalDate('2026-11-30', 'quartal'), '2027-02-28')
assert.equal(nextIntervalDate('2026-08-31', 'quartal'), '2026-11-30')
assert.equal(nextIntervalDate('2024-02-29', 'jahr'), '2025-02-28')
assert.equal(nextIntervalDate('2023-02-28', 'jahr'), '2024-02-28')
assert.equal(nextIntervalDate('1900-01-31', 'monat'), '1900-02-28') // 1900 ist kein Schaltjahr
assert.equal(nextIntervalDate('2000-01-31', 'monat'), '2000-02-29')
})
test('nextIntervalDate: ungültige Eingaben ergeben null', () => {
assert.equal(nextIntervalDate('2026-02-30', 'monat'), null)
assert.equal(nextIntervalDate('2026-13-01', 'monat'), null)
assert.equal(nextIntervalDate('2026-00-10', 'monat'), null)
assert.equal(nextIntervalDate('2026-04-31', 'monat'), null)
assert.equal(nextIntervalDate('27.09.2026', 'monat'), null)
assert.equal(nextIntervalDate('', 'monat'), null)
assert.equal(nextIntervalDate(null, 'monat'), null)
assert.equal(nextIntervalDate(undefined, 'monat'), null)
assert.equal(nextIntervalDate('2026-09-27', 'woche'), null)
assert.equal(nextIntervalDate('2026-09-27', undefined), null)
assert.equal(nextIntervalDate('2026-09-27', 'constructor'), null)
})
test('INTERVAL_LABEL: deutsche Rhythmus-Wörter wie im Portal', () => {
assert.deepEqual(INTERVAL_LABEL, { monat: 'monatlich', quartal: 'vierteljährlich', jahr: 'jährlich' })
})
test('ymdBerlin: Berliner Kalendertag eines Zeitpunkts', () => {
assert.equal(ymdBerlin('2026-10-01T10:55:21.691Z'), '2026-10-01')
assert.equal(ymdBerlin('2026-07-01T23:30:00.000Z'), '2026-07-02') // Sommerzeit: schon der nächste Tag
assert.equal(ymdBerlin('2099-03-29T00:00:00.000Z'), '2099-03-29')
assert.equal(ymdBerlin(new Date('2026-12-31T23:30:00Z')), '2027-01-01')
assert.equal(ymdBerlin(Date.UTC(2026, 9, 1, 12)), '2026-10-01')
assert.equal(ymdBerlin(''), null)
assert.equal(ymdBerlin('kein Datum'), null)
assert.equal(ymdBerlin(null), null)
assert.equal(ymdBerlin(NaN), null)
})
test('percentDe: Dezimalkomma, überflüssige Nullen entfallen', () => {
assert.equal(percentDe(30), '30')
assert.equal(percentDe(27.5), '27,5')
assert.equal(percentDe(30.25), '30,25')
assert.equal(percentDe(0), '0')
assert.equal(percentDe(100), '100')
assert.equal(percentDe(0.32, 1), '0,3')
assert.equal(percentDe(70.4, 1), '70,4')
assert.equal(percentDe(30.256), '30,26')
assert.equal(percentDe(-12.5), `${MINUS}12,5`)
assert.equal(percentDe(-0.001), '0')
})
test('percentDe: Platzhalter für fehlende Werte', () => {
for (const v of [null, undefined, NaN, Infinity, 'x']) assert.equal(percentDe(v), '–')
})
+127
View File
@@ -0,0 +1,127 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { createFinanceRequest, PORTAL_DOWN_TEXT, SESSION_EXPIRED_TEXT } = await loadModule('src/lib/financeRequest.js')
const BASE = 'https://portal.example.invalid/api/admin/finanzen'
/** Antwort wie fetch: { ok, status, json() }; body === undefined -> kein JSON (json() wirft). */
const reply = (status, body) => ({
ok: status >= 200 && status < 300,
status,
json: async () => {
if (body === undefined) throw new SyntaxError('Unexpected token < in JSON')
return body
},
})
function client({ responses = [], createJWT, fetchImpl } = {}) {
const calls = []
const queue = [...responses]
const api = createFinanceRequest({
baseUrl: BASE,
createJWT: createJWT || (async () => ({ jwt: 'jwt-1' })),
fetchImpl:
fetchImpl ||
(async (url, init) => {
calls.push({ url, init })
return queue.shift() || reply(200, {})
}),
})
return { api, calls }
}
const fails = async (promise) => promise.then(() => assert.fail('hätte scheitern müssen'), (err) => err)
test('Erfolg: JSON zurück, Bearer-JWT, JSON-Body nur wenn gesetzt', async () => {
const { api, calls } = client({ responses: [reply(200, { ok: 1 }), reply(200, { id: 'x' })] })
assert.deepEqual(await api('/uebersicht'), { ok: 1 })
assert.equal(calls[0].url, `${BASE}/uebersicht`)
assert.equal(calls[0].init.method, 'GET')
assert.equal(calls[0].init.headers.Authorization, 'Bearer jwt-1')
assert.equal(calls[0].init.body, undefined)
assert.deepEqual(await api('/rechnungen', { method: 'POST', body: { a: 1 } }), { id: 'x' })
assert.equal(calls[1].init.body, '{"a":1}')
})
test('Netzwerkfehler (fetch wirft): „Das Kundenportal ist gerade nicht erreichbar.“ mit status 0', async () => {
const { api } = client({ fetchImpl: async () => { throw new TypeError('Failed to fetch') } })
const err = await fails(api('/uebersicht'))
assert.equal(PORTAL_DOWN_TEXT, 'Das Kundenportal ist gerade nicht erreichbar.')
assert.equal(err.message, PORTAL_DOWN_TEXT)
assert.equal(err.status, 0)
})
test('502/503/504 ohne JSON (Proxy-Fehlerseite): dieselbe Meldung, Status bleibt', async () => {
for (const status of [502, 503, 504]) {
const { api } = client({ responses: [reply(status, undefined)] })
const err = await fails(api('/uebersicht'))
assert.equal(err.message, PORTAL_DOWN_TEXT, String(status))
assert.equal(err.status, status)
}
})
test('502/503/504 mit JSON-Fehler des Portals: die Meldung des Portals und die Rechnungsdaten bleiben erhalten', async () => {
const { api } = client({
responses: [reply(502, { error: 'Stripe ist gerade nicht erreichbar.', invoiceId: 'in_9', invoiceNumber: 'WK-0009' })],
})
const err = await fails(api('/rechnungen/in_9/senden', { method: 'POST' }))
assert.equal(err.message, 'Stripe ist gerade nicht erreichbar.')
assert.equal(err.status, 502)
assert.equal(err.invoiceId, 'in_9')
assert.equal(err.invoiceNumber, 'WK-0009')
// JSON ohne error-Feld: Statusmeldung, nicht die Portal-Meldung
const { api: api2 } = client({ responses: [reply(503, {})] })
assert.equal((await fails(api2('/x'))).message, 'API-Fehler 503')
})
test('andere Fehler ohne JSON bleiben „API-Fehler <Status>“; mit JSON die Meldung des Portals', async () => {
const { api } = client({ responses: [reply(500, undefined), reply(403, { error: 'Admin-Berechtigung erforderlich' }), reply(409, null)] })
const e500 = await fails(api('/x'))
assert.equal(e500.message, 'API-Fehler 500')
assert.equal(e500.status, 500)
const e403 = await fails(api('/x'))
assert.equal(e403.message, 'Admin-Berechtigung erforderlich')
assert.equal(e403.status, 403)
// JSON-null: keine Ausnahme beim Lesen des Fehlers
assert.equal((await fails(api('/x'))).message, 'API-Fehler 409')
})
test('Erfolg ohne lesbaren Body liefert ein leeres Objekt', async () => {
const { api } = client({ responses: [reply(200, undefined)] })
assert.deepEqual(await api('/x'), {})
})
test('createJWT scheitert: „Anmeldung abgelaufen. Bitte neu anmelden.“ (status 401), kein Aufruf am Portal', async () => {
const { api, calls } = client({ createJWT: async () => { throw new Error('User (role: guests) missing scope (account)') } })
const err = await fails(api('/uebersicht'))
assert.equal(SESSION_EXPIRED_TEXT, 'Anmeldung abgelaufen. Bitte neu anmelden.')
assert.equal(err.message, SESSION_EXPIRED_TEXT)
assert.equal(err.status, 401)
assert.equal(calls.length, 0)
})
test('401 des Portals: einmal mit frischem JWT wiederholen; scheitert dann createJWT, kommt die Anmeldemeldung', async () => {
let n = 0
const { api, calls } = client({
createJWT: async () => {
n += 1
if (n === 1) return { jwt: 'old' }
throw new Error('Session expired')
},
responses: [reply(401, { error: 'abgelaufen' })],
})
const err = await fails(api('/uebersicht'))
assert.equal(calls.length, 1)
assert.equal(calls[0].init.headers.Authorization, 'Bearer old')
assert.equal(err.message, SESSION_EXPIRED_TEXT)
let m = 0
const retry = client({
createJWT: async () => ({ jwt: `jwt-${++m}` }),
responses: [reply(401, { error: 'abgelaufen' }), reply(200, { ok: true })],
})
assert.deepEqual(await retry.api('/uebersicht'), { ok: true })
assert.deepEqual(retry.calls.map((c) => c.init.headers.Authorization), ['Bearer jwt-1', 'Bearer jwt-2'])
})
+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')
})
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { startsCollapsed, AUTO_COLLAPSE_MAX_WIDTH } = await loadModule('src/lib/sidebar.js')
// Fenster mit matchMedia, das "(max-width: Npx)" gegen eine feste Breite prueft
const windowOf = (width) => ({
matchMedia: (query) => ({ matches: width <= Number(/max-width:\s*(\d+)px/.exec(query)?.[1]) }),
})
test('startsCollapsed: Handy (390 px) und Grenze (720 px) starten eingeklappt', () => {
assert.equal(AUTO_COLLAPSE_MAX_WIDTH, 720)
assert.equal(startsCollapsed(windowOf(390)), true)
assert.equal(startsCollapsed(windowOf(720)), true)
})
test('startsCollapsed: breitere Fenster starten ausgeklappt', () => {
assert.equal(startsCollapsed(windowOf(721)), false)
assert.equal(startsCollapsed(windowOf(1280)), false)
})
test('startsCollapsed: ohne window oder matchMedia (oder bei Fehler) ausgeklappt', () => {
assert.equal(startsCollapsed(null), false)
assert.equal(startsCollapsed({}), false)
assert.equal(startsCollapsed({ matchMedia: () => { throw new Error('kaputt') } }), false)
})