diff --git a/src/components/AssignedProjectCard.jsx b/src/components/AssignedProjectCard.jsx
index f078200..e69d824 100644
--- a/src/components/AssignedProjectCard.jsx
+++ b/src/components/AssignedProjectCard.jsx
@@ -1,13 +1,40 @@
import { FaTrash, FaCodeBranch } from 'react-icons/fa'
+import { FaGlobe, FaCodeCommit, FaClock } from 'react-icons/fa6'
import ProjectReadme from './ProjectReadme'
import GiteaLinkButton from './GiteaLinkButton'
import PreviewLinkButton from './PreviewLinkButton'
+const STATUS_COLORS = {
+ ready: '#10b981',
+ live: '#10b981',
+ online: '#10b981',
+ provisioning: '#f59e0b',
+ pending: '#f59e0b',
+ building: '#3b82f6',
+ error: '#ef4444',
+ failed: '#ef4444',
+}
+
+function StatusBadge({ label, value }) {
+ if (!value) return null
+ const color = STATUS_COLORS[String(value).toLowerCase()] || '#a0aec0'
+ return (
+
+ {label}:
+
+
+ {value}
+
+
+ )
+}
+
export default function AssignedProjectCard({ project, onUnassign, showActions = false }) {
+ const lastPush = project.lastPushAt || project.giteaSyncedAt
return (
-
+
{project.projectName}
{project.subdomain && ({project.subdomain})}
{project.repoFullName && (
@@ -15,6 +42,14 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
{project.repoFullName}
)}
+ {project.liveDomain && (
+
+ )}
@@ -24,6 +59,38 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
)}
+
+ {/* Status- und Provisioning-Zeile */}
+
+
+
+ {project.hasPreview && }
+
+
+ {/* Letzter Commit */}
+ {(project.lastCommitMessage || project.lastCommitSha) && (
+
+
+
+ {project.lastCommitSha && {String(project.lastCommitSha).slice(0, 7)}}
+ {project.lastCommitBranch || project.lastPushBranch ? (
+ ({project.lastPushBranch || project.lastCommitBranch})
+ ) : null}
+
+ {project.lastCommitMessage && (
+
{project.lastCommitMessage}
+ )}
+
+ {project.lastCommitAuthor && von {project.lastCommitAuthor}}
+ {lastPush && (
+
+ {new Date(lastPush).toLocaleString('de-DE')}
+
+ )}
+
+
+ )}
+
{project.repoFullName &&
}
)
diff --git a/src/components/DocumentsPanel.jsx b/src/components/DocumentsPanel.jsx
new file mode 100644
index 0000000..e66d242
--- /dev/null
+++ b/src/components/DocumentsPanel.jsx
@@ -0,0 +1,158 @@
+import { useState, useEffect, useCallback } from 'react'
+import { FaFileLines, FaDownload, FaEye, FaSpinner, FaRotate, FaUpload, FaMagnifyingGlass } from 'react-icons/fa6'
+import { integrationsApi } from '../lib/integrationsApi'
+import FileUploadModal from './FileUploadModal'
+
+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 DocumentsPanel({ ticket }) {
+ const [docs, setDocs] = useState([])
+ const [count, setCount] = useState(0)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [query, setQuery] = useState('')
+ const [scope, setScope] = useState('ticket') // 'ticket' | 'customer'
+ const [showUpload, setShowUpload] = useState(false)
+ const [busyId, setBusyId] = useState(null)
+
+ const correspondent = ticket.customerName || ''
+ const woid = ticket.woid || ''
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const params = { page_size: 30 }
+ if (query) params.query = query
+ if (scope === 'customer' && correspondent) params.correspondent = correspondent
+ if (scope === 'ticket' && woid) params.tag = `WOID-${woid}`
+ const data = await integrationsApi.listDocuments(params)
+ setDocs(data.documents || [])
+ setCount(data.count || 0)
+ } catch (err) {
+ setError(err.message)
+ setDocs([])
+ } finally {
+ setLoading(false)
+ }
+ }, [query, scope, correspondent, woid])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ const open = async (id, kind) => {
+ setBusyId(id + kind)
+ try {
+ await integrationsApi.openDocument(id, kind)
+ } catch (err) {
+ alert('Dokument konnte nicht geoeffnet werden: ' + err.message)
+ } finally {
+ setBusyId(null)
+ }
+ }
+
+ return (
+
+
+
+ Dokumente (Paperless)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setQuery(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && load()}
+ />
+
+
+
+
+ {error &&
{error}
}
+
+ {loading ? (
+
Dokumente werden geladen...
+ ) : docs.length === 0 ? (
+
+ Keine Dokumente gefunden{scope === 'ticket' ? ' fuer dieses Ticket' : correspondent ? ` fuer ${correspondent}` : ''}.
+
+ ) : (
+ <>
+
{count} Dokument(e)
+
+ {docs.map((d) => (
+
+
+
+
+
{d.title}
+
{d.created ? new Date(d.created).toLocaleDateString('de-DE') : ''}
+
+
+
+
+
+
+
+ ))}
+
+ >
+ )}
+
+
setShowUpload(false)}
+ workorderId={woid}
+ ticket={ticket}
+ onUploadComplete={() => setTimeout(load, 1500)}
+ />
+
+ )
+}
diff --git a/src/components/FileUploadModal.jsx b/src/components/FileUploadModal.jsx
index 78da298..abe25da 100644
--- a/src/components/FileUploadModal.jsx
+++ b/src/components/FileUploadModal.jsx
@@ -1,10 +1,12 @@
import { useState, useRef } from 'react'
import { FaTimes, FaSpinner } from 'react-icons/fa'
import { storage, BUCKET_ID, ID } from '../lib/appwrite'
+import { integrationsApi } from '../lib/integrationsApi'
-export default function FileUploadModal({ isOpen, onClose, workorderId, onUploadComplete }) {
+export default function FileUploadModal({ isOpen, onClose, workorderId, ticket, onUploadComplete }) {
const [uploading, setUploading] = useState(false)
const [dragOver, setDragOver] = useState(false)
+ const [status, setStatus] = useState('')
const fileInputRef = useRef(null)
const handleDrop = async (e) => {
@@ -33,14 +35,39 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
}
setUploading(true)
-
+ setStatus('Lade in Ablage hoch...')
+
try {
const response = await storage.createFile(
BUCKET_ID,
ID.unique(),
file
)
-
+
+ // Automatisch nach Paperless-ngx schieben (wichtige Kundendokumente)
+ const woid = ticket?.woid || workorderId
+ const correspondent = ticket?.customerName || undefined
+ if (correspondent || woid) {
+ setStatus('Synchronisiere mit Paperless...')
+ const tags = []
+ if (correspondent) tags.push(correspondent)
+ if (woid) tags.push(`WOID-${woid}`)
+ tags.push('Ticket')
+ try {
+ await integrationsApi.uploadToPaperless({
+ bucketId: BUCKET_ID,
+ fileId: response.$id,
+ filename: file.name,
+ title: `${woid ? `[${woid}] ` : ''}${file.name}`,
+ correspondent,
+ tags,
+ })
+ } catch (e) {
+ console.warn('Paperless-Sync fehlgeschlagen:', e.message)
+ // Upload in Appwrite ist trotzdem erfolgt - nicht hart abbrechen
+ }
+ }
+
onUploadComplete?.(response)
onClose()
} catch (error) {
@@ -48,6 +75,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
alert('Error uploading file: ' + error.message)
} finally {
setUploading(false)
+ setStatus('')
}
}
@@ -72,7 +100,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
{uploading ? (
-
Uploading file...
+
{status || 'Uploading file...'}
) : (
<>
diff --git a/src/components/InvoicePanel.jsx b/src/components/InvoicePanel.jsx
new file mode 100644
index 0000000..7c4ea8b
--- /dev/null
+++ b/src/components/InvoicePanel.jsx
@@ -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 (
+
+ Kunde wird geladen...
+
+ )
+ }
+
+ if (!ticket.customerId) {
+ return (
+
+
Kein Kunde am Ticket verknuepft - keine Rechnungen verfuegbar.
+
+ )
+ }
+
+ return (
+
+
+
+ Rechnungen {customer?.name ? `- ${customer.name}` : ''}
+
+ {clientId && (
+
+ )}
+
+
+ {customerError &&
{customerError}
}
+
+ {!clientId ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------- 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 (
+
+
+ Dieser Kunde ist noch nicht mit InvoiceNinja verknuepft.
+
+
+ setSearch(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && doSearch()}
+ />
+
+
+
+ {error &&
{error}
}
+
+
+ {results.map((c) => (
+
+
+
{c.name}
+ {c.number ?
· {c.number} : null}
+ {c.email ?
{c.email}
: null}
+
+
+
+ ))}
+ {!busy && results.length === 0 && (
+
Keine Treffer.
+ )}
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------- 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 (
+
+
+
+ {showCreate && (
+
setShowCreate(false)}
+ onSubmit={async (payload) => {
+ const r = await createInvoice(payload)
+ if (r.success) setShowCreate(false)
+ return r
+ }}
+ />
+ )}
+
+ {error && {error}
}
+
+ {loading ? (
+ Rechnungen werden geladen...
+ ) : invoices.length === 0 ? (
+ Noch keine Rechnungen fuer diesen Kunden.
+ ) : (
+
+
+
+
+ | Nr. | Datum | Faellig | Betrag | Offen | Status | |
+
+
+
+ {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 (
+
+ | {inv.number || inv.id?.slice(-6)} |
+ {inv.date || '-'} |
+ {inv.due_date || '-'} |
+ {formatMoney(inv.amount)} |
+ 0 ? '#f59e0b' : '#10b981' }}>{formatMoney(inv.balance)} |
+ {st.label}{overdue ? ' (ueberfaellig)' : ''} |
+
+
+ |
+
+ )
+ })}
+
+
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------- 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 (
+
+ )
+}
diff --git a/src/components/TicketRow.jsx b/src/components/TicketRow.jsx
index b242108..b9ba8e3 100644
--- a/src/components/TicketRow.jsx
+++ b/src/components/TicketRow.jsx
@@ -12,6 +12,8 @@ import WorksheetList from './WorksheetList'
import WorksheetStats from './WorksheetStats'
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
import TicketProjectsModal from './TicketProjectsModal'
+import InvoicePanel from './InvoicePanel'
+import DocumentsPanel from './DocumentsPanel'
import { useWorksheets } from '../hooks/useWorksheets'
const PRIORITY_CLASSES = {
@@ -22,14 +24,6 @@ const PRIORITY_CLASSES = {
4: 'priority-critical'
}
-const PRIORITY_LABELS = {
- 0: 'NONE',
- 1: 'LOW',
- 2: 'MEDIUM',
- 3: 'HIGH',
- 4: 'CRITICAL'
-}
-
const STATUS_CLASSES = {
'Open': 'status-open',
'Occupied': 'status-occupied',
@@ -45,40 +39,37 @@ const APPROVAL_ICONS = {
'shipping': FaTruck
}
+const TABS = [
+ { id: 'details', label: 'Details' },
+ { id: 'worksheets', label: 'Arbeitsblaetter' },
+ { id: 'invoices', label: 'Rechnungen' },
+ { id: 'documents', label: 'Dokumente' },
+ { id: 'project', label: 'Projekt / Preview' },
+]
+
export default function TicketRow({ ticket, onUpdate, onExpand }) {
const [expanded, setExpanded] = useState(false)
const [locked, setLocked] = useState(true)
+ const [activeTab, setActiveTab] = useState('details')
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
const [showHistoryModal, setShowHistoryModal] = useState(false)
const [showProjectsModal, setShowProjectsModal] = useState(false)
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
-
- // Worksheets für dieses Ticket laden (nur wenn expanded)
- const {
- worksheets,
- loading: worksheetsLoading,
+
+ const {
+ worksheets,
+ loading: worksheetsLoading,
createWorksheet,
- getTotalTime
+ getTotalTime
} = useWorksheets(expanded ? ticket.woid : null)
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
const elapsed = formatDistanceToNow(createdAt, { locale: de })
-
- const handleStatusChange = (newStatus) => {
- onUpdate(ticket.$id, { status: newStatus })
- }
- const handlePriorityChange = (newPriority) => {
- onUpdate(ticket.$id, { priority: newPriority })
- }
-
- const handleEditorChange = (newEditor) => {
- onUpdate(ticket.$id, { assignedTo: newEditor })
- }
-
- const handleResponseChange = (newResponse) => {
- onUpdate(ticket.$id, { responseLevel: newResponse })
- }
+ const handleStatusChange = (newStatus) => onUpdate(ticket.$id, { status: newStatus })
+ const handlePriorityChange = (newPriority) => onUpdate(ticket.$id, { priority: newPriority })
+ const handleEditorChange = (newEditor) => onUpdate(ticket.$id, { assignedTo: newEditor })
+ const handleResponseChange = (newResponse) => onUpdate(ticket.$id, { responseLevel: newResponse })
const toggleLock = () => {
if (locked) {
@@ -92,15 +83,12 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
const handleCreateWorksheet = async (worksheetData, currentUser) => {
const result = await createWorksheet(worksheetData, currentUser)
-
- // Wenn Status geändert wurde, aktualisiere Work Order
if (result.success && worksheetData.newStatus !== ticket.status) {
- await onUpdate(ticket.$id, {
+ await onUpdate(ticket.$id, {
status: worksheetData.newStatus,
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel
})
}
-
return result
}
@@ -140,25 +128,19 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
{ticket.topic}
-
+
|
-
+
|
-
|
-
|
|
-
+
|
-
+
|
{expanded && (
- <>
-
-
-
-
- {/* Bento Box Layout: 2 Spalten */}
-
- {/* Linke Spalte: Ticket-Beschreibung (50%) */}
-
+
+
+
+
+ {/* Tab-Leiste */}
+
+ {TABS.map((t) => (
+
+ ))}
+
+
+
+ {activeTab === 'details' && (
+
-
- 📋 Ticket-Beschreibung
+
+ Ticket-Beschreibung
-
+
{ticket.details || 'Keine Details vorhanden.'}
+ )}
- {/* Rechte Spalte: Statistics, Buttons (50%) */}
-
- {/* Button Row: Add Worksheet (100%) + History Icon Button */}
-
- {/* Add Worksheet Button - 100% width minus icon button */}
-
+ )}
-
+ {activeTab === 'invoices' && }
- {/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */}
-
-
-
+ {activeTab === 'documents' && }
+
+ {activeTab === 'project' && (
+
+ setShowProjectsModal(true)}
+ >
+ Projekte zuweisen / bearbeiten
+
+
+
+ )}
- |
- |
- >
+
+
+
)}
-
- setShowCreateWorksheet(false)}
workorder={ticket}
onCreate={handleCreateWorksheet}
/>
-
+
setShowHistoryModal(false)}
diff --git a/src/hooks/useInvoices.js b/src/hooks/useInvoices.js
new file mode 100644
index 0000000..0739fa5
--- /dev/null
+++ b/src/hooks/useInvoices.js
@@ -0,0 +1,49 @@
+import { useState, useEffect, useCallback } from 'react'
+import { integrationsApi } from '../lib/integrationsApi'
+
+/**
+ * Laedt die InvoiceNinja-Rechnungen eines verknuepften Clients.
+ * clientId = customers.invoiceNinjaClientId (oder null -> nichts laden).
+ */
+export function useInvoices(clientId) {
+ const [invoices, setInvoices] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const fetchInvoices = useCallback(async () => {
+ if (!clientId) {
+ setInvoices([])
+ return
+ }
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await integrationsApi.listInvoices(clientId)
+ setInvoices(data.invoices || [])
+ } catch (err) {
+ setError(err.message)
+ setInvoices([])
+ } finally {
+ setLoading(false)
+ }
+ }, [clientId])
+
+ useEffect(() => {
+ fetchInvoices()
+ }, [fetchInvoices])
+
+ const createInvoice = useCallback(
+ async (payload) => {
+ try {
+ const data = await integrationsApi.createInvoice(payload)
+ await fetchInvoices()
+ return { success: true, invoice: data.invoice }
+ } catch (err) {
+ return { success: false, error: err.message }
+ }
+ },
+ [fetchInvoices]
+ )
+
+ return { invoices, loading, error, refresh: fetchInvoices, createInvoice }
+}
diff --git a/src/lib/integrationsApi.js b/src/lib/integrationsApi.js
new file mode 100644
index 0000000..1dc410c
--- /dev/null
+++ b/src/lib/integrationsApi.js
@@ -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}`
+ }
+}
diff --git a/src/styles/global.css b/src/styles/global.css
index 0b625cd..a674d18 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -789,8 +789,42 @@ textarea.form-control {
margin-top: 32px;
}
+/* Ticket-Detail Tabs */
+.ticket-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 16px;
+ border-bottom: 1px solid rgba(16, 185, 129, 0.2);
+ padding-bottom: 8px;
+}
+.ticket-tab {
+ background: rgba(45, 55, 72, 0.5);
+ color: #cbd5e0;
+ border: 1px solid rgba(16, 185, 129, 0.15);
+ border-radius: 8px 8px 0 0;
+ padding: 10px 18px;
+ font-weight: 600;
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+.ticket-tab:hover {
+ background: rgba(16, 185, 129, 0.12);
+ color: var(--dark-text);
+}
+.ticket-tab-active {
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+ color: #fff;
+ border-color: transparent;
+}
+.ticket-tab-content {
+ min-height: 120px;
+}
+
/* Responsive */
@media (max-width: 768px) {
.col-6 { width: 100%; }
.navbar-nav { display: none; }
+ .ticket-tab { padding: 8px 12px; font-size: 13px; }
}