Files
IT-Nexus/frontend/src/pages/TicketDetailPage.jsx

1250 lines
84 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '<br>') };
}
};
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 (
<div style={{
width: size, height: size, borderRadius: '50%',
background: 'linear-gradient(135deg, var(--cereda-primary), var(--cereda-accent))',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.375, fontWeight: 700, color: 'white', flexShrink: 0,
}}>
{initials}
</div>
);
};
const FieldRow = ({ label, children }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '16px' }}>
<span style={{ fontSize: '0.6875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>
{label}
</span>
{children}
</div>
);
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 <LoadingSpinner />;
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 (
<>
<div className="main-content">
<div className="container">
{/* Breadcrumb */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<button className="btn btn-secondary btn-small" onClick={() => navigate('/tickets')} style={{ fontSize: '0.8125rem' }}>
Tickets
</button>
<span style={{ color: 'var(--text-muted)' }}>/</span>
<span style={{ fontFamily: 'monospace', fontWeight: 700, color: 'var(--cereda-primary)', fontSize: '0.9rem' }}>
{ticket.ticket_number}
</span>
{ticket.source === 'email' && (
<span style={{ fontSize: '0.6875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', padding: '2px 8px', borderRadius: '4px', background: 'var(--bg-tertiary)', color: 'var(--text-muted)', border: '1px solid var(--border-color)' }}>
📧 via E-Mail
</span>
)}
{isSnoozed && (
<span style={{ fontSize: '0.6875rem', fontWeight: 700, padding: '2px 8px', borderRadius: '4px', background: 'rgba(245,158,11,0.12)', color: 'var(--warning)', border: '1px solid rgba(245,158,11,0.3)' }}>
Vorlage: {new Date(ticket.snoozed_until).toLocaleDateString('de-DE')}
</span>
)}
{saving && <span style={{ marginLeft: 'auto', fontSize: '0.8125rem', color: 'var(--text-muted)' }}> Speichern...</span>}
{/* Action Buttons in Breadcrumb */}
<div style={{ marginLeft: 'auto', display: 'flex', gap: '8px', alignItems: 'center' }}>
<a
href={ticketService.getPdfUrl(id)}
target="_blank"
rel="noreferrer"
className="btn btn-secondary btn-small"
style={{ fontSize: '0.8125rem', textDecoration: 'none' }}
>
📄 PDF
</a>
{canEdit && isCloseable && (
<button className="btn btn-small" onClick={handleSolveAndClose}
style={{ background: 'var(--success)', color: 'white', border: 'none', fontSize: '0.8125rem' }}>
Lösen & Schließen
</button>
)}
</div>
</div>
{/* Main grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: '24px', alignItems: 'start' }}>
{/* ── LEFT COLUMN ── */}
<div>
{/* Ticket title card */}
<div className="card" style={{ marginBottom: '16px', overflow: 'hidden' }}>
<div style={{ height: '4px', background: prioConf.color }} />
<div style={{ padding: '20px 24px' }}>
<h1 style={{ margin: '0 0 12px', fontSize: '1.375rem', fontWeight: 800, color: 'var(--text-primary)', lineHeight: 1.3 }}>
{ticket.title}
</h1>
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'center', marginBottom: '16px' }}>
<span className={`status-badge ${statusConf.css}`}>{statusConf.label}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.8125rem', fontWeight: 600, color: prioConf.color }}>
{prioConf.icon} {prioConf.label}
</span>
<span style={{ fontSize: '0.75rem', fontWeight: 600, padding: '2px 8px', borderRadius: '4px', background: 'var(--bg-tertiary)', color: 'var(--text-secondary)', border: '1px solid var(--border-color)' }}>
{ticket.category}
</span>
{ticket.satisfaction_rating === 'gut' && (
<span title="Kundenfeedback: Positiv" style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.75rem', fontWeight: 600, padding: '2px 8px', borderRadius: '4px', background: '#dcfce7', color: '#16a34a', border: '1px solid #86efac' }}>
👍 Positives Feedback
</span>
)}
{ticket.satisfaction_rating === 'schlecht' && (
<span title="Kundenfeedback: Negativ" style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.75rem', fontWeight: 600, padding: '2px 8px', borderRadius: '4px', background: '#fee2e2', color: '#dc2626', border: '1px solid #fca5a5' }}>
👎 Negatives Feedback
</span>
)}
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginLeft: 'auto' }}>
Erstellt {formatDate(ticket.created_at)}
</span>
</div>
{ticket.satisfaction_comment && (
<div style={{ background: ticket.satisfaction_rating === 'gut' ? '#f0fdf4' : '#fef2f2', border: `1px solid ${ticket.satisfaction_rating === 'gut' ? '#86efac' : '#fca5a5'}`, borderRadius: '8px', padding: '10px 14px', fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '12px', display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
<span style={{ fontSize: '1rem', flexShrink: 0 }}>{ticket.satisfaction_rating === 'gut' ? '👍' : '👎'}</span>
<div>
<div style={{ fontWeight: 600, fontSize: '0.75rem', color: ticket.satisfaction_rating === 'gut' ? '#16a34a' : '#dc2626', marginBottom: '2px' }}>Feedback-Kommentar</div>
<div style={{ lineHeight: 1.5 }}>{ticket.satisfaction_comment}</div>
</div>
</div>
)}
{ticket.description ? (
<div style={{ background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)', borderRadius: '8px', padding: '14px 16px', fontSize: '0.875rem', color: 'var(--text-secondary)', lineHeight: 1.6 }}>
{ticket.description.split('\n').map((line, i) => {
const imgMatch = line.match(/^!\[.*?\]\((https?:\/\/[^)]+)\)$/);
if (imgMatch) return (
<div key={i} style={{ margin: '8px 0' }}>
<img src={imgMatch[1]} alt="Anhang" style={{ maxWidth: '100%', maxHeight: '400px', borderRadius: '6px', border: '1px solid var(--border-color)', cursor: 'pointer' }} onClick={() => window.open(imgMatch[1], '_blank')} />
</div>
);
const boldLine = line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
return <div key={i} style={{ minHeight: line ? undefined : '0.5em' }} dangerouslySetInnerHTML={{ __html: boldLine || '&nbsp;' }} />;
})}
</div>
) : (
<p style={{ margin: 0, fontSize: '0.875rem', color: 'var(--text-muted)', fontStyle: 'italic' }}>Keine Beschreibung vorhanden.</p>
)}
{ticket.ai_suggestion && (
<div style={{ marginTop: '16px', background: 'linear-gradient(135deg, rgba(99,102,241,0.06), rgba(139,92,246,0.06))', border: '1px solid rgba(99,102,241,0.2)', borderRadius: '8px', padding: '14px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<span style={{ fontSize: '1rem' }}>🤖</span>
<span style={{ fontSize: '0.6875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--cereda-primary)' }}>
KI-Lösungsvorschlag
</span>
</div>
<p style={{ margin: 0, fontSize: '0.875rem', color: 'var(--text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
{ticket.ai_suggestion}
</p>
{canEdit && (
<button
onClick={() => { setCommentText(ticket.ai_suggestion); setActiveTab('activity'); }}
style={{ marginTop: '10px', fontSize: '0.75rem', padding: '4px 10px', borderRadius: '6px', border: '1px solid rgba(99,102,241,0.3)', background: 'rgba(99,102,241,0.08)', color: 'var(--cereda-primary)', cursor: 'pointer' }}
>
Als Kommentar übernehmen
</button>
)}
</div>
)}
{(ticket.requester_name || ticket.requester_email) && (
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', gap: '10px', fontSize: '0.8125rem', color: 'var(--text-muted)' }}>
<Avatar name={ticket.requester_name || ticket.requester_email} size={28} />
<span>
Anfragender:&nbsp;
<strong style={{ color: 'var(--text-primary)' }}>{ticket.requester_name || '-'}</strong>
{ticket.requester_email && (
<> · <a href={`mailto:${ticket.requester_email}`} style={{ color: 'var(--cereda-primary)' }}>{ticket.requester_email}</a></>
)}
</span>
</div>
)}
</div>
</div>
{/* Aktivität / Verlauf / Verknüpfungen Tabs */}
<div className="card" style={{ overflow: 'hidden' }}>
{/* Tab Bar */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color)', padding: '0 8px' }}>
<button style={TAB_STYLE(activeTab === 'activity')} onClick={() => setActiveTab('activity')}>
💬 Aktivität ({(ticket.comments || []).length})
</button>
<button style={TAB_STYLE(activeTab === 'history')} onClick={() => { setActiveTab('history'); loadHistory(); }}>
📋 Verlauf
</button>
<button style={TAB_STYLE(activeTab === 'links')} onClick={() => setActiveTab('links')}>
🔗 Verknüpfungen ({links.length})
</button>
</div>
<div style={{ padding: '20px 24px' }}>
{/* ── ACTIVITY TAB ── */}
{activeTab === 'activity' && (
<>
{(ticket.comments || []).length === 0 ? (
<div style={{ textAlign: 'center', padding: '2rem 0', color: 'var(--text-muted)' }}>
<div style={{ fontSize: '2rem', marginBottom: '8px' }}>💬</div>
<p style={{ margin: 0, fontSize: '0.875rem' }}>Noch keine Kommentare</p>
{!canEdit && ticket.ai_active !== 0 && (
<div style={{ marginTop: '16px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', color: '#6366f1', fontSize: '0.8125rem' }}>
<span style={{ fontSize: '1rem' }}>🤖</span>
<span>KI-Assistent antwortet</span>
<span style={{ display: 'inline-flex', gap: '3px' }}>
{[0,1,2].map(i => (
<span key={i} style={{ width: 5, height: 5, borderRadius: '50%', background: '#6366f1', animation: `dotPulse 1.2s ease-in-out ${i*0.2}s infinite` }} />
))}
</span>
</div>
)}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginBottom: '24px' }}>
{(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 (
<div key={comment.id} style={{ display: 'flex', gap: '12px', alignItems: 'flex-start' }}>
{isAi ? (
<div style={{ width: 32, height: 32, borderRadius: '50%', flexShrink: 0, background: 'linear-gradient(135deg,#6366f1,#8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '1rem' }}>🤖</div>
) : (
<Avatar name={authorName} size={32} />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ background: bgColor, border: `1px solid ${borderColor}`, borderRadius: '8px', padding: '10px 14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontWeight: 700, fontSize: '0.8125rem', color: isAi ? '#6366f1' : 'var(--text-primary)' }}>{authorName}</span>
{isAi && (
<span style={{ fontSize: '0.6875rem', fontWeight: 700, padding: '1px 6px', borderRadius: '4px', background: 'rgba(99,102,241,0.12)', color: '#6366f1', border: '1px solid rgba(99,102,241,0.25)' }}>
🤖 KI
</span>
)}
{!isAi && !!comment.is_internal && (
<span style={{ fontSize: '0.6875rem', fontWeight: 700, padding: '1px 6px', borderRadius: '4px', background: 'rgba(234,179,8,0.15)', color: 'var(--warning)', border: '1px solid rgba(234,179,8,0.3)' }}>
🔒 Intern
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }} title={formatDateFull(comment.created_at)}>
{formatDate(comment.created_at)}
</span>
{canEdit && (
<button onClick={() => handleDeleteComment(comment.id)} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: '14px', padding: '0 2px', lineHeight: 1 }} title="Löschen"></button>
)}
</div>
</div>
{isAi ? (
<div className="md-content" style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', lineHeight: 1.5 }} dangerouslySetInnerHTML={renderMd(comment.comment)} />
) : (
<div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', whiteSpace: 'pre-wrap', lineHeight: 1.5 }}>
{comment.comment}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* KI-Chat für normale User */}
{!canEdit && (
<div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '20px' }}>
<div style={{ marginBottom: '10px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: 28, height: 28, borderRadius: '50%', background: ticket.ai_active === 0 ? 'var(--bg-tertiary)' : 'linear-gradient(135deg,#6366f1,#8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.875rem' }}>
{ticket.ai_active === 0 ? '👤' : '🤖'}
</div>
<span style={{ fontWeight: 600, fontSize: '0.875rem', color: ticket.ai_active === 0 ? 'var(--text-secondary)' : '#6366f1' }}>
{ticket.ai_active === 0 ? 'Support' : 'KI-Assistent'}
</span>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
· {ticket.ai_active === 0 ? 'Ein Mitarbeiter hat die Bearbeitung übernommen' : 'Stell mir Fragen zu deinem Problem'}
</span>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<textarea
ref={kiInputRef}
value={kiInput}
onChange={e => setKiInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
ticket.ai_active === 0 ? sendUserComment() : sendKiReply();
}
}}
placeholder={ticket.ai_active === 0 ? 'Nachricht an Support schreiben… (Enter zum Senden)' : 'Frage den KI-Assistenten oder schreibe eine Antwort… (Enter zum Senden)'}
disabled={kiLoading || commentLoading}
rows={2}
className="form-textarea"
style={{ flex: 1, resize: 'vertical', marginBottom: 0 }}
/>
<button
type="button"
onClick={ticket.ai_active === 0 ? sendUserComment : sendKiReply}
disabled={kiLoading || commentLoading || !kiInput.trim()}
className="btn btn-primary btn-small"
style={{ alignSelf: 'flex-end', height: 38, padding: '0 16px' }}
>
{(kiLoading || commentLoading) ? '⏳' : '➤'}
</button>
</div>
<div ref={kiBottomRef} />
</div>
)}
{/* Kommentar-Formular (nur für Staff) */}
{canEdit && <div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '20px' }}>
<form onSubmit={handleAddComment}>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-start' }}>
<Avatar name={user?.first_name ? `${user.first_name} ${user.last_name || ''}` : user?.username} size={32} />
<div style={{ flex: 1 }}>
<div style={{ position: 'relative' }}>
<textarea
className="form-textarea"
rows="3"
placeholder="Antwort schreiben..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
style={{ marginBottom: '4px', resize: 'vertical' }}
/>
{/* Schnellantwort-Dropdown */}
{canEdit && (
<div style={{ marginBottom: '8px' }}>
<button
type="button"
onClick={() => setShowQuickReplies(!showQuickReplies)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.75rem', color: 'var(--text-muted)', padding: '0' }}
>
Schnellantwort {showQuickReplies ? '▲' : '▼'}
</button>
{showQuickReplies && (
<div style={{ marginTop: '4px', border: '1px solid var(--border-color)', borderRadius: '8px', overflow: 'hidden', background: 'var(--bg-card)' }}>
{QUICK_REPLIES.map((reply, i) => (
<button
key={i}
type="button"
onClick={() => { setCommentText(reply); setShowQuickReplies(false); }}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '8px 12px', border: 'none', borderBottom: i < QUICK_REPLIES.length - 1 ? '1px solid var(--border-color)' : 'none',
background: 'none', cursor: 'pointer', fontSize: '0.8125rem', color: 'var(--text-secondary)',
}}
onMouseEnter={e => e.target.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.target.style.background = 'none'}
>
{reply.length > 80 ? reply.substring(0, 80) + '…' : reply}
</button>
))}
</div>
)}
</div>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{canEdit && (
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.8125rem', color: 'var(--text-secondary)', cursor: 'pointer' }}>
<input type="checkbox" checked={isInternal} onChange={(e) => setIsInternal(e.target.checked)} />
🔒 Interne Notiz
</label>
)}
<button type="submit" className="btn btn-primary btn-small" disabled={commentLoading || !commentText.trim()} style={{ marginLeft: 'auto' }}>
{commentLoading ? '⏳ Senden...' : '➤ Senden'}
</button>
</div>
</div>
</div>
</form>
</div>}
</>
)}
{/* ── HISTORY TAB ── */}
{activeTab === 'history' && (
<>
{historyLoading ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}> Lade Verlauf</div>
) : history.length === 0 ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
<div style={{ fontSize: '2rem', marginBottom: '8px' }}>📋</div>
<p style={{ margin: 0, fontSize: '0.875rem' }}>Keine Verlaufseinträge</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{history.map(entry => {
let oldV = {}, newV = {};
try { oldV = JSON.parse(entry.old_value) || {}; } catch (_) {}
try { newV = JSON.parse(entry.new_value) || {}; } catch (_) {}
const changes = Object.keys(newV).map(k => {
const labels = { status: 'Status', priority: 'Priorität', category: 'Kategorie', assigned_to_user_id: 'Zugewiesen', asset_id: 'Asset' };
return `${labels[k] || k}: ${oldV[k] || ''}${newV[k] || ''}`;
});
return (
<div key={entry.id} style={{ display: 'flex', gap: '12px', alignItems: 'flex-start', padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: '8px', border: '1px solid var(--border-color)' }}>
<div style={{ width: '32px', height: '32px', borderRadius: '50%', background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14px', flexShrink: 0 }}>
{entry.action === 'UPDATE' ? '✏️' : entry.action === 'BULK_UPDATE' ? '📦' : '📝'}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--text-primary)' }}>
{entry.username || 'System'} · <span style={{ fontWeight: 400, color: 'var(--text-muted)' }}>{formatDate(entry.created_at)}</span>
</div>
{changes.map((c, i) => (
<div key={i} style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)', marginTop: '2px' }}>{c}</div>
))}
{changes.length === 0 && (
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)' }}>{entry.action}</div>
)}
</div>
<span style={{ fontSize: '0.6875rem', color: 'var(--text-muted)' }}>{formatDateFull(entry.created_at)}</span>
</div>
);
})}
</div>
)}
</>
)}
{/* ── LINKS TAB ── */}
{activeTab === 'links' && (
<>
{links.length === 0 ? (
<div style={{ textAlign: 'center', padding: '1.5rem 0', color: 'var(--text-muted)' }}>
<div style={{ fontSize: '1.5rem', marginBottom: '8px' }}>🔗</div>
<p style={{ margin: 0, fontSize: '0.875rem' }}>Keine Verknüpfungen</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }}>
{links.map(link => {
const linkLabel = LINK_TYPES.find(t => t.value === link.link_type)?.label || link.link_type;
const prC = PRIORITY_CONFIG[link.priority] || {};
const stC = STATUS_CONFIG[link.status] || {};
return (
<div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: '8px', border: '1px solid var(--border-color)' }}>
<span style={{ fontSize: '0.6875rem', fontWeight: 700, color: 'var(--text-muted)', minWidth: '80px' }}>{linkLabel}</span>
<span style={{ fontFamily: 'monospace', fontSize: '0.8125rem', fontWeight: 700, color: 'var(--cereda-primary)', cursor: 'pointer' }}
onClick={() => navigate(`/tickets/${link.linked_ticket_id}`)}>
{link.ticket_number}
</span>
<span style={{ flex: 1, fontSize: '0.8125rem', color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{link.title}
</span>
<span className={`status-badge ${stC.css}`} style={{ fontSize: '0.6875rem' }}>{stC.label}</span>
{canEdit && (
<button onClick={() => handleRemoveLink(link.id)} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: '14px', padding: '0 2px' }} title="Entfernen"></button>
)}
</div>
);
})}
</div>
)}
{canEdit && (
<form onSubmit={handleAddLink} style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<select className="form-select" value={linkType} onChange={e => setLinkType(e.target.value)} style={{ width: '140px' }}>
{LINK_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<input
type="text"
className="form-input"
placeholder="Ticket-Nr. z.B. TK-2025-0042"
value={linkInput}
onChange={e => setLinkInput(e.target.value)}
style={{ flex: 1, minWidth: '160px' }}
/>
<button type="submit" className="btn btn-primary btn-small" disabled={linkLoading || !linkInput.trim()}>
{linkLoading ? '⏳' : '🔗 Verknüpfen'}
</button>
</form>
)}
</>
)}
</div>
</div>
</div>
{/* ── RIGHT SIDEBAR ── */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Details card */}
<div className="card" style={{ padding: '20px' }}>
<h3 style={{ margin: '0 0 16px', fontSize: '0.875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Details</h3>
<FieldRow label="Status">
{canEdit ? (
<select className="form-select" value={ticket.status} onChange={(e) => {
if (e.target.value === 'geschlossen') { handleSolveAndClose(); }
else handleFieldChange('status', e.target.value);
}}>
<option value="offen">Offen</option>
<option value="in_bearbeitung">In Bearbeitung</option>
<option value="warten_auf_mitarbeiter">Warten auf Mitarbeiter</option>
<option value="warten_auf_support">Warten auf Support</option>
<option value="geschlossen">Geschlossen</option>
</select>
) : (
<span className={`status-badge ${statusConf.css}`}>{statusConf.label}</span>
)}
</FieldRow>
<FieldRow label="Priorität">
{canEdit ? (
<select className="form-select" value={ticket.priority} onChange={(e) => handleFieldChange('priority', e.target.value)}>
<option value="niedrig">🟢 Niedrig</option>
<option value="mittel">🔵 Mittel</option>
<option value="hoch">🟠 Hoch</option>
<option value="kritisch">🔴 Kritisch</option>
</select>
) : (
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: prioConf.color }}>{prioConf.icon} {prioConf.label}</span>
)}
</FieldRow>
<FieldRow label="Kategorie">
{canEdit ? (
<select className="form-select" value={ticket.category} onChange={(e) => handleFieldChange('category', e.target.value)}>
{CATEGORY_LIST.map(c => <option key={c} value={c}>{c}</option>)}
</select>
) : (
<span style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>{ticket.category}</span>
)}
</FieldRow>
{canEdit && (
<FieldRow label="Zugewiesen an">
<div style={{ display: 'flex', gap: '6px' }}>
<select className="form-select" value={ticket.assigned_to_user_id || ''} onChange={(e) => handleFieldChange('assigned_to_user_id', e.target.value)} style={{ flex: 1 }}>
<option value=""> Nicht zugewiesen</option>
{users.filter(u => ['super_admin', 'admin', 'support'].includes(u.role_name)).map(u => (
<option key={u.id} value={u.id}>{u.first_name ? `${u.first_name} ${u.last_name}` : u.username}</option>
))}
</select>
<button
className="btn btn-secondary btn-small"
onClick={handleAssignSelf}
title="Mir zuweisen"
style={{ flexShrink: 0, padding: '6px 10px' }}
>
👤
</button>
</div>
</FieldRow>
)}
{!canEdit && assigneeName && (
<FieldRow label="Zugewiesen an">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Avatar name={assigneeName} size={24} />
<span style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>{assigneeName}</span>
</div>
</FieldRow>
)}
{/* Weitere Bearbeiter */}
{canEdit && (
<FieldRow label="Weitere Bearbeiter">
<div>
{/* Liste der aktuellen Assignees */}
{assignees.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{assignees.map(a => {
const name = (a.first_name || a.last_name) ? `${a.first_name || ''} ${a.last_name || ''}`.trim() : a.username;
return (
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px 3px 4px', borderRadius: 20, background: 'rgba(99,102,241,0.15)', border: '1px solid rgba(99,102,241,0.3)', fontSize: '0.78rem' }}>
<Avatar name={name} size={18} />
<span style={{ fontWeight: 600 }}>{name}</span>
<button
type="button"
onClick={async () => {
const updated = await ticketService.removeAssignee(ticket.id, a.id);
setAssignees(updated || []);
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '0.7rem', padding: '0 2px', lineHeight: 1 }}
></button>
</div>
);
})}
</div>
)}
{/* Suche zum Hinzufügen */}
<div style={{ position: 'relative' }}>
<input
ref={assigneeInputRef}
className="form-input"
style={{ width: '100%', fontSize: '0.85rem' }}
placeholder="Bearbeiter hinzufügen…"
value={assigneeSearch}
onChange={e => { setAssigneeSearch(e.target.value); updateAssigneeDropPos(); setAssigneeDropOpen(true); }}
onFocus={() => { updateAssigneeDropPos(); setAssigneeDropOpen(true); }}
onBlur={() => setTimeout(() => setAssigneeDropOpen(false), 150)}
/>
{assigneeDropOpen && (
<div style={{ position: 'fixed', top: assigneeDropPos.top, left: assigneeDropPos.left, width: assigneeDropPos.width, zIndex: 9999, background: 'var(--card-bg, #1e1e2e)', border: '1px solid var(--border-color)', borderRadius: 6, boxShadow: '0 8px 24px rgba(0,0,0,0.5)', maxHeight: 220, overflowY: 'auto' }}>
{users
.filter(u => {
if (!['super_admin', 'admin', 'support'].includes(u.role_name)) return false;
const already = assignees.some(a => a.id === u.id);
if (already) return false;
const q = assigneeSearch.toLowerCase();
return !q || u.username?.toLowerCase().includes(q) || u.first_name?.toLowerCase().includes(q) || u.last_name?.toLowerCase().includes(q);
})
.slice(0, 30)
.map(u => {
const name = (u.first_name || u.last_name) ? `${u.first_name || ''} ${u.last_name || ''}`.trim() : u.username;
return (
<div key={u.id}
onMouseDown={async () => {
const updated = await ticketService.addAssignee(ticket.id, u.id);
setAssignees(updated || []);
setAssigneeSearch('');
setAssigneeDropOpen(false);
}}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 12px', cursor: 'pointer' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.07)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Avatar name={name} size={22} />
<div>
<div style={{ fontSize: '0.83rem', fontWeight: 600 }}>{name}</div>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>@{u.username}</div>
</div>
</div>
);
})
}
{users.filter(u => ['super_admin', 'admin', 'support'].includes(u.role_name) && !assignees.some(a => a.id === u.id) && (!assigneeSearch || u.username?.toLowerCase().includes(assigneeSearch.toLowerCase()) || u.first_name?.toLowerCase().includes(assigneeSearch.toLowerCase()) || u.last_name?.toLowerCase().includes(assigneeSearch.toLowerCase()))).length === 0 && (
<div style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: '0.82rem' }}>Kein Benutzer gefunden</div>
)}
</div>
)}
</div>
</div>
</FieldRow>
)}
{canEdit && (
<FieldRow label="Asset verknüpfen">
<div style={{ position: 'relative' }}>
<input
ref={assetInputRef}
className="form-input"
style={{ width: '100%' }}
placeholder={ticket.asset_id ? (assets.find(a => a.id === ticket.asset_id || a.id === Number(ticket.asset_id))?.name || 'Asset suchen…') : 'Asset suchen…'}
value={assetSearch}
onChange={e => { setAssetSearch(e.target.value); setAssetDropOpen(true); }}
onFocus={() => { updateAssetDropPos(); setAssetDropOpen(true); }}
onBlur={() => setTimeout(() => setAssetDropOpen(false), 150)}
/>
{assetDropOpen && (
<div style={{ position: 'fixed', zIndex: 9999, top: assetDropPos.top, left: assetDropPos.left, width: assetDropPos.width, background: 'var(--card-bg, #1e1e2e)', border: '1px solid var(--border-color)', borderRadius: 6, boxShadow: '0 8px 24px rgba(0,0,0,0.5)', maxHeight: 260, overflowY: 'auto' }}>
<div
style={{ padding: '8px 12px', cursor: 'pointer', fontSize: '0.85rem', color: 'var(--text-muted)', borderBottom: '1px solid var(--border-color)' }}
onMouseDown={() => { handleFieldChange('asset_id', ''); setAssetSearch(''); setAssetDropOpen(false); }}
>
Kein Asset
</div>
{assets
.filter(a => {
const q = assetSearch.toLowerCase();
return !q || a.name?.toLowerCase().includes(q) || a.serial_number?.toLowerCase().includes(q) || a.model?.toLowerCase().includes(q);
})
.slice(0, 50)
.map(a => (
<div
key={a.id}
style={{ padding: '8px 12px', cursor: 'pointer', background: Number(ticket.asset_id) === a.id ? 'rgba(99,102,241,0.15)' : 'transparent', overflow: 'hidden' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.07)'}
onMouseLeave={e => e.currentTarget.style.background = Number(ticket.asset_id) === a.id ? 'rgba(99,102,241,0.15)' : 'transparent'}
onMouseDown={() => { handleFieldChange('asset_id', a.id); setAssetSearch(''); setAssetDropOpen(false); }}
>
<div style={{ fontWeight: 600, fontSize: '0.85rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{a.name}</div>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{[a.serial_number, a.model].filter(Boolean).join(' · ')}
</div>
</div>
))
}
{assets.filter(a => { const q = assetSearch.toLowerCase(); return !q || a.name?.toLowerCase().includes(q) || a.serial_number?.toLowerCase().includes(q) || a.model?.toLowerCase().includes(q); }).length === 0 && (
<div style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>Kein Asset gefunden</div>
)}
</div>
)}
</div>
</FieldRow>
)}
{/* Timestamps */}
<div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '12px', marginTop: '4px', fontSize: '0.75rem', color: 'var(--text-muted)', display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div>📅 Erstellt: <span style={{ color: 'var(--text-secondary)' }}>{formatDateFull(ticket.created_at)}</span></div>
{ticket.resolved_at && <div> Gelöst: <span style={{ color: 'var(--text-secondary)' }}>{formatDateFull(ticket.resolved_at)}</span></div>}
{ticket.closed_at && <div>🔒 Geschlossen: <span style={{ color: 'var(--text-secondary)' }}>{formatDateFull(ticket.closed_at)}</span></div>}
</div>
</div>
{/* Wiedervorlage Card */}
{canEdit && (
<div className="card" style={{ padding: '16px 20px' }}>
<h3 style={{ margin: '0 0 10px', fontSize: '0.875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>
Wiedervorlage
</h3>
{isSnoozed ? (
<div>
<div style={{ fontSize: '0.8125rem', color: 'var(--warning)', marginBottom: '8px' }}>
Vorlage am {new Date(ticket.snoozed_until).toLocaleString('de-DE')}
</div>
<button className="btn btn-secondary btn-small" style={{ width: '100%' }} onClick={handleUnsnooze} disabled={snoozeLoading}>
Aufheben
</button>
</div>
) : (
<>
{!showSnooze ? (
<button className="btn btn-secondary btn-small" style={{ width: '100%' }} onClick={() => setShowSnooze(true)}>
🔔 Vorlegen bis
</button>
) : (
<form onSubmit={handleSnooze} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<input
type="datetime-local"
className="form-input"
value={snoozeDate}
onChange={e => setSnoozeDate(e.target.value)}
min={new Date().toISOString().slice(0, 16)}
/>
<div style={{ display: 'flex', gap: '6px' }}>
<button type="submit" className="btn btn-primary btn-small" style={{ flex: 1 }} disabled={snoozeLoading || !snoozeDate}>
{snoozeLoading ? '⏳' : '✓ Setzen'}
</button>
<button type="button" className="btn btn-secondary btn-small" onClick={() => setShowSnooze(false)}></button>
</div>
</form>
)}
</>
)}
</div>
)}
{/* Asset info card */}
{ticket.asset_name && (
<div className="card" style={{ padding: '16px 20px' }}>
<h3 style={{ margin: '0 0 12px', fontSize: '0.875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Asset</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '1.5rem' }}>📦</span>
<div>
<div style={{ fontWeight: 700, fontSize: '0.9rem', color: 'var(--text-primary)' }}>{ticket.asset_name}</div>
{ticket.asset_serial && <div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontFamily: 'monospace' }}>{ticket.asset_serial}</div>}
</div>
</div>
{hasTeamViewer && (
<button className="btn btn-primary" style={{ width: '100%', marginTop: '12px', background: '#0051a2', borderColor: '#0051a2' }}
onClick={() => window.open(`https://start.teamviewer.com/device/${ticket.asset_teamviewer_id}`, '_blank')}>
🖥 TeamViewer öffnen
</button>
)}
</div>
)}
{/* Danger zone */}
{isAdmin() && (
<div className="card" style={{ padding: '16px 20px', borderColor: 'rgba(239,68,68,0.3)' }}>
<h3 style={{ margin: '0 0 10px', fontSize: '0.875rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--danger)' }}>Gefahrenzone</h3>
<p style={{ margin: '0 0 12px', fontSize: '0.8125rem', color: 'var(--text-muted)' }}>Dieses Ticket und alle Kommentare werden dauerhaft gelöscht.</p>
<button className="btn btn-danger" style={{ width: '100%' }} onClick={handleDeleteTicket}>🗑 Ticket löschen</button>
</div>
)}
</div>
</div>
</div>
</div>
{/* Schließen-Modal */}
{showCloseModal && (
<div className="modal-overlay" onClick={() => setShowCloseModal(false)}>
<div className="modal-content" style={{ maxWidth: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title"> Ticket schließen</h2>
<button className="modal-close" onClick={() => setShowCloseModal(false)}>×</button>
</div>
<div style={{ padding: '20px 24px' }}>
<p style={{ margin: '0 0 16px', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
Das Ticket wird geschlossen und in die Wissensdatenbank übertragen. Eine Lösungsbeschreibung ist optional.
</p>
<div className="form-group">
<label className="form-label">Lösungsbeschreibung (optional)</label>
<textarea
className="form-textarea"
rows={4}
placeholder="Was hat das Problem gelöst? (kann leer gelassen werden)"
value={closeResolution}
onChange={e => setCloseResolution(e.target.value)}
autoFocus
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
<button className="btn btn-secondary" onClick={() => setShowCloseModal(false)} disabled={closeModalLoading}>
Abbrechen
</button>
<button className="btn btn-secondary" onClick={executeClose} disabled={closeModalLoading} style={{ opacity: 0.7 }}>
{closeModalLoading ? '⏳' : 'Schließen ohne Notiz'}
</button>
<button
className="btn btn-primary"
onClick={executeClose}
disabled={closeModalLoading || !closeResolution.trim()}
style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
>
{closeModalLoading ? '⏳' : '✅ Lösung speichern & schließen'}
</button>
</div>
</div>
</div>
</div>
)}
</>
);
};
export default TicketDetailPage;