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:
94
src/lib/integrationsApi.js
Normal file
94
src/lib/integrationsApi.js
Normal 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}`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user