Spaces:
Running
Running
| 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> | |
| ); | |
| }; | |