Files
IT-Nexus/frontend/src/pages/KnowledgeBasePage.jsx
Simon Grüssing 81b1c326fc
Some checks failed
IT Nexus Deploy / Build Frontend (push) Has been cancelled
IT Nexus Deploy / Deploy to Production (push) Has been cancelled
Security: WS-Rollenprüfung, JWT-Cookie statt localStorage, XSS/SSRF-Fixes, RDP-Consent-Secret
- 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>
2026-06-26 13:27:17 +02:00

591 lines
40 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, 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 &amp; 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 12 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>
);
}