Spaces:
Running
Running
| import React, { useState, useCallback } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { Upload, FileText, AlertTriangle, CheckCircle, Loader2, Info, ChevronRight, Sparkles, Download, ShieldCheck } from 'lucide-react'; | |
| import { uploadReverseAuditDocument, uploadGapReportDocument, exportGapReportFromFile, AuditMode } from '../../api/client'; | |
| import toast from 'react-hot-toast'; | |
| export const ReverseAuditPanel: React.FC = () => { | |
| const [isDragging, setIsDragging] = useState(false); | |
| const [file, setFile] = useState<File | null>(null); | |
| const [programName, setProgramName] = useState(''); | |
| const [auditMode, setAuditMode] = useState<AuditMode>('pro'); | |
| const [isAuditing, setIsAuditing] = useState(false); | |
| const [isExporting, setIsExporting] = useState<'pdf' | 'docx' | null>(null); | |
| const [auditResult, setAuditResult] = useState<any>(null); | |
| const onDragOver = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setIsDragging(true); | |
| }; | |
| const onDragLeave = () => { | |
| setIsDragging(false); | |
| }; | |
| const onDrop = useCallback((e: React.DragEvent) => { | |
| e.preventDefault(); | |
| setIsDragging(false); | |
| if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { | |
| setFile(e.dataTransfer.files[0]); | |
| } | |
| }, []); | |
| const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| if (e.target.files && e.target.files.length > 0) { | |
| setFile(e.target.files[0]); | |
| } | |
| }; | |
| const handleAudit = async () => { | |
| if (!file) { | |
| toast.error("Proszę wybrać plik do audytu."); | |
| return; | |
| } | |
| if (auditMode === 'pro' && !programName.trim()) { | |
| toast.error("Tryb Pro wymaga nazwy programu / naboru."); | |
| return; | |
| } | |
| setIsAuditing(true); | |
| setAuditResult(null); | |
| try { | |
| if (auditMode === 'pro') { | |
| const data = await uploadGapReportDocument(file, programName.trim()); | |
| setAuditResult(data); | |
| } else { | |
| const data = await uploadReverseAuditDocument(file, programName || undefined); | |
| setAuditResult({ ...data, mode: 'llm' }); | |
| } | |
| toast.success(auditMode === 'pro' ? "Audyt deterministyczny zakończony!" : "Audyt LLM zakończony!"); | |
| } catch (error: any) { | |
| toast.error(error.response?.data?.detail || "Wystąpił błąd podczas audytu."); | |
| } finally { | |
| setIsAuditing(false); | |
| } | |
| }; | |
| const handleExport = async (format: 'pdf' | 'docx') => { | |
| if (!file || !programName.trim()) { | |
| toast.error("Wymagany plik i nazwa programu do eksportu."); | |
| return; | |
| } | |
| setIsExporting(format); | |
| try { | |
| await exportGapReportFromFile(file, programName.trim(), format); | |
| toast.success(`Pobrano raport ${format.toUpperCase()}`); | |
| } catch (error: any) { | |
| toast.error(error.response?.data?.detail || "Eksport nie powiódł się."); | |
| } finally { | |
| setIsExporting(null); | |
| } | |
| }; | |
| const renderSeverityIcon = (severity: string) => { | |
| switch (severity.toLowerCase()) { | |
| case 'critical': return <AlertTriangle size={20} color="var(--accent-red)" />; | |
| case 'high': return <AlertTriangle size={20} color="var(--accent-orange)" />; | |
| case 'medium': return <Info size={20} color="var(--accent-blue)" />; | |
| default: return <Info size={20} color="var(--text-secondary)" />; | |
| } | |
| }; | |
| return ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '2rem', padding: '1rem' }}> | |
| <div style={{ background: 'var(--bg-secondary)', padding: '2rem', borderRadius: '12px', border: '1px solid var(--border-color)' }}> | |
| <h2 style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1.5rem', color: 'var(--text-primary)' }}> | |
| <Sparkles size={24} color="var(--accent-blue)" /> Reverse Audit (Weryfikacja Wniosku) | |
| </h2> | |
| <p style={{ color: 'var(--text-secondary)', marginBottom: '1.5rem', lineHeight: '1.6' }}> | |
| Prześlij gotowy wniosek (PDF, DOCX lub TXT). <strong>Tryb Pro</strong> używa deterministycznego audytu luk (snapshot gap audit) bez LLM. | |
| <strong> Tryb LLM</strong> uruchamia GraphRAG + Regulation Engine z wieloperspektywiczną analizą. | |
| </p> | |
| <div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}> | |
| <button | |
| type="button" | |
| onClick={() => setAuditMode('pro')} | |
| style={{ | |
| padding: '0.5rem 1rem', borderRadius: '8px', border: '1px solid var(--border-color)', | |
| background: auditMode === 'pro' ? 'rgba(56,189,248,0.15)' : 'var(--bg-primary)', | |
| color: auditMode === 'pro' ? 'var(--accent-blue)' : 'var(--text-secondary)', | |
| cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem', fontWeight: 600, | |
| }} | |
| > | |
| <ShieldCheck size={16} /> Pro (deterministyczny) | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => setAuditMode('llm')} | |
| style={{ | |
| padding: '0.5rem 1rem', borderRadius: '8px', border: '1px solid var(--border-color)', | |
| background: auditMode === 'llm' ? 'rgba(167,139,250,0.15)' : 'var(--bg-primary)', | |
| color: auditMode === 'llm' ? '#a78bfa' : 'var(--text-secondary)', | |
| cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem', fontWeight: 600, | |
| }} | |
| > | |
| <Sparkles size={16} /> LLM (GraphRAG) | |
| </button> | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', marginBottom: '2rem' }}> | |
| <label style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}> | |
| Docelowy program / nabór {auditMode === 'pro' ? '(wymagany w trybie Pro)' : '(opcjonalnie)'}: | |
| </label> | |
| <input | |
| type="text" | |
| placeholder="np. Ścieżka SMART, Horizon Europe, Kredyt Ekologiczny..." | |
| value={programName} | |
| onChange={(e) => setProgramName(e.target.value)} | |
| style={{ | |
| width: '100%', padding: '0.75rem 1rem', borderRadius: '8px', | |
| border: '1px solid var(--border-color)', background: 'var(--bg-primary)', | |
| color: 'var(--text-primary)', outline: 'none' | |
| }} | |
| /> | |
| </div> | |
| <div | |
| onDragOver={onDragOver} | |
| onDragLeave={onDragLeave} | |
| onDrop={onDrop} | |
| style={{ | |
| border: `2px dashed ${isDragging ? 'var(--accent-blue)' : 'var(--border-color)'}`, | |
| borderRadius: '12px', padding: '3rem 2rem', textAlign: 'center', | |
| background: isDragging ? 'rgba(56, 189, 248, 0.05)' : 'var(--bg-primary)', | |
| transition: 'all 0.2s ease', cursor: 'pointer', marginBottom: '2rem' | |
| }} | |
| onClick={() => document.getElementById('audit-file-upload')?.click()} | |
| > | |
| <input | |
| id="audit-file-upload" | |
| type="file" | |
| accept=".pdf,.docx,.txt" | |
| style={{ display: 'none' }} | |
| onChange={handleFileChange} | |
| /> | |
| <Upload size={40} color={isDragging ? 'var(--accent-blue)' : 'var(--text-secondary)'} style={{ margin: '0 auto 1rem auto' }} /> | |
| <div style={{ fontSize: '1.1rem', color: 'var(--text-primary)', marginBottom: '0.5rem' }}> | |
| {file ? file.name : 'Przeciągnij plik tutaj lub kliknij, aby wybrać'} | |
| </div> | |
| <div style={{ fontSize: '0.9rem', color: 'var(--text-secondary)' }}>Obsługiwane formaty: PDF, DOCX, TXT</div> | |
| </div> | |
| <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', flexWrap: 'wrap' }}> | |
| {auditMode === 'pro' && auditResult && ( | |
| <> | |
| <button | |
| onClick={() => handleExport('pdf')} | |
| disabled={!!isExporting} | |
| style={{ | |
| background: 'transparent', color: 'var(--text-primary)', border: '1px solid var(--border-color)', | |
| padding: '0.75rem 1.25rem', borderRadius: '8px', cursor: 'pointer', | |
| display: 'flex', alignItems: 'center', gap: '0.5rem', | |
| }} | |
| > | |
| {isExporting === 'pdf' ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />} | |
| </button> | |
| <button | |
| onClick={() => handleExport('docx')} | |
| disabled={!!isExporting} | |
| style={{ | |
| background: 'transparent', color: 'var(--text-primary)', border: '1px solid var(--border-color)', | |
| padding: '0.75rem 1.25rem', borderRadius: '8px', cursor: 'pointer', | |
| display: 'flex', alignItems: 'center', gap: '0.5rem', | |
| }} | |
| > | |
| {isExporting === 'docx' ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />} | |
| DOCX | |
| </button> | |
| </> | |
| )} | |
| <button | |
| onClick={handleAudit} | |
| disabled={!file || isAuditing} | |
| style={{ | |
| background: auditMode === 'pro' ? 'var(--accent-blue)' : '#7c3aed', color: 'white', border: 'none', | |
| padding: '0.75rem 2rem', borderRadius: '8px', fontWeight: '500', | |
| cursor: (!file || isAuditing) ? 'not-allowed' : 'pointer', | |
| display: 'flex', alignItems: 'center', gap: '0.5rem', | |
| opacity: (!file || isAuditing) ? 0.6 : 1 | |
| }} | |
| > | |
| {isAuditing ? <Loader2 size={18} className="animate-spin" /> : <FileText size={18} />} | |
| {isAuditing ? 'Analizowanie...' : auditMode === 'pro' ? 'Audyt Pro (gap-report)' : 'Audyt LLM'} | |
| </button> | |
| </div> | |
| </div> | |
| <AnimatePresence> | |
| {auditResult && ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, height: 0 }} | |
| style={{ background: 'var(--bg-secondary)', padding: '2rem', borderRadius: '12px', border: '1px solid var(--border-color)' }} | |
| > | |
| <h3 style={{ fontSize: '1.2rem', marginBottom: '1rem', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', gap: '0.5rem' }}> | |
| Wyniki Audytu | |
| {auditResult.mode === 'pro' && ( | |
| <span style={{ fontSize: '0.75rem', background: 'rgba(56,189,248,0.15)', color: 'var(--accent-blue)', padding: '2px 8px', borderRadius: '4px' }}>PRO</span> | |
| )} | |
| {auditResult.is_approved ? <CheckCircle color="var(--accent-green)" size={20} /> : <AlertTriangle color="var(--accent-red)" size={20} />} | |
| </h3> | |
| <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '2rem' }}> | |
| <div style={{ background: 'var(--bg-primary)', padding: '1rem', borderRadius: '8px', border: '1px solid var(--border-color)' }}> | |
| <div style={{ fontSize: '0.9rem', color: 'var(--text-secondary)', marginBottom: '0.2rem' }}>Ocena ogólna (Score)</div> | |
| <div style={{ fontSize: '1.5rem', fontWeight: 'bold', color: auditResult.overall_score > 75 ? 'var(--accent-green)' : (auditResult.overall_score > 50 ? 'var(--accent-orange)' : 'var(--accent-red)') }}> | |
| {auditResult.overall_score} / 100 | |
| </div> | |
| </div> | |
| <div style={{ background: 'var(--bg-primary)', padding: '1rem', borderRadius: '8px', border: '1px solid var(--border-color)' }}> | |
| <div style={{ fontSize: '0.9rem', color: 'var(--text-secondary)', marginBottom: '0.2rem' }}>Wymaga weryfikacji eksperta</div> | |
| <div style={{ fontSize: '1.1rem', fontWeight: 'bold', color: auditResult.human_review_required ? 'var(--accent-orange)' : 'var(--accent-green)' }}> | |
| {auditResult.human_review_required ? 'TAK' : 'NIE'} | |
| </div> | |
| </div> | |
| </div> | |
| {auditResult.summary && ( | |
| <p style={{ color: 'var(--text-secondary)', marginBottom: '1.5rem', fontSize: '0.95rem' }}>{auditResult.summary}</p> | |
| )} | |
| <h4 style={{ marginBottom: '1rem', color: 'var(--text-primary)' }}>Wykryte luki i błędy ({auditResult.issues?.length || 0}):</h4> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> | |
| {auditResult.issues?.length === 0 ? ( | |
| <div style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--text-secondary)', background: 'rgba(16, 185, 129, 0.05)', borderRadius: '8px', border: '1px dashed var(--accent-green)' }}> | |
| <CheckCircle size={32} color="var(--accent-green)" style={{ margin: '0 auto 0.5rem auto' }} /> | |
| Nie znaleziono żadnych luk względem regulaminu! | |
| </div> | |
| ) : ( | |
| auditResult.issues?.map((issue: any, idx: number) => ( | |
| <div key={idx} style={{ background: 'var(--bg-primary)', padding: '1.25rem', borderRadius: '8px', borderLeft: `4px solid ${issue.severity === 'critical' ? 'var(--accent-red)' : (issue.severity === 'high' ? 'var(--accent-orange)' : 'var(--accent-blue)')}` }}> | |
| <div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem' }}> | |
| {renderSeverityIcon(issue.severity)} | |
| <div style={{ flex: 1 }}> | |
| <div style={{ fontWeight: '600', color: 'var(--text-primary)', marginBottom: '0.5rem' }}> | |
| {issue.category} <span style={{ fontSize: '0.8rem', fontWeight: 'normal', color: 'var(--text-secondary)', marginLeft: '0.5rem' }}>[{issue.perspective}]</span> | |
| </div> | |
| <div style={{ color: 'var(--text-primary)', marginBottom: '0.75rem', lineHeight: '1.5' }}> | |
| {issue.message} | |
| </div> | |
| {issue.rule_citation && ( | |
| <div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', background: 'var(--bg-secondary)', padding: '0.5rem', borderRadius: '4px', marginBottom: '0.75rem', fontStyle: 'italic' }}> | |
| <strong>Cytat z regulaminu:</strong> {issue.rule_citation} | |
| </div> | |
| )} | |
| {issue.recommendation && ( | |
| <div style={{ fontSize: '0.9rem', color: 'var(--accent-green)', display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}> | |
| <ChevronRight size={16} style={{ marginTop: '0.1rem', flexShrink: 0 }} /> | |
| <span><strong>Rekomendacja:</strong> {issue.recommendation}</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| ); | |
| }; | |