Finanzen: Einstellungen, Rechnungen am Kunden, InvoiceNinja aus der Oberflaeche entfernt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
24a3cc41ef
commit
2aacdb2d56
24 files changed
+1409
-498
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).
|
||||
@@ -10,6 +10,7 @@ import AdminPage from './pages/AdminPage'
|
||||
import CustomersPage from './pages/CustomersPage'
|
||||
import CustomerDetailPage from './pages/CustomerDetailPage'
|
||||
import FinancePage from './pages/FinancePage'
|
||||
import FinanceSettingsPage from './pages/FinanceSettingsPage'
|
||||
import LegalPage from './pages/LegalPage'
|
||||
import AssignProjectPage from './pages/AssignProjectPage'
|
||||
import ServicesPage from './pages/ServicesPage'
|
||||
@@ -52,6 +53,7 @@ function AppRoutes() {
|
||||
<Route path="/leads" element={<Navigate to="/tickets" replace />} />
|
||||
{/* Finanzen: nur Admins, alle anderen gehen zu /tickets */}
|
||||
<Route path="/finance" element={<ProtectedRoute><AdminRoute><FinancePage /></AdminRoute></ProtectedRoute>} />
|
||||
<Route path="/finance/einstellungen" element={<ProtectedRoute><AdminRoute><FinanceSettingsPage /></AdminRoute></ProtectedRoute>} />
|
||||
<Route path="/finanzen/bank-callback" element={<ProtectedRoute><AdminRoute><BankCallbackPage /></AdminRoute></ProtectedRoute>} />
|
||||
<Route path="/rechtliches" element={<ProtectedRoute><LegalPage /></ProtectedRoute>} />
|
||||
<Route path="/serverpuls" element={<ProtectedRoute><ServerpulsPage /></ProtectedRoute>} />
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { FaFilePdf, FaPlus, FaLink, FaSpinner, FaRotate } from 'react-icons/fa6'
|
||||
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
|
||||
import { integrationsApi, formatMoney, INVOICE_STATUS } from '../lib/integrationsApi'
|
||||
import { useInvoices } from '../hooks/useInvoices'
|
||||
import { customerStage, isInvoiceReady } from '../lib/leads'
|
||||
|
||||
const card = {
|
||||
background: 'rgba(45, 55, 72, 0.5)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
}
|
||||
|
||||
export default function InvoicePanel({ ticket }) {
|
||||
const [customer, setCustomer] = useState(null)
|
||||
const [customerLoading, setCustomerLoading] = useState(true)
|
||||
const [customerError, setCustomerError] = useState(null)
|
||||
|
||||
const loadCustomer = useCallback(async () => {
|
||||
if (!ticket.customerId) {
|
||||
setCustomer(null)
|
||||
setCustomerLoading(false)
|
||||
return
|
||||
}
|
||||
setCustomerLoading(true)
|
||||
try {
|
||||
const doc = await databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ticket.customerId)
|
||||
setCustomer(doc)
|
||||
setCustomerError(null)
|
||||
} catch (err) {
|
||||
setCustomerError(err.message)
|
||||
setCustomer(null)
|
||||
} finally {
|
||||
setCustomerLoading(false)
|
||||
}
|
||||
}, [ticket.customerId])
|
||||
|
||||
useEffect(() => {
|
||||
loadCustomer()
|
||||
}, [loadCustomer])
|
||||
|
||||
const clientId = customer?.invoiceNinjaClientId || null
|
||||
const { invoices, loading, error, refresh } = useInvoices(clientId)
|
||||
|
||||
if (customerLoading) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<FaSpinner className="spinner" /> Kunde wird geladen...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!ticket.customerId) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<p style={{ color: '#a0aec0', margin: 0 }}>Kein Kunde am Ticket verknüpft, keine Rechnungen verfügbar.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', margin: 0, fontSize: '16px' }}>
|
||||
Rechnungen {customer?.name ? `- ${customer.name}` : ''}
|
||||
</h5>
|
||||
{clientId && (
|
||||
<button className="btn" onClick={refresh} title="Aktualisieren"
|
||||
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}>
|
||||
<FaRotate />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{customerError && <div className="text-red" style={{ marginBottom: 8 }}>{customerError}</div>}
|
||||
|
||||
{customer && customerStage(customer) === 'lead' ? (
|
||||
<p style={{ color: '#a0aec0', margin: 0 }}>
|
||||
Dieser Kunde ist noch ein Lead. Erst zum festen Kunden machen (im Kunden-Bereich, vollständige Daten nötig), dann sind Rechnungen möglich.
|
||||
</p>
|
||||
) : !clientId && !isInvoiceReady(customer) ? (
|
||||
<p style={{ color: '#a0aec0', margin: 0 }}>
|
||||
Rechnungsdaten unvollständig. Bitte im Kunden-Bereich Unternehmen, E-Mail, Straße, PLZ und Stadt ergänzen.
|
||||
</p>
|
||||
) : !clientId ? (
|
||||
<LinkClient customer={customer} onLinked={loadCustomer} />
|
||||
) : (
|
||||
<InvoiceList invoices={invoices} loading={loading} error={error} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- Verknuepfung -----------
|
||||
function LinkClient({ customer, onLinked }) {
|
||||
const [search, setSearch] = useState(customer?.name || '')
|
||||
const [results, setResults] = useState([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const doSearch = async () => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await integrationsApi.listClients(search)
|
||||
setResults(data.clients || [])
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
doSearch()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const saveMapping = async (clientId) => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, {
|
||||
invoiceNinjaClientId: clientId,
|
||||
})
|
||||
onLinked()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createAndLink = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await integrationsApi.createClient({
|
||||
name: customer.name || customer.companyName || 'Neuer Kunde',
|
||||
email: customer.email || '',
|
||||
phone: customer.phone || '',
|
||||
address: customer.location || '',
|
||||
})
|
||||
if (data.client?.id) {
|
||||
await saveMapping(data.client.id)
|
||||
} else {
|
||||
throw new Error('Client konnte nicht angelegt werden')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ color: '#a0aec0', fontSize: '13px', marginTop: 0 }}>
|
||||
Dieser Kunde ist noch nicht mit InvoiceNinja verknüpft.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
|
||||
<input
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
placeholder="InvoiceNinja-Kunde suchen …"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && doSearch()}
|
||||
/>
|
||||
<button className="btn btn-green" style={{ margin: 0 }} onClick={doSearch} disabled={busy}>
|
||||
{busy ? <FaSpinner className="spinner" /> : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
|
||||
<div style={{ maxHeight: '180px', overflowY: 'auto', marginBottom: '10px' }}>
|
||||
{results.map((c) => (
|
||||
<div key={c.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<div>
|
||||
<strong style={{ color: 'var(--dark-text)' }}>{c.name}</strong>
|
||||
{c.number ? <span style={{ color: '#a0aec0' }}> · {c.number}</span> : null}
|
||||
{c.email ? <div style={{ color: '#a0aec0', fontSize: '12px' }}>{c.email}</div> : null}
|
||||
</div>
|
||||
<button className="btn btn-green" style={{ margin: 0 }} disabled={saving} onClick={() => saveMapping(c.id)}>
|
||||
<FaLink /> Verknüpfen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!busy && results.length === 0 && (
|
||||
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Keine Treffer.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="btn" style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '10px 16px', borderRadius: '8px', cursor: 'pointer' }} disabled={saving} onClick={createAndLink}>
|
||||
{saving ? <FaSpinner className="spinner" /> : <><FaPlus /> Neuen InvoiceNinja-Kunden anlegen & verknüpfen</>}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- Rechnungsliste ---------
|
||||
// Nur anzeigen: neue Rechnungen entstehen direkt in InvoiceNinja
|
||||
function InvoiceList({ invoices, loading, error }) {
|
||||
const [openingId, setOpeningId] = useState(null)
|
||||
|
||||
const openPdf = async (id) => {
|
||||
setOpeningId(id)
|
||||
try {
|
||||
await integrationsApi.openInvoicePdf(id)
|
||||
} catch (err) {
|
||||
alert('PDF konnte nicht geöffnet werden: ' + err.message)
|
||||
} finally {
|
||||
setOpeningId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Rechnungen werden geladen...</p>
|
||||
) : invoices.length === 0 ? (
|
||||
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Noch keine Rechnungen für diesen Kunden.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table" style={{ width: '100%', fontSize: '13px' }}>
|
||||
<thead>
|
||||
<tr style={{ color: '#a0aec0', textAlign: 'left' }}>
|
||||
<th>Nr.</th><th>Datum</th><th>Fällig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((inv) => {
|
||||
const st = INVOICE_STATUS[inv.status_id] || { label: '–', color: '#a0aec0' }
|
||||
const overdue = Number(inv.balance) > 0 && inv.due_date && new Date(inv.due_date) < new Date()
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ color: 'var(--dark-text)' }}>{inv.number || inv.id?.slice(-6)}</td>
|
||||
<td style={{ color: '#cbd5e0' }}>{inv.date || '-'}</td>
|
||||
<td style={{ color: overdue ? '#ef4444' : '#cbd5e0' }}>{inv.due_date || '-'}</td>
|
||||
<td style={{ color: 'var(--dark-text)' }}>{formatMoney(inv.amount)}</td>
|
||||
<td style={{ color: Number(inv.balance) > 0 ? '#f59e0b' : '#10b981' }}>{formatMoney(inv.balance)}</td>
|
||||
<td><span style={{ color: st.color, fontWeight: 'bold' }}>{st.label}{overdue ? ' (überfällig)' : ''}</span></td>
|
||||
<td>
|
||||
<button className="btn" title="PDF öffnen" disabled={openingId === inv.id}
|
||||
style={{ background: '#ef4444', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
|
||||
onClick={() => openPdf(inv.id)}>
|
||||
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import OwnSupportModal from './OwnSupportModal'
|
||||
import WorksheetSummary from './WorksheetSummary'
|
||||
import TicketProjects from './TicketProjects'
|
||||
import ProjectContextPanel from './ProjectContextPanel'
|
||||
import InvoicePanel from './InvoicePanel'
|
||||
import Tabs from './ui/Tabs'
|
||||
import Badge from './ui/Badge'
|
||||
import { StatusPill, UrgentPill } from './ui/StatusPill'
|
||||
@@ -28,7 +27,6 @@ import { ticketTypeLabel } from '../lib/ticketStatus'
|
||||
const TABS = [
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'worksheets', label: 'Arbeitsblätter' },
|
||||
{ id: 'invoices', label: 'Rechnungen' },
|
||||
]
|
||||
|
||||
export default function TicketCard({ ticket, onUpdate }) {
|
||||
@@ -155,8 +153,6 @@ export default function TicketCard({ ticket, onUpdate }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FaRotate, FaSpinner } from 'react-icons/fa6'
|
||||
import { Badge, Button, Card } from '../ui'
|
||||
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
||||
import { getBank, refreshBank, startBankAuth } from '../../lib/financeApi'
|
||||
import { bankCardModel, refreshResultMessage } from '../../lib/financeSettings'
|
||||
import { safeUrl } from '../../lib/financeView'
|
||||
|
||||
const LOAD_ERROR = 'Der Bankzugang konnte nicht geladen werden.'
|
||||
|
||||
/**
|
||||
* Karte „Bankzugang“: Status, IBAN (maskiert), Zugang gültig bis, letzter Abruf und der letzte Fehler.
|
||||
* „Jetzt abrufen“ holt die Buchungen sofort (der Server begrenzt die Häufigkeit, die Meldung steht dann hier),
|
||||
* „Neu verbinden“ führt zur Freigabe bei der Bank (nur http/https-Adressen). Ohne Zugang gibt es nur „Bank verbinden“.
|
||||
*/
|
||||
export default function BankAccessCard() {
|
||||
const res = useFinanceResource(getBank, LOAD_ERROR)
|
||||
const [busy, setBusy] = useState(null) // 'refresh' | 'connect'
|
||||
const [message, setMessage] = useState(null) // { tone: 'ok' | 'info' | 'warn', text }
|
||||
const lockRef = useRef(false)
|
||||
const aliveRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
return () => {
|
||||
aliveRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function refresh() {
|
||||
if (lockRef.current) return
|
||||
lockRef.current = true
|
||||
setBusy('refresh')
|
||||
setMessage(null)
|
||||
try {
|
||||
const result = await refreshBank()
|
||||
await res.reload()
|
||||
if (aliveRef.current) setMessage(refreshResultMessage(result))
|
||||
} catch (err) {
|
||||
// 429 „Bitte in N Minuten erneut versuchen.“ ist kein Fehler, nur ein Hinweis
|
||||
if (aliveRef.current) setMessage({ tone: err?.status === 429 ? 'info' : 'warn', text: err?.message || 'Der Abruf ist fehlgeschlagen.' })
|
||||
} finally {
|
||||
lockRef.current = false
|
||||
if (aliveRef.current) setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (lockRef.current) return
|
||||
lockRef.current = true
|
||||
setBusy('connect')
|
||||
setMessage(null)
|
||||
try {
|
||||
const { url } = await startBankAuth()
|
||||
const target = safeUrl(url)
|
||||
if (!target) throw new Error('Die Bank hat keine Weiterleitung geliefert.')
|
||||
window.location.assign(target)
|
||||
} catch (err) {
|
||||
if (aliveRef.current) setMessage({ tone: 'warn', text: err?.message || 'Die Verbindung konnte nicht gestartet werden.' })
|
||||
} finally {
|
||||
lockRef.current = false
|
||||
if (aliveRef.current) setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.data) {
|
||||
return (
|
||||
<Card title="Bankzugang">
|
||||
{res.loading ? (
|
||||
<div className="empty" role="status">
|
||||
<FaSpinner className="spinner" aria-hidden="true" />
|
||||
<p className="muted">Bankzugang wird geladen …</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fin-load-error" style={{ padding: 0 }}>
|
||||
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
|
||||
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const model = bankCardModel(res.data.verbindung)
|
||||
return (
|
||||
<Card title="Bankzugang" actions={<Badge tone={model.tone}>{model.statusLabel}</Badge>}>
|
||||
{res.error && (
|
||||
<p className="fin-hint fin-hint-warn fin-form-notice" role="alert">
|
||||
Der Stand konnte nicht neu geladen werden: {res.error}
|
||||
</p>
|
||||
)}
|
||||
{model.rows.length > 0 && (
|
||||
<dl className="fin-bank-facts">
|
||||
{model.rows.map((row) => (
|
||||
<div key={row.key}>
|
||||
<dt>{row.label}</dt>
|
||||
<dd>{row.text}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{model.error && (
|
||||
<p className="fin-hint fin-hint-warn fin-form-notice" role="alert">Fehler: {model.error}</p>
|
||||
)}
|
||||
{model.hint && <p className="fin-hint fin-form-notice">{model.hint}</p>}
|
||||
{message && (
|
||||
<p className={`fin-hint fin-form-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
<div className="fin-form-actions">
|
||||
{model.canRefresh && (
|
||||
<Button variant="primary" onClick={refresh} disabled={busy !== null}>
|
||||
<FaRotate className={busy === 'refresh' ? 'fin-spin' : undefined} aria-hidden="true" /> {busy === 'refresh' ? 'Rufe ab …' : 'Jetzt abrufen'}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant={model.canRefresh ? 'ghost' : 'primary'} onClick={connect} disabled={busy !== null}>
|
||||
{busy === 'connect' ? 'Leite weiter …' : model.connectLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Button, Card, EmptyState } from '../ui'
|
||||
import InvoiceTable from './InvoiceTable'
|
||||
import { customerInvoicesGate } from '../../lib/financeView'
|
||||
|
||||
/**
|
||||
* Reiter „Rechnungen“ am Kunden. Kann für den Kunden keine Rechnung geschrieben werden (noch Lead, abgesagt, keine
|
||||
* E-Mail), steht der Grund da, mit „Daten erfassen“ (onShowOverview wechselt zum Reiter Übersicht). Sonst die
|
||||
* Rechnungen dieses Kunden mit „Rechnung schreiben“ (der Kunde steht im Dialog fest).
|
||||
*/
|
||||
export default function CustomerInvoices({ customer, onShowOverview }) {
|
||||
const gate = customerInvoicesGate(customer)
|
||||
if (!gate.ok) {
|
||||
return (
|
||||
<Card>
|
||||
<EmptyState
|
||||
title={gate.title}
|
||||
hint={gate.hint}
|
||||
action={onShowOverview ? <Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button> : null}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return <InvoiceTable customer={customer} />
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FaPlus, FaSpinner } from 'react-icons/fa6'
|
||||
import { Badge, Button, Card, EmptyState } from '../ui'
|
||||
import FormField from './FormField'
|
||||
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
||||
import { createFixedCost, deleteFixedCost, getFixedCosts, updateFixedCost } from '../../lib/financeApi'
|
||||
import {
|
||||
FIXED_COST_DELETE_CONFIRM,
|
||||
FIXED_COST_FIELD_ORDER,
|
||||
INTERVAL_OPTIONS,
|
||||
SETTINGS_LIMITS,
|
||||
apiErrorMessage,
|
||||
emptyFixedCostForm,
|
||||
fixedCostRows,
|
||||
fixedCostToForm,
|
||||
validateFixedCostForm,
|
||||
} from '../../lib/financeSettings'
|
||||
|
||||
const LOAD_ERROR = 'Die Fixkosten konnten nicht geladen werden.'
|
||||
const fieldId = (name) => `fin-fix-${name}`
|
||||
|
||||
/**
|
||||
* Karte „Fixkosten“: regelmäßige Ausgaben (Name, Betrag in Euro, Rhythmus, nächster Termin, Suchbegriff). Anlegen und
|
||||
* Bearbeiten im Formular über der Liste, „Auf inaktiv setzen“ / „Aktivieren“ je Zeile, Löschen mit Rückfrage
|
||||
* (Besser inaktiv: bereits zugeordnete Buchungen behalten den Verweis). Es läuft immer nur eine Anfrage; danach wird
|
||||
* die Liste neu geladen.
|
||||
*/
|
||||
export default function FixedCostsCard() {
|
||||
const res = useFinanceResource(getFixedCosts, LOAD_ERROR)
|
||||
// null = Formular zu; { id: null } = neue Position; { id } = Position bearbeiten
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [form, setForm] = useState(emptyFixedCostForm)
|
||||
const [errors, setErrors] = useState({})
|
||||
const [busy, setBusy] = useState(null) // 'save' | `${id}:toggle` | `${id}:delete`
|
||||
const [message, setMessage] = useState(null) // { tone: 'ok' | 'warn', text }
|
||||
const lockRef = useRef(false)
|
||||
const aliveRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
return () => {
|
||||
aliveRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const list = res.data?.fixkosten
|
||||
const rows = useMemo(() => fixedCostRows(list), [list])
|
||||
const byId = useMemo(() => Object.fromEntries((Array.isArray(list) ? list : []).map((p) => [p.$id, p])), [list])
|
||||
|
||||
// Der Fokus geht beim Öffnen des Formulars ins erste Feld
|
||||
const formKey = editing ? editing.id || 'new' : ''
|
||||
useEffect(() => {
|
||||
if (formKey) document.getElementById(fieldId('name'))?.focus()
|
||||
}, [formKey])
|
||||
|
||||
function openNew() {
|
||||
if (lockRef.current) return
|
||||
setEditing({ id: null })
|
||||
setForm(emptyFixedCostForm())
|
||||
setErrors({})
|
||||
setMessage(null)
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
if (lockRef.current) return
|
||||
setEditing({ id: row.id })
|
||||
setForm(fixedCostToForm(byId[row.id]))
|
||||
setErrors({})
|
||||
setMessage(null)
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
if (lockRef.current) return
|
||||
setEditing(null)
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
const change = (name) => (e) => {
|
||||
const value = e.target.value
|
||||
setForm((f) => ({ ...f, [name]: value }))
|
||||
setErrors((er) => (er[name] ? { ...er, [name]: undefined } : er))
|
||||
}
|
||||
|
||||
// Eine Anfrage nach der anderen; danach die Liste neu laden (der Server ist die Wahrheit)
|
||||
async function run(key, call, okText, { onOk } = {}) {
|
||||
if (lockRef.current) return
|
||||
lockRef.current = true
|
||||
setBusy(key)
|
||||
setMessage(null)
|
||||
try {
|
||||
await call()
|
||||
// Erst die neue Liste, dann die Meldung: sie soll nie einen Stand beschreiben, den die Liste noch nicht zeigt
|
||||
await res.reload()
|
||||
if (aliveRef.current) {
|
||||
setMessage({ tone: 'ok', text: okText })
|
||||
if (onOk) onOk()
|
||||
}
|
||||
} catch (err) {
|
||||
if (aliveRef.current) {
|
||||
setMessage({ tone: 'warn', text: apiErrorMessage(err, 'Die Anfrage ist fehlgeschlagen.') })
|
||||
}
|
||||
// Der Stand kann ein anderer sein (Position schon gelöscht): neu laden
|
||||
await res.reload()
|
||||
} finally {
|
||||
lockRef.current = false
|
||||
if (aliveRef.current) setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault()
|
||||
if (lockRef.current || !editing) return
|
||||
const check = validateFixedCostForm(form)
|
||||
if (!check.ok) {
|
||||
setErrors(check.errors)
|
||||
setMessage({ tone: 'warn', text: 'Bitte die markierten Felder prüfen. Es wurde nichts gespeichert.' })
|
||||
const first = FIXED_COST_FIELD_ORDER.find((name) => check.errors[name])
|
||||
if (first) document.getElementById(fieldId(first))?.focus()
|
||||
return
|
||||
}
|
||||
const id = editing.id
|
||||
run(
|
||||
'save',
|
||||
() => (id ? updateFixedCost(id, check.payload) : createFixedCost(check.payload)),
|
||||
id ? 'Position gespeichert.' : 'Position angelegt.',
|
||||
{ onOk: () => { setEditing(null); setErrors({}) } }
|
||||
)
|
||||
}
|
||||
|
||||
function toggle(row) {
|
||||
run(
|
||||
`${row.id}:toggle`,
|
||||
() => updateFixedCost(row.id, { active: !row.active }),
|
||||
row.active ? `„${row.name}“ ist jetzt inaktiv.` : `„${row.name}“ ist wieder aktiv.`
|
||||
)
|
||||
}
|
||||
|
||||
function remove(row) {
|
||||
if (lockRef.current) return
|
||||
if (!window.confirm(FIXED_COST_DELETE_CONFIRM)) return
|
||||
run(`${row.id}:delete`, () => deleteFixedCost(row.id), `„${row.name}“ wurde gelöscht.`, {
|
||||
onOk: () => { if (editing?.id === row.id) setEditing(null) },
|
||||
})
|
||||
}
|
||||
|
||||
let body
|
||||
if (!res.data) {
|
||||
body = res.loading ? (
|
||||
<div className="empty" role="status">
|
||||
<FaSpinner className="spinner" aria-hidden="true" />
|
||||
<p className="muted">Fixkosten werden geladen …</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fin-load-error" style={{ padding: 0 }}>
|
||||
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
|
||||
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<>
|
||||
{res.error && (
|
||||
<p className="fin-hint fin-hint-warn" role="alert">
|
||||
Die Liste konnte nicht neu geladen werden: {res.error}{' '}
|
||||
<Button size="sm" variant="ghost" onClick={res.reload} disabled={res.loading}>Erneut versuchen</Button>
|
||||
</p>
|
||||
)}
|
||||
{editing && (
|
||||
<form className="fin-fixed-form" onSubmit={submit} noValidate aria-labelledby="fin-fix-form-title">
|
||||
<h4 id="fin-fix-form-title" className="fin-fixed-form-title">{editing.id ? 'Position bearbeiten' : 'Neue Position'}</h4>
|
||||
<fieldset className="fin-fieldset" disabled={busy === 'save'}>
|
||||
<div className="fin-form-grid">
|
||||
<FormField id={fieldId('name')} label="Name" error={errors.name}>
|
||||
<input className="form-control" autoComplete="off" value={form.name} onChange={change('name')} placeholder="z. B. Hetzner" />
|
||||
</FormField>
|
||||
<FormField id={fieldId('amount')} label="Betrag (€)" error={errors.amount}>
|
||||
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.amount} onChange={change('amount')} placeholder="z. B. 49,90" />
|
||||
</FormField>
|
||||
<FormField id={fieldId('interval')} label="Rhythmus" error={errors.interval}>
|
||||
<select className="form-control" value={form.interval} onChange={change('interval')}>
|
||||
{INTERVAL_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField id={fieldId('nextDate')} label="Nächster Termin" error={errors.nextDate}>
|
||||
<input className="form-control" type="date" value={form.nextDate} onChange={change('nextDate')} />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
id={fieldId('counterpartyMatch')}
|
||||
label="Suchbegriff in der Buchung (optional)"
|
||||
hint={`Teil des Namens der Gegenpartei in der Bankbuchung, z. B. hetzner (höchstens ${SETTINGS_LIMITS.maxMatch} Zeichen). Damit schlägt die Seite diese Abbuchung nicht noch einmal als neue Fixkosten vor.`}
|
||||
error={errors.counterpartyMatch}
|
||||
>
|
||||
<input className="form-control" autoComplete="off" value={form.counterpartyMatch} onChange={change('counterpartyMatch')} />
|
||||
</FormField>
|
||||
</fieldset>
|
||||
<div className="fin-form-actions">
|
||||
<Button type="submit" variant="primary" disabled={busy !== null}>
|
||||
{busy === 'save' ? 'Speichere …' : 'Speichern'}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={closeForm} disabled={busy !== null}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{message && (
|
||||
<p className={`fin-hint fin-form-notice${message.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={message.tone === 'warn' ? 'alert' : 'status'}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState title="Noch keine Fixkosten." hint="Lege regelmäßige Ausgaben an, damit Vorschau und „Verfügbar“ sie berücksichtigen." />
|
||||
) : (
|
||||
<ul className="fin-fixed-list" aria-label="Fixkosten">
|
||||
{rows.map((row) => (
|
||||
<li key={row.key} className={`fin-fixed${row.active ? '' : ' fin-fixed-off'}`}>
|
||||
<div className="fin-fixed-main">
|
||||
<span className="fin-fixed-name">{row.name}</span>
|
||||
<Badge tone={row.active ? 'ok' : 'muted'}>{row.statusLabel}</Badge>
|
||||
</div>
|
||||
<div className="fin-amount fin-fixed-amount">{row.amountText}</div>
|
||||
<div className="fin-fixed-meta muted">{row.metaText}</div>
|
||||
<div className="fin-actions fin-fixed-actions">
|
||||
<Button size="sm" variant="ghost" onClick={() => openEdit(row)} disabled={busy !== null} aria-label={`Bearbeiten: ${row.name}`}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => toggle(row)} disabled={busy !== null} aria-label={`${row.toggleLabel}: ${row.name}`}>
|
||||
{busy === `${row.id}:toggle` ? 'Speichere …' : row.toggleLabel}
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={() => remove(row)} disabled={busy !== null} aria-label={`Löschen: ${row.name}`}>
|
||||
{busy === `${row.id}:delete` ? 'Lösche …' : 'Löschen'}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Fixkosten"
|
||||
actions={
|
||||
res.data && (
|
||||
<Button size="sm" variant="primary" onClick={openNew} disabled={busy !== null || editing?.id === null}>
|
||||
<FaPlus aria-hidden="true" /> Neue Position
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cloneElement } from 'react'
|
||||
|
||||
/**
|
||||
* Formularfeld mit Beschriftung, Hinweis und Fehlermeldung. `children` ist genau ein Eingabeelement; es bekommt
|
||||
* `id`, `aria-invalid` und `aria-describedby` (Hinweis und Fehler). Die Fehlermeldung wird vorgelesen (role="alert").
|
||||
*/
|
||||
export default function FormField({ id, label, hint, error, children }) {
|
||||
const hintId = hint ? `${id}-hint` : undefined
|
||||
const errorId = error ? `${id}-error` : undefined
|
||||
const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined
|
||||
return (
|
||||
<div className="field fin-form-field">
|
||||
<label htmlFor={id}>{label}</label>
|
||||
{cloneElement(children, { id, 'aria-invalid': error ? 'true' : undefined, 'aria-describedby': describedBy })}
|
||||
{hint && (
|
||||
<div id={hintId} className="faint fin-form-hint">
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div id={errorId} className="fin-field-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { FaFloppyDisk, FaSpinner } from 'react-icons/fa6'
|
||||
import { Button, Card } from '../ui'
|
||||
import FormField from './FormField'
|
||||
import { useFinanceResource } from '../../hooks/useFinanceResource'
|
||||
import { getSettings, patchSettings } from '../../lib/financeApi'
|
||||
import {
|
||||
BANK_DETAILS_EXAMPLE,
|
||||
SETTINGS_FIELD_ORDER,
|
||||
SETTINGS_LIMITS,
|
||||
apiErrorMessage,
|
||||
bankDetailsLength,
|
||||
settingsToForm,
|
||||
validateSettingsForm,
|
||||
} from '../../lib/financeSettings'
|
||||
|
||||
const LOAD_ERROR = 'Die Einstellungen konnten nicht geladen werden.'
|
||||
const fieldId = (name) => `fin-set-${name}`
|
||||
|
||||
/**
|
||||
* Karte „Einstellungen“: Mindestpuffer, Steuer-Rücklage, Vorjahresumsatz, ESt-Vorauszahlung und Bankverbindungstext
|
||||
* für den Rechnungsfuß. Beträge werden in Euro eingegeben (z. B. 1.234,56; 0 ist bei Mindestpuffer und ESt erlaubt),
|
||||
* ein leerer Vorjahresumsatz heißt „unbekannt“. Fehler stehen am Feld, Fehler des Servers über dem Knopf.
|
||||
*/
|
||||
export default function SettingsCard() {
|
||||
const res = useFinanceResource(getSettings, LOAD_ERROR)
|
||||
|
||||
if (!res.data) {
|
||||
return (
|
||||
<Card title="Einstellungen">
|
||||
{res.loading ? (
|
||||
<div className="empty" role="status">
|
||||
<FaSpinner className="spinner" aria-hidden="true" />
|
||||
<p className="muted">Einstellungen werden geladen …</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fin-load-error" style={{ padding: 0 }}>
|
||||
<p className="fin-hint fin-hint-warn" role="alert">{res.error || LOAD_ERROR}</p>
|
||||
<Button size="sm" onClick={res.reload}>Erneut versuchen</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
return <SettingsForm initial={res.data.einstellungen} />
|
||||
}
|
||||
|
||||
function SettingsForm({ initial }) {
|
||||
const [form, setForm] = useState(() => settingsToForm(initial))
|
||||
const [errors, setErrors] = useState({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [notice, setNotice] = useState(null) // { tone: 'ok' | 'warn', text }
|
||||
const lockRef = useRef(false)
|
||||
const aliveRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
return () => {
|
||||
aliveRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const change = (name) => (e) => {
|
||||
const value = e.target.value
|
||||
setForm((f) => ({ ...f, [name]: value }))
|
||||
setErrors((er) => (er[name] ? { ...er, [name]: undefined } : er))
|
||||
if (notice) setNotice(null)
|
||||
}
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
if (lockRef.current) return
|
||||
const check = validateSettingsForm(form)
|
||||
if (!check.ok) {
|
||||
setErrors(check.errors)
|
||||
setNotice({ tone: 'warn', text: 'Bitte die markierten Felder prüfen. Es wurde nichts gespeichert.' })
|
||||
const first = SETTINGS_FIELD_ORDER.find((name) => check.errors[name])
|
||||
if (first) document.getElementById(fieldId(first))?.focus()
|
||||
return
|
||||
}
|
||||
lockRef.current = true
|
||||
setBusy(true)
|
||||
setErrors({})
|
||||
setNotice(null)
|
||||
try {
|
||||
const data = await patchSettings(check.payload)
|
||||
if (!aliveRef.current) return
|
||||
if (data?.einstellungen) setForm(settingsToForm(data.einstellungen))
|
||||
setNotice({ tone: 'ok', text: 'Gespeichert.' })
|
||||
} catch (err) {
|
||||
if (aliveRef.current) setNotice({ tone: 'warn', text: apiErrorMessage(err) })
|
||||
} finally {
|
||||
lockRef.current = false
|
||||
if (aliveRef.current) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const length = bankDetailsLength(form.bankDetailsText)
|
||||
const tooLong = length > SETTINGS_LIMITS.maxBankDetails
|
||||
|
||||
return (
|
||||
<Card title="Einstellungen">
|
||||
<form onSubmit={submit} noValidate>
|
||||
{/* Während des Speicherns gesperrt, damit die Antwort keine neuen Eingaben überschreibt */}
|
||||
<fieldset className="fin-fieldset" disabled={busy}>
|
||||
<div className="fin-form-grid">
|
||||
<FormField
|
||||
id={fieldId('minBuffer')}
|
||||
label="Mindestpuffer (€)"
|
||||
hint="Dieser Betrag bleibt bei „Verfügbar“ immer auf dem Konto. 0 ist möglich."
|
||||
error={errors.minBuffer}
|
||||
>
|
||||
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.minBuffer} onChange={change('minBuffer')} placeholder="z. B. 500,00" />
|
||||
</FormField>
|
||||
<FormField
|
||||
id={fieldId('taxReservePercent')}
|
||||
label="Steuer-Rücklage (%)"
|
||||
hint={`Anteil des Gewinns, der als Rücklage gilt (0 bis ${SETTINGS_LIMITS.maxPercent}).`}
|
||||
error={errors.taxReservePercent}
|
||||
>
|
||||
<input className="form-control fin-input-amount" inputMode="numeric" autoComplete="off" value={form.taxReservePercent} onChange={change('taxReservePercent')} placeholder="30" />
|
||||
</FormField>
|
||||
<FormField
|
||||
id={fieldId('previousYearRevenue')}
|
||||
label="Vorjahresumsatz (€)"
|
||||
hint="Für die Kleinunternehmer-Grenze. Leer lassen, wenn unbekannt."
|
||||
error={errors.previousYearRevenue}
|
||||
>
|
||||
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.previousYearRevenue} onChange={change('previousYearRevenue')} placeholder="leer = unbekannt" />
|
||||
</FormField>
|
||||
<FormField
|
||||
id={fieldId('estPrepayment')}
|
||||
label="ESt-Vorauszahlung (€)"
|
||||
hint="Betrag je Termin (10.03., 10.06., 10.09., 10.12.). 0 = keine Vorauszahlung."
|
||||
error={errors.estPrepayment}
|
||||
>
|
||||
<input className="form-control fin-input-amount" inputMode="decimal" autoComplete="off" value={form.estPrepayment} onChange={change('estPrepayment')} placeholder="0,00" />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id={fieldId('bankDetailsText')}
|
||||
label="Bankverbindung auf Rechnungen"
|
||||
hint={`Steht im Fuß jeder Rechnung, z. B. „${BANK_DETAILS_EXAMPLE}“.`}
|
||||
error={errors.bankDetailsText}
|
||||
>
|
||||
<textarea className="form-control" rows={3} value={form.bankDetailsText} onChange={change('bankDetailsText')} />
|
||||
</FormField>
|
||||
<div className={`fin-counter${tooLong ? ' fin-counter-over' : ''}`}>
|
||||
{length} / {SETTINGS_LIMITS.maxBankDetails} Zeichen
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{notice && (
|
||||
<p className={`fin-hint fin-form-notice${notice.tone === 'warn' ? ' fin-hint-warn' : ''}`} role={notice.tone === 'warn' ? 'alert' : 'status'}>
|
||||
{notice.text}
|
||||
</p>
|
||||
)}
|
||||
<div className="fin-form-actions">
|
||||
<Button type="submit" variant="primary" disabled={busy}>
|
||||
{busy ? <FaSpinner className="spinner" aria-hidden="true" /> : <FaFloppyDisk aria-hidden="true" />} {busy ? 'Speichere …' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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,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 }
|
||||
}
|
||||
@@ -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,327 @@
|
||||
// Reine Helfer fuer die Einstellungsseite der Finanzen (Einstellungen, Fixkosten, Bankzugang); kein React, kein
|
||||
// Appwrite, unter Node testbar. Betraege sind Cent, in Formularen Euro-Texte (de-DE); Texte deutsch.
|
||||
import { INTERVAL_LABEL, dateDe, euro, parseEuro, sinceLabel, ymdBerlin } from './financeFormat'
|
||||
|
||||
const isNum = (v) => typeof v === 'number' && Number.isFinite(v)
|
||||
const text = (v) => (typeof v === 'string' ? v.trim() : '')
|
||||
|
||||
/** Grenzen der Portal-API (settings.js); die Oberfläche prüft vorab, der Server entscheidet. */
|
||||
export const SETTINGS_LIMITS = {
|
||||
maxPercent: 60,
|
||||
maxBufferCents: 100_000_000,
|
||||
maxBankDetails: 500,
|
||||
maxName: 120,
|
||||
maxMatch: 120,
|
||||
}
|
||||
|
||||
/** Beispiel für den Bankverbindungstext (steht im Fuß jeder Rechnung). */
|
||||
export const BANK_DETAILS_EXAMPLE = 'Bankverbindung: WEBklar GbR · IBAN DE.. · BIC ..'
|
||||
|
||||
/** Rückfrage vor dem Löschen einer Fixkosten-Position. */
|
||||
export const FIXED_COST_DELETE_CONFIRM =
|
||||
'Position wirklich löschen? Bereits zugeordnete Buchungen behalten den Verweis. Besser auf inaktiv setzen.'
|
||||
|
||||
const AMOUNT_HELP = 'Bitte einen Betrag wie 1.234,56 eingeben.'
|
||||
|
||||
// Felder, deren Namen der Server der Fehlermeldung voranstellt („minBufferCents: Der Mindestpuffer …“)
|
||||
const API_FIELDS = [
|
||||
'minBufferCents',
|
||||
'taxReservePercent',
|
||||
'previousYearRevenueCents',
|
||||
'estPrepaymentCents',
|
||||
'bankDetailsText',
|
||||
'name',
|
||||
'amountCents',
|
||||
'interval',
|
||||
'nextDate',
|
||||
'counterpartyMatch',
|
||||
'active',
|
||||
]
|
||||
const FIELD_PREFIX = new RegExp(`^(?:${API_FIELDS.join('|')}): `)
|
||||
|
||||
/** Meldung eines API-Fehlers ohne den technischen Feldnamen am Anfang. */
|
||||
export function apiErrorMessage(err, fallback = 'Das Speichern ist fehlgeschlagen.') {
|
||||
const message = text(err?.message)
|
||||
return message ? message.replace(FIELD_PREFIX, '') : fallback
|
||||
}
|
||||
|
||||
/** 123456 -> '1.234,56' (ohne Euro-Zeichen, für Eingabefelder); null/ungültig -> ''. */
|
||||
export function euroField(cents) {
|
||||
return isNum(cents) ? euro(cents).replace(/ €$/, '') : ''
|
||||
}
|
||||
|
||||
/** Text -> Cent oder { error }; leer ist Sache des Aufrufers. */
|
||||
function amountCents(value) {
|
||||
try {
|
||||
return { cents: parseEuro(String(value ?? '')) }
|
||||
} catch {
|
||||
return { error: `Ungültiger Betrag. ${AMOUNT_HELP}` }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Einstellungen -------------------------------------------------------------------------------------------
|
||||
|
||||
/** Zeichen des Bankverbindungstexts, wie sie gespeichert werden (ohne Leerraum am Rand). */
|
||||
export function bankDetailsLength(value) {
|
||||
return text(value).length
|
||||
}
|
||||
|
||||
/** Antwort des Servers (Cent) -> Formular (Texte). */
|
||||
export function settingsToForm(settings) {
|
||||
const s = settings && typeof settings === 'object' ? settings : {}
|
||||
return {
|
||||
minBuffer: euroField(s.minBufferCents),
|
||||
taxReservePercent: isNum(s.taxReservePercent) ? String(s.taxReservePercent) : '',
|
||||
previousYearRevenue: euroField(s.previousYearRevenueCents),
|
||||
estPrepayment: euroField(s.estPrepaymentCents),
|
||||
bankDetailsText: typeof s.bankDetailsText === 'string' ? s.bankDetailsText : '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular -> PATCH-Körper. { ok: true, payload } oder { ok: false, errors: { feld: 'Meldung' } } (alle Fehler).
|
||||
* Mindestpuffer und ESt-Vorauszahlung: Betrag ab 0 (0 ist erlaubt), Pflicht. Vorjahresumsatz: leer = null.
|
||||
* Rücklage: ganze Zahl 0 bis 60. Bankverbindungstext: höchstens 500 Zeichen.
|
||||
*/
|
||||
export function validateSettingsForm(form) {
|
||||
const f = form && typeof form === 'object' ? form : {}
|
||||
const errors = {}
|
||||
const payload = {}
|
||||
|
||||
const buffer = text(f.minBuffer)
|
||||
if (!buffer) {
|
||||
errors.minBuffer = 'Bitte einen Betrag eingeben (0 ist möglich).'
|
||||
} else {
|
||||
const r = amountCents(buffer)
|
||||
if (r.error) errors.minBuffer = r.error
|
||||
else if (r.cents > SETTINGS_LIMITS.maxBufferCents) errors.minBuffer = `Der Mindestpuffer darf höchstens ${euro(SETTINGS_LIMITS.maxBufferCents)} betragen.`
|
||||
else payload.minBufferCents = r.cents
|
||||
}
|
||||
|
||||
const percent = text(f.taxReservePercent).replace(/\s*%$/, '')
|
||||
if (/^[0-9]{1,3}$/.test(percent) && Number(percent) <= SETTINGS_LIMITS.maxPercent) {
|
||||
payload.taxReservePercent = Number(percent)
|
||||
} else {
|
||||
errors.taxReservePercent = `Die Rücklage muss eine ganze Zahl von 0 bis ${SETTINGS_LIMITS.maxPercent} sein.`
|
||||
}
|
||||
|
||||
const revenue = text(f.previousYearRevenue)
|
||||
if (!revenue) {
|
||||
payload.previousYearRevenueCents = null
|
||||
} else {
|
||||
const r = amountCents(revenue)
|
||||
if (r.error) errors.previousYearRevenue = r.error
|
||||
else payload.previousYearRevenueCents = r.cents
|
||||
}
|
||||
|
||||
const prepayment = text(f.estPrepayment)
|
||||
if (!prepayment) {
|
||||
errors.estPrepayment = 'Bitte einen Betrag eingeben (0 ist möglich).'
|
||||
} else {
|
||||
const r = amountCents(prepayment)
|
||||
if (r.error) errors.estPrepayment = r.error
|
||||
else payload.estPrepaymentCents = r.cents
|
||||
}
|
||||
|
||||
const bank = text(f.bankDetailsText)
|
||||
if (bank.length > SETTINGS_LIMITS.maxBankDetails) {
|
||||
errors.bankDetailsText = `Der Text darf höchstens ${SETTINGS_LIMITS.maxBankDetails} Zeichen lang sein (aktuell ${bank.length}).`
|
||||
} else {
|
||||
payload.bankDetailsText = bank
|
||||
}
|
||||
|
||||
return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, payload }
|
||||
}
|
||||
|
||||
/** Reihenfolge der Felder im Formular (für den Fokus auf das erste fehlerhafte). */
|
||||
export const SETTINGS_FIELD_ORDER = ['minBuffer', 'taxReservePercent', 'previousYearRevenue', 'estPrepayment', 'bankDetailsText']
|
||||
|
||||
// --- Fixkosten -----------------------------------------------------------------------------------------------
|
||||
|
||||
/** Auswahl für den Rhythmus: [{ value: 'monat', label: 'monatlich' }, …]. */
|
||||
export const INTERVAL_OPTIONS = Object.entries(INTERVAL_LABEL).map(([value, label]) => ({ value, label }))
|
||||
|
||||
export function emptyFixedCostForm() {
|
||||
return { name: '', amount: '', interval: 'monat', nextDate: '', counterpartyMatch: '' }
|
||||
}
|
||||
|
||||
/** Position des Servers -> Formular (Texte). */
|
||||
export function fixedCostToForm(position) {
|
||||
const p = position && typeof position === 'object' ? position : {}
|
||||
return {
|
||||
name: typeof p.name === 'string' ? p.name : '',
|
||||
amount: euroField(p.amountCents),
|
||||
interval: Object.prototype.hasOwnProperty.call(INTERVAL_LABEL, p.interval) ? p.interval : 'monat',
|
||||
nextDate: typeof p.nextDate === 'string' ? p.nextDate : '',
|
||||
counterpartyMatch: typeof p.counterpartyMatch === 'string' ? p.counterpartyMatch : '',
|
||||
}
|
||||
}
|
||||
|
||||
function isRealDay(value) {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(value) && dateDe(value) !== '–'
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular -> Körper für POST/PATCH /fixkosten: { ok: true, payload: { name, amountCents, interval, nextDate,
|
||||
* counterpartyMatch } } oder { ok: false, errors: { feld: 'Meldung' } }. Betrag größer als 0.
|
||||
*/
|
||||
export function validateFixedCostForm(form) {
|
||||
const f = form && typeof form === 'object' ? form : {}
|
||||
const errors = {}
|
||||
|
||||
const name = text(f.name)
|
||||
if (name.length < 1 || name.length > SETTINGS_LIMITS.maxName) {
|
||||
errors.name = `Bitte einen Namen eingeben (höchstens ${SETTINGS_LIMITS.maxName} Zeichen).`
|
||||
}
|
||||
|
||||
let cents = 0
|
||||
if (!text(f.amount)) {
|
||||
errors.amount = 'Bitte einen Betrag eingeben.'
|
||||
} else {
|
||||
const r = amountCents(f.amount)
|
||||
if (r.error) errors.amount = r.error
|
||||
else if (r.cents < 1) errors.amount = 'Der Betrag muss größer als 0 sein (z. B. 49,90).'
|
||||
else cents = r.cents
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(INTERVAL_LABEL, f.interval)) errors.interval = 'Bitte einen Rhythmus wählen.'
|
||||
|
||||
const nextDate = text(f.nextDate)
|
||||
if (!isRealDay(nextDate)) errors.nextDate = 'Bitte ein gültiges Datum wählen.'
|
||||
|
||||
const match = text(f.counterpartyMatch)
|
||||
if (match.length > SETTINGS_LIMITS.maxMatch) {
|
||||
errors.counterpartyMatch = `Der Suchbegriff darf höchstens ${SETTINGS_LIMITS.maxMatch} Zeichen lang sein.`
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) return { ok: false, errors }
|
||||
return { ok: true, payload: { name, amountCents: cents, interval: f.interval, nextDate, counterpartyMatch: match } }
|
||||
}
|
||||
|
||||
/** Reihenfolge der Felder im Fixkosten-Formular. */
|
||||
export const FIXED_COST_FIELD_ORDER = ['name', 'amount', 'interval', 'nextDate', 'counterpartyMatch']
|
||||
|
||||
/**
|
||||
* Zeilen der Fixkostenliste: aktive zuerst, dann inaktive (Reihenfolge des Servers bleibt je Gruppe).
|
||||
* Je Zeile: { key, id, name, amountText, intervalText, nextDateText, matchText, metaText, active, statusLabel,
|
||||
* toggleLabel }; metaText = „monatlich · nächster Termin 01.11.2026 · Suchbegriff „hetzner““.
|
||||
*/
|
||||
export function fixedCostRows(list) {
|
||||
const items = (Array.isArray(list) ? list : []).filter((p) => p && typeof p === 'object')
|
||||
const rows = items.map((p, index) => {
|
||||
const active = p.active !== false
|
||||
const name = text(p.name) || 'Ohne Namen'
|
||||
const intervalText = INTERVAL_LABEL[p.interval] || '–'
|
||||
const nextDateText = dateDe(p.nextDate)
|
||||
const matchText = text(p.counterpartyMatch)
|
||||
return {
|
||||
key: text(p.$id) || `fixed-${index}`,
|
||||
id: text(p.$id),
|
||||
name,
|
||||
amountText: euro(p.amountCents),
|
||||
intervalText,
|
||||
nextDateText,
|
||||
matchText,
|
||||
metaText: [intervalText, `nächster Termin ${nextDateText}`, matchText && `Suchbegriff „${matchText}“`].filter(Boolean).join(' · '),
|
||||
active,
|
||||
statusLabel: active ? 'Aktiv' : 'Inaktiv',
|
||||
toggleLabel: active ? 'Auf inaktiv setzen' : 'Aktivieren',
|
||||
}
|
||||
})
|
||||
return [...rows.filter((r) => r.active), ...rows.filter((r) => !r.active)]
|
||||
}
|
||||
|
||||
// --- Bankzugang ----------------------------------------------------------------------------------------------
|
||||
|
||||
const berlinTime = new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
|
||||
/** ISO-Zeitpunkt -> '01.10.2026, 12:55 Uhr' (Berliner Zeit); ungültig -> '–'. */
|
||||
export function dateTimeDe(value) {
|
||||
const t = typeof value === 'string' && value.trim() ? Date.parse(value) : NaN
|
||||
if (Number.isNaN(t)) return '–'
|
||||
const p = Object.fromEntries(berlinTime.formatToParts(new Date(t)).map((x) => [x.type, x.value]))
|
||||
return `${p.day}.${p.month}.${p.year}, ${p.hour}:${p.minute} Uhr`
|
||||
}
|
||||
|
||||
/**
|
||||
* Karte „Bankzugang“: { connected, statusLabel, tone, rows: [{ key, label, text }], error, canRefresh, connectLabel,
|
||||
* hint }. Ohne Verbindung (null) nur „Bank verbinden“. Abgelaufener Zugang (Status oder Datum): kein Abruf, nur
|
||||
* „Neu verbinden“. error ist der Text des letzten Fehlers (auch wenn der Server keinen nennt, der Abruf aber scheiterte).
|
||||
*/
|
||||
export function bankCardModel(verbindung, now = Date.now()) {
|
||||
if (!verbindung || typeof verbindung !== 'object') {
|
||||
return {
|
||||
connected: false,
|
||||
statusLabel: 'Nicht verbunden',
|
||||
tone: 'muted',
|
||||
rows: [],
|
||||
error: '',
|
||||
canRefresh: false,
|
||||
connectLabel: 'Bank verbinden',
|
||||
hint: 'Ohne Bankzugang fehlen Kontostand und Buchungen.',
|
||||
}
|
||||
}
|
||||
|
||||
const validDay = ymdBerlin(verbindung.validUntil)
|
||||
const today = ymdBerlin(now)
|
||||
const expired = verbindung.status === 'abgelaufen' || (validDay !== null && today !== null && validDay < today)
|
||||
const failed = verbindung.lastSyncOk === false || verbindung.status === 'fehler'
|
||||
|
||||
let statusLabel = 'Unbekannt'
|
||||
let tone = 'muted'
|
||||
if (expired) {
|
||||
statusLabel = 'Zugang abgelaufen'
|
||||
tone = 'danger'
|
||||
} else if (failed) {
|
||||
statusLabel = 'Abruf fehlgeschlagen'
|
||||
tone = 'warn'
|
||||
} else if (verbindung.status === 'aktiv') {
|
||||
statusLabel = 'Verbunden'
|
||||
tone = 'ok'
|
||||
} else if (text(verbindung.status)) {
|
||||
statusLabel = text(verbindung.status)
|
||||
}
|
||||
|
||||
const synced = text(verbindung.lastSyncAt)
|
||||
const lastError = text(verbindung.lastError)
|
||||
return {
|
||||
connected: true,
|
||||
statusLabel,
|
||||
tone,
|
||||
rows: [
|
||||
{ key: 'iban', label: 'IBAN', text: text(verbindung.ibanMasked) || '–' },
|
||||
{ key: 'valid', label: 'Zugang gültig bis', text: dateDe(verbindung.validUntil) },
|
||||
{
|
||||
key: 'sync',
|
||||
label: 'Letzter Abruf',
|
||||
text: synced ? `${dateTimeDe(synced)} · ${sinceLabel(synced, now)}` : 'noch kein Abruf',
|
||||
},
|
||||
],
|
||||
error: lastError || (failed ? 'Der letzte Abruf ist fehlgeschlagen.' : ''),
|
||||
canRefresh: !expired,
|
||||
connectLabel: 'Neu verbinden',
|
||||
hint: expired ? 'Der Zugang ist abgelaufen. Bitte neu verbinden.' : '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Ergebnis von POST /bank/aktualisieren ({ ok, neu, aktualisiert, geloescht, error? }) -> { tone, text }. */
|
||||
export function refreshResultMessage(result) {
|
||||
const r = result && typeof result === 'object' ? result : {}
|
||||
if (r.ok === false) {
|
||||
const error = text(r.error)
|
||||
return { tone: 'warn', text: error ? `Abruf fehlgeschlagen: ${error}` : 'Abruf fehlgeschlagen.' }
|
||||
}
|
||||
const parts = []
|
||||
if (r.neu > 0) parts.push(`${r.neu} neu`)
|
||||
if (r.aktualisiert > 0) parts.push(`${r.aktualisiert} aktualisiert`)
|
||||
if (r.geloescht > 0) parts.push(`${r.geloescht} entfernt`)
|
||||
return { tone: 'ok', text: parts.length > 0 ? `Abruf abgeschlossen: ${parts.join(', ')}.` : 'Abruf abgeschlossen, keine neuen Buchungen.' }
|
||||
}
|
||||
+29
-1
@@ -746,9 +746,37 @@ export function resolveCustomerText(options, input) {
|
||||
return { state: option.ok ? 'ok' : 'blocked', option }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiter „Rechnungen“ am Kunden: { ok: true } oder { ok: false, title, hint } (Grund nach canInvoice plus ein Satz,
|
||||
* was zu tun ist). Der Reiter ist für jeden Kunden sichtbar; ohne ok zeigt er statt der Liste diesen Hinweis.
|
||||
*/
|
||||
export function customerInvoicesGate(customer) {
|
||||
const check = canInvoice(customer)
|
||||
if (check.ok) return { ok: true }
|
||||
const status = text(customer?.customerStatus).toLowerCase()
|
||||
if (status === 'lead') {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'Noch ein Lead',
|
||||
hint: 'Erst zum festen Kunden machen (vollständige Daten nötig), dann sind Rechnungen möglich.',
|
||||
}
|
||||
}
|
||||
if (status === 'lost') {
|
||||
return { ok: false, title: check.reason, hint: 'Rechnungen gibt es nur für feste Kunden.' }
|
||||
}
|
||||
if (customer && !text(customer.email)) {
|
||||
return {
|
||||
ok: false,
|
||||
title: check.reason,
|
||||
hint: 'Rechnungen gehen per E-Mail an den Kunden. Bitte zuerst die E-Mail-Adresse in den Stammdaten eintragen.',
|
||||
}
|
||||
}
|
||||
return { ok: false, title: check.reason, hint: '' }
|
||||
}
|
||||
|
||||
// --- Buchungen (Block 7) -----------------------------------------------------------------------------------
|
||||
|
||||
const RANK = { hoch: 0, mittel: 1, niedrig: 2 }
|
||||
const RANK ={ hoch: 0, mittel: 1, niedrig: 2 }
|
||||
const dayOf = (tx) => text(tx.bookingDate) || text(tx.valueDate)
|
||||
|
||||
/** Buchungen neueste zuerst (Buchungsdatum, sonst Wertstellung); gleiche Tage behalten die Reihenfolge des Servers. */
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -4,13 +4,13 @@ import { FaSpinner, FaStar, FaArrowLeft, FaFloppyDisk, FaArrowUp, FaKey, FaArrow
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Field } from '../components/ui'
|
||||
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
||||
import InvoicePanel from '../components/InvoicePanel'
|
||||
import CustomerInvoices from '../components/finance/CustomerInvoices'
|
||||
import AssignedProjectCard from '../components/AssignedProjectCard'
|
||||
import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
|
||||
import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '../lib/customerAdminApi'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer, syncCustomerToNinja } from '../lib/leads'
|
||||
import { customerStage, isArchived, isInvoiceReady, setCustomerArchived, upgradeToCustomer } from '../lib/leads'
|
||||
|
||||
const PORTAL_URL = 'https://project.webklar.com'
|
||||
|
||||
@@ -49,7 +49,6 @@ export default function CustomerDetailPage() {
|
||||
|
||||
const stage = customerStage(customer)
|
||||
const archived = isArchived(customer)
|
||||
const syntheticTicket = { customerId: customer.$id, customerName: customer.name, woid: '' }
|
||||
|
||||
const toggleArchived = async () => {
|
||||
await setCustomerArchived(customer, !archived)
|
||||
@@ -96,15 +95,7 @@ export default function CustomerDetailPage() {
|
||||
|
||||
{tab === 'overview' && <OverviewTab customer={customer} stage={stage} onSaved={load} />}
|
||||
{tab === 'tickets' && <CustomerTickets customerId={customer.$id} />}
|
||||
{tab === 'invoices' && (
|
||||
stage === 'lead' ? (
|
||||
<Card><EmptyState title="Noch ein Lead" hint="Erst zum festen Kunden machen (vollstaendige Daten noetig), dann sind Rechnungen moeglich." action={<Button variant="primary" onClick={() => setTab('overview')}>Daten erfassen</Button>} /></Card>
|
||||
) : !isInvoiceReady(customer) ? (
|
||||
<Card><EmptyState title="Daten unvollstaendig" hint="Fuer Rechnungen werden Unternehmen, E-Mail, Strasse, Stadt und PLZ benoetigt." action={<Button variant="primary" onClick={() => setTab('overview')}>Daten vervollstaendigen</Button>} /></Card>
|
||||
) : (
|
||||
<InvoicePanel ticket={syntheticTicket} />
|
||||
)
|
||||
)}
|
||||
{tab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />}
|
||||
{tab === 'projects' && <CustomerProjects customerId={customer.$id} />}
|
||||
</div>
|
||||
)
|
||||
@@ -135,13 +126,8 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
const save = async () => {
|
||||
setBusy(true); setMsg(null)
|
||||
try {
|
||||
const updated = await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
|
||||
let note = 'Gespeichert'
|
||||
// Wenn fester Kunde: Aenderungen nach InvoiceNinja syncen (sofern Daten reichen oder bereits verknuepft)
|
||||
if (stage === 'customer' && (updated.invoiceNinjaClientId || isInvoiceReady(updated))) {
|
||||
try { await syncCustomerToNinja(updated) } catch (e) { note = 'Gespeichert, InvoiceNinja-Sync fehlgeschlagen: ' + e.message }
|
||||
}
|
||||
setMsg(note)
|
||||
await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
|
||||
setMsg('Gespeichert')
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setMsg('Fehler: ' + err.message)
|
||||
@@ -151,11 +137,13 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
const upgrade = async () => {
|
||||
if (!ready) { setMsg('Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.'); return }
|
||||
setBusy(true); setMsg(null)
|
||||
const r = await upgradeToCustomer(customer, form)
|
||||
if (r.clientId) setMsg('Zum festen Kunden gemacht und mit InvoiceNinja verknuepft.')
|
||||
else setMsg('Status gesetzt, aber InvoiceNinja-Verknuepfung fehlgeschlagen: ' + (r.syncError || 'unbekannt'))
|
||||
setBusy(false)
|
||||
try {
|
||||
await upgradeToCustomer(customer, form)
|
||||
setMsg('Zum festen Kunden gemacht.')
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setMsg('Fehler: ' + err.message)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -183,8 +171,8 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Rechnungsdaten (fuer InvoiceNinja)">
|
||||
{!ready && <div className="badge badge-warn" style={{ marginBottom: 12 }}>Unvollstaendig - fuer Rechnungen erforderlich</div>}
|
||||
<Card title="Rechnungsdaten">
|
||||
{!ready && <div className="badge badge-warn" style={{ marginBottom: 12 }}>Unvollständig – für Rechnungen erforderlich</div>}
|
||||
<Field label="Strasse + Nr."><input className="form-control" value={form.street} onChange={(e) => setForm({ ...form, street: e.target.value })} /></Field>
|
||||
<div className="flex gap-3 wrap">
|
||||
<Field label="PLZ"><input className="form-control" value={form.postalCode} onChange={(e) => setForm({ ...form, postalCode: e.target.value })} /></Field>
|
||||
@@ -194,10 +182,6 @@ function OverviewTab({ customer, stage, onSaved }) {
|
||||
<Field label="Land"><input className="form-control" value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value })} /></Field>
|
||||
<Field label="USt-IdNr."><input className="form-control" value={form.vatNumber} onChange={(e) => setForm({ ...form, vatNumber: e.target.value })} /></Field>
|
||||
</div>
|
||||
<div className="flex items-center justify-between" style={{ marginTop: 8 }}>
|
||||
<span className="muted">InvoiceNinja</span>
|
||||
{customer.invoiceNinjaClientId ? <Badge tone="ok" dot>verknuepft</Badge> : <Badge tone="muted">nicht verknuepft</Badge>}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<PortalAccessCard customer={customer} onSaved={onSaved} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FaSpinner } from 'react-icons/fa6'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { FaGear, FaSpinner } from 'react-icons/fa6'
|
||||
import { PageHeader, Card, Button } from '../components/ui'
|
||||
import StatusLine from '../components/finance/StatusLine'
|
||||
import LiquidityHead from '../components/finance/LiquidityHead'
|
||||
@@ -191,7 +191,15 @@ export default function FinancePage() {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader title="Finanzen" subtitle="Kontostand, Aufgaben und Zahlungen im Blick" />
|
||||
<PageHeader
|
||||
title="Finanzen"
|
||||
subtitle="Kontostand, Aufgaben und Zahlungen im Blick"
|
||||
actions={
|
||||
<Button as={Link} variant="ghost" to="/finance/einstellungen">
|
||||
<FaGear aria-hidden="true" /> Einstellungen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{assignNotice && (
|
||||
<p className="fin-hint fin-hint-warn fin-page-notice" role="alert">
|
||||
{assignNotice}{' '}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1431,3 +1431,38 @@ textarea.form-control {
|
||||
.fin-dialog-actions .ui-btn { flex: 1 1 auto; }
|
||||
.fin-toolbar .seg button { padding: 6px 9px; }
|
||||
}
|
||||
|
||||
/* Finanzen: Einstellungen (Formular, Bankzugang, Fixkosten) */
|
||||
.fin-fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
|
||||
.fin-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--sp-4); }
|
||||
.fin-form-field { min-width: 0; }
|
||||
.fin-form-hint { font-size: 12px; overflow-wrap: anywhere; }
|
||||
.fin-field-error { font-size: 13px; font-weight: 600; color: var(--danger); overflow-wrap: anywhere; }
|
||||
.fin-form-field .form-control[aria-invalid="true"] { border-color: var(--danger) !important; }
|
||||
.fin-input-amount { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.fin-counter { margin: -6px 0 var(--sp-3); text-align: right; font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.fin-counter-over { color: var(--danger); font-weight: 700; }
|
||||
.fin-form-notice { margin: 0 0 var(--sp-3); }
|
||||
.fin-form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); margin-top: var(--sp-3); }
|
||||
.fin-bank-facts { display: grid; gap: 6px; margin: 0 0 var(--sp-3); }
|
||||
.fin-bank-facts > div { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: var(--sp-3); align-items: baseline; }
|
||||
.fin-bank-facts dt { color: var(--text-muted); font-size: 13px; }
|
||||
.fin-bank-facts dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.fin-fixed-form { margin-bottom: var(--sp-4); padding: var(--sp-4); border: 1px solid var(--border-strong); border-radius: var(--r-sm); }
|
||||
.fin-fixed-form-title { margin: 0 0 var(--sp-3); font-size: 15px; font-weight: 700; }
|
||||
.fin-fixed-list { list-style: none; margin: 0; padding: 0; }
|
||||
.fin-fixed {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--sp-4); align-items: baseline;
|
||||
padding: var(--sp-3) 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.fin-fixed:last-child { border-bottom: none; }
|
||||
.fin-fixed-off .fin-fixed-name, .fin-fixed-off .fin-fixed-amount { color: var(--text-muted); }
|
||||
.fin-fixed-main { display: flex; flex-wrap: wrap; align-items: center; gap: 6px var(--sp-2); min-width: 0; }
|
||||
.fin-fixed-name { font-weight: 700; overflow-wrap: anywhere; }
|
||||
.fin-fixed-amount { font-weight: 700; }
|
||||
.fin-fixed-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 2px var(--sp-4); font-size: 13px; overflow-wrap: anywhere; }
|
||||
.fin-fixed-actions { grid-column: 1 / -1; padding-top: 4px; }
|
||||
@media (max-width: 640px) {
|
||||
.fin-form-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.fin-bank-facts > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -46,6 +46,7 @@ const {
|
||||
customerPickerOptions,
|
||||
customerSuggestions,
|
||||
resolveCustomerText,
|
||||
customerInvoicesGate,
|
||||
sortTransactions,
|
||||
strongestSuggestion,
|
||||
suggestionText,
|
||||
@@ -1184,3 +1185,29 @@ test('assignBody und invoiceAssignConfirmText', () => {
|
||||
assert.deepEqual(assignBody(null, 'x'), { note: 'x' })
|
||||
assert.equal(invoiceAssignConfirmText('WK-0005'), 'Die Rechnung WK-0005 wird in Stripe als bezahlt markiert. Das lässt sich nicht rückgängig machen.')
|
||||
})
|
||||
|
||||
test('customerInvoicesGate: fester Kunde mit E-Mail darf, alle anderen bekommen Grund und Hinweis', () => {
|
||||
assert.deepEqual(customerInvoicesGate({ $id: 'c1', name: 'A', email: 'a@example.invalid', customerStatus: 'customer' }), { ok: true })
|
||||
// Bestandsdaten ohne Status zählen als fester Kunde
|
||||
assert.deepEqual(customerInvoicesGate({ $id: 'c1', name: 'A', email: 'a@example.invalid' }), { ok: true })
|
||||
|
||||
const lead = customerInvoicesGate({ name: 'A', email: 'a@example.invalid', customerStatus: 'lead' })
|
||||
assert.equal(lead.ok, false)
|
||||
assert.equal(lead.title, 'Noch ein Lead')
|
||||
assert.equal(lead.hint, 'Erst zum festen Kunden machen (vollständige Daten nötig), dann sind Rechnungen möglich.')
|
||||
// Lead ohne E-Mail: der Lead-Hinweis hat Vorrang
|
||||
assert.equal(customerInvoicesGate({ name: 'A', email: '', customerStatus: 'LEAD' }).title, 'Noch ein Lead')
|
||||
|
||||
const lost = customerInvoicesGate({ name: 'A', email: 'a@example.invalid', customerStatus: 'lost' })
|
||||
assert.deepEqual(lost, { ok: false, title: 'Noch kein fester Kunde', hint: 'Rechnungen gibt es nur für feste Kunden.' })
|
||||
|
||||
const noMail = customerInvoicesGate({ name: 'A', email: ' ', customerStatus: 'customer' })
|
||||
assert.equal(noMail.ok, false)
|
||||
assert.equal(noMail.title, 'E-Mail fehlt')
|
||||
assert.match(noMail.hint, /E-Mail-Adresse in den Stammdaten/)
|
||||
|
||||
const none = customerInvoicesGate(null)
|
||||
assert.equal(none.ok, false)
|
||||
assert.equal(none.title, 'Kein Kunde ausgewählt')
|
||||
assert.equal(none.hint, '')
|
||||
})
|
||||
Reference in new issue
Block a user