- Zwei Kundenstufen: Lead (potenziell) und fester Kunde; Lead braucht nur Unternehmen + E-Mail und erzeugt automatisch ein Akquise-Ticket - Akquise-Worksheet-Ergebnis (In Kontakt/Zugesagt/Abgesagt) sichtbar in WSIDs; "Zugesagt" macht den Lead automatisch zum festen Kunden, "Abgesagt" -> verloren - Kunde wird nur in Appwrite gepflegt und automatisch als InvoiceNinja-Client angelegt/aktualisiert (beim Upgrade bzw. bei Datenpflege) - Rechnungen nur fuer feste Kunden mit vollstaendigen Daten (Strasse/PLZ/Stadt) - Kundenliste mit Stufen-Filter, neue Rechnungs-/Adressfelder Co-authored-by: Cursor <cursoragent@cursor.com>
100 lines
3.5 KiB
JavaScript
100 lines
3.5 KiB
JavaScript
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 }),
|
|
updateClient: (id, payload) => call(`/clients/${encodeURIComponent(id)}`, { method: 'PUT', body: payload }),
|
|
|
|
// ---- InvoiceNinja: invoices ----
|
|
listInvoices: (clientId, perPage) => call(`/invoices${qs({ clientId, per_page: perPage })}`),
|
|
invoicesSummary: () => call('/invoices/summary'),
|
|
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)
|
|
},
|
|
}
|
|
|
|
// Oeffentliche InvoiceNinja-URL (fuer Deeplinks ins Portal)
|
|
export const INVOICE_NINJA_PUBLIC_URL = 'https://invoice.webklar.com'
|
|
|
|
// 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}`
|
|
}
|
|
}
|