Finanzen nur noch anzeigen, Dashboard und Admin-Konfiguration entfernt (Paket D)
- Finanzen: Liste, Zahlen, PDF und Link zu InvoiceNinja bleiben. Das Formular "Rechnung erstellen" (auf der Seite und als "Rechnung aus Ticket erstellen" im Ticket) ist weg; Rechnungen entstehen direkt in InvoiceNinja. Das Verknüpfen von Kunden mit InvoiceNinja bleibt. - Filter "Bezahlt" repariert: InvoiceNinja liefert status_id als Text. - FinancePage.jsx und InvoicePanel.jsx waren Windows-1252 kodiert und zeigten live "�"; jetzt UTF-8, dazu Umlaute in den Texten. - Dashboard entfernt (doppelte Finanzzahlen, "Offen" zählte falsch); alte Links führen zu den Tickets. - Admin: Reiter "Konfiguration" (nur noch Prioritäten, die seit Paket B niemand liest) mit useAdminConfig entfernt; Admin startet bei Kunden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
34c4db0228
commit
1a8604f7e6
12 files changed
+40
-617
No files matched your search
@@ -101,13 +101,6 @@
|
||||
.admin-notice.success { border-left-color: var(--ok); }
|
||||
.admin-notice.error { border-left-color: var(--danger); }
|
||||
|
||||
.admin-config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-config-card,
|
||||
.admin-form-card,
|
||||
.admin-list-card {
|
||||
border: 1px solid var(--admin-line);
|
||||
@@ -115,8 +108,6 @@
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.admin-config-card.priorities { grid-column: 1 / -1; }
|
||||
|
||||
.admin-section-head {
|
||||
display: flex;
|
||||
min-height: 62px;
|
||||
@@ -134,21 +125,6 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.admin-config-list { padding: 10px; }
|
||||
|
||||
.admin-config-row,
|
||||
.admin-priority-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
.admin-priority-row { grid-template-columns: 90px minmax(0, 1fr) auto; }
|
||||
.admin-config-row:last-child,
|
||||
.admin-priority-row:last-child { border-bottom: 0; }
|
||||
|
||||
.admin-icon-button {
|
||||
display: inline-flex;
|
||||
min-width: 34px;
|
||||
@@ -179,19 +155,6 @@
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.admin-savebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 14px;
|
||||
padding: 13px 16px;
|
||||
border: 1px solid var(--admin-line);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.admin-savebar span { color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.admin-primary-button {
|
||||
display: inline-flex;
|
||||
min-height: 40px;
|
||||
@@ -449,8 +412,5 @@
|
||||
.admin-current-user { width: 100%; }
|
||||
.admin-tabs { overflow-x: auto; }
|
||||
.admin-tabs button { flex: 0 0 auto; padding: 11px 12px; }
|
||||
.admin-config-grid { grid-template-columns: 1fr; }
|
||||
.admin-config-card.priorities { grid-column: auto; }
|
||||
.admin-savebar { align-items: stretch; flex-direction: column; }
|
||||
.admin-credential-result { grid-template-columns: 1fr; }
|
||||
}
|
||||
+3
-121
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
FaFloppyDisk,
|
||||
FaPlus,
|
||||
@@ -11,22 +11,10 @@ import CopyableCredential, {
|
||||
getCustomerPortalPassword,
|
||||
} from '../components/CopyableCredential'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useAdminConfig } from '../hooks/useAdminConfig'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import { useEmployees } from '../hooks/useEmployees'
|
||||
import './AdminPage.css'
|
||||
|
||||
// Ticketarten, Systeme, Reaktions- und Leistungsarten gibt das Ticketformular
|
||||
// fest vor; sie sind hier nicht mehr editierbar, bleiben beim Speichern aber
|
||||
// im Config-Dokument erhalten. Editierbar sind nur noch die Prioritaeten.
|
||||
const EMPTY_CONFIG = {
|
||||
ticketTypes: [],
|
||||
systems: [],
|
||||
responseLevels: [],
|
||||
serviceTypes: [],
|
||||
priorities: [],
|
||||
}
|
||||
|
||||
const EMPTY_CUSTOMER = {
|
||||
code: '',
|
||||
name: '',
|
||||
@@ -51,7 +39,6 @@ function Notice({ notice }) {
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user, isAdmin } = useAuth()
|
||||
const { config, loading, error, updateConfig } = useAdminConfig()
|
||||
const {
|
||||
customers,
|
||||
loading: customersLoading,
|
||||
@@ -68,9 +55,7 @@ export default function AdminPage() {
|
||||
deleteEmployee,
|
||||
} = useEmployees()
|
||||
|
||||
const [activeSection, setActiveSection] = useState('config')
|
||||
const [localConfig, setLocalConfig] = useState(EMPTY_CONFIG)
|
||||
const [savingConfig, setSavingConfig] = useState(false)
|
||||
const [activeSection, setActiveSection] = useState('customers')
|
||||
const [customerForm, setCustomerForm] = useState(EMPTY_CUSTOMER)
|
||||
const [editingCustomer, setEditingCustomer] = useState(null)
|
||||
const [customerBusy, setCustomerBusy] = useState(false)
|
||||
@@ -80,12 +65,6 @@ export default function AdminPage() {
|
||||
const [createdCredential, setCreatedCredential] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (config && Object.keys(config).length > 0) {
|
||||
setLocalConfig({ ...EMPTY_CONFIG, ...config })
|
||||
}
|
||||
}, [config])
|
||||
|
||||
const showNotice = (type, text) => {
|
||||
setNotice({ type, text })
|
||||
window.setTimeout(() => setNotice(null), 4500)
|
||||
@@ -103,45 +82,6 @@ export default function AdminPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const addConfigItem = (field) => {
|
||||
setLocalConfig((current) => ({
|
||||
...current,
|
||||
[field]: [
|
||||
...(current[field] || []),
|
||||
field === 'priorities'
|
||||
? { value: current.priorities?.length || 0, label: 'Neue Priorität' }
|
||||
: '',
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
const removeConfigItem = (field, index) => {
|
||||
setLocalConfig((current) => ({
|
||||
...current,
|
||||
[field]: (current[field] || []).filter((_, itemIndex) => itemIndex !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
const updateConfigItem = (field, index, value) => {
|
||||
setLocalConfig((current) => {
|
||||
const next = [...(current[field] || [])]
|
||||
next[index] = value
|
||||
return { ...current, [field]: next }
|
||||
})
|
||||
}
|
||||
|
||||
const saveConfiguration = async () => {
|
||||
setSavingConfig(true)
|
||||
const result = await updateConfig(localConfig)
|
||||
setSavingConfig(false)
|
||||
showNotice(
|
||||
result.success ? 'success' : 'error',
|
||||
result.success
|
||||
? 'Konfiguration gespeichert.'
|
||||
: `Speichern fehlgeschlagen: ${result.error || 'Unbekannter Fehler'}`
|
||||
)
|
||||
}
|
||||
|
||||
const resetCustomerForm = () => {
|
||||
setCustomerForm(EMPTY_CUSTOMER)
|
||||
setEditingCustomer(null)
|
||||
@@ -261,7 +201,7 @@ export default function AdminPage() {
|
||||
<div>
|
||||
<span className="admin-eyebrow">Systemverwaltung</span>
|
||||
<h1>Admin</h1>
|
||||
<p>Konfiguration, Kundenkonten und Mitarbeiterzugänge zentral verwalten.</p>
|
||||
<p>Kundenkonten und Mitarbeiterzugänge verwalten.</p>
|
||||
</div>
|
||||
<div className="admin-current-user">
|
||||
<span>Angemeldet als</span>
|
||||
@@ -270,11 +210,9 @@ export default function AdminPage() {
|
||||
</header>
|
||||
|
||||
<Notice notice={notice} />
|
||||
{error && <Notice notice={{ type: 'error', text: error }} />}
|
||||
|
||||
<nav className="admin-tabs" aria-label="Admin-Bereiche">
|
||||
{[
|
||||
['config', 'Konfiguration'],
|
||||
['customers', `Kunden (${customers.length})`],
|
||||
['employees', `Mitarbeiter (${employees.length})`],
|
||||
].map(([id, label]) => (
|
||||
@@ -289,62 +227,6 @@ export default function AdminPage() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{activeSection === 'config' && (
|
||||
<section>
|
||||
{loading && !config ? (
|
||||
<div className="admin-loading"><FaSpinner className="spinner" /> Konfiguration wird geladen …</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-config-grid">
|
||||
<section className="admin-config-card priorities">
|
||||
<div className="admin-section-head">
|
||||
<div><h3>Prioritäten</h3><span>{localConfig.priorities?.length || 0} Einträge</span></div>
|
||||
<button type="button" className="admin-icon-button" onClick={() => addConfigItem('priorities')}>
|
||||
<FaPlus /><span>Hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-config-list">
|
||||
{(localConfig.priorities || []).map((priority, index) => (
|
||||
<div className="admin-priority-row" key={`priority-${index}`}>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
value={priority.value}
|
||||
aria-label="Prioritätswert"
|
||||
onChange={(event) => updateConfigItem('priorities', index, {
|
||||
...priority,
|
||||
value: Number(event.target.value),
|
||||
})}
|
||||
/>
|
||||
<input
|
||||
className="form-control"
|
||||
value={priority.label}
|
||||
aria-label="Prioritätsname"
|
||||
onChange={(event) => updateConfigItem('priorities', index, {
|
||||
...priority,
|
||||
label: event.target.value,
|
||||
})}
|
||||
/>
|
||||
<button type="button" className="admin-icon-button danger" onClick={() => removeConfigItem('priorities', index)}>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="admin-savebar">
|
||||
<span>Änderungen gelten für neue und bestehende Ticketformulare.</span>
|
||||
<button type="button" className="admin-primary-button" disabled={savingConfig} onClick={saveConfiguration}>
|
||||
{savingConfig ? <FaSpinner className="spin" /> : <FaFloppyDisk />}
|
||||
Konfiguration speichern
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeSection === 'customers' && (
|
||||
<section className="admin-split-layout">
|
||||
<form className="admin-form-card" onSubmit={submitCustomer}>
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||
import { FaTicket, FaClipboardList, FaClock, FaCircleCheck, FaPlus, FaFileInvoiceDollar } from 'react-icons/fa6'
|
||||
import { formatMoney } from '../lib/integrationsApi'
|
||||
import { useFinanceSummary } from '../hooks/useFinanceSummary'
|
||||
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
|
||||
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { summary, loading: finLoading } = useFinanceSummary()
|
||||
const [stats, setStats] = useState({ total: 0, open: 0, awaiting: 0, closed: 0 })
|
||||
const [recent, setRecent] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
;(async () => {
|
||||
try {
|
||||
const [total, open, closed, awaiting, recentRes] = await Promise.all([
|
||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.limit(1)]),
|
||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Open'), Query.limit(1)]),
|
||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Closed'), Query.limit(1)]),
|
||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.equal('status', 'Awaiting'), Query.limit(1)]),
|
||||
databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [Query.orderDesc('$createdAt'), Query.limit(6)]),
|
||||
])
|
||||
if (!active) return
|
||||
setStats({ total: total.total, open: open.total, awaiting: awaiting.total, closed: closed.total })
|
||||
setRecent(recentRes.documents)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle="Überblick über Tickets und Finanzen"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" as={Link} to="/finance"><FaFileInvoiceDollar /> Finanzen</Button>
|
||||
<Button variant="primary" as={Link} to="/tickets"><FaPlus /> Neues Ticket</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="kpi-grid">
|
||||
<Kpi label={<><FaTicket /> Tickets gesamt</>} value={loading ? '...' : stats.total} accent="var(--info)" />
|
||||
<Kpi label={<><FaClipboardList /> Offen</>} value={loading ? '...' : stats.open} accent="var(--ok)" />
|
||||
<Kpi label={<><FaClock /> Wartend</>} value={loading ? '...' : stats.awaiting} accent="var(--warn)" />
|
||||
<Kpi label={<><FaCircleCheck /> Geschlossen</>} value={loading ? '...' : stats.closed} accent="var(--text-faint)" />
|
||||
</div>
|
||||
|
||||
<div className="kpi-grid">
|
||||
<Kpi label="Offener Betrag" value={finLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
|
||||
<Kpi label="Ueberfaellig" value={finLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} ueberfaellig`} accent="var(--danger)" />
|
||||
<Kpi label="Bezahlt (Monat)" value={finLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 16 }}>
|
||||
<Card title="Letzte Tickets" pad={false}>
|
||||
{loading ? (
|
||||
<div className="empty">...</div>
|
||||
) : recent.length === 0 ? (
|
||||
<EmptyState title="Keine Tickets" />
|
||||
) : (
|
||||
<div className="list">
|
||||
{recent.map((t) => (
|
||||
<Link key={t.$id} to="/tickets" className="list-row">
|
||||
<span className="mono" style={{ color: 'var(--accent)', minWidth: 56 }}>{t.woid || t.$id.slice(-5)}</span>
|
||||
<div className="grow">
|
||||
<div className="text-ellipsis" style={{ fontWeight: 600 }}>{t.topic || t.title || '-'}</div>
|
||||
<div className="muted">{t.customerName || '-'}</div>
|
||||
</div>
|
||||
<UrgentPill priority={t.priority} />
|
||||
<StatusPill status={t.status} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Schnellaktionen">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button as={Link} to="/tickets"><FaPlus /> Neues Ticket erstellen</Button>
|
||||
<Button as={Link} to="/finance"><FaFileInvoiceDollar /> Rechnung erstellen</Button>
|
||||
<Button as={Link} to="/customers" variant="ghost">Kunden verwalten</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+12
-74
@@ -1,20 +1,17 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate, FaPlus } from 'react-icons/fa6'
|
||||
import { FaFilePdf, FaArrowUpRightFromSquare, FaSpinner, FaRotate } from 'react-icons/fa6'
|
||||
import { integrationsApi, formatMoney, INVOICE_STATUS, INVOICE_NINJA_PUBLIC_URL } from '../lib/integrationsApi'
|
||||
import { useFinanceSummary } from '../hooks/useFinanceSummary'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import { PageHeader, Card, Button, Kpi, EmptyState, Field } from '../components/ui'
|
||||
import { PageHeader, Card, Button, Kpi, EmptyState } from '../components/ui'
|
||||
|
||||
export default function FinancePage() {
|
||||
const { summary, loading: sumLoading, refresh: refreshSummary } = useFinanceSummary()
|
||||
const { customers } = useCustomers()
|
||||
const [invoices, setInvoices] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [filter, setFilter] = useState('all') // all | open | overdue | paid
|
||||
const [search, setSearch] = useState('')
|
||||
const [openingId, setOpeningId] = useState(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -37,7 +34,8 @@ export default function FinancePage() {
|
||||
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
|
||||
if (filter === 'open' && !(Number(inv.balance) > 0)) return false
|
||||
if (filter === 'overdue' && !overdue) return false
|
||||
if (filter === 'paid' && inv.status_id !== 4) return false
|
||||
// InvoiceNinja liefert status_id als Text ("4" = bezahlt)
|
||||
if (filter === 'paid' && String(inv.status_id) !== '4') return false
|
||||
if (search) {
|
||||
const s = search.toLowerCase()
|
||||
if (!`${inv.number} ${inv.client_name}`.toLowerCase().includes(s)) return false
|
||||
@@ -57,39 +55,32 @@ export default function FinancePage() {
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title="Finanzen"
|
||||
subtitle="Rechnungen aus InvoiceNinja - Uebersicht, Status und schnelle Aktionen"
|
||||
subtitle="Rechnungen aus InvoiceNinja: Übersicht und Status. Neue Rechnungen direkt in InvoiceNinja schreiben."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" as="a" href={INVOICE_NINJA_PUBLIC_URL} target="_blank" rel="noopener noreferrer">
|
||||
<FaArrowUpRightFromSquare /> InvoiceNinja
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => { load(); refreshSummary() }}><FaRotate /> Aktualisieren</Button>
|
||||
<Button variant="primary" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Rechnung erstellen</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="kpi-grid">
|
||||
<Kpi label="Offener Betrag" value={sumLoading ? '...' : formatMoney(summary?.outstanding)} sub={`${summary?.openCount ?? 0} offene Rechnungen`} accent="var(--warn)" />
|
||||
<Kpi label="Ueberfaellig" value={sumLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} ueberfaellig`} accent="var(--danger)" />
|
||||
<Kpi label="Überfällig" value={sumLoading ? '...' : formatMoney(summary?.overdue)} sub={`${summary?.overdueCount ?? 0} überfällig`} accent="var(--danger)" />
|
||||
<Kpi label="Bezahlt (Monat)" value={sumLoading ? '...' : formatMoney(summary?.paidThisMonth)} accent="var(--ok)" />
|
||||
<Kpi label="Rechnungen gesamt" value={sumLoading ? '...' : (summary?.total ?? 0)} accent="var(--info)" />
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<CreateInvoiceForm customers={customers} onDone={() => { setShowCreate(false); load(); refreshSummary() }} onCancel={() => setShowCreate(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card pad={false}>
|
||||
<div className="ui-card-head">
|
||||
<div className="seg">
|
||||
{[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Ueberfaellig'], ['paid', 'Bezahlt']].map(([id, label]) => (
|
||||
{[['all', 'Alle'], ['open', 'Offen'], ['overdue', 'Überfällig'], ['paid', 'Bezahlt']].map(([id, label]) => (
|
||||
<button key={id} className={filter === id ? 'active' : ''} onClick={() => setFilter(id)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
<input className="form-control search-input" placeholder="Nummer oder Kunde suchen..." value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 280 }} />
|
||||
<input className="form-control search-input" placeholder="Nummer oder Kunde suchen …" value={search} onChange={(e) => setSearch(e.target.value)} style={{ maxWidth: 280 }} />
|
||||
</div>
|
||||
<div className="ui-card-body" style={{ padding: 0 }}>
|
||||
{loading ? (
|
||||
@@ -97,16 +88,16 @@ export default function FinancePage() {
|
||||
) : error ? (
|
||||
<div className="empty text-red">{error}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<EmptyState title="Keine Rechnungen" hint="Keine Rechnungen fuer diesen Filter." />
|
||||
<EmptyState title="Keine Rechnungen" hint="Keine Rechnungen für diesen Filter." />
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="ui-table">
|
||||
<thead>
|
||||
<tr><th>Nr.</th><th>Kunde</th><th>Datum</th><th>Faellig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr>
|
||||
<tr><th>Nr.</th><th>Kunde</th><th>Datum</th><th>Fällig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((inv) => {
|
||||
const st = INVOICE_STATUS[inv.status_id] || { label: '—', color: 'var(--text-muted)' }
|
||||
const st = INVOICE_STATUS[inv.status_id] || { label: '–', color: 'var(--text-muted)' }
|
||||
const overdue = Number(inv.balance) > 0 && inv.due_date && inv.due_date < today
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
@@ -116,7 +107,7 @@ export default function FinancePage() {
|
||||
<td style={{ color: overdue ? 'var(--danger)' : 'var(--text-muted)' }}>{inv.due_date || '-'}</td>
|
||||
<td>{formatMoney(inv.amount)}</td>
|
||||
<td style={{ color: Number(inv.balance) > 0 ? 'var(--warn)' : 'var(--ok)' }}>{formatMoney(inv.balance)}</td>
|
||||
<td><span style={{ color: st.color, fontWeight: 700 }}>{st.label}{overdue ? ' (ueberf.)' : ''}</span></td>
|
||||
<td><span style={{ color: st.color, fontWeight: 700 }}>{st.label}{overdue ? ' (überfällig)' : ''}</span></td>
|
||||
<td>
|
||||
<Button size="sm" variant="ghost" disabled={openingId === inv.id} onClick={() => openPdf(inv.id)} title="PDF">
|
||||
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
|
||||
@@ -134,56 +125,3 @@ export default function FinancePage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateInvoiceForm({ customers, onDone, onCancel }) {
|
||||
const linkable = (customers || []).filter((c) => c.invoiceNinjaClientId)
|
||||
const [clientId, setClientId] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [cost, setCost] = useState('')
|
||||
const [quantity, setQuantity] = useState('1')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!clientId) { setError('Bitte Kunde waehlen'); return }
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await integrationsApi.createInvoice({
|
||||
client_id: clientId,
|
||||
public_notes: description,
|
||||
line_items: [{ notes: description, cost: Number(cost || 0), quantity: Number(quantity || 1) }],
|
||||
})
|
||||
onDone()
|
||||
} catch (err) { setError(err.message); setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Neue Rechnung">
|
||||
{linkable.length === 0 ? (
|
||||
<p className="muted">Kein Kunde ist mit InvoiceNinja verknuepft. Bitte zuerst im Kunden-Bereich verknuepfen.</p>
|
||||
) : (
|
||||
<form onSubmit={submit}>
|
||||
<Field label="Kunde">
|
||||
<select className="form-control" value={clientId} onChange={(e) => setClientId(e.target.value)} required>
|
||||
<option value="">Kunde waehlen...</option>
|
||||
{linkable.map((c) => <option key={c.$id} value={c.invoiceNinjaClientId}>{c.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Leistung / Beschreibung">
|
||||
<textarea className="form-control" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} required />
|
||||
</Field>
|
||||
<div className="flex gap-3">
|
||||
<Field label="Einzelpreis (EUR)"><input className="form-control" type="number" step="0.01" value={cost} onChange={(e) => setCost(e.target.value)} required /></Field>
|
||||
<Field label="Menge"><input className="form-control" type="number" step="1" value={quantity} onChange={(e) => setQuantity(e.target.value)} /></Field>
|
||||
</div>
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" type="submit" disabled={busy}>{busy ? <FaSpinner className="spinner" /> : 'Rechnung erstellen'}</Button>
|
||||
<Button variant="ghost" type="button" onClick={onCancel}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user