Files
Investidor-V2/resources/js/components/project-dossier-export.tsx
T
nala e65cb26901
tests / ci (push) Canceled after 0s
varias alteracoes
2026-10-06 14:50:12 +02:00

183 lines
11 KiB
TypeScript

import { useMemo, useState } from 'react';
import {
createProjectDossierPrintHtml,
createProjectDossierReport,
dossierReportSections,
generateProjectDossierDocx,
generateProjectDossierPackage,
validateDossierAttachments,
type DossierAttachment,
type DossierReference,
type DossierCurrentCalculation,
type DossierReportSectionId,
} from '@/lib/project-dossier-report';
import { exportProjectDossierCsv } from '@/lib/project-dossier';
import type { SimulationDraft } from '@/lib/simulation-form';
type Props = {
draft: SimulationDraft;
version: string;
references?: DossierReference[];
currentCalculation?: DossierCurrentCalculation | null;
};
const defaultSections = new Set<DossierReportSectionId>(dossierReportSections.map((section) => section.id));
function filenameStem(title: string): string {
return title.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLocaleLowerCase('en')
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 70) || 'projecto';
}
function download(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.rel = 'noopener';
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function ProjectDossierExport({ draft, version, references = [], currentCalculation = null }: Props) {
const [selectedSections, setSelectedSections] = useState(defaultSections);
const [attachments, setAttachments] = useState<DossierAttachment[]>([]);
const [error, setError] = useState('');
const [busy, setBusy] = useState('');
const selected = useMemo(() => attachments.filter((item) => item.include), [attachments]);
const selectedSectionSet = useMemo(() => selectedSections, [selectedSections]);
function addFiles(files: FileList | null): void {
if (!files?.length) return;
const incoming = Array.from(files);
const combined = [...attachments.map((item) => item.file), ...incoming];
const problem = validateDossierAttachments(combined);
if (problem) { setError(problem); return; }
setAttachments((current) => [
...current,
...incoming.map((file) => ({ file, include: true, caption: '' })),
]);
setError('');
}
function toggleSection(id: DossierReportSectionId, checked: boolean): void {
setSelectedSections((current) => {
const next = new Set(current);
if (checked) next.add(id); else next.delete(id);
return next;
});
}
function updateAttachment(index: number, patch: Partial<Pick<DossierAttachment, 'include' | 'caption'>>): void {
setAttachments((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
}
function removeAttachment(index: number): void {
setAttachments((current) => current.filter((_, itemIndex) => itemIndex !== index));
}
function model() {
return createProjectDossierReport(draft, {
selectedSections: selectedSectionSet,
version,
references,
currentCalculation,
});
}
async function exportWord(): Promise<void> {
setBusy('word'); setError('');
try {
const report = model();
const blob = await generateProjectDossierDocx(report, selected, selectedSections.has('annexes'));
download(blob, `relatorio-investmoz-${filenameStem(report.title)}.docx`);
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Não foi possível gerar o relatório Word.');
} finally { setBusy(''); }
}
async function exportPackage(): Promise<void> {
setBusy('package'); setError('');
try {
const report = model();
const docx = await generateProjectDossierDocx(report, selected, selectedSections.has('annexes'));
const archive = await generateProjectDossierPackage(docx, selected, selectedSections.has('annexes'));
download(archive, `relatorio-investmoz-${filenameStem(report.title)}-anexos.zip`);
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Não foi possível preparar o pacote de anexos.');
} finally { setBusy(''); }
}
async function exportPdf(): Promise<void> {
const printWindow = window.open('about:blank', '_blank');
if (!printWindow) { setError('O browser bloqueou a janela de impressão. Permita pop-ups para abrir a versão PDF.'); return; }
setBusy('pdf'); setError('');
try {
const html = await createProjectDossierPrintHtml(model(), selected, selectedSections.has('annexes'));
printWindow.document.open(); printWindow.document.write(html); printWindow.document.close();
} catch (cause) {
printWindow.close();
setError(cause instanceof Error ? cause.message : 'Não foi possível preparar a versão para PDF.');
} finally { setBusy(''); }
}
function exportCsv(): void {
const blob = new Blob([exportProjectDossierCsv(draft)], { type: 'text/csv;charset=utf-8' });
download(blob, `dossier-projecto-investmoz-${filenameStem(String(draft.project_title ?? ''))}.csv`);
}
const totalBytes = attachments.reduce((sum, item) => sum + item.file.size, 0);
const annexesIncluded = selectedSections.has('annexes');
const imageCount = selected.filter((item) => item.file.type === 'image/png' || item.file.type === 'image/jpeg').length;
const originalOnlyCount = selected.length - imageCount - selected.filter((item) => item.file.type === 'text/csv' || item.file.name.toLocaleLowerCase('en').endsWith('.csv') || item.file.type === 'text/plain' || item.file.name.toLocaleLowerCase('en').endsWith('.txt')).length;
return (
<section className="space-y-4 rounded border p-4" aria-labelledby="project-report-title">
<div>
<h4 id="project-report-title" className="font-semibold">Relatório consolidado do projecto</h4>
<p className="mt-1 text-sm">Word (.docx), impressão/guardar em PDF ou CSV. A exportação é feita neste dispositivo; não publica nem partilha o dossier.</p>
</div>
<fieldset className="space-y-2">
<legend className="font-medium">Escolha as secções a incluir</legend>
<div className="grid gap-2 sm:grid-cols-2">
{dossierReportSections.map((section) => (
<label key={section.id} className="flex items-start gap-2 rounded border p-2 text-sm">
<input type="checkbox" checked={selectedSections.has(section.id)} onChange={(event) => toggleSection(section.id, event.target.checked)} />
<span>{section.title}<span className="block text-xs text-amber-900">Pode conter informação privada</span></span>
</label>
))}
</div>
</fieldset>
<div className="space-y-2">
<label htmlFor="project-report-files" className="block font-medium">Anexos privados seleccionados pelo utilizador</label>
<p className="text-sm">Fotografias JPEG/PNG, tabelas CSV/TSV e texto TXT podem ser incorporados. PDF, DOCX e outros formatos não suportados seguem como ficheiros originais no pacote ZIP, identificados no índice. Máximo: 10 ficheiros, 15 MB por ficheiro e 50 MB no conjunto.</p>
<input id="project-report-files" type="file" multiple accept="image/jpeg,image/png,text/csv,text/plain,.csv,.tsv,.txt,.pdf,.doc,.docx,.xlsx,.odt" onChange={(event) => { addFiles(event.currentTarget.files); event.currentTarget.value = ''; }} />
{attachments.length > 0 && <p className="text-xs text-slate-700">{selected.length} seleccionado(s) · {(totalBytes / (1024 * 1024)).toFixed(2)} MB no browser · {imageCount} imagem(ns) · {originalOnlyCount} original(is) não incorporado(s).</p>}
<ul className="space-y-2">
{attachments.map((item, index) => (
<li key={`${item.file.name}-${item.file.size}-${index}`} className="grid gap-2 rounded border p-3 sm:grid-cols-[1fr_auto]">
<div className="flex items-start gap-2">
<input aria-label={`Incluir ${item.file.name}`} type="checkbox" checked={item.include} onChange={(event) => updateAttachment(index, { include: event.target.checked })} />
<span className="min-w-0 break-words text-sm"><strong>{item.file.name}</strong><span className="block text-xs">{(item.file.size / 1024).toFixed(1)} KB · informação privada</span></span>
</div>
<button type="button" className="rounded border px-2 py-1 text-sm" onClick={() => removeAttachment(index)}>Remover</button>
{item.include && (item.file.type === 'image/jpeg' || item.file.type === 'image/png') && <label className="grid gap-1 text-sm sm:col-span-2">Legenda da fotografia<input className="rounded border px-3 py-2" maxLength={300} value={item.caption} onChange={(event) => updateAttachment(index, { caption: event.target.value })} /></label>}
</li>
))}
</ul>
</div>
<p className="rounded bg-amber-50 p-3 text-sm">Os valores e a proveniência mantêm a classificação do dossier. Campos vazios aparecem como “Não informado”; não são convertidos em zero. O relatório identifica pressupostos, estimativas e referências sem as apresentar como resultados confirmados.</p>
{error && <p role="alert" className="text-sm text-red-800">{error}</p>}
<div className="flex flex-wrap gap-2">
<button type="button" className="rounded bg-emerald-800 px-4 py-2 font-semibold text-white disabled:opacity-50" disabled={Boolean(busy) || selectedSections.size === 0} onClick={() => void exportWord()}>{busy === 'word' ? 'A gerar Word…' : 'Transferir relatório Word (.docx)'}</button>
<button type="button" className="rounded border px-4 py-2 disabled:opacity-50" disabled={Boolean(busy) || selectedSections.size === 0} onClick={() => void exportPdf()}>{busy === 'pdf' ? 'A preparar impressão…' : 'Imprimir / guardar como PDF'}</button>
{selected.length > 0 && <button type="button" className="rounded border px-4 py-2 disabled:opacity-50" disabled={Boolean(busy)} onClick={() => void exportPackage()}>{busy === 'package' ? 'A preparar pacote…' : 'Transferir relatório + originais (.zip)'}</button>}
<button type="button" className="rounded border px-4 py-2" disabled={Boolean(busy)} onClick={exportCsv}>Exportar CSV complementar</button>
</div>
{selected.length > 0 && <p className="text-xs text-slate-700">O ZIP contém o Word, os anexos marcados e um índice com o estado de incorporação. Nenhum ficheiro é carregado para o portal.</p>}
</section>
);
}