183 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|