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 && ( +
+ + + {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.

+ ) : ( +
+ + + + + + + + {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 ( + + + + + + + + + + ) + })} + +
Nr.DatumFaelligBetragOffenStatus
{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 ( +
+
+ +