- 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>
263 lines
9.7 KiB
React
263 lines
9.7 KiB
React
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>
|
||
)
|
||
}
|