import React, { useState, useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import ticketService from '../services/ticketService'; import userService from '../services/userService'; import assetService from '../services/assetService'; import LoadingSpinner from '../components/common/LoadingSpinner'; import { toast } from 'react-toastify'; import { marked } from 'marked'; marked.use({ breaks: true, gfm: true }); const renderMd = (text) => { try { const html = marked.parse(String(text || ''), { async: false }); return { __html: typeof html === 'string' ? html : String(html) }; } catch { return { __html: String(text || '').replace(/\n/g, '
') }; } }; const STATUS_CONFIG = { offen: { label: 'Offen', css: 'status-pending' }, in_bearbeitung: { label: 'In Bearbeitung', css: 'status-in_progress' }, warten_auf_mitarbeiter: { label: 'Warten auf Mitarbeiter', css: 'status-completed' }, warten_auf_support: { label: 'Warten auf Support', css: 'status-warning' }, geschlossen: { label: 'Geschlossen', css: 'status-inaktiv' }, }; const PRIORITY_CONFIG = { niedrig: { label: 'Niedrig', icon: '🟢', color: 'var(--success)' }, mittel: { label: 'Mittel', icon: '🔵', color: 'var(--info)' }, hoch: { label: 'Hoch', icon: '🟠', color: 'var(--warning)' }, kritisch:{ label: 'Kritisch',icon: '🔴', color: 'var(--danger)' }, }; const CATEGORY_LIST = ['Software', 'Allgemein', 'SelectLine', 'Hardware']; const LINK_TYPES = [ { value: 'related', label: 'Verwandt mit' }, { value: 'blocks', label: 'Blockiert' }, { value: 'blocked_by',label: 'Blockiert durch' }, { value: 'duplicate', label: 'Duplikat von' }, ]; const QUICK_REPLIES = [ 'Wir haben Ihre Anfrage erhalten und bearbeiten diese schnellstmöglich.', 'Für weitere Informationen benötigen wir bitte folgende Angaben: …', 'Das Problem wurde behoben. Bitte testen Sie es und geben Sie uns Rückmeldung.', 'Wir haben das Problem eskaliert und informieren Sie über den Fortschritt.', 'Vielen Dank für Ihre Geduld. Wir melden uns baldmöglichst.', ]; // SQLite gibt Timestamps ohne Timezone zurück → als UTC parsen const toUTC = (d) => d ? new Date(typeof d === 'string' && !d.endsWith('Z') && !d.includes('+') ? d.replace(' ', 'T') + 'Z' : d) : null; const formatDate = (d) => { if (!d) return '-'; const date = toUTC(d); const now = new Date(); const diff = Math.floor((now - date) / 1000); if (diff < 60) return 'gerade eben'; if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`; if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std`; if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`; return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); }; const formatDateFull = (d) => d ? toUTC(d).toLocaleString('de-DE') : '-'; const Avatar = ({ name, size = 32 }) => { if (!name) return null; const initials = name.split(' ').map(n => n[0]).join('').toUpperCase().substring(0, 2); return (
{initials}
); }; const FieldRow = ({ label, children }) => (
{label} {children}
); const TicketDetailPage = () => { const { id } = useParams(); const navigate = useNavigate(); const { canModifyTickets, isAdmin, user } = useAuth(); const [ticket, setTicket] = useState(null); const [loading, setLoading] = useState(true); const [users, setUsers] = useState([]); const [assets, setAssets] = useState([]); const [assignees, setAssignees] = useState([]); const [assigneeSearch, setAssigneeSearch] = useState(''); const [assigneeDropOpen, setAssigneeDropOpen] = useState(false); const [assigneeDropPos, setAssigneeDropPos] = useState({ top: 0, left: 0, width: 280 }); const assigneeInputRef = useRef(null); const updateAssigneeDropPos = () => { if (assigneeInputRef.current) { const r = assigneeInputRef.current.getBoundingClientRect(); setAssigneeDropPos({ top: r.bottom + 2, left: r.left, width: r.width }); } }; const [assetSearch, setAssetSearch] = useState(''); const [assetDropOpen, setAssetDropOpen] = useState(false); const [assetDropPos, setAssetDropPos] = useState({ top: 0, left: 0, width: 280 }); const assetInputRef = useRef(null); const updateAssetDropPos = () => { if (assetInputRef.current) { const r = assetInputRef.current.getBoundingClientRect(); setAssetDropPos({ top: r.bottom + 2, left: r.left, width: r.width }); } }; useEffect(() => { if (!assetDropOpen) return; window.addEventListener('scroll', updateAssetDropPos, true); return () => window.removeEventListener('scroll', updateAssetDropPos, true); }, [assetDropOpen]); useEffect(() => { if (!assigneeDropOpen) return; window.addEventListener('scroll', updateAssigneeDropPos, true); return () => window.removeEventListener('scroll', updateAssigneeDropPos, true); }, [assigneeDropOpen]); const [activeTab, setActiveTab] = useState('activity'); // activity | history | links const [commentText, setCommentText] = useState(''); const [isInternal, setIsInternal] = useState(false); const [commentLoading, setCommentLoading] = useState(false); const [saving, setSaving] = useState(false); const [showQuickReplies, setShowQuickReplies] = useState(false); // KI-Chat (für normale User) const [kiInput, setKiInput] = useState(''); const [kiLoading, setKiLoading] = useState(false); const [kiHistory, setKiHistory] = useState([]); // [{role, content}] für AI-Kontext const kiBottomRef = useRef(null); const kiInputRef = useRef(null); // Verlauf const [history, setHistory] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); // Verknüpfungen const [links, setLinks] = useState([]); const [linkInput, setLinkInput] = useState(''); const [linkType, setLinkType] = useState('related'); const [linkLoading, setLinkLoading] = useState(false); // Wiedervorlage const [snoozeDate, setSnoozeDate] = useState(''); const [snoozeLoading, setSnoozeLoading] = useState(false); const [showSnooze, setShowSnooze] = useState(false); // Schließen-Modal const [showCloseModal, setShowCloseModal] = useState(false); const [closeResolution, setCloseResolution] = useState(''); const [closeModalLoading, setCloseModalLoading] = useState(false); useEffect(() => { loadTicket(); if (canModifyTickets()) { Promise.all([userService.getAll(), assetService.getAll()]) .then(([u, a]) => { setUsers(u); setAssets(a); }) .catch(() => {}); } }, [id]); // Vorhandene Links laden useEffect(() => { if (!id) return; ticketService.getLinks(id).then(setLinks).catch(() => {}); }, [id]); // SSE: Live-Kommentare useEffect(() => { const token = localStorage.getItem('token'); if (!token || !id) return; const url = `/api/tickets/${id}/events?token=${encodeURIComponent(token)}`; const es = new EventSource(url); es.addEventListener('comment', (e) => { try { const newComment = JSON.parse(e.data); setTicket(prev => { if (!prev) return prev; const exists = (prev.comments || []).some(c => c.id === newComment.id); if (exists) return prev; // Staff-Kommentar (nicht KI, Rolle ≤ 3) → KI deaktivieren const isStaffComment = !newComment.is_ai_comment && newComment.user_id && newComment.role_id && newComment.role_id <= 3; return { ...prev, comments: [...(prev.comments || []), newComment], ...(isStaffComment ? { ai_active: 0 } : {}), }; }); } catch (_) {} }); es.onerror = () => {}; return () => es.close(); }, [id]); const loadTicket = async () => { try { const data = await ticketService.getById(id); setTicket(data); if (data.assignees) setAssignees(data.assignees); } catch { toast.error('Ticket nicht gefunden'); navigate('/tickets'); } finally { setLoading(false); } }; const loadHistory = async () => { if (historyLoading || history.length > 0) return; setHistoryLoading(true); try { const data = await ticketService.getHistory(id); setHistory(data); } catch { toast.error('Verlauf konnte nicht geladen werden'); } finally { setHistoryLoading(false); } }; const handleFieldChange = async (field, value) => { setSaving(true); try { const updated = await ticketService.update(id, { [field]: value || null }); setTicket(prev => ({ ...prev, ...updated })); toast.success('Gespeichert'); } catch (err) { toast.error(err.message || 'Fehler beim Speichern'); } finally { setSaving(false); } }; const handleAssignSelf = () => { if (!user?.id) return; handleFieldChange('assigned_to_user_id', user.id); }; const handleSolveAndClose = () => { setCloseResolution(''); setShowCloseModal(true); }; const executeClose = async () => { setCloseModalLoading(true); try { const updated = await ticketService.update(id, { status: 'geschlossen' }); if (closeResolution.trim()) { await ticketService.addComment(id, `Lösung: ${closeResolution.trim()}`, false); } setTicket(prev => ({ ...prev, ...updated })); setShowCloseModal(false); toast.success('Ticket geschlossen & in Wissensdatenbank archiviert'); } catch (err) { toast.error(err.message || 'Fehler'); } finally { setCloseModalLoading(false); } }; const handleAddComment = async (e) => { e.preventDefault(); if (!commentText.trim()) return; setCommentLoading(true); try { await ticketService.addComment(id, commentText, isInternal); // Kein manueller State-Update – SSE-Event übernimmt das für alle Clients inkl. Sender setCommentText(''); setIsInternal(false); setShowQuickReplies(false); toast.success('Kommentar hinzugefügt'); } catch (err) { toast.error(err.message || 'Fehler beim Kommentieren'); } finally { setCommentLoading(false); } }; const handleDeleteTicket = async () => { if (!window.confirm(`Ticket ${ticket.ticket_number} wirklich löschen?`)) return; try { await ticketService.delete(id); toast.success('Ticket gelöscht'); navigate('/tickets'); } catch (err) { toast.error(err.message || 'Fehler beim Löschen'); } }; const handleDeleteComment = async (commentId) => { if (!window.confirm('Kommentar löschen?')) return; try { await ticketService.deleteComment(id, commentId); setTicket(prev => ({ ...prev, comments: prev.comments.filter(c => c.id !== commentId) })); toast.success('Kommentar gelöscht'); } catch { toast.error('Fehler beim Löschen'); } }; // KI-Chat auto-scroll useEffect(() => { kiBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [ticket?.comments]); const sendKiReply = async () => { const text = kiInput.trim(); if (!text || kiLoading) return; setKiInput(''); setKiLoading(true); try { const { userComment, aiComment } = await ticketService.aiReply(id, text, kiHistory); // Konversationshistorie für nächste KI-Anfrage aktualisieren setKiHistory(prev => [ ...prev, { role: 'user', content: text }, { role: 'assistant', content: aiComment.comment }, ]); // Kommentare erscheinen via SSE automatisch in der Timeline } catch (err) { if (err.response?.status === 403) { setTicket(prev => prev ? { ...prev, ai_active: 0 } : prev); } else { toast.error('KI-Assistent konnte nicht antworten'); } } finally { setKiLoading(false); setTimeout(() => kiInputRef.current?.focus(), 50); } }; const sendUserComment = async () => { const text = kiInput.trim(); if (!text || commentLoading) return; setKiInput(''); setCommentLoading(true); try { await ticketService.addComment(id, text, false); // Kommentar erscheint via SSE automatisch } catch (err) { toast.error(err.message || 'Nachricht konnte nicht gesendet werden'); setKiInput(text); } finally { setCommentLoading(false); setTimeout(() => kiInputRef.current?.focus(), 50); } }; const handleAddLink = async (e) => { e.preventDefault(); if (!linkInput.trim()) return; setLinkLoading(true); try { const newLinks = await ticketService.addLink(id, linkInput.trim(), linkType); setLinks(newLinks); setLinkInput(''); toast.success('Verknüpfung hinzugefügt'); } catch (err) { toast.error(err.message || 'Fehler beim Verknüpfen'); } finally { setLinkLoading(false); } }; const handleRemoveLink = async (linkId) => { try { await ticketService.removeLink(id, linkId); setLinks(prev => prev.filter(l => l.id !== linkId)); toast.success('Verknüpfung entfernt'); } catch { toast.error('Fehler beim Entfernen'); } }; const handleSnooze = async (e) => { e.preventDefault(); if (!snoozeDate) return; setSnoozeLoading(true); try { const updated = await ticketService.snooze(id, snoozeDate); setTicket(prev => ({ ...prev, ...updated })); setShowSnooze(false); setSnoozeDate(''); toast.success('Wiedervorlage gesetzt'); } catch (err) { toast.error(err.message || 'Fehler'); } finally { setSnoozeLoading(false); } }; const handleUnsnooze = async () => { setSnoozeLoading(true); try { const updated = await ticketService.snooze(id, null); setTicket(prev => ({ ...prev, ...updated })); toast.success('Wiedervorlage aufgehoben'); } catch { toast.error('Fehler'); } finally { setSnoozeLoading(false); } }; if (loading) return ; if (!ticket) return null; const canEdit = canModifyTickets(); const hasTeamViewer = ticket.asset_id && ticket.asset_teamviewer_id; const prioConf = PRIORITY_CONFIG[ticket.priority] || {}; const statusConf = STATUS_CONFIG[ticket.status] || {}; const assigneeName = ticket.assigned_to_first_name ? `${ticket.assigned_to_first_name} ${ticket.assigned_to_last_name || ''}`.trim() : ticket.assigned_to_username; const isSnoozed = ticket.snoozed_until && new Date(ticket.snoozed_until) > new Date(); const isCloseable = ticket.status !== 'geschlossen'; const TAB_STYLE = (active) => ({ padding: '8px 16px', border: 'none', borderBottom: active ? '2px solid var(--cereda-primary)' : '2px solid transparent', background: 'none', cursor: 'pointer', fontSize: '0.875rem', fontWeight: active ? 700 : 500, color: active ? 'var(--cereda-primary)' : 'var(--text-muted)', }); return ( <>
{/* Breadcrumb */}
/ {ticket.ticket_number} {ticket.source === 'email' && ( 📧 via E-Mail )} {isSnoozed && ( ⏰ Vorlage: {new Date(ticket.snoozed_until).toLocaleDateString('de-DE')} )} {saving && ⏳ Speichern...} {/* Action Buttons in Breadcrumb */}
📄 PDF {canEdit && isCloseable && ( )}
{/* Main grid */}
{/* ── LEFT COLUMN ── */}
{/* Ticket title card */}

{ticket.title}

{statusConf.label} {prioConf.icon} {prioConf.label} {ticket.category} {ticket.satisfaction_rating === 'gut' && ( 👍 Positives Feedback )} {ticket.satisfaction_rating === 'schlecht' && ( 👎 Negatives Feedback )} Erstellt {formatDate(ticket.created_at)}
{ticket.satisfaction_comment && (
{ticket.satisfaction_rating === 'gut' ? '👍' : '👎'}
Feedback-Kommentar
{ticket.satisfaction_comment}
)} {ticket.description ? (
{ticket.description.split('\n').map((line, i) => { const imgMatch = line.match(/^!\[.*?\]\((https?:\/\/[^)]+)\)$/); if (imgMatch) return (
Anhang window.open(imgMatch[1], '_blank')} />
); const boldLine = line.replace(/\*\*(.+?)\*\*/g, '$1'); return
; })}
) : (

Keine Beschreibung vorhanden.

)} {ticket.ai_suggestion && (
🤖 KI-Lösungsvorschlag

{ticket.ai_suggestion}

{canEdit && ( )}
)} {(ticket.requester_name || ticket.requester_email) && (
Anfragender:  {ticket.requester_name || '-'} {ticket.requester_email && ( <> · {ticket.requester_email} )}
)}
{/* Aktivität / Verlauf / Verknüpfungen Tabs */}
{/* Tab Bar */}
{/* ── ACTIVITY TAB ── */} {activeTab === 'activity' && ( <> {(ticket.comments || []).length === 0 ? (
💬

Noch keine Kommentare

{!canEdit && ticket.ai_active !== 0 && (
🤖 KI-Assistent antwortet… {[0,1,2].map(i => ( ))}
)}
) : (
{(ticket.comments || []).map(comment => { const isAi = !!comment.is_ai_comment; const authorName = isAi ? 'KI-Assistent' : comment.first_name ? `${comment.first_name} ${comment.last_name || ''}`.trim() : comment.username || 'Unbekannt'; const bgColor = isAi ? 'rgba(99,102,241,0.06)' : comment.is_internal ? 'rgba(234,179,8,0.08)' : 'var(--bg-tertiary)'; const borderColor = isAi ? 'rgba(99,102,241,0.25)' : comment.is_internal ? 'rgba(234,179,8,0.3)' : 'var(--border-color)'; return (
{isAi ? (
🤖
) : ( )}
{authorName} {isAi && ( 🤖 KI )} {!isAi && !!comment.is_internal && ( 🔒 Intern )}
{formatDate(comment.created_at)} {canEdit && ( )}
{isAi ? (
) : (
{comment.comment}
)}
); })}
)} {/* KI-Chat für normale User */} {!canEdit && (
{ticket.ai_active === 0 ? '👤' : '🤖'}
{ticket.ai_active === 0 ? 'Support' : 'KI-Assistent'} · {ticket.ai_active === 0 ? 'Ein Mitarbeiter hat die Bearbeitung übernommen' : 'Stell mir Fragen zu deinem Problem'}