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 { FaTrash, FaCodeBranch } from 'react-icons/fa'
|
||||||
|
import { FaGlobe, FaCodeCommit, FaClock } from 'react-icons/fa6'
|
||||||
import ProjectReadme from './ProjectReadme'
|
import ProjectReadme from './ProjectReadme'
|
||||||
import GiteaLinkButton from './GiteaLinkButton'
|
import GiteaLinkButton from './GiteaLinkButton'
|
||||||
import PreviewLinkButton from './PreviewLinkButton'
|
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 }) {
|
export default function AssignedProjectCard({ project, onUnassign, showActions = false }) {
|
||||||
|
const lastPush = project.lastPushAt || project.giteaSyncedAt
|
||||||
return (
|
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={{ 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 style={{ display: 'flex', gap: 8, alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||||
<div>
|
<div style={{ minWidth: 0 }}>
|
||||||
<strong>{project.projectName}</strong>
|
<strong>{project.projectName}</strong>
|
||||||
{project.subdomain && <span className="text-grey" style={{ marginLeft: 8, fontSize: 13 }}>({project.subdomain})</span>}
|
{project.subdomain && <span className="text-grey" style={{ marginLeft: 8, fontSize: 13 }}>({project.subdomain})</span>}
|
||||||
{project.repoFullName && (
|
{project.repoFullName && (
|
||||||
@@ -15,6 +42,14 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
|||||||
<FaCodeBranch style={{ marginRight: 4 }} />{project.repoFullName}
|
<FaCodeBranch style={{ marginRight: 4 }} />{project.repoFullName}
|
||||||
</div>
|
</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>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
|
||||||
<PreviewLinkButton href={project.previewUrl} />
|
<PreviewLinkButton href={project.previewUrl} />
|
||||||
@@ -24,6 +59,38 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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} />}
|
{project.repoFullName && <ProjectReadme repoFullName={project.repoFullName} />}
|
||||||
</div>
|
</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 { useState, useRef } from 'react'
|
||||||
import { FaTimes, FaSpinner } from 'react-icons/fa'
|
import { FaTimes, FaSpinner } from 'react-icons/fa'
|
||||||
import { storage, BUCKET_ID, ID } from '../lib/appwrite'
|
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 [uploading, setUploading] = useState(false)
|
||||||
const [dragOver, setDragOver] = useState(false)
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
const handleDrop = async (e) => {
|
const handleDrop = async (e) => {
|
||||||
@@ -33,6 +35,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
|
setStatus('Lade in Ablage hoch...')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await storage.createFile(
|
const response = await storage.createFile(
|
||||||
@@ -41,6 +44,30 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
|||||||
file
|
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)
|
onUploadComplete?.(response)
|
||||||
onClose()
|
onClose()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -48,6 +75,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
|||||||
alert('Error uploading file: ' + error.message)
|
alert('Error uploading file: ' + error.message)
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
|
setStatus('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +100,7 @@ export default function FileUploadModal({ isOpen, onClose, workorderId, onUpload
|
|||||||
{uploading ? (
|
{uploading ? (
|
||||||
<div>
|
<div>
|
||||||
<FaSpinner className="spinner" size={32} />
|
<FaSpinner className="spinner" size={32} />
|
||||||
<p className="mt-1">Uploading file...</p>
|
<p className="mt-1">{status || 'Uploading file...'}</p>
|
||||||
</div>
|
</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 WorksheetStats from './WorksheetStats'
|
||||||
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
|
import TicketAssignedProjectsPanel from './TicketAssignedProjectsPanel'
|
||||||
import TicketProjectsModal from './TicketProjectsModal'
|
import TicketProjectsModal from './TicketProjectsModal'
|
||||||
|
import InvoicePanel from './InvoicePanel'
|
||||||
|
import DocumentsPanel from './DocumentsPanel'
|
||||||
import { useWorksheets } from '../hooks/useWorksheets'
|
import { useWorksheets } from '../hooks/useWorksheets'
|
||||||
|
|
||||||
const PRIORITY_CLASSES = {
|
const PRIORITY_CLASSES = {
|
||||||
@@ -22,14 +24,6 @@ const PRIORITY_CLASSES = {
|
|||||||
4: 'priority-critical'
|
4: 'priority-critical'
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_LABELS = {
|
|
||||||
0: 'NONE',
|
|
||||||
1: 'LOW',
|
|
||||||
2: 'MEDIUM',
|
|
||||||
3: 'HIGH',
|
|
||||||
4: 'CRITICAL'
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_CLASSES = {
|
const STATUS_CLASSES = {
|
||||||
'Open': 'status-open',
|
'Open': 'status-open',
|
||||||
'Occupied': 'status-occupied',
|
'Occupied': 'status-occupied',
|
||||||
@@ -45,15 +39,23 @@ const APPROVAL_ICONS = {
|
|||||||
'shipping': FaTruck
|
'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 }) {
|
export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [locked, setLocked] = useState(true)
|
const [locked, setLocked] = useState(true)
|
||||||
|
const [activeTab, setActiveTab] = useState('details')
|
||||||
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
||||||
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
const [showHistoryModal, setShowHistoryModal] = useState(false)
|
||||||
const [showProjectsModal, setShowProjectsModal] = useState(false)
|
const [showProjectsModal, setShowProjectsModal] = useState(false)
|
||||||
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
|
const [projectsRefreshKey, setProjectsRefreshKey] = useState(0)
|
||||||
|
|
||||||
// Worksheets für dieses Ticket laden (nur wenn expanded)
|
|
||||||
const {
|
const {
|
||||||
worksheets,
|
worksheets,
|
||||||
loading: worksheetsLoading,
|
loading: worksheetsLoading,
|
||||||
@@ -64,21 +66,10 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
|
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
|
||||||
const elapsed = formatDistanceToNow(createdAt, { locale: de })
|
const elapsed = formatDistanceToNow(createdAt, { locale: de })
|
||||||
|
|
||||||
const handleStatusChange = (newStatus) => {
|
const handleStatusChange = (newStatus) => onUpdate(ticket.$id, { status: 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 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 = () => {
|
const toggleLock = () => {
|
||||||
if (locked) {
|
if (locked) {
|
||||||
@@ -92,15 +83,12 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
|
|
||||||
const handleCreateWorksheet = async (worksheetData, currentUser) => {
|
const handleCreateWorksheet = async (worksheetData, currentUser) => {
|
||||||
const result = await createWorksheet(worksheetData, currentUser)
|
const result = await createWorksheet(worksheetData, currentUser)
|
||||||
|
|
||||||
// Wenn Status geändert wurde, aktualisiere Work Order
|
|
||||||
if (result.success && worksheetData.newStatus !== ticket.status) {
|
if (result.success && worksheetData.newStatus !== ticket.status) {
|
||||||
await onUpdate(ticket.$id, {
|
await onUpdate(ticket.$id, {
|
||||||
status: worksheetData.newStatus,
|
status: worksheetData.newStatus,
|
||||||
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel
|
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,16 +128,10 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
{ticket.topic}
|
{ticket.topic}
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
||||||
<StatusDropdown
|
<StatusDropdown value={ticket.status} onChange={handleStatusChange} />
|
||||||
value={ticket.status}
|
|
||||||
onChange={handleStatusChange}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
||||||
<PriorityDropdown
|
<PriorityDropdown value={ticket.priority} onChange={handlePriorityChange} />
|
||||||
value={ticket.priority}
|
|
||||||
onChange={handlePriorityChange}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={`text-center ${ticket.approvalStatus === 'approved' ? 'bg-green' : 'bg-yellow'}`}
|
className={`text-center ${ticket.approvalStatus === 'approved' ? 'bg-green' : 'bg-yellow'}`}
|
||||||
@@ -169,20 +151,13 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr className="ticket-row">
|
<tr className="ticket-row">
|
||||||
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
<td className={`text-center ${STATUS_CLASSES[ticket.status] || 'status-open'}`}>
|
||||||
<EditorDropdown
|
<EditorDropdown value={ticket.assignedTo} onChange={handleEditorChange} />
|
||||||
value={ticket.assignedTo}
|
|
||||||
onChange={handleEditorChange}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
<td className={`text-center ${PRIORITY_CLASSES[ticket.priority] || 'priority-low'}`}>
|
||||||
<ResponseDropdown
|
<ResponseDropdown value={ticket.responseLevel} onChange={handleResponseChange} />
|
||||||
value={ticket.responseLevel}
|
|
||||||
onChange={handleResponseChange}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<>
|
|
||||||
<tr className="worksheet-expansion">
|
<tr className="worksheet-expansion">
|
||||||
<td colSpan={10} className="worksheet-cell">
|
<td colSpan={10} className="worksheet-cell">
|
||||||
<div className="card" style={{
|
<div className="card" style={{
|
||||||
@@ -192,122 +167,63 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
borderTop: 'none'
|
borderTop: 'none'
|
||||||
}}>
|
}}>
|
||||||
<div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}>
|
<div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}>
|
||||||
{/* Bento Box Layout: 2 Spalten */}
|
|
||||||
<div style={{
|
{/* Tab-Leiste */}
|
||||||
display: 'grid',
|
<div className="ticket-tabs">
|
||||||
gridTemplateColumns: '1fr 1fr',
|
{TABS.map((t) => (
|
||||||
gap: '20px',
|
<button
|
||||||
alignItems: 'stretch'
|
key={t.id}
|
||||||
}}>
|
type="button"
|
||||||
{/* Linke Spalte: Ticket-Beschreibung (50%) */}
|
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={{
|
<div style={{
|
||||||
background: 'rgba(45, 55, 72, 0.5)',
|
background: 'rgba(45, 55, 72, 0.5)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
height: '100%'
|
|
||||||
}}>
|
}}>
|
||||||
<h5 style={{
|
<h5 style={{ color: 'var(--dark-text)', fontWeight: 'bold', marginBottom: '16px', fontSize: '18px' }}>
|
||||||
color: 'var(--dark-text)',
|
Ticket-Beschreibung
|
||||||
fontWeight: 'bold',
|
|
||||||
marginBottom: '16px',
|
|
||||||
fontSize: '18px',
|
|
||||||
flex: '0 0 auto'
|
|
||||||
}}>
|
|
||||||
📋 Ticket-Beschreibung
|
|
||||||
</h5>
|
</h5>
|
||||||
<p style={{
|
<p style={{ fontSize: '14px', lineHeight: '1.8', color: 'rgba(226, 232, 240, 0.85)', whiteSpace: 'pre-wrap', margin: 0 }}>
|
||||||
fontSize: '14px',
|
|
||||||
lineHeight: '1.8',
|
|
||||||
color: 'rgba(226, 232, 240, 0.8)',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
margin: 0,
|
|
||||||
flex: '1 1 auto',
|
|
||||||
overflowY: 'auto'
|
|
||||||
}}>
|
|
||||||
{ticket.details || 'Keine Details vorhanden.'}
|
{ticket.details || 'Keine Details vorhanden.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Rechte Spalte: Statistics, Buttons (50%) */}
|
{activeTab === 'worksheets' && (
|
||||||
<div style={{
|
<div>
|
||||||
display: 'flex',
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch', marginBottom: '16px' }}>
|
||||||
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
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||||
color: 'white',
|
color: 'white', border: 'none', padding: '12px 20px', borderRadius: '8px',
|
||||||
border: 'none',
|
fontWeight: 'bold', cursor: 'pointer', flex: 1
|
||||||
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'
|
|
||||||
}}
|
}}
|
||||||
onClick={() => setShowCreateWorksheet(true)}
|
onClick={() => setShowCreateWorksheet(true)}
|
||||||
>
|
>
|
||||||
<FaPlus style={{ marginRight: '8px' }} /> Add Worksheet
|
<FaPlus style={{ marginRight: '8px' }} /> Add Worksheet
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style={{
|
style={{ background: '#3b82f6', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
|
||||||
background: '#3b82f6', color: 'white', border: 'none', padding: '12px',
|
|
||||||
borderRadius: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center',
|
|
||||||
justifyContent: 'center', minWidth: '44px', width: '44px',
|
|
||||||
}}
|
|
||||||
onClick={() => setShowProjectsModal(true)}
|
onClick={() => setShowProjectsModal(true)}
|
||||||
title="Projekte bearbeiten"
|
title="Projekte bearbeiten"
|
||||||
>
|
>
|
||||||
<FaPen size={18} />
|
<FaPen size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* History Icon Button - klein, grau, nur Icon */}
|
|
||||||
<button
|
<button
|
||||||
style={{
|
type="button"
|
||||||
background: '#616161',
|
style={{ background: '#616161', color: 'white', border: 'none', padding: '12px', borderRadius: '8px', cursor: 'pointer', minWidth: '44px' }}
|
||||||
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)'
|
|
||||||
}}
|
|
||||||
onClick={() => setShowHistoryModal(true)}
|
onClick={() => setShowHistoryModal(true)}
|
||||||
title="Status History"
|
title="Status History"
|
||||||
>
|
>
|
||||||
@@ -315,42 +231,38 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Statistiken */}
|
|
||||||
{worksheets.length > 0 && (
|
{worksheets.length > 0 && (
|
||||||
<div style={{
|
<div style={{ background: 'rgba(45, 55, 72, 0.5)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(16, 185, 129, 0.2)', marginBottom: '16px' }}>
|
||||||
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
|
|
||||||
}}>
|
|
||||||
<WorksheetStats worksheets={worksheets} />
|
<WorksheetStats worksheets={worksheets} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<WorksheetList worksheets={worksheets} totalTime={getTotalTime()} loading={worksheetsLoading} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'invoices' && <InvoicePanel ticket={ticket} />}
|
||||||
|
|
||||||
|
{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} />
|
<TicketAssignedProjectsPanel ticket={ticket} refreshKey={projectsRefreshKey} />
|
||||||
|
</div>
|
||||||
{/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */}
|
)}
|
||||||
<div style={{
|
|
||||||
marginTop: '20px',
|
|
||||||
width: '100%'
|
|
||||||
}}>
|
|
||||||
<WorksheetList
|
|
||||||
worksheets={worksheets}
|
|
||||||
totalTime={getTotalTime()}
|
|
||||||
loading={worksheetsLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CreateWorksheetModal
|
<CreateWorksheetModal
|
||||||
|
|||||||
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;
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.col-6 { width: 100%; }
|
.col-6 { width: 100%; }
|
||||||
.navbar-nav { display: none; }
|
.navbar-nav { display: none; }
|
||||||
|
.ticket-tab { padding: 8px 12px; font-size: 13px; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user