1250 lines
84 KiB
JavaScript
1250 lines
84 KiB
JavaScript
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 || ' ' }} />;
|
||
})}
|
||
</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:
|
||
<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;
|