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:
@@ -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 (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#cbd5e0', marginRight: 12 }}>
|
||||
{label}:
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, display: 'inline-block' }} />
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AssignedProjectCard({ project, onUnassign, showActions = false }) {
|
||||
const lastPush = project.lastPushAt || project.giteaSyncedAt
|
||||
return (
|
||||
<div style={{ background: 'rgba(30,41,59,0.5)', border: '1px solid rgba(59,130,246,0.25)', borderRadius: 10, padding: '12px 14px', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<strong>{project.projectName}</strong>
|
||||
{project.subdomain && <span className="text-grey" style={{ marginLeft: 8, fontSize: 13 }}>({project.subdomain})</span>}
|
||||
{project.repoFullName && (
|
||||
@@ -15,6 +42,14 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
||||
<FaCodeBranch style={{ marginRight: 4 }} />{project.repoFullName}
|
||||
</div>
|
||||
)}
|
||||
{project.liveDomain && (
|
||||
<div style={{ fontSize: 12, marginTop: 4 }}>
|
||||
<FaGlobe style={{ marginRight: 4, color: '#34d399' }} />
|
||||
<a href={`https://${project.liveDomain}`} target="_blank" rel="noopener noreferrer" style={{ color: '#34d399', textDecoration: 'none' }}>
|
||||
{project.liveDomain}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
||||
<PreviewLinkButton href={project.previewUrl} />
|
||||
@@ -24,6 +59,38 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status- und Provisioning-Zeile */}
|
||||
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<StatusBadge label="Status" value={project.status} />
|
||||
<StatusBadge label="Provisioning" value={project.provisioningStatus} />
|
||||
{project.hasPreview && <StatusBadge label="Preview" value="aktiv" />}
|
||||
</div>
|
||||
|
||||
{/* Letzter Commit */}
|
||||
{(project.lastCommitMessage || project.lastCommitSha) && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#94a3b8', background: 'rgba(0,0,0,0.2)', borderRadius: 8, padding: '8px 10px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<FaCodeCommit style={{ color: '#3b82f6' }} />
|
||||
{project.lastCommitSha && <code style={{ color: '#cbd5e0' }}>{String(project.lastCommitSha).slice(0, 7)}</code>}
|
||||
{project.lastCommitBranch || project.lastPushBranch ? (
|
||||
<span className="text-grey">({project.lastPushBranch || project.lastCommitBranch})</span>
|
||||
) : null}
|
||||
</div>
|
||||
{project.lastCommitMessage && (
|
||||
<div style={{ marginTop: 4, color: '#cbd5e0', whiteSpace: 'pre-wrap' }}>{project.lastCommitMessage}</div>
|
||||
)}
|
||||
<div style={{ marginTop: 4, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{project.lastCommitAuthor && <span>von {project.lastCommitAuthor}</span>}
|
||||
{lastPush && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<FaClock /> {new Date(lastPush).toLocaleString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.repoFullName && <ProjectReadme repoFullName={project.repoFullName} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
158
src/components/DocumentsPanel.jsx
Normal file
158
src/components/DocumentsPanel.jsx
Normal file
@@ -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 (
|
||||
<div style={card}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', flexWrap: 'wrap', gap: '8px' }}>
|
||||
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', margin: 0, fontSize: '16px' }}>
|
||||
Dokumente (Paperless)
|
||||
</h5>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-green" style={{ margin: 0 }} onClick={() => setShowUpload(true)}>
|
||||
<FaUpload /> Hochladen
|
||||
</button>
|
||||
<button className="btn" onClick={load} title="Aktualisieren"
|
||||
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}>
|
||||
<FaRotate />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
className={`btn ${scope === 'ticket' ? 'btn-green' : ''}`}
|
||||
style={{ margin: 0, background: scope === 'ticket' ? undefined : '#374151', color: 'white' }}
|
||||
onClick={() => setScope('ticket')}
|
||||
>
|
||||
Dieses Ticket
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${scope === 'customer' ? 'btn-green' : ''}`}
|
||||
style={{ margin: 0, background: scope === 'customer' ? undefined : '#374151', color: 'white' }}
|
||||
onClick={() => setScope('customer')}
|
||||
disabled={!correspondent}
|
||||
>
|
||||
Ganzer Kunde
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flex: 1, minWidth: '180px' }}>
|
||||
<input
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
placeholder="Volltextsuche..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && load()}
|
||||
/>
|
||||
<button className="btn btn-green" style={{ margin: 0 }} onClick={load}>
|
||||
<FaMagnifyingGlass />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Dokumente werden geladen...</p>
|
||||
) : docs.length === 0 ? (
|
||||
<p style={{ color: '#a0aec0', fontSize: '13px' }}>
|
||||
Keine Dokumente gefunden{scope === 'ticket' ? ' fuer dieses Ticket' : correspondent ? ` fuer ${correspondent}` : ''}.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ color: '#718096', fontSize: '12px', marginTop: 0 }}>{count} Dokument(e)</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{docs.map((d) => (
|
||||
<div key={d.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', background: 'rgba(0,0,0,0.15)', borderRadius: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 }}>
|
||||
<FaFileLines style={{ color: '#10b981', flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: 'var(--dark-text)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.title}</div>
|
||||
<div style={{ color: '#718096', fontSize: '12px' }}>{d.created ? new Date(d.created).toLocaleDateString('de-DE') : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
|
||||
<button className="btn" title="Vorschau" disabled={busyId === d.id + 'preview'}
|
||||
style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
|
||||
onClick={() => open(d.id, 'preview')}>
|
||||
{busyId === d.id + 'preview' ? <FaSpinner className="spinner" /> : <FaEye />}
|
||||
</button>
|
||||
<button className="btn" title="Download" disabled={busyId === d.id + 'download'}
|
||||
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
|
||||
onClick={() => open(d.id, 'download')}>
|
||||
{busyId === d.id + 'download' ? <FaSpinner className="spinner" /> : <FaDownload />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FileUploadModal
|
||||
isOpen={showUpload}
|
||||
onClose={() => setShowUpload(false)}
|
||||
workorderId={woid}
|
||||
ticket={ticket}
|
||||
onUploadComplete={() => setTimeout(load, 1500)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
<div>
|
||||
<FaSpinner className="spinner" size={32} />
|
||||
<p className="mt-1">Uploading file...</p>
|
||||
<p className="mt-1">{status || 'Uploading file...'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
334
src/components/InvoicePanel.jsx
Normal file
334
src/components/InvoicePanel.jsx
Normal file
@@ -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 (
|
||||
<div style={card}>
|
||||
<FaSpinner className="spinner" /> Kunde wird geladen...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!ticket.customerId) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<p style={{ color: '#a0aec0', margin: 0 }}>Kein Kunde am Ticket verknuepft - keine Rechnungen verfuegbar.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', margin: 0, fontSize: '16px' }}>
|
||||
Rechnungen {customer?.name ? `- ${customer.name}` : ''}
|
||||
</h5>
|
||||
{clientId && (
|
||||
<button className="btn" onClick={refresh} title="Aktualisieren"
|
||||
style={{ background: '#374151', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}>
|
||||
<FaRotate />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{customerError && <div className="text-red" style={{ marginBottom: 8 }}>{customerError}</div>}
|
||||
|
||||
{!clientId ? (
|
||||
<LinkClient customer={customer} onLinked={loadCustomer} />
|
||||
) : (
|
||||
<InvoiceList
|
||||
ticket={ticket}
|
||||
clientId={clientId}
|
||||
invoices={invoices}
|
||||
loading={loading}
|
||||
error={error}
|
||||
createInvoice={createInvoice}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- 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 (
|
||||
<div>
|
||||
<p style={{ color: '#a0aec0', fontSize: '13px', marginTop: 0 }}>
|
||||
Dieser Kunde ist noch nicht mit InvoiceNinja verknuepft.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px' }}>
|
||||
<input
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
placeholder="InvoiceNinja-Kunde suchen..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && doSearch()}
|
||||
/>
|
||||
<button className="btn btn-green" style={{ margin: 0 }} onClick={doSearch} disabled={busy}>
|
||||
{busy ? <FaSpinner className="spinner" /> : 'Suchen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
|
||||
<div style={{ maxHeight: '180px', overflowY: 'auto', marginBottom: '10px' }}>
|
||||
{results.map((c) => (
|
||||
<div key={c.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<div>
|
||||
<strong style={{ color: 'var(--dark-text)' }}>{c.name}</strong>
|
||||
{c.number ? <span style={{ color: '#a0aec0' }}> <EFBFBD> {c.number}</span> : null}
|
||||
{c.email ? <div style={{ color: '#a0aec0', fontSize: '12px' }}>{c.email}</div> : null}
|
||||
</div>
|
||||
<button className="btn btn-green" style={{ margin: 0 }} disabled={saving} onClick={() => saveMapping(c.id)}>
|
||||
<FaLink /> Verknuepfen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!busy && results.length === 0 && (
|
||||
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Keine Treffer.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="btn" style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '10px 16px', borderRadius: '8px', cursor: 'pointer' }} disabled={saving} onClick={createAndLink}>
|
||||
{saving ? <FaSpinner className="spinner" /> : <><FaPlus /> Neuen InvoiceNinja-Kunden anlegen & verknuepfen</>}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- 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 (
|
||||
<div>
|
||||
<button className="btn btn-green" style={{ marginBottom: '12px' }} onClick={() => setShowCreate((s) => !s)}>
|
||||
<FaPlus /> Rechnung aus Ticket erstellen
|
||||
</button>
|
||||
|
||||
{showCreate && (
|
||||
<CreateInvoiceForm
|
||||
ticket={ticket}
|
||||
clientId={clientId}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
onSubmit={async (payload) => {
|
||||
const r = await createInvoice(payload)
|
||||
if (r.success) setShowCreate(false)
|
||||
return r
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: '#a0aec0' }}><FaSpinner className="spinner" /> Rechnungen werden geladen...</p>
|
||||
) : invoices.length === 0 ? (
|
||||
<p style={{ color: '#a0aec0', fontSize: '13px' }}>Noch keine Rechnungen fuer diesen Kunden.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="table" style={{ width: '100%', fontSize: '13px' }}>
|
||||
<thead>
|
||||
<tr style={{ color: '#a0aec0', textAlign: 'left' }}>
|
||||
<th>Nr.</th><th>Datum</th><th>Faellig</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((inv) => {
|
||||
const st = INVOICE_STATUS[inv.status_id] || { label: '<27>', color: '#a0aec0' }
|
||||
const overdue = Number(inv.balance) > 0 && inv.due_date && new Date(inv.due_date) < new Date()
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ color: 'var(--dark-text)' }}>{inv.number || inv.id?.slice(-6)}</td>
|
||||
<td style={{ color: '#cbd5e0' }}>{inv.date || '-'}</td>
|
||||
<td style={{ color: overdue ? '#ef4444' : '#cbd5e0' }}>{inv.due_date || '-'}</td>
|
||||
<td style={{ color: 'var(--dark-text)' }}>{formatMoney(inv.amount)}</td>
|
||||
<td style={{ color: Number(inv.balance) > 0 ? '#f59e0b' : '#10b981' }}>{formatMoney(inv.balance)}</td>
|
||||
<td><span style={{ color: st.color, fontWeight: 'bold' }}>{st.label}{overdue ? ' (ueberfaellig)' : ''}</span></td>
|
||||
<td>
|
||||
<button className="btn" title="PDF oeffnen" disabled={openingId === inv.id}
|
||||
style={{ background: '#ef4444', color: 'white', border: 'none', padding: '6px 10px', borderRadius: '6px', cursor: 'pointer' }}
|
||||
onClick={() => openPdf(inv.id)}>
|
||||
{openingId === inv.id ? <FaSpinner className="spinner" /> : <FaFilePdf />}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- 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 (
|
||||
<form onSubmit={submit} style={{ ...card, marginBottom: '12px' }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Leistung / Beschreibung</label>
|
||||
<textarea className="form-control" rows={2} value={description} onChange={(e) => setDescription(e.target.value)} required />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label className="form-label">Einzelpreis (EUR)</label>
|
||||
<input type="number" step="0.01" className="form-control" value={cost} onChange={(e) => setCost(e.target.value)} required />
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label className="form-label">Menge</label>
|
||||
<input type="number" step="1" className="form-control" value={quantity} onChange={(e) => setQuantity(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#cbd5e0', marginBottom: '12px' }}>
|
||||
<input type="checkbox" checked={markSent} onChange={(e) => setMarkSent(e.target.checked)} />
|
||||
Direkt als "versendet" markieren
|
||||
</label>
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button type="submit" className="btn btn-green" disabled={busy}>
|
||||
{busy ? <FaSpinner className="spinner" /> : 'Rechnung erstellen'}
|
||||
</button>
|
||||
<button type="button" className="btn" style={{ background: '#374151', color: 'white' }} onClick={onCancel}>Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</td>
|
||||
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
||||
<StatusDropdown
|
||||
value={ticket.status}
|
||||
onChange={handleStatusChange}
|
||||
/>
|
||||
<StatusDropdown value={ticket.status} onChange={handleStatusChange} />
|
||||
</td>
|
||||
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
||||
<PriorityDropdown
|
||||
value={ticket.priority}
|
||||
onChange={handlePriorityChange}
|
||||
/>
|
||||
<PriorityDropdown value={ticket.priority} onChange={handlePriorityChange} />
|
||||
</td>
|
||||
<td
|
||||
<td
|
||||
className={`text-center ${ticket.approvalStatus === 'approved' ? 'bg-green' : 'bg-yellow'}`}
|
||||
rowSpan={2}
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
>
|
||||
<ApprovalIcon size={24} />
|
||||
</td>
|
||||
<td
|
||||
<td
|
||||
className="bg-dark-grey text-center text-white"
|
||||
rowSpan={2}
|
||||
style={{ verticalAlign: 'middle', cursor: 'pointer' }}
|
||||
@@ -169,145 +151,79 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
||||
</tr>
|
||||
<tr className="ticket-row">
|
||||
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
||||
<EditorDropdown
|
||||
value={ticket.assignedTo}
|
||||
onChange={handleEditorChange}
|
||||
/>
|
||||
<EditorDropdown value={ticket.assignedTo} onChange={handleEditorChange} />
|
||||
</td>
|
||||
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
||||
<ResponseDropdown
|
||||
value={ticket.responseLevel}
|
||||
onChange={handleResponseChange}
|
||||
/>
|
||||
<ResponseDropdown value={ticket.responseLevel} onChange={handleResponseChange} />
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<>
|
||||
<tr className="worksheet-expansion">
|
||||
<td colSpan={10} className="worksheet-cell">
|
||||
<div className="card" style={{
|
||||
borderRadius: '0 0 12px 12px',
|
||||
marginTop: 0,
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
borderTop: 'none'
|
||||
}}>
|
||||
<div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}>
|
||||
{/* Bento Box Layout: 2 Spalten */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '20px',
|
||||
alignItems: 'stretch'
|
||||
}}>
|
||||
{/* Linke Spalte: Ticket-Beschreibung (50%) */}
|
||||
<div style={{
|
||||
<tr className="worksheet-expansion">
|
||||
<td colSpan={10} className="worksheet-cell">
|
||||
<div className="card" style={{
|
||||
borderRadius: '0 0 12px 12px',
|
||||
marginTop: 0,
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
borderTop: 'none'
|
||||
}}>
|
||||
<div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}>
|
||||
|
||||
{/* Tab-Leiste */}
|
||||
<div className="ticket-tabs">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`ticket-tab ${activeTab === t.id ? 'ticket-tab-active' : ''}`}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ticket-tab-content">
|
||||
{activeTab === 'details' && (
|
||||
<div style={{
|
||||
background: 'rgba(45, 55, 72, 0.5)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%'
|
||||
}}>
|
||||
<h5 style={{
|
||||
color: 'var(--dark-text)',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
fontSize: '18px',
|
||||
flex: '0 0 auto'
|
||||
}}>
|
||||
📋 Ticket-Beschreibung
|
||||
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', marginBottom: '16px', fontSize: '18px' }}>
|
||||
Ticket-Beschreibung
|
||||
</h5>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.8',
|
||||
color: 'rgba(226, 232, 240, 0.8)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
margin: 0,
|
||||
flex: '1 1 auto',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<p style={{ fontSize: '14px', lineHeight: '1.8', color: 'rgba(226, 232, 240, 0.85)', whiteSpace: 'pre-wrap', margin: 0 }}>
|
||||
{ticket.details || 'Keine Details vorhanden.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rechte Spalte: Statistics, Buttons (50%) */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
height: '100%'
|
||||
}}>
|
||||
{/* Button Row: Add Worksheet (100%) + History Icon Button */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'stretch'
|
||||
}}>
|
||||
{/* Add Worksheet Button - 100% width minus icon button */}
|
||||
<button
|
||||
{activeTab === 'worksheets' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch', marginBottom: '16px' }}>
|
||||
<button
|
||||
className="btn"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
flex: 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
color: 'white', border: 'none', padding: '12px 20px', borderRadius: '8px',
|
||||
fontWeight: 'bold', cursor: 'pointer', flex: 1
|
||||
}}
|
||||
onClick={() => setShowCreateWorksheet(true)}
|
||||
>
|
||||
<FaPlus style={{ marginRight: '8px' }} /> Add Worksheet
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
background: '#3b82f6', color: 'white', border: 'none', padding: '12px',
|
||||
borderRadius: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', minWidth: '44px', width: '44px',
|
||||
}}
|
||||
style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
|
||||
onClick={() => setShowProjectsModal(true)}
|
||||
title="Projekte bearbeiten"
|
||||
>
|
||||
<FaPen size={18} />
|
||||
</button>
|
||||
|
||||
{/* History Icon Button - klein, grau, nur Icon */}
|
||||
<button
|
||||
style={{
|
||||
background: '#616161',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '44px',
|
||||
width: '44px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#757575'
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#616161'
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
}}
|
||||
type="button"
|
||||
style={{ background: '#616161', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
|
||||
onClick={() => setShowHistoryModal(true)}
|
||||
title="Status History"
|
||||
>
|
||||
@@ -315,51 +231,47 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Statistiken */}
|
||||
{worksheets.length > 0 && (
|
||||
<div style={{
|
||||
background: 'rgba(45, 55, 72, 0.5)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
flex: '1 1 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0
|
||||
}}>
|
||||
<div style={{ background: 'rgba(45, 55, 72, 0.5)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(16, 185, 129, 0.2)', marginBottom: '16px' }}>
|
||||
<WorksheetStats worksheets={worksheets} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WorksheetList worksheets={worksheets} totalTime={getTotalTime()} loading={worksheetsLoading} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
|
||||
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
|
||||
|
||||
{/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */}
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
width: '100%'
|
||||
}}>
|
||||
<WorksheetList
|
||||
worksheets={worksheets}
|
||||
totalTime={getTotalTime()}
|
||||
loading={worksheetsLoading}
|
||||
/>
|
||||
</div>
|
||||
{activeTab === 'documents' && <DocumentsPanel ticket={ticket} />}
|
||||
|
||||
{activeTab === 'project' && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-green"
|
||||
style={{ marginBottom: '12px' }}
|
||||
onClick={() => setShowProjectsModal(true)}
|
||||
>
|
||||
<FaPen style={{ marginRight: 6 }} /> Projekte zuweisen / bearbeiten
|
||||
</button>
|
||||
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
<CreateWorksheetModal
|
||||
|
||||
<CreateWorksheetModal
|
||||
isOpen={showCreateWorksheet}
|
||||
onClose={() => setShowCreateWorksheet(false)}
|
||||
workorder={ticket}
|
||||
onCreate={handleCreateWorksheet}
|
||||
/>
|
||||
|
||||
|
||||
<StatusHistoryModal
|
||||
isOpen={showHistoryModal}
|
||||
onClose={() => setShowHistoryModal(false)}
|
||||
|
||||
49
src/hooks/useInvoices.js
Normal file
49
src/hooks/useInvoices.js
Normal file
@@ -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 }
|
||||
}
|
||||
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}`
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user