Compare commits
13
Commits
8ecf07fecd
...
72f07410fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72f07410fe | ||
|
|
1e72faf97c | ||
|
|
e9cfae28ee | ||
|
|
0b1101d384 | ||
|
|
edf118a044 | ||
|
|
2aacdb2d56 | ||
|
|
24a3cc41ef | ||
|
|
1feeb18c08 | ||
|
|
18bc3012e0 | ||
|
|
3826f1ab72 | ||
|
|
88a98b858f | ||
|
|
0069a62a09 | ||
|
|
c910bedcec |
No files matched your search
@@ -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
@@ -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>} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { FaFilePdf, FaPlus, FaLink, FaSpinner, FaRotate } from 'react-icons/fa6'
|
||||
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
|
||||
import { integrationsApi, formatMoney, INVOICE_STATUS } from '../lib/integrationsApi'
|
||||
import { useInvoices } from '../hooks/useInvoices'
|
||||
import { customerStage, isInvoiceReady } from '../lib/leads'
|
||||
|
||||
const card = {
|
||||
background: 'rgba(45, 55, 72, 0.5)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
}
|
||||
|
||||
export default function InvoicePanel({ ticket }) {
|
||||
const [customer, setCustomer] = useState(null)
|
||||
const [customerLoading, setCustomerLoading] = useState(true)
|
||||
const [customerError, setCustomerError] = useState(null)
|
||||
|
||||
const loadCustomer = useCallback(async () => {
|
||||
if (!ticket.customerId) {
|
||||
setCustomer(null)
|
||||
setCustomerLoading(false)
|
||||
return
|
||||
}
|
||||
setCustomerLoading(true)
|
||||
try {
|
||||
const doc = await databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ticket.customerId)
|
||||
setCustomer(doc)
|
||||
setCustomerError(null)
|
||||
} catch (err) {
|
||||
setCustomerError(err.message)
|
||||
setCustomer(null)
|
||||
} finally {
|
||||
setCustomerLoading(false)
|
||||
}
|
||||
}, [ticket.customerId])
|
||||
|
||||
useEffect(() => {
|
||||
loadCustomer()
|
||||
}, [loadCustomer])
|
||||
|
||||
const clientId = customer?.invoiceNinjaClientId || null
|
||||
const { invoices, loading, error, refresh } = useInvoices(clientId)
|
||||
|
||||
if (customerLoading) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<FaSpinner className="spinner" /> Kunde wird geladen...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!ticket.customerId) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<p style={{ color: '#a0aec0', margin: 0 }}>Kein Kunde am Ticket verknüpft, keine Rechnungen verfügbar.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', margin: 0, fontSize: '16px' }}>
|
||||
Rechnungen {customer?.name ? `- ${customer.name}` : ''}
|
||||
</h5>
|
||||
{clientId && (
|
||||
<button className="btn" onClick={refresh} title="Aktualisieren"
|
||||
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}>
|
||||
<FaRotate />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{customerError && <div className="text-red" style={{ marginBottom: 8 }}>{customerError}</div>}
|
||||
|
||||
{customer && customerStage(customer) === 'lead' ? (
|
||||
<p style={{ color: '#a0aec0', margin: 0 }}>
|
||||
Dieser Kunde ist noch ein Lead. Erst zum festen Kunden machen (im Kunden-Bereich, vollständige Daten nötig), dann sind Rechnungen möglich.
|
||||
</p>
|
||||
) : !clientId && !isInvoiceReady(customer) ? (
|
||||
<p style={{ color: '#a0aec0', margin: 0 }}>
|
||||
Rechnungsdaten unvollständig. Bitte im Kunden-Bereich Unternehmen, E-Mail, Straße, PLZ und Stadt ergänzen.
|
||||
</p>
|
||||
) : !clientId ? (
|
||||
<LinkClient customer={customer} onLinked={loadCustomer} />
|
||||
) : (
|
||||
<InvoiceList invoices={invoices} loading={loading} error={error} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- Verknuepfung -----------
|
||||
function LinkClient({ customer, onLinked }) {
|
||||
const [search, setSearch] = useState(customer?.name || '')
|
||||
const [results, setResults] = useState([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const doSearch = async () => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await integrationsApi.listClients(search)
|
||||
setResults(data.clients || [])
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
doSearch()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const saveMapping = async (clientId) => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
|
||||
invoiceNinjaClientId: clientId,
|
||||
})
|
||||
onLinked()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createAndLink = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await integrationsApi.createClient({
|
||||
name: customer.name || customer.companyName || 'Neuer Kunde',
|
||||
email: customer.email || '',
|
||||
phone: customer.phone || '',
|
||||
address: customer.location || '',
|
||||
})
|
||||
if (data.client?.id) {
|
||||
await saveMapping(data.client.id)
|
||||
} else {
|
||||
throw new Error('Client konnte nicht angelegt werden')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ color: '#a0aec0', fontSize: '13px', marginTop: 0 }}>
|
||||
Dieser Kunde ist noch nicht mit InvoiceNinja verknüpft.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
|
||||
<input
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
placeholder="InvoiceNinja-Kunde suchen …"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && doSearch()}
|
||||
/>
|
||||
<button className="btn btn-green" style={{ margin: 0 }} onClick={doSearch} disabled={busy}>
|
||||
{busy ? <FaSpinner className="spinner" /> : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
|
||||
<div style={{ maxHeight: '180px', overflowY: 'auto', marginBottom: '10px' }}>
|
||||
{results.map((c) => (
|
||||
<div key={c.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<div>
|
||||
<strong style={{ color: 'var(--dark-text)' }}>{c.name}</strong>
|
||||
{c.number ? <span style={{ color: '#a0aec0' }}> · {c.number}</span> : null}
|
||||
{c.email ? <div style={{ color: '#a0aec0', fontSize: '12px' }}>{c.email}</div> : null}
|
||||
</div>
|
||||
<button className="btn btn-green" style={{ margin: 0 }} disabled={saving} onClick={() => saveMapping(c.id)}>
|
||||
<FaLink /> Verknüpfen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!busy && results.length === 0 && (
|
||||
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Keine Treffer.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="btn" style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '10px 16px', borderRadius: '8px', cursor: 'pointer' }} disabled={saving} onClick={createAndLink}>
|
||||
{saving ? <FaSpinner className="spinner" /> : <><FaPlus /> Neuen InvoiceNinja-Kunden anlegen & verknüpfen</>}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- Rechnungsliste ---------
|
||||
// Nur anzeigen: neue Rechnungen entstehen direkt in InvoiceNinja
|
||||
function InvoiceList({ invoices, loading, error }) {
|
||||
const [openingId, setOpeningId] = useState(null)
|
||||
|
||||
const openPdf = async (id) => {
|
||||
setOpeningId(id)
|
||||
try {
|
||||
await integrationsApi.openInvoicePdf(id)
|
||||
} catch (err) {
|
||||
alert('PDF konnte nicht geöffnet werden: ' + err.message)
|
||||
} finally {
|
||||
setOpeningId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Rechnungen werden geladen...</p>
|
||||
) : invoices.length === 0 ? (
|
||||
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Noch keine Rechnungen für diesen Kunden.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table" style={{ width: '100%', fontSize: '13px' }}>
|
||||
<thead>
|
||||
<tr style={{ color: '#a0aec0', textAlign: 'left' }}>
|
||||
<th>Nr.</th><th>Datum</th><th>Fällig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((inv) => {
|
||||
const st = INVOICE_STATUS[inv.status_id] || { label: '–', color: '#a0aec0' }
|
||||
const overdue = Number(inv.balance) > 0 && inv.due_date && new Date(inv.due_date) < new Date()
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ color: 'var(--dark-text)' }}>{inv.number || inv.id?.slice(-6)}</td>
|
||||
<td style={{ color: '#cbd5e0' }}>{inv.date || '-'}</td>
|
||||
<td style={{ color: overdue ? '#ef4444' : '#cbd5e0' }}>{inv.due_date || '-'}</td>
|
||||
<td style={{ color: 'var(--dark-text)' }}>{formatMoney(inv.amount)}</td>
|
||||
<td style={{ color: Number(inv.balance) > 0 ? '#f59e0b' : '#10b981' }}>{formatMoney(inv.balance)}</td>
|
||||
<td><span style={{ color: st.color, fontWeight: 'bold' }}>{st.label}{overdue ? ' (überfällig)' : ''}</span></td>
|
||||
<td>
|
||||
<button className="btn" title="PDF öffnen" disabled={openingId === inv.id}
|
||||
style={{ background: '#ef4444', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
|
||||
onClick={() => openPdf(inv.id)}>
|
||||
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -12,7 +12,6 @@ import OwnSupportModal from './OwnSupportModal'
|
||||
import WorksheetSummary from './WorksheetSummary'
|
||||
import TicketProjects from './TicketProjects'
|
||||
import ProjectContextPanel from './ProjectContextPanel'
|
||||
import InvoicePanel from './InvoicePanel'
|
||||
import Tabs from './ui/Tabs'
|
||||
import Badge from './ui/Badge'
|
||||
import { StatusPill, UrgentPill } from './ui/StatusPill'
|
||||
@@ -28,7 +27,6 @@ import { ticketTypeLabel } from '../lib/ticketStatus'
|
||||
const TABS = [
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'worksheets', label: 'Arbeitsblätter' },
|
||||
{ id: 'invoices', label: 'Rechnungen' },
|
||||
]
|
||||
|
||||
export default function TicketCard({ ticket, onUpdate }) {
|
||||
@@ -155,8 +153,6 @@ export default function TicketCard({ ticket, onUpdate }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FaPlus, FaSpinner } from 'react-icons/fa6'
|
||||
import { Badge, Button, Card, EmptyState } from '../ui'
|
||||
import FormField from './FormField'
|
||||
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
||||
import { createFixedCost, deleteFixedCost, getFixedCosts, updateFixedCost } from '../../lib/financeApi'
|
||||
import {
|
||||
FIXED_COST_DELETE_CONFIRM,
|
||||
FIXED_COST_FIELD_ORDER,
|
||||
INTERVAL_OPTIONS,
|
||||
SETTINGS_LIMITS,
|
||||
apiErrorMessage,
|
||||
emptyFixedCostForm,
|
||||
fixedCostRows,
|
||||
fixedCostToForm,
|
||||
validateFixedCostForm,
|
||||
} from '../../lib/financeSettings'
|
||||
|
||||
const LOAD_ERROR = 'Die Fixkosten konnten nicht geladen werden.'
|
||||
const fieldId = (name) => `fin-fix-${name}`
|
||||
|
||||
/**
|
||||
* Karte „Fixkosten“: regelmäßige Ausgaben (Name, Betrag in Euro, Rhythmus, nächster Termin, Suchbegriff). Anlegen und
|
||||
* Bearbeiten im Formular über der Liste, „Auf inaktiv setzen“ / „Aktivieren“ je Zeile, Löschen mit Rückfrage
|
||||
* (Besser inaktiv: bereits zugeordnete Buchungen behalten den Verweis). Es läuft immer nur eine Anfrage; danach wird
|
||||
* die Liste neu geladen.
|
||||
*/
|
||||
export default function FixedCostsCard() {
|
||||
const res = useFinanceResource(getFixedCosts, LOAD_ERROR)
|
||||
// null = Formular zu; { id: null } = neue Position; { id } = Position bearbeiten
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [form, setForm] = useState(emptyFixedCostForm)
|
||||
const [errors, setErrors] = useState({})
|
||||
const [busy, setBusy] = useState(null) // 'save' | `${id}:toggle` | `${id}:delete`
|
||||
const [message, setMessage] = useState(null) // { tone: 'ok' | 'warn', text }
|
||||
const lockRef = useRef(false)
|
||||
const aliveRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
return () => {
|
||||
aliveRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const list = res.data?.fixkosten
|
||||
const rows = useMemo(() => fixedCostRows(list), [list])
|
||||
const byId = useMemo(() => Object.fromEntries((Array.isArray(list) ? list : []).map((p) => [p.$id, p])), [list])
|
||||
|
||||
// Der Fokus geht beim Öffnen des Formulars ins erste Feld
|
||||
const formKey = editing ? editing.id || 'new' : ''
|
||||
useEffect(() => {
|
||||
if (formKey) document.getElementById(fieldId('name'))?.focus()
|
||||
}, [formKey])
|
||||
|
||||
function openNew() {
|
||||
if (lockRef.current) return
|
||||
setEditing({ id: null })
|
||||
setForm(emptyFixedCostForm())
|
||||
setErrors({})
|
||||
setMessage(null)
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
if (lockRef.current) return
|
||||
setEditing({ id: row.id })
|
||||
setForm(fixedCostToForm(byId[row.id]))
|
||||
setErrors({})
|
||||
setMessage(null)
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
if (lockRef.current) return
|
||||
setEditing(null)
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
const change = (name) => (e) => {
|
||||
const value = e.target.value
|
||||
setForm((f) => ({ ...f, [name]: value }))
|
||||
setErrors((er) => (er[name] ? { ...er, [name]: undefined } : er))
|
||||
}
|
||||
|
||||
// Eine Anfrage nach der anderen; danach die Liste neu laden (der Server ist die Wahrheit)
|
||||
async function run(key, call, okText, { onOk } = {}) {
|
||||
if (lockRef.current) return
|
||||
lockRef.current = true
|
||||
setBusy(key)
|
||||
setMessage(null)
|
||||
try {
|
||||
await call()
|
||||
// Erst die neue Liste, dann die Meldung: sie soll nie einen Stand beschreiben, den die Liste noch nicht zeigt
|
||||
await res.reload()
|
||||
if (aliveRef.current) {
|
||||
setMessage({ tone: 'ok', text: okText })
|
||||
if (onOk) onOk()
|
||||
}
|
||||
} catch (err) {
|
||||
if (aliveRef.current) {
|
||||
setMessage({ tone: 'warn', text: apiErrorMessage(err, 'Die Anfrage ist fehlgeschlagen.') })
|
||||
}
|
||||
// Der Stand kann ein anderer sein (Position schon gelöscht): neu laden
|
||||
await res.reload()
|
||||
} finally {
|
||||
lockRef.current = false
|
||||
if (aliveRef.current) setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault()
|
||||
if (lockRef.current || !editing) return
|
||||
const check = validateFixedCostForm(form)
|
||||
if (!check.ok) {
|
||||
setErrors(check.errors)
|
||||
setMessage({ tone: 'warn', text: 'Bitte die markierten Felder prüfen. Es wurde nichts gespeichert.' })
|
||||
const first = FIXED_COST_FIELD_ORDER.find((name) => check.errors[name])
|
||||
if (first) document.getElementById(fieldId(first))?.focus()
|
||||
return
|
||||
}
|
||||
const id = editing.id
|
||||
run(
|
||||
'save',
|
||||
() => (id ? updateFixedCost(id, check.payload) : createFixedCost(check.payload)),
|
||||
id ? 'Position gespeichert.' : 'Position angelegt.',
|
||||
{ onOk: () => { setEditing(null); setErrors({}) } }
|
||||
)
|
||||
}
|
||||
|
||||
function toggle(row) {
|
||||
run(
|
||||
`${row.id}:toggle`,
|
||||
() => updateFixedCost(row.id, { active: !row.active }),
|
||||
row.active ? `„${row.name}“ ist jetzt inaktiv.` : `„${row.name}“ ist wieder aktiv.`
|
||||
)
|
||||
}
|
||||
|
||||
function remove(row) {
|
||||
if (lockRef.current) return
|
||||
if (!window.confirm(FIXED_COST_DELETE_CONFIRM)) return
|
||||
run(`${row.id}:delete`, () => deleteFixedCost(row.id), `„${row.name}“ wurde gelöscht.`, {
|
||||
onOk: () => { if (editing?.id === row.id) setEditing(null) },
|
||||
})
|
||||
}
|
||||
|
||||
let body
|
||||
if (!res.data) {
|
||||
body = res.loading ? (
|
||||
<div className="empty" role="status">
|
||||
<FaSpinner className="spinner" aria-hidden="true" />
|
||||
<p className="muted">Fixkosten werden geladen …</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fin-load-error" style={{ padding: 0 }}>
|
||||
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
|
||||
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
{res.error && (
|
||||
<p className="fin-hint fin-hint-warn" role="alert">
|
||||
Die Liste konnte nicht neu geladen werden: {res.error}{' '}
|
||||
<Button size="sm" variant="ghost" onClick={res.reload} disabled={res.loading}>Erneut versuchen</Button>
|
||||
</p>
|
||||
)}
|
||||
{editing && (
|
||||
<form className="fin-fixed-form" onSubmit={submit} noValidate aria-labelledby="fin-fix-form-title">
|
||||
<h4 id="fin-fix-form-title" className="fin-fixed-form-title">{editing.id ? 'Position bearbeiten' : 'Neue Position'}</h4>
|
||||
<fieldset className="fin-fieldset" disabled={busy === 'save'}>
|
||||
<div className="fin-form-grid">
|
||||
<FormField id={fieldId('name')} label="Name" error={errors.name}>
|
||||
<input className="form-control" autoComplete="off" value={form.name} onChange={change('name')} placeholder="z. B. Hetzner" />
|
||||
</FormField>
|
||||
<FormField id={fieldId('amount')} label="Betrag (€)" error={errors.amount}>
|
||||
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.amount} onChange={change('amount')} placeholder="z. B. 49,90" />
|
||||
</FormField>
|
||||
<FormField id={fieldId('interval')} label="Rhythmus" error={errors.interval}>
|
||||
<select className="form-control" value={form.interval} onChange={change('interval')}>
|
||||
{INTERVAL_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField id={fieldId('nextDate')} label="Nächster Termin" error={errors.nextDate}>
|
||||
<input className="form-control" type="date" value={form.nextDate} onChange={change('nextDate')} />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
id={fieldId('counterpartyMatch')}
|
||||
label="Suchbegriff in der Buchung (optional)"
|
||||
hint={`Teil des Namens der Gegenpartei in der Bankbuchung, z. B. hetzner (höchstens ${SETTINGS_LIMITS.maxMatch} Zeichen). Damit schlägt die Seite diese Abbuchung nicht noch einmal als neue Fixkosten vor.`}
|
||||
error={errors.counterpartyMatch}
|
||||
>
|
||||
<input className="form-control" autoComplete="off" value={form.counterpartyMatch} onChange={change('counterpartyMatch')} />
|
||||
</FormField>
|
||||
</fieldset>
|
||||
<div className="fin-form-actions">
|
||||
<Button type="submit" variant="primary" disabled={busy !== null}>
|
||||
{busy === 'save' ? 'Speichere …' : 'Speichern'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={closeForm} disabled={busy !== null}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{message && (
|
||||
<p className={`fin-hint fin-form-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState title="Noch keine Fixkosten." hint="Lege regelmäßige Ausgaben an, damit Vorschau und „Verfügbar“ sie berücksichtigen." />
|
||||
) : (
|
||||
<ul className="fin-fixed-list" aria-label="Fixkosten">
|
||||
{rows.map((row) => (
|
||||
<li key={row.key} className={`fin-fixed${row.active ? '' : ' fin-fixed-off'}`}>
|
||||
<div className="fin-fixed-main">
|
||||
<span className="fin-fixed-name">{row.name}</span>
|
||||
<Badge tone={row.active ? 'ok' : 'muted'}>{row.statusLabel}</Badge>
|
||||
</div>
|
||||
<div className="fin-amount fin-fixed-amount">{row.amountText}</div>
|
||||
<div className="fin-fixed-meta muted">{row.metaText}</div>
|
||||
<div className="fin-actions fin-fixed-actions">
|
||||
<Button size="sm" variant="ghost" onClick={() => openEdit(row)} disabled={busy !== null} aria-label={`Bearbeiten: ${row.name}`}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => toggle(row)} disabled={busy !== null} aria-label={`${row.toggleLabel}: ${row.name}`}>
|
||||
{busy === `${row.id}:toggle` ? 'Speichere …' : row.toggleLabel}
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => remove(row)} disabled={busy !== null} aria-label={`Löschen: ${row.name}`}>
|
||||
{busy === `${row.id}:delete` ? 'Lösche …' : 'Löschen'}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Fixkosten"
|
||||
actions={
|
||||
res.data && (
|
||||
<Button size="sm" variant="primary" onClick={openNew} disabled={busy !== null || editing?.id === null}>
|
||||
<FaPlus aria-hidden="true" /> Neue Position
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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' })
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 ?? {}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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), '–')
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user