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:
Webklar Deploy
2026-06-17 09:13:35 +00:00
parent 8abf11ad18
commit 8f6927fbb0
8 changed files with 862 additions and 186 deletions

View File

@@ -0,0 +1,94 @@
import { account } from './appwrite'
/**
* Client fuer das Integrations-Backend (InvoiceNinja + Paperless).
* 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 }),
// ---- InvoiceNinja: invoices ----
listInvoices: (clientId) => call(`/invoices${qs({ clientId })}`),
createInvoice: (payload) => call('/invoices', { method: 'POST', body: payload }),
async openInvoicePdf(id) {
const blob = await callBlob(`/invoices/${encodeURIComponent(id)}/pdf`)
await openBlobInNewTab(blob)
},
// ---- Paperless ----
listDocuments: (params = {}) => call(`/paperless/documents${qs(params)}`),
ensureCorrespondent: (name) => call('/paperless/ensure-correspondent', { method: 'POST', body: { name } }),
ensureTag: (name) => call('/paperless/ensure-tag', { method: 'POST', body: { name } }),
uploadToPaperless: (payload) => call('/paperless/upload', { method: 'POST', body: payload }),
async openDocument(id, kind = 'preview') {
const blob = await callBlob(`/paperless/documents/${encodeURIComponent(id)}/${kind}`)
await openBlobInNewTab(blob)
},
}
// 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}`
}
}