grantforge-api / frontend-react /src /components /project /FinalDocumentPanel.tsx
GrantForge Bot
Deploy to Hugging Face
afd56bc
import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import { Sparkles, Printer, FileDown, RefreshCw, Layers, ShieldCheck, ShieldAlert, AlertTriangle, History, Save } from 'lucide-react';
import { compileFinalDocument, auditFinalDocument, getProjectVersions, createProjectVersion, exportProjectPDF, exportProjectDOCX } from '../../api/client';
import toast from 'react-hot-toast';
interface FinalDocumentPanelProps {
project: any;
onUpdate: () => void;
}
const FinalDocumentPanel: React.FC<FinalDocumentPanelProps> = ({ project, onUpdate }) => {
const [isCompiling, setIsCompiling] = useState(false);
const [isAuditing, setIsAuditing] = useState(false);
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
const [approvedOnly, setApprovedOnly] = useState(false);
const [showAuditDetails, setShowAuditDetails] = useState(false);
const [acceptedAI, setAcceptedAI] = useState(false);
const auditPanelRef = React.useRef<HTMLDivElement>(null);
const [versions, setVersions] = useState<any[]>([]);
const loadVersions = async () => {
try {
const data = await getProjectVersions(project.id);
setVersions(data);
} catch (err) {
console.error("Failed to load versions", err);
}
};
useEffect(() => {
loadVersions();
}, [project.id]);
const formatDate = (dateStr: string) => {
return new Intl.DateTimeFormat('pl-PL', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(dateStr));
};
const handleCreateVersion = async () => {
try {
if (!window.confirm("Czy chcesz zapisać obecną wersję wniosku jako nową migawkę?")) return;
const title = prompt("Podaj nazwę dla tej wersji (opcjonalnie):");
if (title === null) return; // cancelled
setIsCreatingVersion(true);
toast.loading("Zapisywanie wersji...", { id: "version" });
const result = await createProjectVersion(project.id, title);
if (result && result.version_number) {
toast.success(`Wersja V${result.version_number} została zapisana pomyślnie.`, { id: "version" });
} else {
toast.success(`Wersja została zapisana pomyślnie.`, { id: "version" });
}
loadVersions();
} catch (error: any) {
let errMsg = "Błąd podczas zapisu wersji.";
if (error.response?.data?.detail) {
const detail = error.response.data.detail;
errMsg = typeof detail === 'string' ? detail : (detail.message || JSON.stringify(detail));
}
toast.error(errMsg, { id: "version" });
} finally {
setIsCreatingVersion(false);
}
};
const handleDownloadVers = async (type: 'pdf' | 'docx', v: any) => {
toast(`Pobierasz wersję V${v.version_number} z ${formatDate(v.created_at).split(',')[0]}`, { icon: '⬇️' });
try {
if (type === 'pdf') {
await exportProjectPDF(project.id, v.id);
} else {
await exportProjectDOCX(project.id, false, v.id);
}
} catch (e: any) {
alert(e.message);
}
};
const handleAudit = async () => {
try {
setIsAuditing(true);
toast.loading("Przeprowadzanie weryfikacji krzyżowej (Audyt)...", { id: "audit" });
await auditFinalDocument(project.id);
toast.success("Audyt zakończony.", { id: "audit" });
setShowAuditDetails(true);
setTimeout(() => {
auditPanelRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 100);
onUpdate();
} catch (error: any) {
let errMsg = "Błąd podczas audytu wniosku.";
if (error.response?.data?.detail) {
const detail = error.response.data.detail;
errMsg = typeof detail === 'string' ? detail : (detail.message || JSON.stringify(detail));
}
toast.error(errMsg, { id: "audit" });
} finally {
setIsAuditing(false);
}
};
const handleCompile = async () => {
try {
setIsCompiling(true);
toast.loading("Kompilowanie i redagowanie wniosku...", { id: "compile" });
const result = await compileFinalDocument(project.id, approvedOnly);
toast.success(`Wniosek gotowy! Połączono ${result.sections_used} sekcji. Pamiętaj o weryfikacji przez Audytor.`, { id: "compile" });
onUpdate(); // Odśwież dane w nadrzędnym widoku
} catch (error: any) {
let errMsg = "Błąd podczas kompilacji lub audytu wniosku.";
if (error.response?.data?.detail) {
const detail = error.response.data.detail;
errMsg = typeof detail === 'string' ? detail : (detail.message || JSON.stringify(detail));
}
toast.error(errMsg, { id: "compile" });
} finally {
setIsCompiling(false);
}
};
const handlePrint = () => {
window.print();
};
const handleDownloadTxt = () => {
if (!project.final_document_markdown) return;
const blob = new Blob([project.final_document_markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Wniosek_${project.title.replace(/\s+/g, '_')}.md`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ padding: '1rem', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div className="print-hide" style={{ display: 'flex', gap: '2rem', marginBottom: '1.5rem' }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: 'rgba(255,255,255,0.02)', padding: '1.5rem', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ marginBottom: '1.5rem' }}>
<h2 style={{ fontSize: '1.4rem', fontWeight: 700, margin: '0 0 0.5rem 0', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Sparkles size={20} color="var(--accent-green)" /> Gotowy Wniosek
</h2>
<p style={{ margin: 0, color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
Połącz wszystkie sekcje robocze, przepuść przez korektorę redaktorską LLM i wyeksportuj swój wniosek.
</p>
</div>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', marginRight: '0.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem', color: 'var(--text-secondary)', cursor: 'pointer' }}>
<input type="checkbox" checked={approvedOnly} onChange={(e) => setApprovedOnly(e.target.checked)} />
Tylko zatwierdzone sekcje
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem', color: 'var(--accent-green)', cursor: 'pointer', fontWeight: 600 }}>
<input type="checkbox" checked={acceptedAI} onChange={(e) => setAcceptedAI(e.target.checked)} />
Akceptuję, że AI to tylko asystent
</label>
</div>
<button onClick={handleCompile} disabled={isCompiling || !acceptedAI} className="btn" style={{ background: acceptedAI ? 'var(--accent-blue)' : 'rgba(255,255,255,0.1)', color: acceptedAI ? 'white' : 'var(--text-muted)' }}>
{isCompiling ? <RefreshCw className="spin" size={16} /> : <Layers size={16} />}
{project.final_document_markdown ? "Odśwież wniosek końcowy" : "Wygeneruj wniosek końcowy"}
</button>
{project.final_document_markdown && (
<>
<button
onClick={handleAudit}
disabled={isAuditing || !acceptedAI}
className="btn hover-lift"
style={{ background: 'var(--accent-purple)', color: 'white', opacity: acceptedAI ? 1 : 0.5 }}
>
{isAuditing ? <RefreshCw className="spin" size={16} /> : <ShieldCheck size={16} />}
Przeprowadź Globalny Audyt
</button>
<button
onClick={() => {
if (project.final_document_audit_result && project.final_document_audit_result.overall_score < 60) {
if(!window.confirm("UWAGA: Dokument wykazuje krytyczne ryzyko odrzucenia (wynik < 60). Kontynuować pobieranie?")) return;
} else if (!project.final_document_audit_result) {
if(!window.confirm("Dokument niezweryfikowany Audytorem - Zwiększone ryzyko odrzucenia. Pobrać mimo to?")) return;
}
handlePrint();
}}
disabled={!acceptedAI}
className="btn hover-lift"
style={{ background: (project.final_document_audit_result && project.final_document_audit_result.overall_score < 60) ? 'rgba(239, 68, 68, 0.2)' : 'rgba(255,255,255,0.1)', opacity: acceptedAI ? 1 : 0.5 }}
>
<Printer size={16} /> Drukuj
</button>
<button
onClick={() => {
if (project.final_document_audit_result && project.final_document_audit_result.overall_score < 60) {
if(!window.confirm("UWAGA: Dokument wykazuje krytyczne ryzyko odrzucenia (wynik < 60). Kontynuować pobieranie?")) return;
} else if (!project.final_document_audit_result) {
if(!window.confirm("Dokument niezweryfikowany Audytorem - Zwiększone ryzyko odrzucenia. Pobrać mimo to?")) return;
}
handleDownloadTxt();
}}
disabled={!acceptedAI}
className="btn hover-lift"
style={{ background: (project.final_document_audit_result && project.final_document_audit_result.overall_score < 60) ? 'rgba(239, 68, 68, 0.2)' : 'rgba(255,255,255,0.1)', opacity: acceptedAI ? 1 : 0.5 }}
>
<FileDown size={16} /> Markdown
</button>
</>
)}
</div>
</div>
<div style={{ width: '320px', background: 'rgba(255,255,255,0.02)', padding: '1.5rem', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.05)', display: 'flex', flexDirection: 'column', maxHeight: '300px' }}>
<h3 style={{ margin: '0 0 1rem 0', fontSize: '1.1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--text-primary)' }}>
<History size={18} color="var(--accent-blue)" /> Historia Wersji
</h3>
<button onClick={handleCreateVersion} disabled={isCreatingVersion} className="btn" style={{ background: 'rgba(59, 130, 246, 0.1)', color: 'var(--accent-blue)', border: '1px solid rgba(59, 130, 246, 0.2)', marginBottom: '1rem', padding: '0.4rem 0.8rem', fontSize: '0.85rem' }}>
{isCreatingVersion ? <RefreshCw className="spin" size={14} /> : <Save size={14} />} Zapisz obecny stan
</button>
<div style={{ overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: '0.5rem' }} className="hide-scrollbar">
{versions.map((v, idx) => (
<div key={v.id} style={{ background: 'rgba(0,0,0,0.2)', padding: '0.8rem', borderRadius: '8px', fontSize: '0.85rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.05)', paddingBottom: '0.4rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<strong style={{ color: '#fff' }}>V{v.version_number}</strong>
{idx === 0 && <span style={{ background: 'var(--accent-green)', color: 'white', padding: '0.1rem 0.4rem', borderRadius: '4px', fontSize: '0.65rem', fontWeight: 'bold' }}>Bieżąca</span>}
</div>
<span style={{ color: 'var(--text-muted)' }}>{formatDate(v.created_at)}</span>
</div>
{v.title && <div style={{ color: 'var(--accent-blue)', marginBottom: '0.4rem', fontStyle: 'italic', fontSize: '0.8rem' }}>"{v.title}"</div>}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button onClick={() => handleDownloadVers('pdf', v)} style={{ background: 'transparent', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer', padding: 0, fontSize: '0.8rem', outline: 'none' }} className="hover-text-blue">PDF</button>
<span style={{color: 'var(--text-muted)'}}></span>
<button onClick={() => handleDownloadVers('docx', v)} style={{ background: 'transparent', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer', padding: 0, fontSize: '0.8rem', outline: 'none' }} className="hover-text-blue">DOCX</button>
</div>
</div>
))}
{versions.length === 0 && <div style={{ color: 'var(--text-muted)', fontSize: '0.85rem', textAlign: 'center', marginTop: '1rem' }}>Brak zapisanych wersji.</div>}
</div>
</div>
</div>
<div className="print-only-container" style={{ flex: 1, background: '#fff', borderRadius: '12px', padding: '3rem', overflowY: 'auto', color: '#000', boxShadow: '0 10px 30px rgba(0,0,0,0.1)', fontFamily: 'Merriweather, "Times New Roman", serif' }}>
{/* PANEL AUDYTU */}
{!project.final_document_audit_result && project.final_document_markdown && (
<div className="print-hide" style={{ marginBottom: '2rem', padding: '1.5rem', borderRadius: '12px', background: 'rgba(245, 158, 11, 0.05)', border: '1px solid rgba(245, 158, 11, 0.2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<AlertTriangle size={24} color="#f59e0b" />
<h3 style={{ margin: 0, fontWeight: 600, color: '#111', fontSize: '1.1rem' }}>Dokument niezweryfikowany Audytorem - Zwiększone ryzyko odrzucenia</h3>
</div>
</div>
)}
{project.final_document_audit_result && (
<div ref={auditPanelRef} className="print-hide" style={{
marginBottom: '2rem',
padding: '1.5rem',
borderRadius: '12px',
background: project.final_document_audit_result.overall_score >= 80 ? 'rgba(34, 197, 94, 0.05)' : (project.final_document_audit_result.overall_score >= 60 ? 'rgba(234, 179, 8, 0.05)' : 'rgba(239, 68, 68, 0.05)'),
border: `1px solid ${project.final_document_audit_result.overall_score >= 80 ? 'rgba(34,197,94,0.2)' : (project.final_document_audit_result.overall_score >= 60 ? 'rgba(234,179,8,0.2)' : 'rgba(239,68,68,0.2)')}`
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
{project.final_document_audit_result.overall_score >= 80 ? (
<ShieldCheck size={24} color="#22c55e" />
) : (
<ShieldAlert size={24} color={project.final_document_audit_result.overall_score >= 60 ? "#eab308" : "#ef4444"} />
)}
<h3 style={{ margin: 0, fontWeight: 600, color: '#111', fontSize: '1.2rem' }}>
Audyt Zgodności Wniosku: {project.final_document_audit_result.overall_score >= 80 ? "Niski poziom ryzyka odrzucenia wniosku. Dokument jest dobrze przygotowany." : (project.final_document_audit_result.overall_score >= 60 ? "Średnie ryzyko. Zalecamy wprowadzenie poprawek zgodnie z poniższymi rekomendacjami przed złożeniem." : "Wysokie ryzyko odrzucenia. Silnie zalecamy wprowadzenie zmian. Eksport możliwy wyłącznie na własne ryzyko.")}
</h3>
<span style={{ marginLeft: 'auto', background: project.final_document_audit_result.overall_score >= 80 ? '#22c55e' : (project.final_document_audit_result.overall_score >= 60 ? '#eab308' : '#ef4444'), color: 'white', padding: '0.25rem 0.75rem', borderRadius: '20px', fontSize: '0.85rem', fontWeight: 600 }}>
{project.final_document_audit_result.overall_score} / 100
</span>
</div>
{project.final_document_audit_result.issues?.length > 0 && (
<div style={{ marginTop: '1rem' }}>
{(!showAuditDetails && project.final_document_audit_result.overall_score >= 80) ? (
<button
onClick={() => setShowAuditDetails(true)}
className="btn"
style={{ background: 'rgba(0,0,0,0.05)', color: '#333', border: '1px solid rgba(0,0,0,0.1)', fontSize: '0.9rem' }}
>
Pokaż szczegóły audytu ({project.final_document_audit_result.issues.length} uwag)
</button>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{project.final_document_audit_result.issues.map((issue: any, idx: number) => (
<div key={idx} style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-start', background: '#f9f9f9', padding: '1rem', borderRadius: '8px', borderLeft: `4px solid ${issue.severity === 'high' ? '#ef4444' : issue.severity === 'medium' ? '#eab308' : '#3b82f6'}` }}>
<AlertTriangle size={18} color={issue.severity === 'high' ? '#ef4444' : issue.severity === 'medium' ? '#eab308' : '#3b82f6'} style={{ marginTop: '2px', flexShrink: 0 }} />
<div style={{ flex: 1 }}>
<strong style={{ display: 'inline-block', fontSize: '0.85rem', color: '#555', marginBottom: '0.5rem', textTransform: 'uppercase' }}>{issue.category}</strong>
<p style={{ margin: 0, fontSize: '0.95rem', color: '#111', lineHeight: 1.5, marginBottom: issue.rule_citation || issue.recommendation ? '0.75rem' : 0 }}>{issue.message}</p>
{issue.rule_citation && (
<div style={{ background: 'rgba(59, 130, 246, 0.08)', borderLeft: '3px solid #3b82f6', padding: '0.5rem 0.75rem', marginBottom: '0.5rem', fontSize: '0.85rem', color: '#1e3a8a', fontStyle: 'italic' }}>
<strong>Podstawa / Reguła:</strong> {issue.rule_citation}
</div>
)}
{issue.recommendation && (
<div style={{ background: 'rgba(34, 197, 94, 0.08)', borderLeft: '3px solid #22c55e', padding: '0.5rem 0.75rem', fontSize: '0.85rem', color: '#14532d' }}>
<strong>Rekomendacja:</strong> {issue.recommendation}
</div>
)}
</div>
</div>
))}
<button
onClick={() => setShowAuditDetails(false)}
className="btn"
style={{ marginTop: '0.5rem', background: 'transparent', color: '#555', border: 'none', textDecoration: 'underline', alignSelf: 'flex-start', padding: 0 }}
>
Ukryj szczegóły
</button>
{project.final_document_audit_result.overall_score < 60 && (
<button
onClick={() => {
toast("Przejdź do zakładki 'Sekcje', aby poprawić treść.", { icon: "📝" });
}}
className="btn"
style={{ marginTop: '0.5rem', background: 'var(--accent-red)', color: 'white', border: 'none', alignSelf: 'flex-start' }}
>
Popraw zgodnie z audytem
</button>
)}
</div>
)}
</div>
)}
<div style={{ marginTop: '1rem', paddingTop: '1rem', borderTop: '1px solid rgba(0,0,0,0.05)', fontSize: '0.75rem', color: '#666', fontStyle: 'italic' }}>
* Audyt jest wsparciem AI. Ostateczna odpowiedzialność za treść wniosku spoczywa na użytkowniku.
</div>
</div>
)}
{project.final_document_markdown ? (
<div className="final-document-content" style={{ maxWidth: '800px', margin: '0 auto', lineHeight: 1.8 }}>
<div className="print-hide" style={{ textAlign: 'center', marginBottom: '2rem', borderBottom: '1px solid #eee', paddingBottom: '1rem' }}>
<p style={{ color: '#666', fontSize: '0.85rem' }}>Podgląd wygenerowanego wniosku. Ostatnia generacja: {new Date(project.final_document_generated_at).toLocaleString('pl-PL')}</p>
</div>
<ReactMarkdown>
{project.final_document_markdown}
</ReactMarkdown>
<div className="ai-disclaimer" style={{
marginTop: '3rem',
padding: '1.25rem',
background: '#f8fafc',
borderLeft: '4px solid #94a3b8',
borderRadius: '0 8px 8px 0',
fontSize: '0.85rem',
color: '#475569',
lineHeight: 1.6
}}>
<strong>Wygenerowano przy wsparciu AI</strong><br/>
Ten dokument został częściowo utworzony przez sztuczną inteligencję na podstawie regulaminów urzędowych. Wiedza systemu aktualna na: 12 kwietnia 2026.<br/>
Zawsze zweryfikuj treść samodzielnie przed złożeniem wniosku do instytucji publicznej.
</div>
</div>
) : (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
<Layers size={48} style={{ opacity: 0.2, marginBottom: '1rem' }} />
<h3 style={{ margin: 0, fontWeight: 500 }}>Brak wygenerowanego wniosku</h3>
<p style={{ fontSize: '0.9rem' }}>Kliknij na przycisk powyżej, aby scalić części i przygotować dokument.</p>
</div>
)}
</div>
<style>{`
@media print {
body * {
visibility: hidden;
}
.print-only-container, .print-only-container * {
visibility: visible;
}
.print-only-container {
position: absolute;
left: 0;
top: 0;
width: 100% !important;
height: auto !important;
overflow: visible !important;
padding: 0 !important;
margin: 0 !important;
box-shadow: none !important;
border-radius: 0 !important;
}
.print-hide {
display: none !important;
}
}
.final-document-content h1,
.final-document-content h2,
.final-document-content h3 {
font-family: 'Inter', sans-serif;
color: #111;
margin-top: 2rem;
}
.final-document-content h1 {
font-size: 2rem;
border-bottom: 2px solid #222;
padding-bottom: 0.5rem;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin { 100% { transform: rotate(360deg); } }
.hover-text-blue:hover {
color: var(--accent-blue) !important;
}
`}</style>
</div>
);
};
export default FinalDocumentPanel;