GrantForge Bot
Deploy sha-3d4ebe3261a5907e50617c6c64d42c59bd3d4877 — source build (no GHCR)
61d1e19
Raw
History Blame Contribute Delete
18.1 kB
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} />}
PDF
</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>
);
};