- WebSocket Shell/RDP: Rollenprüfung statt nur JWT-Gültigkeit (war: jeder eingeloggte User konnte fremde Agents per Shell/RDP übernehmen) - JWT_SECRET: Server bricht ab statt mit unsicherem Default weiterzulaufen - Auth: Token läuft jetzt über httpOnly-Cookie statt localStorage (XSS-Schutz gegen Session-Diebstahl) - WS-Auth: Token nicht mehr als URL-Query-Param (landete in nginx-Logs), sondern als erste Message bzw. automatisch via Cookie - Frontend: toter Rollen-Check (isSuperAdmin/isAdmin ohne Funktionsaufruf) in AgentDetailPage gefixt - XSS: DOMPurify-Sanitizing für alle marked.parse()-Renderstellen (KI-Antworten, Kommentare, Knowledge Base) - E-Mail: HTML-Escaping für alle ticket-gesteuerten Felder (auch über öffentliche Ticket-Route erreichbar) - SSRF-Schutz beim Knowledge-Base-URL-Import (blockt private/Loopback-Adressen) - TV-Dashboard: Shared-Key statt komplett offenem Endpoint - Striktes Rate-Limit auf /login, must_change_password serverseitig erzwungen - Agent (C#) v2.7.0: RDP-Consent/Disconnect/Capture verlangen jetzt ein Pro-Session-Secret (war: jeder lokale Prozess konnte Consent vortäuschen), DataDir-ACL für agent.log/status.json - FIDO-PINs AES-256-GCM-verschlüsselt statt Klartext, Retention-Job für alte patch_commands/audit_log Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
591 lines
40 KiB
JavaScript
591 lines
40 KiB
JavaScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { useAuth } from '../context/AuthContext';
|
||
import aiService from '../services/aiService';
|
||
import ticketService from '../services/ticketService';
|
||
import { toast } from 'react-toastify';
|
||
import { renderMd } from '../utils/sanitizeMarkdown';
|
||
|
||
const CATEGORIES = ['Allgemein', 'Software', 'Hardware', 'Netzwerk', 'SelectLine', 'Sonstiges'];
|
||
const CAT_COLOR = { Software: '#6366f1', Hardware: '#f59e0b', SelectLine: '#10b981', Netzwerk: '#3b82f6', Allgemein: '#64748b', Sonstiges: '#8b5cf6' };
|
||
const catColor = (c) => CAT_COLOR[c] || '#64748b';
|
||
|
||
const EMPTY_FORM = { problem: '', solution: '', category: 'Allgemein', tags: '' };
|
||
|
||
export default function KnowledgeBasePage() {
|
||
const { isAdmin, isSuperAdmin } = useAuth();
|
||
const canEdit = isAdmin() || isSuperAdmin();
|
||
|
||
// Tab state
|
||
const [tab, setTab] = useState('artikel');
|
||
|
||
// KB Articles state
|
||
const [articles, setArticles] = useState([]);
|
||
const [loadingArt, setLoadingArt] = useState(true);
|
||
const [searchArt, setSearchArt] = useState('');
|
||
const [catFilter, setCatFilter] = useState('Alle');
|
||
const [expanded, setExpanded] = useState(null);
|
||
const [showModal, setShowModal] = useState(false);
|
||
const [editingId, setEditingId] = useState(null);
|
||
const [form, setForm] = useState(EMPTY_FORM);
|
||
const [saving, setSaving] = useState(false);
|
||
const [showImport, setShowImport] = useState(false);
|
||
const [importTab, setImportTab] = useState('text'); // 'text' | 'file' | 'url'
|
||
const [importText, setImportText] = useState('');
|
||
const [importFile, setImportFile] = useState(null);
|
||
const [importUrl, setImportUrl] = useState('');
|
||
const [importCrawl, setImportCrawl] = useState(false);
|
||
const [importCrawlMax, setImportCrawlMax] = useState(20);
|
||
const [importing, setImporting] = useState(false);
|
||
|
||
// Closed tickets state
|
||
const [tickets, setTickets] = useState([]);
|
||
const [loadingTickets, setLoadingTickets] = useState(false);
|
||
const [searchTickets, setSearchTickets] = useState('');
|
||
const [ticketCat, setTicketCat] = useState('Alle');
|
||
const [ticketsLoaded, setTicketsLoaded] = useState(false);
|
||
|
||
const loadArticles = useCallback(async () => {
|
||
setLoadingArt(true);
|
||
try {
|
||
const data = await aiService.getKnowledgeBase();
|
||
setArticles(data);
|
||
} catch { toast.error('Fehler beim Laden der Wissensdatenbank'); }
|
||
finally { setLoadingArt(false); }
|
||
}, []);
|
||
|
||
const loadTickets = useCallback(async () => {
|
||
if (ticketsLoaded) return;
|
||
setLoadingTickets(true);
|
||
try {
|
||
const data = await ticketService.getAll({ status: 'geschlossen' });
|
||
setTickets(Array.isArray(data) ? data : []);
|
||
setTicketsLoaded(true);
|
||
} catch { setTickets([]); }
|
||
finally { setLoadingTickets(false); }
|
||
}, [ticketsLoaded]);
|
||
|
||
useEffect(() => { loadArticles(); }, [loadArticles]);
|
||
useEffect(() => { if (tab === 'tickets') loadTickets(); }, [tab, loadTickets]);
|
||
|
||
// ── Article handlers ─────────────────────────────────────────────────────
|
||
|
||
const openCreate = () => {
|
||
setEditingId(null);
|
||
setForm(EMPTY_FORM);
|
||
setShowModal(true);
|
||
};
|
||
|
||
const openEdit = (art, e) => {
|
||
e.stopPropagation();
|
||
setEditingId(art.id);
|
||
setForm({ problem: art.problem, solution: art.solution, category: art.category || 'Allgemein', tags: art.tags || '' });
|
||
setShowModal(true);
|
||
};
|
||
|
||
const handleSave = async (e) => {
|
||
e.preventDefault();
|
||
setSaving(true);
|
||
try {
|
||
if (editingId) {
|
||
await aiService.updateKnowledgeEntry(editingId, form);
|
||
toast.success('Artikel aktualisiert');
|
||
} else {
|
||
await aiService.addKnowledgeEntry(form);
|
||
toast.success('Artikel erstellt');
|
||
}
|
||
setShowModal(false);
|
||
loadArticles();
|
||
} catch { toast.error('Fehler beim Speichern'); }
|
||
finally { setSaving(false); }
|
||
};
|
||
|
||
const handleImport = async (e) => {
|
||
e.preventDefault();
|
||
setImporting(true);
|
||
try {
|
||
let result;
|
||
if (importTab === 'file' && importFile) {
|
||
result = await aiService.importFileToKb(importFile);
|
||
} else if (importTab === 'url') {
|
||
result = importCrawl
|
||
? await aiService.importCrawlToKb(importUrl, importCrawlMax)
|
||
: await aiService.importUrlToKb(importUrl);
|
||
} else {
|
||
result = await aiService.importTextToKb(importText);
|
||
}
|
||
const pagesInfo = result.pages ? ` (${result.pages} Seiten)` : '';
|
||
toast.success(`${result.created} Artikel erstellt${pagesInfo}`);
|
||
setShowImport(false);
|
||
setImportText('');
|
||
setImportFile(null);
|
||
setImportUrl('');
|
||
setImportCrawl(false);
|
||
setImportCrawlMax(20);
|
||
setImportTab('text');
|
||
loadArticles();
|
||
} catch (err) { toast.error(err?.response?.data?.message || err?.message || 'Fehler beim Importieren'); }
|
||
finally { setImporting(false); }
|
||
};
|
||
|
||
const handleDelete = async (id, e) => {
|
||
e.stopPropagation();
|
||
if (!window.confirm('Artikel wirklich löschen?')) return;
|
||
try {
|
||
await aiService.deleteKnowledgeEntry(id);
|
||
toast.success('Artikel gelöscht');
|
||
setExpanded(null);
|
||
loadArticles();
|
||
} catch { toast.error('Fehler beim Löschen'); }
|
||
};
|
||
|
||
const handleImageUpload = async (artId, e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
if (file.size > 5 * 1024 * 1024) { toast.error('Max. 5 MB'); return; }
|
||
try {
|
||
await aiService.uploadKbImage(artId, file);
|
||
toast.success('Bild hochgeladen');
|
||
loadArticles();
|
||
} catch { toast.error('Fehler beim Hochladen'); }
|
||
e.target.value = '';
|
||
};
|
||
|
||
const handleImageDelete = async (artId, filename, e) => {
|
||
e.stopPropagation();
|
||
if (!window.confirm('Bild löschen?')) return;
|
||
try {
|
||
await aiService.deleteKbImage(artId, filename);
|
||
loadArticles();
|
||
} catch { toast.error('Fehler beim Löschen'); }
|
||
};
|
||
|
||
// ── Filtered data ─────────────────────────────────────────────────────────
|
||
|
||
const filteredArticles = articles.filter(a => {
|
||
const q = searchArt.toLowerCase();
|
||
const matchQ = !q || a.problem?.toLowerCase().includes(q) || a.solution?.toLowerCase().includes(q) || a.tags?.toLowerCase().includes(q);
|
||
const matchC = catFilter === 'Alle' || a.category === catFilter;
|
||
return matchQ && matchC;
|
||
});
|
||
|
||
const ticketCats = ['Alle', ...Array.from(new Set(tickets.map(t => t.category).filter(Boolean)))];
|
||
const filteredTickets = tickets.filter(t => {
|
||
const q = searchTickets.toLowerCase();
|
||
const matchQ = !q || t.title?.toLowerCase().includes(q) || t.ticket_number?.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q);
|
||
const matchC = ticketCat === 'Alle' || t.category === ticketCat;
|
||
return matchQ && matchC;
|
||
});
|
||
|
||
const formatDate = (d) => d ? new Date(d).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '—';
|
||
const getDuration = (a, b) => {
|
||
if (!a || !b) return null;
|
||
const h = Math.floor((new Date(b) - new Date(a)) / 3600000);
|
||
return h < 24 ? `${h}h` : `${Math.floor(h / 24)}d`;
|
||
};
|
||
|
||
// ── Render ────────────────────────────────────────────────────────────────
|
||
|
||
return (
|
||
<div className="main-content">
|
||
<div style={{ maxWidth: 1100, margin: '0 auto' }}>
|
||
|
||
{/* Header */}
|
||
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
|
||
<div>
|
||
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}>📚 Wissensdatenbank</h1>
|
||
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||
{tab === 'artikel'
|
||
? `${articles.length} Artikel · durchsuchbare Lösungen`
|
||
: `${tickets.length} geschlossene Tickets`}
|
||
</p>
|
||
</div>
|
||
{tab === 'artikel' && canEdit && (
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button className="btn btn-secondary" onClick={() => setShowImport(true)}>📥 Text importieren</button>
|
||
<button className="btn btn-primary" onClick={openCreate}>+ Neuer Artikel</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--border-color)', paddingBottom: 0 }}>
|
||
{[
|
||
{ key: 'artikel', label: '📖 Artikel', count: articles.length },
|
||
{ key: 'tickets', label: '🎫 Geschlossene Tickets', count: ticketsLoaded ? tickets.length : null },
|
||
].map(t => (
|
||
<button key={t.key} onClick={() => setTab(t.key)} style={{
|
||
padding: '8px 18px', border: 'none', background: 'transparent', cursor: 'pointer',
|
||
fontSize: '0.875rem', fontWeight: tab === t.key ? 700 : 400,
|
||
color: tab === t.key ? 'var(--primary)' : 'var(--text-muted)',
|
||
borderBottom: tab === t.key ? '2px solid var(--primary)' : '2px solid transparent',
|
||
marginBottom: -1,
|
||
}}>
|
||
{t.label}{t.count !== null ? <span style={{ marginLeft: 6, fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 10 }}>{t.count}</span> : null}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── ARTIKEL TAB ── */}
|
||
{tab === 'artikel' && (
|
||
<>
|
||
{/* Filter bar */}
|
||
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<input className="form-input" style={{ flex: 1, minWidth: 200 }}
|
||
placeholder="Suchen in Artikeln…"
|
||
value={searchArt} onChange={e => setSearchArt(e.target.value)} />
|
||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||
{['Alle', ...CATEGORIES].map(c => (
|
||
<button key={c} onClick={() => setCatFilter(c)} style={{
|
||
padding: '5px 12px', borderRadius: 20,
|
||
border: `1px solid ${catFilter === c ? catColor(c) : 'var(--border-color)'}`,
|
||
background: catFilter === c ? catColor(c) : 'transparent',
|
||
color: catFilter === c ? '#fff' : 'var(--text-secondary)',
|
||
cursor: 'pointer', fontSize: '0.78rem', fontWeight: catFilter === c ? 600 : 400,
|
||
}}>{c}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{loadingArt ? (
|
||
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>Lade…</div>
|
||
) : filteredArticles.length === 0 ? (
|
||
<div className="card" style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
|
||
<div style={{ fontSize: '2.5rem', marginBottom: 8 }}>📭</div>
|
||
<div>{articles.length === 0 ? 'Noch keine Artikel. Erstelle den ersten!' : 'Keine Treffer.'}</div>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{filteredArticles.map(art => (
|
||
<div key={art.id} className="card" style={{ padding: 0, overflow: 'hidden', cursor: 'pointer', borderColor: expanded === art.id ? 'var(--primary)' : undefined }}
|
||
onClick={() => setExpanded(expanded === art.id ? null : art.id)}>
|
||
|
||
{/* Header row */}
|
||
<div style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: catColor(art.category), flexShrink: 0 }} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontWeight: 600, fontSize: '0.9rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{art.problem}
|
||
</div>
|
||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||
<span style={{ padding: '1px 7px', borderRadius: 10, background: catColor(art.category) + '22', color: catColor(art.category), fontWeight: 600 }}>{art.category}</span>
|
||
{art.tags && art.tags.split(',').map(tag => (
|
||
<span key={tag.trim()} style={{ padding: '1px 7px', borderRadius: 10, background: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}>#{tag.trim()}</span>
|
||
))}
|
||
{art.auto_generated ? <span style={{ color: '#8b5cf6', fontWeight: 600 }}>🤖 KI</span> : null}
|
||
{art.source_ticket_id ? <span style={{ color: 'var(--text-muted)' }}>Ticket #{art.source_ticket_id}</span> : null}
|
||
<span>📅 {formatDate(art.created_at)}</span>
|
||
{art.created_by_username && <span>von {art.created_by_username}</span>}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||
{canEdit && (
|
||
<>
|
||
<button className="btn btn-secondary btn-small" onClick={(e) => openEdit(art, e)}
|
||
style={{ fontSize: '11px', padding: '3px 8px' }}>✏️</button>
|
||
<button className="btn btn-danger btn-small" onClick={(e) => handleDelete(art.id, e)}
|
||
style={{ fontSize: '11px', padding: '3px 8px' }}>🗑️</button>
|
||
</>
|
||
)}
|
||
<span style={{ color: 'var(--text-muted)', fontSize: '0.75rem' }}>{expanded === art.id ? '▲' : '▼'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Expanded solution */}
|
||
{expanded === art.id && (
|
||
<div style={{ padding: '0 16px 14px 16px', borderTop: '1px solid var(--border-color)' }}>
|
||
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', margin: '12px 0 6px' }}>
|
||
Lösung
|
||
</div>
|
||
<div className="md-content" style={{ fontSize: '0.875rem', lineHeight: 1.6, color: 'var(--text-primary)' }}
|
||
dangerouslySetInnerHTML={renderMd(art.solution)} />
|
||
|
||
{/* Images */}
|
||
{(() => {
|
||
const imgs = (() => { try { return JSON.parse(art.images || '[]'); } catch { return []; } })();
|
||
return (imgs.length > 0 || canEdit) && (
|
||
<div style={{ marginTop: 14 }}>
|
||
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>
|
||
Bilder
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-start' }}>
|
||
{imgs.map(img => (
|
||
<div key={img} style={{ position: 'relative', display: 'inline-block' }}>
|
||
<img src={`/uploads/kb/${img}`} alt={img}
|
||
style={{ maxWidth: 220, maxHeight: 160, borderRadius: 6, border: '1px solid var(--border-color)', cursor: 'pointer', display: 'block' }}
|
||
onClick={e => { e.stopPropagation(); window.open(`/uploads/kb/${img}`, '_blank'); }} />
|
||
{canEdit && (
|
||
<button onClick={e => handleImageDelete(art.id, img, e)}
|
||
style={{ position: 'absolute', top: 4, right: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: 4, padding: '2px 6px', cursor: 'pointer', fontSize: '11px' }}>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
{canEdit && (
|
||
<label onClick={e => e.stopPropagation()} style={{ width: 80, height: 80, border: '2px dashed var(--border-color)', borderRadius: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: '0.72rem', color: 'var(--text-muted)', gap: 4 }}>
|
||
<span style={{ fontSize: '1.2rem' }}>+</span>
|
||
<span>Bild</span>
|
||
<input type="file" accept="image/*" style={{ display: 'none' }}
|
||
onChange={e => handleImageUpload(art.id, e)} />
|
||
</label>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* ── TICKETS TAB ── */}
|
||
{tab === 'tickets' && (
|
||
<>
|
||
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<input className="form-input" style={{ flex: 1, minWidth: 200 }}
|
||
placeholder="Suchen nach Titel, Ticketnummer…"
|
||
value={searchTickets} onChange={e => setSearchTickets(e.target.value)} />
|
||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||
{ticketCats.map(c => (
|
||
<button key={c} onClick={() => setTicketCat(c)} style={{
|
||
padding: '5px 12px', borderRadius: 20,
|
||
border: `1px solid ${ticketCat === c ? catColor(c) : 'var(--border-color)'}`,
|
||
background: ticketCat === c ? catColor(c) : 'transparent',
|
||
color: ticketCat === c ? '#fff' : 'var(--text-secondary)',
|
||
cursor: 'pointer', fontSize: '0.78rem', fontWeight: ticketCat === c ? 600 : 400,
|
||
}}>{c}</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{loadingTickets ? (
|
||
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>Lade…</div>
|
||
) : filteredTickets.length === 0 ? (
|
||
<div className="card" style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
|
||
<div style={{ fontSize: '2.5rem', marginBottom: 8 }}>📭</div>
|
||
<div>Keine geschlossenen Tickets gefunden</div>
|
||
</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{filteredTickets.map(t => (
|
||
<Link key={t.id} to={`/tickets/${t.id}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||
<div className="card" style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 14, transition: 'border-color 0.15s' }}
|
||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--primary)'}
|
||
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-color)'}>
|
||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: catColor(t.category), flexShrink: 0 }} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
<span style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: 'var(--text-muted)' }}>{t.ticket_number}</span>
|
||
<span style={{ fontWeight: 600, fontSize: '0.875rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.title}</span>
|
||
</div>
|
||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', marginTop: 2 }}>
|
||
{t.requester_name && <span>👤 {t.requester_name}</span>}
|
||
{t.assigned_to_username && <span style={{ marginLeft: 10 }}>🛠 {t.assigned_to_first_name || ''} {t.assigned_to_last_name || t.assigned_to_username}</span>}
|
||
</div>
|
||
</div>
|
||
<span style={{ padding: '2px 8px', borderRadius: 10, background: catColor(t.category) + '22', color: catColor(t.category), fontSize: '0.72rem', fontWeight: 600, flexShrink: 0 }}>
|
||
{t.category}
|
||
</span>
|
||
{t.satisfaction_rating === 'gut' && (
|
||
<span title="Feedback: Positiv" style={{ fontSize: '1rem', flexShrink: 0 }}>👍</span>
|
||
)}
|
||
{t.satisfaction_rating === 'schlecht' && (
|
||
<span title="Feedback: Negativ" style={{ fontSize: '1rem', flexShrink: 0 }}>👎</span>
|
||
)}
|
||
<div style={{ textAlign: 'right', flexShrink: 0, fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||
<div>✅ {formatDate(t.closed_at || t.updated_at)}</div>
|
||
{getDuration(t.created_at, t.closed_at || t.updated_at) && (
|
||
<div style={{ marginTop: 2 }}>⏱ {getDuration(t.created_at, t.closed_at || t.updated_at)}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Import Modal ── */}
|
||
{showImport && (
|
||
<div className="modal-overlay" onClick={() => setShowImport(false)}>
|
||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h2 className="modal-title">📥 In Wissensdatenbank importieren</h2>
|
||
<button className="modal-close" onClick={() => { setShowImport(false); setImportTab('text'); setImportFile(null); setImportUrl(''); }}>×</button>
|
||
</div>
|
||
|
||
{/* Sub-tabs */}
|
||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color)', padding: '0 24px' }}>
|
||
{[{ key: 'text', label: '📝 Text einfügen' }, { key: 'file', label: '📎 Datei hochladen' }, { key: 'url', label: '🌐 URL importieren' }].map(t => (
|
||
<button key={t.key} type="button" onClick={() => setImportTab(t.key)} style={{
|
||
padding: '10px 14px', border: 'none', background: 'transparent', cursor: 'pointer',
|
||
fontSize: '0.83rem', fontWeight: importTab === t.key ? 700 : 400,
|
||
color: importTab === t.key ? 'var(--primary)' : 'var(--text-muted)',
|
||
borderBottom: importTab === t.key ? '2px solid var(--primary)' : '2px solid transparent',
|
||
marginBottom: -1,
|
||
}}>{t.label}</button>
|
||
))}
|
||
</div>
|
||
|
||
<form onSubmit={handleImport}>
|
||
<div style={{ padding: '16px 24px 0' }}>
|
||
{importTab === 'text' ? (
|
||
<>
|
||
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: 12, marginTop: 0 }}>
|
||
Füge beliebigen Text ein (Handbuch, Anleitung, E-Mail-Inhalt usw.).<br />
|
||
Die KI extrahiert automatisch Problem/Lösungs-Paare.
|
||
</p>
|
||
<div className="form-group">
|
||
<textarea className="form-textarea" rows="12" required={importTab === 'text'}
|
||
placeholder="Text hier einfügen…"
|
||
value={importText}
|
||
onChange={e => setImportText(e.target.value)} />
|
||
</div>
|
||
</>
|
||
) : importTab === 'file' ? (
|
||
<>
|
||
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: 16, marginTop: 0 }}>
|
||
Lade eine Datei hoch. Unterstützte Formate: <strong>PDF, DOCX, DOC, EML, TXT</strong>.<br />
|
||
Der Inhalt wird von der KI gelesen und in Artikel umgewandelt.
|
||
</p>
|
||
<label style={{
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||
border: '2px dashed var(--border-color)', borderRadius: 8, padding: '2rem', cursor: 'pointer',
|
||
background: 'var(--bg-secondary)', gap: 8, marginBottom: 16,
|
||
borderColor: importFile ? 'var(--primary)' : 'var(--border-color)',
|
||
}}>
|
||
<span style={{ fontSize: '2rem' }}>{importFile ? '✅' : '📂'}</span>
|
||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||
{importFile ? importFile.name : 'Datei auswählen'}
|
||
</span>
|
||
{importFile && (
|
||
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||
{(importFile.size / 1024).toFixed(0)} KB
|
||
</span>
|
||
)}
|
||
<input type="file" accept=".pdf,.docx,.doc,.eml,.txt" style={{ display: 'none' }}
|
||
onChange={e => setImportFile(e.target.files?.[0] || null)} />
|
||
</label>
|
||
{importFile && (
|
||
<button type="button" className="btn btn-secondary btn-small"
|
||
onClick={() => setImportFile(null)}
|
||
style={{ marginBottom: 8 }}>
|
||
Datei entfernen
|
||
</button>
|
||
)}
|
||
</>
|
||
) : importTab === 'url' ? (
|
||
<>
|
||
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: 16, marginTop: 0 }}>
|
||
Gib die URL einer Hilfe- oder Dokumentationsseite ein.<br />
|
||
Die KI liest den Inhalt und erstellt daraus Wissensdatenbank-Artikel.
|
||
</p>
|
||
<div className="form-group">
|
||
<label className="form-label">URL *</label>
|
||
<input className="form-input" type="url" required={importTab === 'url'}
|
||
placeholder="https://hilfe.selectline.de/…"
|
||
value={importUrl}
|
||
onChange={e => setImportUrl(e.target.value)} />
|
||
</div>
|
||
|
||
{/* Crawler option */}
|
||
<div style={{ marginTop: 14, padding: '12px 14px', background: 'var(--bg-secondary)', borderRadius: 8, border: `1px solid ${importCrawl ? 'var(--primary)' : 'var(--border-color)'}` }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', userSelect: 'none' }}>
|
||
<input type="checkbox" checked={importCrawl} onChange={e => setImportCrawl(e.target.checked)}
|
||
style={{ width: 16, height: 16, cursor: 'pointer' }} />
|
||
<div>
|
||
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>🕷️ Unterseiten automatisch crawlen</div>
|
||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 2 }}>
|
||
Folgt allen Links auf derselben Domain & demselben Pfad
|
||
</div>
|
||
</div>
|
||
</label>
|
||
{importCrawl && (
|
||
<div style={{ marginTop: 12 }}>
|
||
<label style={{ fontSize: '0.8rem', fontWeight: 600, display: 'block', marginBottom: 6 }}>
|
||
Max. Seiten: <span style={{ color: 'var(--primary)' }}>{importCrawlMax}</span>
|
||
</label>
|
||
<input type="range" min={2} max={100} step={1}
|
||
value={importCrawlMax} onChange={e => setImportCrawlMax(parseInt(e.target.value))}
|
||
style={{ width: '100%', accentColor: 'var(--primary)' }} />
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.7rem', color: 'var(--text-muted)' }}>
|
||
<span>2</span><span>100</span>
|
||
</div>
|
||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 6 }}>
|
||
⚠️ Mehr Seiten = längere Wartezeit. Bei 20+ Seiten kann es 1–2 Minuten dauern.
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
<div className="card-footer" style={{ marginTop: 8 }}>
|
||
<button type="button" className="btn btn-secondary" onClick={() => { setShowImport(false); setImportTab('text'); setImportFile(null); setImportUrl(''); }}>Abbrechen</button>
|
||
<button type="submit" className="btn btn-primary"
|
||
disabled={importing || (importTab === 'text' ? importText.trim().length < 20 : importTab === 'file' ? !importFile : importUrl.trim().length < 10)}>
|
||
{importing
|
||
? (importTab === 'url' && importCrawl ? `🕷️ Crawle Seiten…` : '🤖 KI analysiert…')
|
||
: '🤖 Artikel generieren'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Create / Edit Modal ── */}
|
||
{showModal && (
|
||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||
<div className="modal-header">
|
||
<h2 className="modal-title">{editingId ? 'Artikel bearbeiten' : 'Neuer Artikel'}</h2>
|
||
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
|
||
</div>
|
||
<form onSubmit={handleSave}>
|
||
<div style={{ padding: '0 24px' }}>
|
||
<div className="form-group">
|
||
<label className="form-label">Problem / Titel *</label>
|
||
<input className="form-input" required placeholder="Kurze Problembeschreibung…"
|
||
value={form.problem} onChange={e => setForm({ ...form, problem: e.target.value })} />
|
||
</div>
|
||
<div className="form-group">
|
||
<label className="form-label">Lösung *</label>
|
||
<textarea className="form-textarea" rows="6" required placeholder="Schritt-für-Schritt-Lösung…"
|
||
value={form.solution} onChange={e => setForm({ ...form, solution: e.target.value })} />
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||
<div className="form-group" style={{ margin: 0 }}>
|
||
<label className="form-label">Kategorie</label>
|
||
<select className="form-select" value={form.category} onChange={e => setForm({ ...form, category: e.target.value })}>
|
||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-group" style={{ margin: 0 }}>
|
||
<label className="form-label">Tags (kommagetrennt)</label>
|
||
<input className="form-input" placeholder="z.B. vpn, outlook, drucker"
|
||
value={form.tags} onChange={e => setForm({ ...form, tags: e.target.value })} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="card-footer" style={{ marginTop: 16 }}>
|
||
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>Abbrechen</button>
|
||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||
{saving ? 'Speichern…' : editingId ? 'Aktualisieren' : 'Erstellen'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|