feat: Rechnungen, Dokumente und Preview im Ticket buendeln
- InvoiceNinja-Rechnungen pro Kunde/Ticket anzeigen, verknuepfen und aus Ticket heraus erstellen (integrationsApi, useInvoices, InvoicePanel) - Paperless-ngx: Ticket-Uploads automatisch synchronisieren und Kunden-/ Ticket-Dokumente durchsuchen/anzeigen (DocumentsPanel, FileUploadModal) - Projekt-/Preview-Karte mit Status, Provisioning und letztem Commit - Einheitliches Ticket-Detail mit Tabs (Details/Arbeitsblaetter/Rechnungen/ Dokumente/Projekt) + zugehoerige Styles Aufruf des Integrations-Backends same-origin per Appwrite-JWT. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
8abf11ad18
commit
8f6927fbb0
8 files changed
+862
-186
No files matched your search
@@ -0,0 +1,334 @@
|
||||
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'
|
||||
|
||||
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, createInvoice } = 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 verknuepft - keine Rechnungen verfuegbar.</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>}
|
||||
|
||||
{!clientId ? (
|
||||
<LinkClient customer={customer} onLinked={loadCustomer} />
|
||||
) : (
|
||||
<InvoiceList
|
||||
ticket={ticket}
|
||||
clientId={clientId}
|
||||
invoices={invoices}
|
||||
loading={loading}
|
||||
error={error}
|
||||
createInvoice={createInvoice}
|
||||
/>
|
||||
)}
|
||||
</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 verknuepft.
|
||||
</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 /> Verknuepfen
|
||||
</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 & verknuepfen</>}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- Rechnungsliste ---------
|
||||
function InvoiceList({ ticket, clientId, invoices, loading, error, createInvoice }) {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [openingId, setOpeningId] = useState(null)
|
||||
|
||||
const openPdf = async (id) => {
|
||||
setOpeningId(id)
|
||||
try {
|
||||
await integrationsApi.openInvoicePdf(id)
|
||||
} catch (err) {
|
||||
alert('PDF konnte nicht geoeffnet werden: ' + err.message)
|
||||
} finally {
|
||||
setOpeningId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button className="btn btn-green" style={{ marginBottom: '12px' }} onClick={() => setShowCreate((s) => !s)}>
|
||||
<FaPlus /> Rechnung aus Ticket erstellen
|
||||
</button>
|
||||
|
||||
{showCreate && (
|
||||
<CreateInvoiceForm
|
||||
ticket={ticket}
|
||||
clientId={clientId}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
onSubmit={async (payload) => {
|
||||
const r = await createInvoice(payload)
|
||||
if (r.success) setShowCreate(false)
|
||||
return r
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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 fuer 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>Faellig</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 ? ' (ueberfaellig)' : ''}</span></td>
|
||||
<td>
|
||||
<button className="btn" title="PDF oeffnen" 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>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- Rechnung anlegen -------
|
||||
function CreateInvoiceForm({ ticket, clientId, onCancel, onSubmit }) {
|
||||
const [description, setDescription] = useState(ticket.topic || ticket.title || '')
|
||||
const [cost, setCost] = useState('')
|
||||
const [quantity, setQuantity] = useState('1')
|
||||
const [markSent, setMarkSent] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
const r = await onSubmit({
|
||||
client_id: clientId,
|
||||
po_number: ticket.woid || '',
|
||||
public_notes: `Ticket ${ticket.woid || ''}: ${ticket.topic || ''}`.trim(),
|
||||
line_items: [{ notes: description, cost: Number(cost || 0), quantity: Number(quantity || 1) }],
|
||||
markSent,
|
||||
})
|
||||
if (!r.success) {
|
||||
setError(r.error || 'Fehler beim Anlegen')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{ ...card, marginBottom: '12px' }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Leistung / Beschreibung</label>
|
||||
<textarea className="form-control" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} required />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label className="form-label">Einzelpreis (EUR)</label>
|
||||
<input type="number" step="0.01" className="form-control" value={cost} onChange={(e) => setCost(e.target.value)} required />
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label className="form-label">Menge</label>
|
||||
<input type="number" step="1" className="form-control" value={quantity} onChange={(e) => setQuantity(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#cbd5e0', marginBottom: '12px' }}>
|
||||
<input type="checkbox" checked={markSent} onChange={(e) => setMarkSent(e.target.checked)} />
|
||||
Direkt als "versendet" markieren
|
||||
</label>
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button type="submit" className="btn btn-green" disabled={busy}>
|
||||
{busy ? <FaSpinner className="spinner" /> : 'Rechnung erstellen'}
|
||||
</button>
|
||||
<button type="button" className="btn" style={{ background: '#374151', color: 'white' }} onClick={onCancel}>Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user