GrantForge Bot
Deploy sha-565ad85979610064f6d1c18ab3b6404357d61073 — source build (no GHCR)
ce8f04a
Raw
History Blame Contribute Delete
17.8 kB
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Download, FileText, File as FileIcon, X, AlertTriangle, ShieldAlert } from 'lucide-react';
import { apiClient, exportProjectDocument, getExportGateStatus } from '../../api/client';
import toast from 'react-hot-toast';
interface ExportGateGuidance {
reason: string;
tab?: string;
actions: string[];
}
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
}
export const ExportModal: React.FC<ExportModalProps> = ({ isOpen, onClose, projectId }) => {
const [format, setFormat] = useState<'pdf' | 'docx'>('pdf');
const [template, setTemplate] = useState<string>('official');
const [isExporting, setIsExporting] = useState(false);
const [gateStatus, setGateStatus] = useState<{
blocked: boolean;
reasons: string[];
guidance: ExportGateGuidance[];
summary?: string;
} | null>(null);
useEffect(() => {
if (!isOpen || !projectId) return;
getExportGateStatus(projectId)
.then(setGateStatus)
.catch(() => setGateStatus(null));
}, [isOpen, projectId]);
if (!isOpen) return null;
const parseExportError = async (error: any): Promise<string> => {
let detail = error?.message;
if (!detail && error?.response?.data) {
if (error.response.data instanceof Blob) {
try {
const text = await error.response.data.text();
const parsed = JSON.parse(text);
const d = parsed.detail;
if (d?.guidance?.length) {
setGateStatus({
blocked: true,
reasons: d.reasons || [],
guidance: d.guidance,
summary: d.message,
});
}
detail = typeof d === 'string' ? d : d?.message || JSON.stringify(d);
} catch {
detail = 'Błąd generowania pliku';
}
} else {
const d = error.response.data.detail;
if (d?.guidance?.length) {
setGateStatus({
blocked: true,
reasons: d.reasons || [],
guidance: d.guidance,
summary: d.message,
});
}
detail = typeof d === 'string' ? d : d?.message || JSON.stringify(d);
}
}
return detail || 'Wystąpił błąd podczas generowania dokumentu.';
};
const handleExport = async () => {
try {
setIsExporting(true);
toast.loading('Generowanie dokumentu...', { id: 'export' });
const response = await exportProjectDocument(projectId, format, template);
// Create a blob URL and trigger download
const blob = new Blob([response.data], { type: response.headers['content-type'] as string });
const url = window.URL.createObjectURL(blob);
// Generate filename based on headers or default
let fileName = `Wniosek_${projectId}.${format}`;
const contentDisposition = response.headers['content-disposition'];
if (contentDisposition && contentDisposition.indexOf('filename=') !== -1) {
const matches = contentDisposition.match(/filename="?([^"]+)"?/);
if (matches && matches[1]) {
fileName = matches[1];
}
}
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast.success('Pomyślnie wyeksportowano dokument! Świadectwo Zgodności jest dostępne w panelu Final Document.', { id: 'export' });
// Cycle 12: Quick access to certificate after export
setTimeout(() => {
toast((t) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span>Chcesz pobrać Świadectwo Zgodności?</span>
<button
onClick={async () => {
try {
const res = await apiClient.get(
`/api/admin/certificate/${projectId}?format=pdf`,
{ responseType: 'blob' },
);
const url = window.URL.createObjectURL(res.data);
const link = document.createElement('a');
link.href = url;
link.download = `certificate_${projectId}.pdf`;
link.click();
window.URL.revokeObjectURL(url);
} catch {
toast.error('Nie udało się pobrać świadectwa');
}
toast.dismiss(t.id);
}}
style={{ background: '#1e40af', color: 'white', border: 'none', padding: '4px 10px', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem' }}
>
Pobierz PDF
</button>
</div>
), { duration: 8000, id: 'certificate-hint' });
}, 1200);
onClose();
} catch (error: any) {
console.error(error);
const detail = await parseExportError(error);
toast.error(detail, { id: 'export', duration: 8000 });
} finally {
setIsExporting(false);
}
};
return (
<AnimatePresence>
<div style={{
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.7)',
zIndex: 2000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(3px)'
}}>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
style={{
background: '#1a1b1e',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '16px',
width: '90%',
maxWidth: '500px',
overflow: 'hidden',
boxShadow: '0 20px 40px rgba(0,0,0,0.5)'
}}
>
<div style={{ padding: '1.5rem', borderBottom: '1px solid rgba(255,255,255,0.1)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '1.25rem' }}>
<Download size={20} color="#6366f1" />
Eksportuj Wniosek
</h2>
<button
onClick={onClose}
style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer' }}
>
<X size={20} />
</button>
</div>
<div style={{ padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{gateStatus?.blocked && (
<div style={{
padding: '1rem',
borderRadius: '10px',
background: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.35)',
}}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.5rem', color: '#fca5a5', fontWeight: 700 }}>
<ShieldAlert size={18} />
Eksport zablokowany — bramka jakości
</div>
<p style={{ margin: '0 0 0.75rem 0', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
{gateStatus.summary || 'Ukończ wymagane kroki przed pobraniem pliku.'}
</p>
{(gateStatus.guidance || []).map((g, idx) => (
<div key={idx} style={{ marginBottom: '0.75rem', fontSize: '0.85rem' }}>
<div style={{ color: '#fecaca', fontWeight: 600, marginBottom: '0.25rem' }}>{g.reason}</div>
<ol style={{ margin: 0, paddingLeft: '1.2rem', color: 'var(--text-secondary)' }}>
{g.actions.map((a, i) => <li key={i}>{a}</li>)}
</ol>
</div>
))}
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
<AlertTriangle size={14} />
Eksport na własne ryzyko możliwy tylko po świadomym potwierdzeniu w API (override).
</div>
</div>
)}
{/* FORMAT WIDGET */}
<div>
<label style={{ display: 'block', marginBottom: '0.75rem', fontWeight: 600, color: 'var(--text-primary)' }}>Wybierz Format</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<button
onClick={() => setFormat('pdf')}
style={{
padding: '1rem',
background: format === 'pdf' ? 'rgba(99, 102, 241, 0.1)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${format === 'pdf' ? '#6366f1' : 'rgba(255,255,255,0.1)'}`,
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.5rem',
cursor: 'pointer',
color: format === 'pdf' ? '#fff' : 'var(--text-muted)'
}}
>
<FileIcon size={32} color={format === 'pdf' ? '#6366f1' : 'currentColor'} />
<span style={{ fontWeight: 600 }}>Dokument PDF</span>
</button>
<button
onClick={() => setFormat('docx')}
style={{
padding: '1rem',
background: format === 'docx' ? 'rgba(99, 102, 241, 0.1)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${format === 'docx' ? '#6366f1' : 'rgba(255,255,255,0.1)'}`,
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.5rem',
cursor: 'pointer',
color: format === 'docx' ? '#fff' : 'var(--text-muted)'
}}
>
<FileText size={32} color={format === 'docx' ? '#6366f1' : 'currentColor'} />
<span style={{ fontWeight: 600 }}>Microsoft Word (DOCX)</span>
</button>
</div>
</div>
{/* TEMPLATE WIDGET */}
<div>
<label style={{ display: 'block', marginBottom: '0.75rem', fontWeight: 600, color: 'var(--text-primary)' }}>Wybierz Szablon</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{[
{ id: 'standard', name: 'Standardowy', desc: 'Dobry na start (Arial 11, klasyczny układ)' },
{ id: 'official', name: 'Urzędowy', desc: 'Całkowicie ustrukturyzowany, formalny układ zalecany do urzędów' },
{ id: 'modern', name: 'Nowoczesny', desc: 'Więcej przestrzeni i oddechu z subtelnymi akcentami kolorystycznymi' }
].map(tpl => (
<button
key={tpl.id}
onClick={() => setTemplate(tpl.id)}
style={{
padding: '1rem',
background: template === tpl.id ? 'rgba(99, 102, 241, 0.1)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${template === tpl.id ? '#6366f1' : 'rgba(255,255,255,0.1)'}`,
borderRadius: '8px',
textAlign: 'left',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column'
}}
>
<span style={{ fontWeight: 600, color: template === tpl.id ? '#6366f1' : '#fff' }}>{tpl.name}</span>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginTop: '0.2rem' }}>{tpl.desc}</span>
</button>
))}
</div>
{/* TIP FOR DOCX */}
{format === 'docx' && (
<div style={{ marginTop: '1rem', padding: '0.8rem', borderRadius: '8px', background: 'rgba(99, 102, 241, 0.05)', border: '1px dashed rgba(99, 102, 241, 0.3)', fontSize: '0.85rem', color: '#a5b4fc', display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<span style={{ fontSize: '1.2rem' }}>ℹ️</span>
<span>W wygenerowanym pliku DOCX spis treści jest początkowo pusty. Po otwarciu dokumentu w Microsoft Word naciśnij <b>F9</b>, aby zaktualizować stronę.</span>
</div>
)}
</div>
</div>
<div style={{ padding: '1rem 1.5rem', borderTop: '1px solid rgba(255,255,255,0.1)', display: 'flex', justifyContent: 'flex-end', gap: '1rem', background: 'rgba(0,0,0,0.2)' }}>
<button
onClick={onClose}
style={{ padding: '0.6rem 1.2rem', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.2)', background: 'transparent', color: '#fff', cursor: 'pointer' }}
>
Anuluj
</button>
<button
onClick={handleExport}
disabled={isExporting || gateStatus?.blocked === true}
style={{
padding: '0.6rem 1.2rem',
borderRadius: '6px',
border: 'none',
background: isExporting ? '#4f46e5' : '#6366f1',
color: '#fff',
cursor: isExporting ? 'not-allowed' : 'pointer',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '0.5rem'
}}
>
{isExporting ? 'Eksportowanie...' : 'Generuj Dokument'}
</button>
</div>
</motion.div>
</div>
</AnimatePresence>
);
};