grantforge-api / frontend-react /src /components /project /ProjectAuditPanel.tsx
GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
53.5 kB
import React, { useState, useEffect, useRef } from 'react';
import { ShieldAlert, RefreshCw, AlertTriangle, CheckCircle, Info, ChevronRight, Check, Bot, Sparkles, FileText, Activity, DollarSign, ShieldCheck, AlertCircle } from 'lucide-react';
import { getProjectAudit, runProjectAudit, getProjectSections, autofixProjectSection, clearGlobalAudit, getHolisticReview, runHolisticReview } from '../../api/client';
import { motion, AnimatePresence } from 'framer-motion';
import toast from 'react-hot-toast';
interface AuditPanelProps {
projectId: string;
}
const normalizeHolisticReport = (report: any) => {
if (!report || report.status === 'pending' || report.status === 'error') {
return report;
}
const categoryText = (key: string) => {
const value = report[key];
if (typeof value === 'string') return value;
if (value && typeof value === 'object') {
const parts = [
value.feedback,
value.xai_justification,
...(value.inconsistencies_flagged || []).map((item: string) => `- ${item}`),
].filter(Boolean);
return parts.join('\n\n');
}
return '';
};
const criticalFlaws = report.critical_flaws || ['dnsh_assessment', 'budget_consistency', 'logical_flow', 'program_alignment']
.flatMap((key: string) => {
const value = report[key];
return value && typeof value === 'object' ? (value.inconsistencies_flagged || []) : [];
})
.filter(Boolean);
return {
...report,
overall_assessment: report.overall_assessment || report.xai_justification_overall || '',
score: report.score ?? report.overall_score ?? 0,
dnsh_compliance: report.dnsh_compliance || categoryText('dnsh_assessment'),
budget_consistency: typeof report.budget_consistency === 'string'
? report.budget_consistency
: categoryText('budget_consistency'),
logical_flow: typeof report.logical_flow === 'string'
? report.logical_flow
: categoryText('logical_flow'),
program_alignment: typeof report.program_alignment === 'string'
? report.program_alignment
: categoryText('program_alignment'),
recommendations: report.recommendations || report.key_recommendations || [],
critical_flaws: criticalFlaws,
};
};
const ProjectAuditPanel: React.FC<AuditPanelProps> = ({ projectId }) => {
const [activeTab, setActiveTab] = useState<'audit' | 'holistic'>('audit');
const [audit, setAudit] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRunning, setIsRunning] = useState(false);
const [fixProgress, setFixProgress] = useState<{current: number, total: number} | null>(null);
const [expandedIssue, setExpandedIssue] = useState<number | null>(null);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const pollAttemptsRef = useRef(0);
const MAX_POLL_ATTEMPTS = 72; // ~6 min przy interwale 5s
const [holisticReview, setHolisticReview] = useState<any>(null);
const [isHolisticLoading, setIsHolisticLoading] = useState(false);
const [isHolisticRunning, setIsHolisticRunning] = useState(false);
const pollHolisticIntervalRef = useRef<NodeJS.Timeout | null>(null);
const loadAudit = async () => {
try {
setIsLoading(true);
const res = await getProjectAudit(projectId);
if (res && res.status === "pending") {
setIsRunning(true);
startPolling();
} else if (res && res.status === "completed") {
setAudit(res);
setIsRunning(false);
} else if (res && res.status === "error") {
setAudit(null);
setIsRunning(false);
} else if (res && res.status !== "no_audit") {
setAudit(res);
} else {
setAudit(null);
}
} catch (e) {
console.error("Failed to load audit", e);
} finally {
setIsLoading(false);
}
};
const loadHolisticReview = async () => {
try {
setIsHolisticLoading(true);
const res = await getHolisticReview(projectId);
if (res && res.status === "pending") {
setIsHolisticRunning(true);
startHolisticPolling();
} else if (res && res.status === "completed" && res.report) {
setHolisticReview(normalizeHolisticReport(res.report));
} else {
setHolisticReview(null);
}
} catch (e) {
console.error("Failed to load holistic review", e);
} finally {
setIsHolisticLoading(false);
}
};
const clearPolling = () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
};
const startPolling = () => {
clearPolling();
pollAttemptsRef.current = 0;
pollIntervalRef.current = setInterval(async () => {
pollAttemptsRef.current += 1;
if (pollAttemptsRef.current > MAX_POLL_ATTEMPTS) {
clearPolling();
setIsRunning(false);
toast.error('Audyt trwa zbyt długo. Spróbuj ponownie za chwilę.');
return;
}
try {
const auditRes = await getProjectAudit(projectId);
if (auditRes && auditRes.status === "completed") {
clearPolling();
setAudit(auditRes);
setIsRunning(false);
toast.success('Audyt zakończony!');
} else if (auditRes && auditRes.status === "error") {
clearPolling();
toast.error(`Błąd: ${auditRes.message || 'Wystąpił błąd podczas audytu'}`);
setIsRunning(false);
setAudit(null);
} else if (auditRes && auditRes.status === "no_audit") {
clearPolling();
setIsRunning(false);
setAudit(null);
}
} catch (e) {
console.error("Polling error", e);
}
}, 5000);
};
const clearHolisticPolling = () => {
if (pollHolisticIntervalRef.current) {
clearInterval(pollHolisticIntervalRef.current);
pollHolisticIntervalRef.current = null;
}
};
const startHolisticPolling = () => {
clearHolisticPolling();
pollHolisticIntervalRef.current = setInterval(async () => {
try {
const res = await getHolisticReview(projectId);
if (res && res.status === "completed") {
clearHolisticPolling();
setHolisticReview(normalizeHolisticReport(res.report));
setIsHolisticRunning(false);
toast.success('Raport Spójności wygenerowany pomyślnie!', { id: 'holistic' });
} else if (res && res.status === "error") {
clearHolisticPolling();
toast.error(`Błąd: ${res.message || 'Wystąpił błąd podczas generowania raportu'}`, { id: 'holistic' });
setIsHolisticRunning(false);
setHolisticReview(null);
}
} catch (e) {
console.error("Holistic Polling error", e);
}
}, 5000);
};
useEffect(() => {
loadAudit();
loadHolisticReview();
return () => {
clearPolling();
clearHolisticPolling();
};
}, [projectId]);
const handleRunAudit = async () => {
try {
setIsRunning(true);
const res = await runProjectAudit(projectId);
if (res && res.status === "pending") {
toast.success('Audyt został uruchomiony w tle. Proszę czekać...');
startPolling();
} else {
setAudit(res);
setIsRunning(false);
toast.success('Audyt zakończony!');
}
} catch (e: any) {
let msg = e?.response?.data?.detail || 'Wystąpił błąd podczas audytu.';
if (typeof msg !== 'string') msg = JSON.stringify(msg);
toast.error(msg);
console.error('Failed to run audit', e);
setIsRunning(false);
}
};
const handleQualityCycle = async () => {
try {
setIsHolisticRunning(true);
toast.loading('Krytyk całości + korekta wszystkich słabych sekcji...', { id: 'quality-cycle' });
const { runDocumentQualityCycle } = await import('../../api/client');
const res = await runDocumentQualityCycle(projectId, 2);
toast.success(
res.message ||
`Cykl jakości: score ${res.holistic?.overall_score ?? '—'} · gotowość ${res.readiness?.score ?? '—'}%`,
{ id: 'quality-cycle', duration: 7000 },
);
// Refresh holistic view
try {
const h = await getHolisticReview(projectId);
if (h?.report) setHolisticReview(normalizeHolisticReport(h.report));
else if (res.holistic) setHolisticReview(normalizeHolisticReport(res.holistic as any));
} catch {
if (res.holistic) setHolisticReview(normalizeHolisticReport(res.holistic as any));
}
window.dispatchEvent(new Event('refresh-sections'));
} catch (e: any) {
const msg = e?.response?.data?.detail || 'Błąd cyklu jakości.';
toast.error(typeof msg === 'string' ? msg : JSON.stringify(msg), { id: 'quality-cycle' });
} finally {
setIsHolisticRunning(false);
}
};
const handleRunHolisticReview = async () => {
try {
setIsHolisticRunning(true);
toast.loading('Generowanie Raportu Spójności...', { id: 'holistic' });
const res = await runHolisticReview(projectId);
if (res && res.status === "pending") {
startHolisticPolling();
} else if (res && res.status === "completed" && res.report) {
setHolisticReview(normalizeHolisticReport(res.report));
setIsHolisticRunning(false);
toast.success('Raport Spójności wygenerowany pomyślnie!', { id: 'holistic' });
} else {
setIsHolisticRunning(false);
toast.error('Błąd generowania raportu.', { id: 'holistic' });
}
} catch (e: any) {
let msg = e?.response?.data?.detail || 'Wystąpił błąd podczas generowania raportu.';
if (typeof msg !== 'string') msg = JSON.stringify(msg);
toast.error(msg, { id: 'holistic' });
console.error('Failed to run holistic review', e);
setIsHolisticRunning(false);
}
};
const handleAutofix = async () => {
const t = toast.loading('Przygotowywanie automatycznej poprawki...');
try {
const sections = await getProjectSections(projectId);
const validSections = sections.filter((s:any) => s.content && s.content.trim().length > 0);
if (validSections.length === 0) {
toast.dismiss(t);
toast.error('Brak sekcji z treścią do poprawy.');
return;
}
toast.dismiss(t);
setFixProgress({ current: 0, total: validSections.length });
let failedCount = 0;
for (let i = 0; i < validSections.length; i++) {
setFixProgress({ current: i + 1, total: validSections.length });
let success = false;
let retries = 0;
const maxRetries = 4; // Increased from 3 to 4 for better resilience
while (!success && retries < maxRetries) {
try {
await autofixProjectSection(projectId, validSections[i].id);
success = true;
} catch (err: any) {
const status = err?.response?.status;
console.warn(`[Autofix] Error for section ${validSections[i].id}:`, err?.message || err);
retries++;
if (retries < maxRetries) {
// Exponential Backoff: base = 5000ms
// Try 1: 5s, Try 2: 10s, Try 3: 20s
let baseDelay = Math.pow(2, retries - 1) * 5000;
// If API explicitely returns 429, wait even longer
if (status === 429) {
baseDelay = Math.pow(2, retries) * 5000;
toast(`Oczekiwanie na reset limitów AI... (próba ${retries}/${maxRetries})`, { icon: '⏳', id: 'ai-limit' });
}
// Jitter to prevent thundering herd (1000 - 3000 ms)
const jitter = Math.floor(Math.random() * 2000) + 1000;
const totalDelay = baseDelay + jitter;
console.log(`[Autofix] Exponential backoff: waiting ${totalDelay}ms before retry ${retries}/${maxRetries}...`);
await new Promise(resolve => setTimeout(resolve, totalDelay));
} else {
failedCount++;
}
}
}
if (i < validSections.length - 1) {
await new Promise(resolve => setTimeout(resolve, 3000));
}
}
if (failedCount === 0) {
await clearGlobalAudit(projectId);
setAudit(null);
toast.success('Wniosek został automatycznie poprawiony. Zalecamy wykonanie nowego audytu.', { duration: 5000 });
} else if (failedCount < validSections.length) {
toast.success(`Poprawiono część sekcji. Nie udało się poprawić ${failedCount} sekcji. Spróbuj ponownie za chwilę.`, { duration: 6000 });
} else {
toast.error('Nie udało się poprawić żadnej sekcji z powodu błędów.');
}
try {
const updatedSections = await getProjectSections(projectId);
updatedSections.forEach((s: any) => {
window.dispatchEvent(new CustomEvent('external-section-update', {
detail: { sectionType: s.section_type, content: s.content }
}));
});
} catch (e) {
console.error("Failed to fetch updated sections after autofix", e);
}
window.dispatchEvent(new CustomEvent('refresh-sections'));
} catch (e) {
toast.dismiss(t);
console.error('Autofix setup failed', e);
toast.error('Wystąpił błąd podczas uruchamiania automatycznej naprawy dokumentu.');
} finally {
setFixProgress(null);
}
};
const handleGoToSection = (sectionType: string | undefined) => {
if (!sectionType) return;
window.dispatchEvent(new CustomEvent('navigate-to-section', {
detail: { sectionType }
}));
};
const getSeverityColor = (sev: string) => {
if (sev === 'critical') return 'var(--accent-red)';
if (sev === 'high') return 'var(--accent-yellow)';
return 'var(--accent-blue)';
};
const getSeverityIcon = (sev: string) => {
if (sev === 'critical') return <ShieldAlert size={20} />;
if (sev === 'high') return <AlertTriangle size={20} />;
return <Info size={20} />;
};
const getSeverityLabel = (sev: string) => {
if (sev === 'critical') return 'Krytyczny Błąd';
if (sev === 'high') return 'Poważne Ostrzeżenie';
return 'Sugestia / Medium';
};
const renderAuditTab = () => {
if (isLoading) {
return <div style={{ padding: '3rem', color: 'var(--text-muted)', textAlign: 'center' }}>Ładowanie raportu audytu...</div>;
}
if (fixProgress) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '6rem 2rem', color: '#8b5cf6' }}>
<Sparkles size={56} className="spin" style={{ marginBottom: '1.5rem', filter: 'drop-shadow(0 0 10px rgba(139, 92, 246, 0.4))' }} />
<h3 style={{ margin: '0 0 1rem 0', color: 'var(--text-primary)' }}>Trwa automatyczna poprawa wniosku…</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '1.2rem', marginBottom: '1rem' }}>
Sekcja {fixProgress.current} z {fixProgress.total}
</p>
<div style={{ marginTop: '1rem', width: '300px', height: '6px', background: 'rgba(255,255,255,0.05)', borderRadius: '3px', overflow: 'hidden' }}>
<motion.div
initial={{ width: 0 }}
animate={{ width: `${(fixProgress.current / fixProgress.total) * 100}%` }}
transition={{ duration: 0.3 }}
style={{ height: '100%', background: 'linear-gradient(90deg, #6366f1, #8b5cf6)' }}
/>
</div>
<p style={{ marginTop: '2rem', fontSize: '0.85rem', color: 'var(--text-muted)', maxWidth: '400px', textAlign: 'center' }}>
Może to potrwać 30–60 sekund w zależności od długości dokumentu. Prosimy nie odświeżać okna.
</p>
</div>
);
}
if (isRunning) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '6rem 2rem', color: 'var(--accent-blue)' }}>
<RefreshCw size={48} className="spin" style={{ marginBottom: '1.5rem' }} />
<h3 style={{ margin: '0 0 1rem 0', color: 'var(--text-primary)' }}>Trwa szczegółowy audyt sekcji</h3>
<p style={{ color: 'var(--text-muted)', textAlign: 'center', maxWidth: '400px' }}>Sprawdzanie reguł dla poszczególnych akapitów i generowanie rekomendacji...</p>
<div style={{ marginTop: '2rem', width: '200px', height: '4px', background: 'rgba(255,255,255,0.1)', borderRadius: '2px', overflow: 'hidden' }}>
<motion.div
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ repeat: Infinity, duration: 2, ease: "linear" }}
style={{ width: '50%', height: '100%', background: 'linear-gradient(90deg, transparent, var(--accent-blue), transparent)' }}
/>
</div>
</div>
);
}
if (!audit) {
return (
<div className="glass-card" style={{ padding: '6rem 2rem', textAlign: 'center', color: 'var(--text-muted)' }}>
<ShieldAlert size={64} color="rgba(255,255,255,0.2)" style={{ marginBottom: '1.5rem' }} />
<h3 style={{ color: 'var(--text-primary)', margin: '0 0 1rem 0', fontSize: '1.5rem' }}>Brak Danych Audytowych</h3>
<p style={{ margin: '0 auto 2rem auto', maxWidth: '500px', lineHeight: 1.6 }}>Ten projekt nie został jeszcze sprawdzony przez Szczegółowego Audytora. Uruchom proces, aby wykryć drobne niespójności i błędy językowe.</p>
<button
className="btn btn-primary"
onClick={handleRunAudit}
style={{ background: 'var(--accent-blue)', color: '#fff', border: 'none', display: 'inline-flex', alignItems: 'center', gap: '0.8rem', padding: '1rem 2rem', fontWeight: 600, borderRadius: '8px', fontSize: '1.1rem' }}
>
<RefreshCw size={20} /> Uruchom Szczegółowy Audyt
</button>
</div>
);
}
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: '1.5rem', alignItems: 'start' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<h3 style={{ margin: '0 0 0.5rem 0' }}>Wykryte Zagrożenia i Uwagi ({audit.issues?.length || 0})</h3>
{!audit.issues || audit.issues.length === 0 ? (
<div className="glass-card" style={{ padding: '2rem', textAlign: 'center', color: 'var(--accent-green)', background: 'rgba(16, 185, 129, 0.05)' }}>
<CheckCircle size={48} style={{ marginBottom: '1rem' }} />
<h3>Wniosek jest idealny!</h3>
<p style={{ color: 'var(--text-muted)' }}>Audytor nie wykrył żadnych nieprawidłowości, kosztów niekwalifikowalnych czy niespójności.</p>
</div>
) : (
<div className="glass-card" style={{ padding: 0, overflow: 'hidden' }}>
{audit.issues?.map((issue: any, idx: number) => (
<div key={idx} style={{ borderBottom: idx !== audit.issues.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
<div
style={{ padding: '1.5rem', display: 'flex', gap: '1rem', cursor: 'pointer', transition: '0.2s', background: expandedIssue === idx ? 'rgba(255,255,255,0.02)' : 'transparent' }}
className="hover-bg-subtle"
onClick={() => setExpandedIssue(expandedIssue === idx ? null : idx)}
>
<div style={{ color: getSeverityColor(issue.severity), marginTop: '0.2rem' }}>
{getSeverityIcon(issue.severity)}
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.8rem' }}>
<span style={{ fontWeight: 600, color: '#fff' }}>{issue.category}</span>
<span style={{ fontSize: '0.75rem', padding: '0.1rem 0.5rem', borderRadius: '4px', border: `1px solid ${getSeverityColor(issue.severity)}`, color: getSeverityColor(issue.severity), background: 'rgba(0,0,0,0.2)' }}>
{getSeverityLabel(issue.severity)}
</span>
{issue.affected_section && (
<span style={{ fontSize: '0.75rem', padding: '0.1rem 0.5rem', borderRadius: '4px', background: 'rgba(255,255,255,0.1)', color: 'var(--text-secondary)' }}>
Sekcja: {issue.affected_section}
</span>
)}
</div>
<ChevronRight size={18} color="var(--text-muted)" style={{ transform: expandedIssue === idx ? 'rotate(90deg)' : 'rotate(0)', transition: '0.2s' }} />
</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '0.95rem', lineHeight: 1.5 }}>
{issue.message}
</div>
</div>
</div>
<AnimatePresence>
{expandedIssue === idx && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
style={{ overflow: 'hidden' }}
>
<div style={{ padding: '0 1.5rem 1.5rem 3.5rem', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{issue.problem_quote && (
<div style={{ background: 'rgba(0,0,0,0.3)', padding: '1.2rem', borderRadius: '8px', borderLeft: '3px solid var(--accent-red)' }}>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '1px' }}>Zidentyfikowany fragment:</div>
<div style={{ fontStyle: 'italic', fontSize: '0.95rem', color: '#fff', lineHeight: 1.6 }}>"{issue.problem_quote}"</div>
</div>
)}
{issue.recommendation && (
<div style={{ background: 'rgba(59, 130, 246, 0.1)', padding: '1.2rem', borderRadius: '8px', borderLeft: '3px solid var(--accent-blue)' }}>
<div style={{ fontSize: '0.8rem', color: 'var(--accent-blue)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '1px' }}>Rekomendacja Audytora:</div>
<div style={{ fontSize: '0.95rem', color: 'var(--text-primary)', lineHeight: 1.6 }}>{issue.recommendation}</div>
</div>
)}
{issue.rule_citation && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'flex', alignItems: 'flex-start', gap: '0.6rem', padding: '0.5rem 0' }}>
<Info size={16} style={{ marginTop: '0.1rem', flexShrink: 0 }} />
<div><strong>Reguła / Kwalifikowalność:</strong> {issue.rule_citation}</div>
</div>
)}
<button
className="btn hover-lift"
style={{
marginTop: '0.5rem', alignSelf: 'flex-start', padding: '0.6rem 1.2rem',
background: 'rgba(99, 102, 241, 0.1)', color: '#818cf8',
border: '1px solid rgba(99, 102, 241, 0.2)', borderRadius: '6px',
display: 'flex', alignItems: 'center', gap: '0.5rem', fontWeight: 600, fontSize: '0.85rem'
}}
onClick={(e) => {
e.stopPropagation();
window.dispatchEvent(new CustomEvent('open-project-chat', {
detail: {
prefillMessage: `Zauważono problem w sekcji [${issue.affected_section || 'nieznanej'}].\nZarzut audytora: "${issue.message}"\n\nRekomendacja: ${issue.recommendation}\nCytat z wniosku: "${issue.problem_quote}"\n\nProszę, wygeneruj poprawiony tekst dla tej sekcji używając tagu <SUGGESTION section="${issue.affected_section}">.`,
autoSend: true
}
}));
}}
>
<Bot size={16} /> Popraw z Asystentem AI
</button>
{issue.affected_section && (
<button
className="btn hover-lift"
style={{
marginTop: '0.5rem', alignSelf: 'flex-start', padding: '0.6rem 1.2rem',
background: 'rgba(16,185,129,0.08)', color: 'var(--accent-green)',
border: '1px solid rgba(16,185,129,0.2)', borderRadius: '6px',
display: 'flex', alignItems: 'center', gap: '0.5rem', fontWeight: 600, fontSize: '0.85rem'
}}
onClick={(e) => {
e.stopPropagation();
handleGoToSection(issue.affected_section);
}}
>
<ChevronRight size={16} /> Przejdź do sekcji
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div className="glass-card" style={{ padding: '2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem', textAlign: 'center' }}>
<div style={{ fontSize: '0.9rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '1px' }}>Wynik Audytu Sekcji</div>
<div style={{
width: '120px', height: '120px', borderRadius: '50%',
background: `conic-gradient(${audit.overall_score >= 80 ? 'var(--accent-green)' : audit.overall_score >= 50 ? 'var(--accent-yellow)' : 'var(--accent-red)'} ${audit.overall_score}%, rgba(255,255,255,0.05) 0)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
position: 'relative'
}}>
<div style={{ width: '100px', height: '100px', borderRadius: '50%', background: 'var(--bg-glass)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column' }}>
<span style={{ fontSize: '2.5rem', fontWeight: 800, color: '#fff', lineHeight: 1 }}>{audit.overall_score}</span>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>/ 100</span>
</div>
</div>
<div style={{ marginTop: '1rem', padding: '1.2rem', background: 'rgba(0,0,0,0.2)', borderRadius: '12px', width: '100%' }}>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.8rem' }}>Status Audytu:</div>
<div style={{
padding: '0.8rem', borderRadius: '8px', fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem', textAlign: 'center', lineHeight: 1.4,
background: audit.export_status === 'ok' ? 'rgba(16, 185, 129, 0.1)' : audit.export_status === 'warning' ? 'rgba(245, 158, 11, 0.1)' : 'rgba(239, 68, 68, 0.1)',
color: audit.export_status === 'ok' ? 'var(--accent-green)' : audit.export_status === 'warning' ? 'var(--accent-yellow)' : 'var(--accent-red)'
}}>
{audit.overall_score >= 80 ? (
<>Niewielkie poprawki.</>
) : audit.overall_score >= 60 ? (
<>Wymaga poprawek redakcyjnych.</>
) : (
<>Wiele sekcji do poprawy.</>
)}
</div>
</div>
{audit.issues && audit.issues.length > 0 && (
<div style={{ marginTop: '1rem', width: '100%' }}>
<button
className="btn hover-lift"
onClick={handleAutofix}
style={{
width: '100%', padding: '1rem', background: 'rgba(139, 92, 246, 0.1)', color: '#c4b5fd',
border: '1px solid rgba(139, 92, 246, 0.25)', borderRadius: '8px',
display: 'flex', alignItems: 'center', gap: '0.8rem', justifyContent: 'center', fontWeight: 'bold'
}}
>
<Sparkles size={18} /> Popraw cały wniosek (Autofix)
</button>
</div>
)}
<button
className="btn btn-primary"
onClick={handleRunAudit}
style={{ width: '100%', marginTop: '0.5rem', background: 'rgba(255,255,255,0.1)', color: '#fff', border: '1px solid rgba(255,255,255,0.2)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '0.8rem', padding: '0.8rem 1.5rem', fontWeight: 600, borderRadius: '8px' }}
>
<RefreshCw size={16} /> Ponów Audyt Sekcji
</button>
</div>
</div>
</div>
);
};
const renderHolisticTab = () => {
if (isHolisticLoading) {
return <div style={{ padding: '3rem', color: 'var(--text-muted)', textAlign: 'center' }}>Ładowanie raportu spójności...</div>;
}
if (isHolisticRunning) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '6rem 2rem', color: 'var(--accent-green)' }}>
<Activity size={48} className="spin" style={{ marginBottom: '1.5rem' }} />
<h3 style={{ margin: '0 0 1rem 0', color: 'var(--text-primary)' }}>Analiza spójności w toku...</h3>
<p style={{ color: 'var(--text-muted)', textAlign: 'center', maxWidth: '400px' }}>Globalny Krytyk analizuje wniosek pod kątem logicznego przepływu, finansów, DNSH i wytycznych programu.</p>
<div style={{ marginTop: '2rem', width: '200px', height: '4px', background: 'rgba(255,255,255,0.1)', borderRadius: '2px', overflow: 'hidden' }}>
<motion.div
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ repeat: Infinity, duration: 2, ease: "linear" }}
style={{ width: '50%', height: '100%', background: 'linear-gradient(90deg, transparent, var(--accent-green), transparent)' }}
/>
</div>
</div>
);
}
if (!holisticReview) {
return (
<div className="glass-card" style={{ padding: '6rem 2rem', textAlign: 'center', color: 'var(--text-muted)' }}>
<Activity size={64} color="rgba(255,255,255,0.2)" style={{ marginBottom: '1.5rem' }} />
<h3 style={{ color: 'var(--text-primary)', margin: '0 0 1rem 0', fontSize: '1.5rem' }}>Brak Raportu Spójności</h3>
<p style={{ margin: '0 auto 2rem auto', maxWidth: '500px', lineHeight: 1.6 }}>
Ten projekt nie został jeszcze poddany analizie całościowej (Holistic Review).
Uruchom raport albo od razu pełny cykl: krytyk całości + automatyczna korekta wszystkich słabych sekcji
(bez kasowania dobrych fragmentów) — szybsza droga do odblokowania PDF/DOCX.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', justifyContent: 'center' }}>
<button
className="btn btn-primary hover-lift"
onClick={handleQualityCycle}
disabled={isHolisticRunning}
style={{
background: 'linear-gradient(135deg, #7c3aed, #2563eb)',
color: '#fff',
border: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: '0.8rem',
padding: '1rem 1.75rem',
fontWeight: 600,
borderRadius: '8px',
fontSize: '1.05rem',
}}
>
<Sparkles size={20} /> Krytyk całości + auto-korekta
</button>
<button
className="btn btn-primary hover-lift"
onClick={handleRunHolisticReview}
disabled={isHolisticRunning}
style={{
background: 'var(--accent-green)',
color: '#fff',
border: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: '0.8rem',
padding: '1rem 1.75rem',
fontWeight: 600,
borderRadius: '8px',
fontSize: '1.05rem',
}}
>
<Activity size={20} /> Tylko Raport Spójności
</button>
</div>
</div>
);
}
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 350px', gap: '1.5rem', alignItems: 'start' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div className="glass-card" style={{ padding: '1.5rem' }}>
<h3 style={{ margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.8rem' }}>
<FileText size={20} color="var(--accent-blue)" /> Ogólna Ocena Projektu
</h3>
<p style={{ margin: 0, lineHeight: 1.6, color: 'var(--text-secondary)' }}>{holisticReview.overall_assessment}</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="glass-card" style={{ padding: '1.5rem', borderLeft: '3px solid var(--accent-green)' }}>
<h4 style={{ margin: '0 0 0.8rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<ShieldCheck size={18} color="var(--accent-green)" /> Zgodność DNSH
</h4>
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--text-secondary)', lineHeight: 1.5 }}>{holisticReview.dnsh_compliance}</p>
</div>
<div className="glass-card" style={{ padding: '1.5rem', borderLeft: '3px solid var(--accent-yellow)' }}>
<h4 style={{ margin: '0 0 0.8rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<DollarSign size={18} color="var(--accent-yellow)" /> Spójność Budżetowa
</h4>
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--text-secondary)', lineHeight: 1.5 }}>{holisticReview.budget_consistency}</p>
</div>
<div className="glass-card" style={{ padding: '1.5rem', borderLeft: '3px solid var(--accent-blue)' }}>
<h4 style={{ margin: '0 0 0.8rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Activity size={18} color="var(--accent-blue)" /> Przepływ Logiczny
</h4>
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--text-secondary)', lineHeight: 1.5 }}>{holisticReview.logical_flow}</p>
</div>
<div className="glass-card" style={{ padding: '1.5rem', borderLeft: '3px solid #a855f7' }}>
<h4 style={{ margin: '0 0 0.8rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<CheckCircle size={18} color="#a855f7" /> Zgodność z Programem
</h4>
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--text-secondary)', lineHeight: 1.5 }}>{holisticReview.program_alignment}</p>
</div>
</div>
{holisticReview.critical_flaws && holisticReview.critical_flaws.length > 0 && (
<div className="glass-card" style={{ padding: '1.5rem', border: '1px solid rgba(239, 68, 68, 0.3)', background: 'rgba(239, 68, 68, 0.05)' }}>
<h3 style={{ margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.8rem', color: 'var(--accent-red)' }}>
<AlertCircle size={20} /> Wykryte Wady Krytyczne
</h3>
<ul style={{ margin: 0, paddingLeft: '1.2rem', color: 'var(--text-primary)', display: 'flex', flexDirection: 'column', gap: '0.8rem', lineHeight: 1.5 }}>
{holisticReview.critical_flaws.map((flaw: string, i: number) => (
<li key={i}>{flaw}</li>
))}
</ul>
</div>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div className="glass-card" style={{ padding: '2rem', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem', textAlign: 'center' }}>
<div style={{ fontSize: '0.9rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '1px' }}>Ogólna Ocena Spójności</div>
<div style={{
width: '120px', height: '120px', borderRadius: '50%',
background: `conic-gradient(${holisticReview.score >= 80 ? 'var(--accent-green)' : holisticReview.score >= 50 ? 'var(--accent-yellow)' : 'var(--accent-red)'} ${holisticReview.score}%, rgba(255,255,255,0.05) 0)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
position: 'relative'
}}>
<div style={{ width: '100px', height: '100px', borderRadius: '50%', background: 'var(--bg-glass)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column' }}>
<span style={{ fontSize: '2.5rem', fontWeight: 800, color: '#fff', lineHeight: 1 }}>{holisticReview.score}</span>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>/ 100</span>
</div>
</div>
<button
className="btn btn-primary"
onClick={handleQualityCycle}
disabled={isHolisticRunning}
style={{
marginTop: '1rem',
width: '100%',
background: 'linear-gradient(135deg, #7c3aed, #2563eb)',
color: '#fff',
border: 'none',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.8rem',
padding: '0.8rem 1.5rem',
fontWeight: 600,
borderRadius: '8px',
}}
>
<Sparkles size={16} /> Krytyk całości + auto-korekta
</button>
<button
className="btn btn-primary"
onClick={handleRunHolisticReview}
disabled={isHolisticRunning}
style={{
width: '100%',
background: 'rgba(255,255,255,0.1)',
color: '#fff',
border: '1px solid rgba(255,255,255,0.2)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.8rem',
padding: '0.8rem 1.5rem',
fontWeight: 600,
borderRadius: '8px',
}}
>
<RefreshCw size={16} /> Odśwież Raport Spójności
</button>
</div>
{holisticReview.recommendations && holisticReview.recommendations.length > 0 && (
<div className="glass-card" style={{ padding: '1.5rem' }}>
<h4 style={{ margin: '0 0 1rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--accent-blue)' }}>
<Sparkles size={18} /> Rekomendacje
</h4>
<ul style={{ margin: 0, paddingLeft: '1.2rem', color: 'var(--text-secondary)', fontSize: '0.9rem', display: 'flex', flexDirection: 'column', gap: '0.8rem', lineHeight: 1.5 }}>
{holisticReview.recommendations.map((rec: string, i: number) => (
<li key={i}>{rec}</li>
))}
</ul>
</div>
)}
</div>
</div>
);
};
return (
<div style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', borderBottom: '1px solid rgba(255,255,255,0.1)', paddingBottom: '1.5rem' }}>
<div>
<h2 style={{ margin: '0 0 0.5rem 0', display: 'flex', alignItems: 'center', gap: '0.8rem' }}>
Centrum Audytu <span style={{ fontSize: '0.8rem', padding: '0.2rem 0.6rem', background: 'rgba(59, 130, 246, 0.2)', color: 'var(--accent-blue)', borderRadius: '12px' }}>PRO</span>
</h2>
<p style={{ margin: 0, color: 'var(--text-muted)', maxWidth: '700px' }}>
Analizuj poszczególne sekcje pod kątem błędów językowych i merytorycznych, lub uruchom raport spójności całego projektu (Holistic Review), aby upewnić się, że wniosek stanowi logiczną całość.
</p>
</div>
</div>
{/* Tab Navigation */}
<div style={{ display: 'flex', gap: '1rem', borderBottom: '1px solid rgba(255,255,255,0.05)', paddingBottom: '1rem' }}>
<button
onClick={() => setActiveTab('audit')}
style={{
padding: '0.8rem 1.5rem',
background: activeTab === 'audit' ? 'rgba(59, 130, 246, 0.1)' : 'transparent',
color: activeTab === 'audit' ? 'var(--accent-blue)' : 'var(--text-muted)',
border: 'none',
borderBottom: activeTab === 'audit' ? '2px solid var(--accent-blue)' : '2px solid transparent',
borderRadius: '8px 8px 0 0',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
transition: 'all 0.2s'
}}
>
<ShieldAlert size={18} /> Szczegółowy Audyt Sekcji
</button>
<button
onClick={() => setActiveTab('holistic')}
style={{
padding: '0.8rem 1.5rem',
background: activeTab === 'holistic' ? 'rgba(16, 185, 129, 0.1)' : 'transparent',
color: activeTab === 'holistic' ? 'var(--accent-green)' : 'var(--text-muted)',
border: 'none',
borderBottom: activeTab === 'holistic' ? '2px solid var(--accent-green)' : '2px solid transparent',
borderRadius: '8px 8px 0 0',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
transition: 'all 0.2s'
}}
>
<Activity size={18} /> Raport Spójności (Holistic Review)
</button>
</div>
{/* Tab Content */}
<div style={{ marginTop: '0.5rem' }}>
{activeTab === 'audit' ? renderAuditTab() : renderHolisticTab()}
</div>
<style>{`.spin { animation: spin 1s linear infinite; } @keyframes spin { 100% { transform: rotate(360deg); } } .hover-bg-subtle:hover { background: rgba(255,255,255,0.02) !important; }`}</style>
</div>
);
};
export default ProjectAuditPanel;