Files
Investidor-V2/resources/js/components/project-dossier-export.tsx
T
2026-10-21 08:43:12 +02:00

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>
);
}