465 lines
18 KiB
TypeScript
465 lines
18 KiB
TypeScript
import { useMemo, useRef, useState } from 'react';
|
|
import {
|
|
DossierExportGate,
|
|
dossierExportFailure,
|
|
prepareDossierAttachments,
|
|
} from '@/lib/dossier-export-state';
|
|
import {
|
|
createProjectDossierPrintHtml,
|
|
createProjectDossierReport,
|
|
dossierReportSections,
|
|
generateProjectDossierDocx,
|
|
generateProjectDossierPackage,
|
|
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 [notice, setNotice] = useState('');
|
|
const exportGate = useRef(new DossierExportGate());
|
|
const selected = useMemo(
|
|
() => attachments.filter((item) => item.include),
|
|
[attachments],
|
|
);
|
|
const selectedSectionSet = useMemo(
|
|
() => selectedSections,
|
|
[selectedSections],
|
|
);
|
|
|
|
async function addFiles(files: FileList | null): Promise<void> {
|
|
if (!files?.length) return;
|
|
const incoming = Array.from(files);
|
|
await exportGate.current.run(async () => {
|
|
setBusy('attachments');
|
|
setNotice('');
|
|
try {
|
|
const result = await prepareDossierAttachments(
|
|
attachments,
|
|
incoming,
|
|
);
|
|
if (result.problem) {
|
|
setError(result.problem);
|
|
return;
|
|
}
|
|
setAttachments(result.attachments);
|
|
setError('');
|
|
if (result.duplicateCount)
|
|
setNotice(
|
|
'Os ficheiros que já estavam na lista foram mantidos uma única vez.',
|
|
);
|
|
} catch (cause) {
|
|
setError(dossierExportFailure('attachments', cause));
|
|
} finally {
|
|
setBusy('');
|
|
}
|
|
});
|
|
}
|
|
|
|
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> {
|
|
await exportGate.current.run(async () => {
|
|
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(dossierExportFailure('word', cause));
|
|
} finally {
|
|
setBusy('');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function exportPackage(): Promise<void> {
|
|
await exportGate.current.run(async () => {
|
|
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(dossierExportFailure('package', cause));
|
|
} finally {
|
|
setBusy('');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function exportPdf(): Promise<void> {
|
|
await exportGate.current.run(async () => {
|
|
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(dossierExportFailure('pdf', cause));
|
|
} finally {
|
|
setBusy('');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function exportCsv(): Promise<void> {
|
|
await exportGate.current.run(async () => {
|
|
setBusy('csv');
|
|
setError('');
|
|
try {
|
|
const blob = new Blob([exportProjectDossierCsv(draft)], {
|
|
type: 'text/csv;charset=utf-8',
|
|
});
|
|
download(
|
|
blob,
|
|
`dossier-projecto-investmoz-${filenameStem(String(draft.project_title ?? ''))}.csv`,
|
|
);
|
|
} catch (cause) {
|
|
setError(dossierExportFailure('csv', cause));
|
|
} finally {
|
|
setBusy('');
|
|
}
|
|
});
|
|
}
|
|
|
|
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"
|
|
disabled={Boolean(busy)}
|
|
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
|
|
disabled={Boolean(busy)}
|
|
accept="image/jpeg,image/png,text/csv,text/plain,.csv,.tsv,.txt,.pdf,.doc,.docx,.xlsx,.odt"
|
|
onChange={(event) => {
|
|
void 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"
|
|
disabled={Boolean(busy)}
|
|
checked={item.include}
|
|
onChange={(event) =>
|
|
updateAttachment(index, {
|
|
include: event.target.checked,
|
|
})
|
|
}
|
|
/>
|
|
<span className="min-w-0 text-sm break-words">
|
|
<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"
|
|
disabled={Boolean(busy)}
|
|
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}
|
|
disabled={Boolean(busy)}
|
|
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>
|
|
{notice && (
|
|
<p role="status" className="text-sm">
|
|
{notice}
|
|
</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={() => void 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>
|
|
);
|
|
}
|