Merge branch 'main' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import DemographicEvidence, {
|
||||
type DemographicAnalysis,
|
||||
} from './demographic-evidence';
|
||||
|
||||
export default function DemographicContext({
|
||||
territoryId,
|
||||
}: {
|
||||
territoryId?: string | null;
|
||||
}) {
|
||||
const [evidence, setEvidence] = useState<DemographicAnalysis | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!territoryId) return null;
|
||||
return (
|
||||
<details className="rounded border p-4">
|
||||
<summary>Demografia na análise de viabilidade</summary>
|
||||
<p>
|
||||
Consulte a população e a estrutura etária da localização
|
||||
escolhida, com fontes, anos e limitações.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="my-2 underline"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/investment/demography?territories[]=${encodeURIComponent(territoryId)}`,
|
||||
{ headers: { Accept: 'application/json' } },
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error('Consulta indisponível.');
|
||||
setEvidence(await response.json());
|
||||
} catch {
|
||||
setMessage(
|
||||
'Não foi possível consultar a evidência demográfica.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Consultar indicadores do ano actual
|
||||
</button>
|
||||
{message && <p role="alert">{message}</p>}
|
||||
{evidence && <DemographicEvidence evidence={evidence} />}
|
||||
<a
|
||||
className="block underline"
|
||||
href={`/localizacoes/demografia?territories[]=${encodeURIComponent(territoryId)}`}
|
||||
>
|
||||
Escolher período, sector e comparar localizações
|
||||
</a>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
export type DemographicAnalysis = {
|
||||
state: string;
|
||||
start_year: number;
|
||||
end_year: number;
|
||||
limitations: string[];
|
||||
comparisons: {
|
||||
year: number;
|
||||
left: string;
|
||||
right: string;
|
||||
left_value: string;
|
||||
right_value: string;
|
||||
left_import: string;
|
||||
right_import: string;
|
||||
difference: string | null;
|
||||
explanation: string;
|
||||
}[];
|
||||
differences: {
|
||||
territory_id: string;
|
||||
dimension: string;
|
||||
state: string;
|
||||
values: { import_id: string; value: string; data_kind: string }[];
|
||||
}[];
|
||||
territories: {
|
||||
id: string;
|
||||
name: string;
|
||||
level: string;
|
||||
factors_to_assess: string[];
|
||||
series: {
|
||||
import_id: string;
|
||||
source: string;
|
||||
version: string;
|
||||
sha256: string;
|
||||
file_name: string;
|
||||
missing_years: number[];
|
||||
issue_counts: Record<string, number>;
|
||||
values: {
|
||||
year: number;
|
||||
indicator: string;
|
||||
age_group: string;
|
||||
residence: string;
|
||||
value: string;
|
||||
original_value: string;
|
||||
unit: string;
|
||||
data_kind: string;
|
||||
sheet: string;
|
||||
cell: string;
|
||||
validation_status: string;
|
||||
}[];
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
const indicators: Record<string, string> = {
|
||||
population: 'População',
|
||||
life_expectancy: 'Esperança de vida',
|
||||
infant_mortality: 'Mortalidade infantil',
|
||||
crude_death_rate: 'Mortalidade bruta',
|
||||
crude_birth_rate: 'Natalidade bruta',
|
||||
fertility_rate: 'Fecundidade',
|
||||
population_growth_rate: 'Crescimento populacional',
|
||||
};
|
||||
export default function DemographicEvidence({
|
||||
evidence,
|
||||
}: {
|
||||
evidence: DemographicAnalysis;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4" aria-label="Evidência demográfica">
|
||||
{evidence.state !== 'available' && (
|
||||
<p>Sem séries demográficas aprovadas para esta selecção.</p>
|
||||
)}
|
||||
{evidence.territories.map((t) => (
|
||||
<article
|
||||
key={t.id}
|
||||
className="space-y-3 rounded border bg-white p-4 text-slate-900"
|
||||
>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t.name} —{' '}
|
||||
{t.level === 'district' ? 'Distrito' : 'Província'}
|
||||
</h2>
|
||||
{t.factors_to_assess.map((f) => (
|
||||
<p key={f}>{f}</p>
|
||||
))}
|
||||
{t.series.length === 0 && (
|
||||
<p>Sem dados no período escolhido.</p>
|
||||
)}
|
||||
{t.series.map((s) => (
|
||||
<details key={s.import_id} open={t.series.length === 1}>
|
||||
<summary>
|
||||
{s.source} · {s.version} · {s.file_name}
|
||||
</summary>
|
||||
<p className="text-xs break-all">
|
||||
SHA256: {s.sha256}
|
||||
</p>
|
||||
{s.missing_years.length > 0 && (
|
||||
<p>
|
||||
Anos sem dados: {s.missing_years.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{Object.keys(s.issue_counts).length > 0 && (
|
||||
<details>
|
||||
<summary>Advertências da série</summary>
|
||||
<pre className="overflow-auto text-xs">
|
||||
{JSON.stringify(
|
||||
s.issue_counts,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<caption className="text-left">
|
||||
Valores originais por ano e dimensão —
|
||||
versões apresentadas separadamente
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
'Ano',
|
||||
'Indicador',
|
||||
'Idade',
|
||||
'Residência',
|
||||
'Valor',
|
||||
'Unidade',
|
||||
'Tipo',
|
||||
'Referência',
|
||||
].map((h) => (
|
||||
<th className="p-2" key={h}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{s.values.map((v) => (
|
||||
<tr
|
||||
key={`${v.sheet}:${v.cell}`}
|
||||
className="border-t"
|
||||
>
|
||||
<td className="p-2">
|
||||
{v.year}
|
||||
</td>
|
||||
<td>
|
||||
{indicators[v.indicator] ??
|
||||
v.indicator}
|
||||
</td>
|
||||
<td>
|
||||
{v.age_group === 'all'
|
||||
? 'Todas'
|
||||
: v.age_group}
|
||||
</td>
|
||||
<td>
|
||||
{
|
||||
{
|
||||
all: 'Total',
|
||||
urban: 'Urbana',
|
||||
rural: 'Rural',
|
||||
}[v.residence]
|
||||
}
|
||||
</td>
|
||||
<td>{v.original_value}</td>
|
||||
<td>
|
||||
{v.unit === 'unspecified'
|
||||
? 'Não indicada na fonte'
|
||||
: v.unit}
|
||||
</td>
|
||||
<td>
|
||||
{v.data_kind === 'projected'
|
||||
? 'Projecção'
|
||||
: 'Não especificado'}
|
||||
</td>
|
||||
<td>
|
||||
{v.sheet}!{v.cell}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
{evidence.comparisons.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">
|
||||
Diferenças entre localizações
|
||||
</h2>
|
||||
{evidence.comparisons.map((c) => (
|
||||
<p
|
||||
key={`${c.year}:${c.left_import}:${c.right_import}:${c.left}:${c.right}`}
|
||||
>
|
||||
{c.year}: {c.left} ({c.left_value}) / {c.right} (
|
||||
{c.right_value}). Diferença:{' '}
|
||||
{c.difference ?? 'não comparável'}. {c.explanation}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{evidence.differences.length > 0 && (
|
||||
<details>
|
||||
<summary>
|
||||
Reconciliação entre exemplares:{' '}
|
||||
{evidence.differences.length} dimensões comuns
|
||||
</summary>
|
||||
<ul>
|
||||
{evidence.differences.map((d) => (
|
||||
<li key={`${d.territory_id}:${d.dimension}`}>
|
||||
{d.dimension}:{' '}
|
||||
{d.state === 'matching_values'
|
||||
? 'valores coincidentes; conservar ambas as fontes'
|
||||
: 'valores divergentes'}{' '}
|
||||
—{' '}
|
||||
{d.values
|
||||
.map((v) => `${v.value} (${v.data_kind})`)
|
||||
.join(' / ')}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<ul className="list-disc pl-5 text-sm">
|
||||
{evidence.limitations.map((l) => (
|
||||
<li key={l}>{l}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
createInvestorDossierReport,
|
||||
investorDossierReportIndex,
|
||||
type InvestorDossierReportInput,
|
||||
} from '@/lib/investor-dossier-report';
|
||||
import {
|
||||
createProjectDossierPrintHtml,
|
||||
generateProjectDossierDocx,
|
||||
} from '@/lib/project-dossier-report';
|
||||
|
||||
export function InvestorDossierReports({
|
||||
dossier,
|
||||
}: {
|
||||
dossier: InvestorDossierReportInput;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const requiresChoice =
|
||||
dossier.opportunity_options.length > 1 && !dossier.context_opportunity;
|
||||
const entries = requiresChoice ? [] : investorDossierReportIndex(dossier);
|
||||
async function exportReport(format: 'word' | 'html') {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const model = createInvestorDossierReport(dossier);
|
||||
const blob =
|
||||
format === 'word'
|
||||
? await generateProjectDossierDocx(model, [], true)
|
||||
: new Blob(
|
||||
[
|
||||
await createProjectDossierPrintHtml(
|
||||
model,
|
||||
[],
|
||||
true,
|
||||
),
|
||||
],
|
||||
{ type: 'text/html;charset=utf-8' },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `dossier-${dossier.case.id}-v${dossier.case.version}-${dossier.context_opportunity ? encodeURIComponent(dossier.context_opportunity) : 'comum'}.${format === 'word' ? 'docx' : 'html'}`;
|
||||
link.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: 'Não foi possível preparar o relatório. Os registos foram preservados.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section id="relatorios" className="space-y-4 rounded-xl border p-4">
|
||||
<h2 className="text-lg font-semibold">Relatórios deste dossier</h2>
|
||||
<p>
|
||||
Dossier {dossier.case.title} · versão {dossier.case.version} ·
|
||||
oportunidade {dossier.context_opportunity ?? 'contexto comum'}.
|
||||
</p>
|
||||
<p>
|
||||
Reúna as etapas, a viabilidade, a análise SWOT e a preparação
|
||||
APIEX. O relatório mantém os estados e as pendências; os
|
||||
originais continuam nas respectivas versões.
|
||||
</p>
|
||||
{requiresChoice ? (
|
||||
<p role="status">
|
||||
Seleccione a oportunidade em tratamento para reunir os seus
|
||||
relatórios sem misturar contextos.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
className="rounded border px-3 py-2"
|
||||
disabled={busy}
|
||||
onClick={() => exportReport('word')}
|
||||
>
|
||||
Descarregar relatório consolidado (Word)
|
||||
</button>
|
||||
<button
|
||||
className="rounded border px-3 py-2"
|
||||
disabled={busy}
|
||||
onClick={() => exportReport('html')}
|
||||
>
|
||||
Descarregar versão para impressão/PDF
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
A versão HTML pode ser aberta e impressa ou guardada
|
||||
como PDF. As descargas são privadas; não enviam
|
||||
processos ou mensagens a entidades externas.
|
||||
</p>
|
||||
{!!entries.length && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<caption className="sr-only">
|
||||
Índice por etapa, versão e data
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2">Etapa</th>
|
||||
<th className="p-2">
|
||||
Relatório ou documento
|
||||
</th>
|
||||
<th className="p-2">Versão e data</th>
|
||||
<th className="p-2">Estado</th>
|
||||
<th className="p-2">Consultar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id} className="border-t">
|
||||
<td className="p-2">
|
||||
{entry.stage}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{entry.title}
|
||||
</td>
|
||||
<td className="max-w-56 p-2 break-words">
|
||||
{entry.version} ·{' '}
|
||||
{entry.date ||
|
||||
'Data não registada'}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{entry.state}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<a
|
||||
className="underline"
|
||||
href={entry.href}
|
||||
>
|
||||
{entry.action}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!entries.length && (
|
||||
<p>
|
||||
Nenhum relatório ou documento produzido e autorizado
|
||||
neste âmbito. O relatório consolidado identifica as
|
||||
etapas e as pendências existentes.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
Os relatórios financeiros são apresentados apenas ao
|
||||
titular autorizado do rascunho. Consulte os documentos
|
||||
de cada acto antes de executar o investimento.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SimulationIrpcAnnual } from '@/components/simulation-irpc-annual';
|
||||
import DemographicContext from '@/components/demographic-context';
|
||||
import { SimulationIrpcNormal } from '@/components/simulation-irpc-normal';
|
||||
import { SimulationIrpcWithholdings } from '@/components/simulation-irpc-withholdings';
|
||||
import { SimulationIspcResiduals } from '@/components/simulation-ispc-residuals';
|
||||
@@ -3077,6 +3078,10 @@ export function SimulationForm() {
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
<DemographicContext
|
||||
key={String(draft.district_id ?? draft.province_id ?? '')}
|
||||
territoryId={String(draft.district_id || draft.province_id || '')}
|
||||
/>
|
||||
<progress
|
||||
className="h-2 w-full accent-emerald-800"
|
||||
aria-label="Progresso do formulário"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import DemographicContext from './demographic-context';
|
||||
import TerritorialSources, {
|
||||
type TerritorialSourceEvidence,
|
||||
} from './territorial-source-evidence';
|
||||
@@ -95,6 +96,12 @@ export function LocationList({
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<DemographicContext
|
||||
key={location.id}
|
||||
territoryId={location.path
|
||||
.filter((t) => ['province', 'district'].includes(t.location_type))
|
||||
.at(-1)?.id}
|
||||
/>
|
||||
{location.coordinates.map((point) => (
|
||||
<p key={point.geometry_id}>
|
||||
Coordenadas documentadas (
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
import { pathStates, type PathResult } from './investor-path.ts';
|
||||
import { restoreFinancialToolsSnapshot } from './financial-calculators.ts';
|
||||
import { buildProjectDossierRows } from './project-dossier.ts';
|
||||
import type { SwotDraft } from './investment-preparation.ts';
|
||||
import type {
|
||||
DossierReportModel,
|
||||
DossierReportRow,
|
||||
} from './project-dossier-report.ts';
|
||||
|
||||
export type PreparationReport = {
|
||||
case_id: string;
|
||||
opportunity: string | null;
|
||||
version: number;
|
||||
context_revision: number;
|
||||
href: string;
|
||||
api_state: string;
|
||||
api_issues: string[];
|
||||
exports: {
|
||||
id: string;
|
||||
at: string;
|
||||
context_revision: number;
|
||||
fingerprint: string;
|
||||
historical: boolean;
|
||||
href: string;
|
||||
}[];
|
||||
external: {
|
||||
kind: string;
|
||||
date: string;
|
||||
reference: string;
|
||||
export_id: string;
|
||||
outcome?: string;
|
||||
}[];
|
||||
swot: SwotDraft;
|
||||
swot_review_required: boolean;
|
||||
};
|
||||
export type InvestorDossierReportInput = {
|
||||
case: { id: string; title: string; version: number };
|
||||
context_opportunity: string | null;
|
||||
opportunity_options: unknown[];
|
||||
investment_context: {
|
||||
revision: number;
|
||||
fields?: Record<string, string>;
|
||||
} | null;
|
||||
phases: Record<string, string>;
|
||||
steps: {
|
||||
code: string;
|
||||
title: string;
|
||||
phase: number;
|
||||
version?: number;
|
||||
state: string;
|
||||
reason: string;
|
||||
detail: {
|
||||
scope?: string;
|
||||
document_id?: string;
|
||||
authority?: string;
|
||||
reference?: string;
|
||||
decision_date?: string;
|
||||
recorded_at?: string;
|
||||
};
|
||||
}[];
|
||||
results: PathResult[];
|
||||
documents: {
|
||||
id: string;
|
||||
document_id?: string;
|
||||
file_name: string;
|
||||
status: string;
|
||||
malware_scan_status: string;
|
||||
version_no: number;
|
||||
sha256: string;
|
||||
}[];
|
||||
preparation_report?: PreparationReport | null;
|
||||
fiscal_reports?: {
|
||||
simulation_id: string;
|
||||
report_id: string;
|
||||
report_version_no?: number;
|
||||
report_sha256?: string;
|
||||
consented_at?: string;
|
||||
scope_state?: 'identified' | 'common' | 'unconfirmed';
|
||||
}[];
|
||||
land_report?: {
|
||||
id: string;
|
||||
version: number;
|
||||
updated_at: string | null;
|
||||
href: string;
|
||||
} | null;
|
||||
company_reports?: {
|
||||
comparison?: { revision: number; at: string };
|
||||
preparation?: { revision: number; at: string };
|
||||
guides?: Record<
|
||||
string,
|
||||
{ revision: number; at: string; slug: string; title: string }
|
||||
>;
|
||||
};
|
||||
};
|
||||
export type DossierReportIndexEntry = {
|
||||
id: string;
|
||||
stage: string;
|
||||
title: string;
|
||||
version: string;
|
||||
date: string;
|
||||
state: string;
|
||||
href: string;
|
||||
action: string;
|
||||
};
|
||||
const externalStates: Record<string, string> = {
|
||||
preparing: 'Em preparação',
|
||||
ready: 'Pronto para entrega',
|
||||
delivery_recorded: 'Entrega externa registada',
|
||||
decision_received: 'Decisão recebida',
|
||||
};
|
||||
const externalRecords: Record<string, string> = {
|
||||
delivery: 'Entrega externa',
|
||||
clarification: 'Pedido de esclarecimento',
|
||||
response: 'Resposta recebida',
|
||||
decision: 'Decisão recebida',
|
||||
};
|
||||
const decisions: Record<string, string> = {
|
||||
approved: 'Favorável',
|
||||
refused: 'Desfavorável',
|
||||
conditional: 'Condicionada',
|
||||
other: 'Outra decisão',
|
||||
};
|
||||
const dimensions = {
|
||||
strength: 'Forças',
|
||||
weakness: 'Fraquezas',
|
||||
opportunity: 'Oportunidades',
|
||||
threat: 'Ameaças',
|
||||
};
|
||||
const classifications = {
|
||||
documented_fact: 'Facto documentado',
|
||||
hypothesis: 'Hipótese',
|
||||
unconfirmed: 'Por confirmar',
|
||||
};
|
||||
|
||||
function checkScope(input: InvestorDossierReportInput): void {
|
||||
if (input.opportunity_options.length > 1 && !input.context_opportunity)
|
||||
throw new Error(
|
||||
'Seleccione a oportunidade antes de reunir os relatórios.',
|
||||
);
|
||||
const preparation = input.preparation_report;
|
||||
if (
|
||||
preparation &&
|
||||
(preparation.case_id !== input.case.id ||
|
||||
preparation.opportunity !== input.context_opportunity)
|
||||
)
|
||||
throw new Error(
|
||||
'A preparação pertence a outro dossier ou oportunidade.',
|
||||
);
|
||||
}
|
||||
|
||||
function documentsInDossier(input: InvestorDossierReportInput) {
|
||||
const seen = new Set<string>();
|
||||
return input.documents.filter((document) => {
|
||||
const key = `${document.document_id ?? document.id}:${document.version_no}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function investorDossierReportIndex(
|
||||
input: InvestorDossierReportInput,
|
||||
): DossierReportIndexEntry[] {
|
||||
checkScope(input);
|
||||
const base = `/investimentos/${encodeURIComponent(input.case.id)}`;
|
||||
const query = input.context_opportunity
|
||||
? `?opportunity=${encodeURIComponent(input.context_opportunity)}`
|
||||
: '';
|
||||
const entries: DossierReportIndexEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (entry: DossierReportIndexEntry) => {
|
||||
if (!seen.has(entry.id)) {
|
||||
seen.add(entry.id);
|
||||
entries.push(entry);
|
||||
}
|
||||
};
|
||||
for (const result of input.results) {
|
||||
if ((result.context_opportunity ?? null) !== input.context_opportunity)
|
||||
continue;
|
||||
add({
|
||||
id: `finance:${result.id}`,
|
||||
stage: 'S03',
|
||||
title: 'Resultado de viabilidade financeira',
|
||||
version: String(result.draft_version),
|
||||
date: result.recorded_at,
|
||||
state:
|
||||
result.context_state === 'review_required' ||
|
||||
result.context_revision !== input.investment_context?.revision
|
||||
? 'Histórico — requer revisão'
|
||||
: 'Resultado guardado',
|
||||
href: `${base}${query}#resultados-financeiros`,
|
||||
action: 'Consultar resultado',
|
||||
});
|
||||
}
|
||||
for (const source of input.fiscal_reports ?? [])
|
||||
add({
|
||||
id: `fiscal:${source.report_id}`,
|
||||
stage: 'S03',
|
||||
title: 'Relatório de simulação fiscal',
|
||||
version: String(source.report_version_no ?? 'Por identificar'),
|
||||
date: source.consented_at ?? '',
|
||||
state:
|
||||
source.scope_state === 'identified'
|
||||
? 'Versão preservada — estimativa fiscal'
|
||||
: source.scope_state === 'common'
|
||||
? 'Versão preservada — contexto comum do dossier'
|
||||
: 'Versão preservada — âmbito por confirmar',
|
||||
href: `${base}/relatorios/fiscais/${encodeURIComponent(source.report_id)}${query}`,
|
||||
action: 'Descarregar versão preservada (JSON)',
|
||||
});
|
||||
for (const kind of ['comparison', 'preparation'] as const) {
|
||||
const saved = input.company_reports?.[kind];
|
||||
if (saved)
|
||||
add({
|
||||
id: `company:${kind}`,
|
||||
stage: 'S05',
|
||||
title:
|
||||
kind === 'comparison'
|
||||
? 'Comparação de formas jurídicas'
|
||||
: 'Preparação da empresa',
|
||||
version: String(saved.revision),
|
||||
date: saved.at,
|
||||
state: 'Preparação guardada',
|
||||
href: `${base}/${kind === 'comparison' ? 'forma-juridica' : 'preparar-empresa'}`,
|
||||
action: 'Consultar preparação',
|
||||
});
|
||||
}
|
||||
const preparation = input.preparation_report;
|
||||
if (input.land_report) {
|
||||
add({
|
||||
id: `land:${input.land_report.id}`,
|
||||
stage: 'S10',
|
||||
title: 'Preparação de acesso à terra',
|
||||
version: String(input.land_report.version),
|
||||
date: input.land_report.updated_at ?? '',
|
||||
state: 'Preparação guardada — não é título DUAT',
|
||||
href: input.land_report.href,
|
||||
action: 'Abrir versão para impressão',
|
||||
});
|
||||
}
|
||||
for (const exported of preparation?.exports ?? [])
|
||||
add({
|
||||
id: `apiex:${exported.id}`,
|
||||
stage: 'S06',
|
||||
title: 'Dossier documental APIEX',
|
||||
version: exported.id,
|
||||
date: exported.at,
|
||||
state: exported.historical
|
||||
? 'Exportação histórica preservada'
|
||||
: 'Exportação preparada',
|
||||
href: exported.href,
|
||||
action: 'Descarregar pacote',
|
||||
});
|
||||
if (preparation?.swot.rows.length)
|
||||
add({
|
||||
id: 'swot',
|
||||
stage: 'S02',
|
||||
title: 'Análise SWOT',
|
||||
version: String(preparation.version),
|
||||
date: preparation.swot.review?.at ?? '',
|
||||
state: preparation.swot_review_required
|
||||
? 'Revisão necessária'
|
||||
: 'Revista pela equipa',
|
||||
href: preparation.href,
|
||||
action: 'Consultar análise',
|
||||
});
|
||||
for (const guide of Object.values(input.company_reports?.guides ?? {}))
|
||||
add({
|
||||
id: `guide:${guide.slug}`,
|
||||
stage: 'S06',
|
||||
title: guide.title,
|
||||
version: String(guide.revision),
|
||||
date: guide.at,
|
||||
state: 'Referência guardada para o dossier',
|
||||
href: `${base}/guias/${encodeURIComponent(guide.slug)}`,
|
||||
action: 'Consultar guia',
|
||||
});
|
||||
for (const document of documentsInDossier(input)) {
|
||||
const stages = input.steps.filter(
|
||||
(step) => step.detail.document_id === document.document_id,
|
||||
);
|
||||
add({
|
||||
id: `document:${document.id}`,
|
||||
stage:
|
||||
stages.map((step) => step.code).join(', ') || 'Anexos comuns',
|
||||
title: document.file_name,
|
||||
version: String(document.version_no),
|
||||
date: '',
|
||||
state:
|
||||
document.malware_scan_status === 'clean' &&
|
||||
document.status === 'active'
|
||||
? 'Documento associado'
|
||||
: 'Disponibilidade por conferir',
|
||||
href: `/balcao-virtual/dossiers/${encodeURIComponent(input.case.id)}/documentos/${encodeURIComponent(document.id)}`,
|
||||
action: 'Consultar documento',
|
||||
});
|
||||
}
|
||||
for (const step of input.steps) {
|
||||
if (
|
||||
entries.some((entry) => entry.stage.split(', ').includes(step.code))
|
||||
)
|
||||
continue;
|
||||
add({
|
||||
id: `stage:${step.code}`,
|
||||
stage: step.code,
|
||||
title: step.title,
|
||||
version: String(step.version ?? 'Não registada'),
|
||||
date: step.detail.recorded_at ?? '',
|
||||
state:
|
||||
step.state === 'dispensed'
|
||||
? 'Preparação dispensada — consultar fundamento'
|
||||
: 'Sem relatório ou documento associado',
|
||||
href: `${base}${query}#${step.code}`,
|
||||
action: 'Consultar etapa',
|
||||
});
|
||||
}
|
||||
return entries.sort((a, b) => a.stage.localeCompare(b.stage));
|
||||
}
|
||||
|
||||
export function createInvestorDossierReport(
|
||||
input: InvestorDossierReportInput,
|
||||
generatedAt = new Date().toISOString(),
|
||||
): DossierReportModel {
|
||||
const index = investorDossierReportIndex(input);
|
||||
const row = (
|
||||
field: string,
|
||||
value: string,
|
||||
origin = 'Registo guardado neste dossier',
|
||||
classification = 'Preparação do investimento',
|
||||
): DossierReportRow => ({ field, value, origin, classification });
|
||||
const finance: DossierReportRow[] = [];
|
||||
for (const result of input.results.filter(
|
||||
(item) =>
|
||||
(item.context_opportunity ?? null) === input.context_opportunity,
|
||||
)) {
|
||||
const reference = `Resultado ${result.id}; rascunho ${result.draft_id}, versão ${result.draft_version}; ${result.recorded_at}`;
|
||||
const state =
|
||||
result.context_revision === input.investment_context?.revision &&
|
||||
result.context_state !== 'review_required'
|
||||
? 'Estimativa guardada'
|
||||
: 'Histórico — rever antes de utilizar';
|
||||
try {
|
||||
if (
|
||||
result.engine !== 'financial-calculators:v1' ||
|
||||
!restoreFinancialToolsSnapshot(result.financial_snapshot)
|
||||
)
|
||||
throw new Error('Versão não recuperável');
|
||||
const currency = result.currency ?? '';
|
||||
const baseline = new Set(
|
||||
buildProjectDossierRows({ currency }).map((item) =>
|
||||
JSON.stringify(item),
|
||||
),
|
||||
);
|
||||
const sections = new Set([
|
||||
'Plano financeiro',
|
||||
'Empréstimo educativo',
|
||||
'Ponto de equilíbrio',
|
||||
'Financiamento',
|
||||
'Orçamento',
|
||||
]);
|
||||
const rows = buildProjectDossierRows({
|
||||
currency,
|
||||
financial_tools_snapshot: result.financial_snapshot,
|
||||
});
|
||||
for (const item of rows.filter(
|
||||
(item) =>
|
||||
sections.has(item[0]) &&
|
||||
!baseline.has(JSON.stringify(item)),
|
||||
)) {
|
||||
finance.push(
|
||||
row(
|
||||
`${item[0]} — ${item[1]}`,
|
||||
item[2],
|
||||
`${reference}; ${item[3]}`,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
finance.push(
|
||||
row(
|
||||
'Resultado preservado',
|
||||
'Formato indisponível para cálculo; consulte a origem, sem substituir valores por zero.',
|
||||
reference,
|
||||
state,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const preparation = input.preparation_report;
|
||||
const swot = preparation?.swot;
|
||||
return {
|
||||
title: input.case.title,
|
||||
generatedAt,
|
||||
version: `Dossier ${input.case.id} · v${input.case.version} · contexto v${input.investment_context?.revision ?? 'não identificado'} · oportunidade ${input.context_opportunity ?? 'contexto comum'}`,
|
||||
references: [],
|
||||
limitations: [
|
||||
'Relatório privado de preparação; não comprova licenciamento, aprovação ou financiamento.',
|
||||
'Os actos externos só valem pelo respectivo documento e entidade. Pendências e versões históricas permanecem identificadas.',
|
||||
'O índice referencia os originais; não incorpora automaticamente os seus anexos nem envia dados a entidades externas.',
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
id: 'project',
|
||||
title: 'Investimento e localização em preparação',
|
||||
private: true,
|
||||
paragraphs: [
|
||||
'Dados guardados no contexto seleccionado; não representam aprovação ou validação externa.',
|
||||
],
|
||||
rows: Object.entries({
|
||||
project_title: 'Projecto',
|
||||
project_summary: 'Descrição',
|
||||
sector: 'Sector',
|
||||
province: 'Província',
|
||||
district: 'Distrito',
|
||||
location: 'Localização',
|
||||
total_investment: 'Investimento previsto',
|
||||
currency: 'Moeda',
|
||||
expected_start_date: 'Início previsto',
|
||||
})
|
||||
.filter(([key]) =>
|
||||
input.investment_context?.fields?.[key]?.trim(),
|
||||
)
|
||||
.map(([key, label]) =>
|
||||
row(
|
||||
label,
|
||||
input.investment_context?.fields?.[key] ?? '',
|
||||
`Contexto v${input.investment_context?.revision ?? 'não identificado'}`,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
title: 'Identificação e índice dos relatórios',
|
||||
private: true,
|
||||
paragraphs: [
|
||||
`Dossier: ${input.case.id}`,
|
||||
`Oportunidade: ${input.context_opportunity ?? 'Contexto comum do dossier'}`,
|
||||
`Gerado em ${generatedAt}; versão do dossier ${input.case.version}.`,
|
||||
],
|
||||
rows: index.map((entry) =>
|
||||
row(
|
||||
`${entry.stage} — ${entry.title}`,
|
||||
`${entry.state}; versão ${entry.version}; ${entry.date || 'data não registada'}`,
|
||||
entry.href,
|
||||
entry.action,
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'execution',
|
||||
title: 'Etapas, resultados e pendências',
|
||||
private: true,
|
||||
paragraphs: [
|
||||
'A situação de uma etapa não significa que o respectivo relatório ou acto externo tenha sido produzido.',
|
||||
],
|
||||
rows: input.steps.map((step) =>
|
||||
row(
|
||||
`${step.code} — ${step.title}`,
|
||||
`${pathStates[step.state] ?? step.state}; versão ${step.version ?? 'não registada'}. ${step.reason || 'Sem fundamento adicional registado.'} ${step.detail.authority ?? ''} ${step.detail.reference ?? ''} ${step.detail.decision_date ?? ''}`,
|
||||
`Fase ${step.phase}: ${input.phases[String(step.phase)] ?? ''}`,
|
||||
step.state === 'not_started'
|
||||
? 'Pendente'
|
||||
: 'Estado guardado da etapa',
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
title: 'Viabilidade e relatórios fiscais',
|
||||
private: true,
|
||||
paragraphs: finance.length
|
||||
? [
|
||||
'Resultados recuperados das entradas guardadas pelo motor existente; impostos destinam-se à simulação de viabilidade.',
|
||||
]
|
||||
: [
|
||||
'Nenhum resultado financeiro devolvido e autorizado neste âmbito. Não foi gerado um valor em falta.',
|
||||
],
|
||||
rows: [
|
||||
...finance,
|
||||
...(input.fiscal_reports ?? []).map((source) =>
|
||||
row(
|
||||
`Relatório fiscal ${source.report_id}`,
|
||||
`Versão ${source.report_version_no ?? 'não indicada'}; SHA256 ${source.report_sha256 ?? 'não indicado'}`,
|
||||
`/investimentos/${input.case.id}/relatorios/fiscais/${source.report_id}${input.context_opportunity ? `?opportunity=${encodeURIComponent(input.context_opportunity)}` : ''}`,
|
||||
source.scope_state === 'identified'
|
||||
? 'Versão fiscal preservada'
|
||||
: source.scope_state === 'common'
|
||||
? 'Versão preservada no contexto comum do dossier'
|
||||
: 'Origem preservada no dossier; âmbito por confirmar',
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'market',
|
||||
title: 'Análise SWOT',
|
||||
private: true,
|
||||
paragraphs: swot?.rows.length
|
||||
? [
|
||||
`Objecto: ${swot.object_key}; âmbito: ${swot.scope ?? 'não indicado'}. ${preparation?.swot_review_required ? 'Revisão necessária.' : 'Revisão guardada.'}`,
|
||||
]
|
||||
: ['Análise SWOT ainda não produzida neste âmbito.'],
|
||||
rows: (swot?.rows ?? []).map((item) =>
|
||||
row(
|
||||
`${dimensions[item.dimension]} — ${item.text}`,
|
||||
`${classifications[item.classification]}; data ${item.date ?? 'por confirmar'}`,
|
||||
item.reference || 'Fonte por confirmar',
|
||||
classifications[item.classification],
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sources',
|
||||
title: 'Preparação documental APIEX e actos externos',
|
||||
private: true,
|
||||
paragraphs: preparation
|
||||
? [
|
||||
`Estado: ${externalStates[preparation.api_state] ?? preparation.api_state}. Exportar não equivale a entregar; entrega não equivale a aprovação.`,
|
||||
...preparation.api_issues,
|
||||
]
|
||||
: ['Preparação APIEX ainda não disponível neste âmbito.'],
|
||||
rows: (preparation?.external ?? []).map((item) =>
|
||||
row(
|
||||
`${externalRecords[item.kind] ?? item.kind} — ${item.date}`,
|
||||
`${item.reference}; ${item.outcome ? (decisions[item.outcome] ?? item.outcome) : 'sem decisão registada'}`,
|
||||
`Exportação ${item.export_id}`,
|
||||
'Registo de entrega ou resposta externa',
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
title: 'Documentos associados ao dossier',
|
||||
private: true,
|
||||
paragraphs: [
|
||||
'Consulte os originais no dossier. Os controlos de acesso e disponibilidade mantêm-se em cada descarga.',
|
||||
'Documentos comuns não comprovam a sua aplicabilidade a todas as oportunidades; confira a associação à etapa e ao pedido.',
|
||||
],
|
||||
rows: documentsInDossier(input).map((document) =>
|
||||
row(
|
||||
document.file_name,
|
||||
`Versão ${document.version_no}; SHA256 ${document.sha256}`,
|
||||
`/balcao-virtual/dossiers/${input.case.id}/documentos/${document.id}`,
|
||||
document.status === 'active' &&
|
||||
document.malware_scan_status === 'clean'
|
||||
? 'Documento associado'
|
||||
: 'Disponibilidade por conferir',
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
persistOpportunitySelection,
|
||||
} from '@/lib/opportunity-selection';
|
||||
import CatalogueGuideLinks from '@/components/catalogue-guide-links';
|
||||
import DemographicContext from '@/components/demographic-context';
|
||||
import OpportunityCorridorGroups, {
|
||||
type CorridorGroup,
|
||||
} from '@/components/opportunity-corridor-groups';
|
||||
@@ -22,7 +23,7 @@ import type { QuantitativeMapContext } from '@/components/investment-map-indicat
|
||||
import type { InvestmentMapArea } from '@/components/interactive-investment-map';
|
||||
import { type TerritoryOption } from '@/components/investment-territory';
|
||||
import { Frame, button, card, money, api } from './shared';
|
||||
import type { Opportunity, References } from './shared';
|
||||
import type { Opportunity, References, Context } from './shared';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Bookmark,
|
||||
@@ -171,7 +172,9 @@ export default function Catalogue({
|
||||
const [territoryPath, setTerritoryPath] = useState(territorySelection);
|
||||
const [saved, setSaved] = useState(favourites);
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const [comparison, setComparison] = useState<{ record: Opportunity }[]>([]);
|
||||
const [comparison, setComparison] = useState<
|
||||
{ record: Opportunity; context?: Context }[]
|
||||
>([]);
|
||||
const comparisonRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (view === 'corridors' && comparison.length)
|
||||
@@ -406,6 +409,21 @@ export default function Catalogue({
|
||||
: 'Catálogo de investimento'
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className="underline"
|
||||
href={
|
||||
'/localizacoes/demografia' +
|
||||
(territoryPath
|
||||
.filter((t) => ['province', 'district'].includes(t.location_type))
|
||||
.at(-1)?.id
|
||||
? '?territories[]=' + territoryPath
|
||||
.filter((t) => ['province', 'district'].includes(t.location_type))
|
||||
.at(-1)?.id
|
||||
: '')
|
||||
}
|
||||
>
|
||||
Comparar demografia e evolução das localizações
|
||||
</Link>
|
||||
{!!territoryPath.length && (
|
||||
<Link
|
||||
className={button}
|
||||
@@ -1074,6 +1092,19 @@ export default function Catalogue({
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<th className="border p-3">Demografia INE</th>
|
||||
{comparison.map(({ record: r, context: c }) => (
|
||||
<td className="border p-3" key={r.id}>
|
||||
<DemographicContext
|
||||
key={r.id}
|
||||
territoryId={c?.primary_territory_path
|
||||
?.filter((t) => ['province', 'district'].includes(t.location_type))
|
||||
.at(-1)?.id}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import DemographicEvidence, {
|
||||
type DemographicAnalysis,
|
||||
} from '@/components/demographic-evidence';
|
||||
import { Frame, button } from './shared';
|
||||
|
||||
export default function Demography({
|
||||
evidence,
|
||||
filters,
|
||||
territories,
|
||||
sectors,
|
||||
}: {
|
||||
evidence: DemographicAnalysis;
|
||||
filters: {
|
||||
territories?: string[];
|
||||
start_year?: number;
|
||||
end_year?: number;
|
||||
sector?: string;
|
||||
};
|
||||
territories: {
|
||||
id: string;
|
||||
name: string;
|
||||
location_type: string;
|
||||
parent_id: string | null;
|
||||
}[];
|
||||
sectors: Record<string, string>;
|
||||
}) {
|
||||
const [selected, setSelected] = useState(filters.territories ?? []);
|
||||
const [start, setStart] = useState(
|
||||
filters.start_year ?? new Date().getFullYear(),
|
||||
);
|
||||
const [end, setEnd] = useState(filters.end_year ?? start);
|
||||
const [sector, setSector] = useState(filters.sector ?? 'general');
|
||||
return (
|
||||
<Frame title="Demografia e viabilidade territorial">
|
||||
<p>
|
||||
Compare até quatro localizações no mesmo sector e período. Use
|
||||
esta evidência em conjunto com as condições económicas, fiscais
|
||||
e territoriais do investimento.
|
||||
</p>
|
||||
<form
|
||||
className="grid gap-4 rounded border bg-white p-4 md:grid-cols-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
router.get(
|
||||
'/localizacoes/demografia',
|
||||
{
|
||||
territories: selected,
|
||||
start_year: start,
|
||||
end_year: end,
|
||||
sector,
|
||||
},
|
||||
{ preserveState: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Províncias ou distritos (até quatro)
|
||||
<select
|
||||
multiple
|
||||
className="block h-48 w-full rounded border p-2"
|
||||
value={selected}
|
||||
onChange={(e) =>
|
||||
setSelected(
|
||||
Array.from(
|
||||
e.target.selectedOptions,
|
||||
(o) => o.value,
|
||||
).slice(0, 4),
|
||||
)
|
||||
}
|
||||
>
|
||||
{territories.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} —{' '}
|
||||
{t.location_type === 'province'
|
||||
? 'Província'
|
||||
: `Distrito (${territories.find((p) => p.id === t.parent_id)?.name ?? ''})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
Sector
|
||||
<select
|
||||
className="block w-full rounded border p-2"
|
||||
value={sector}
|
||||
onChange={(e) => setSector(e.target.value)}
|
||||
>
|
||||
{Object.entries(sectors).map(([key, label]) => (
|
||||
<option value={key} key={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
Ano inicial
|
||||
<input
|
||||
className="block rounded border p-2"
|
||||
type="number"
|
||||
min="1900"
|
||||
max="2100"
|
||||
value={start}
|
||||
onChange={(e) => setStart(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
Ano final
|
||||
<input
|
||||
className="block rounded border p-2"
|
||||
type="number"
|
||||
min={start}
|
||||
max={Math.min(2100, start + 50)}
|
||||
value={end}
|
||||
onChange={(e) => setEnd(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<button className={button} disabled={selected.length === 0}>
|
||||
Consultar e comparar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<DemographicEvidence evidence={evidence} />
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ProactiveSummary } from '@/components/proactive-investment';
|
||||
import DemographicContext from '@/components/demographic-context';
|
||||
import EducationalGuides from '@/components/educational-guides';
|
||||
import InvestmentComplements, {
|
||||
type ComplementOption,
|
||||
@@ -183,6 +184,12 @@ export default function Detail({
|
||||
filters={{ opportunity_id: r.id }}
|
||||
territoryId={c.primary_territory_path?.at(-1)?.id}
|
||||
/>
|
||||
<DemographicContext
|
||||
key={c.primary_territory_path?.at(-1)?.id}
|
||||
territoryId={c.primary_territory_path
|
||||
?.filter((t) => ['province', 'district'].includes(t.location_type))
|
||||
.at(-1)?.id}
|
||||
/>
|
||||
{c.primary_territory_path &&
|
||||
c.primary_territory_path.length > 0 && (
|
||||
<p>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { SectorResourceSelection } from '@/components/sector-resource-reference';
|
||||
import { InvestorDossierReports } from '@/components/investor-dossier-reports';
|
||||
import type { InvestorDossierReportInput } from '@/lib/investor-dossier-report';
|
||||
import {
|
||||
financialCategories,
|
||||
financialTypes,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
} from '@/lib/investor-path';
|
||||
|
||||
type Step = {
|
||||
version: number;
|
||||
code: string;
|
||||
title: string;
|
||||
phase: number;
|
||||
@@ -45,6 +48,10 @@ type Step = {
|
||||
source_paragraph: number;
|
||||
};
|
||||
type Path = {
|
||||
preparation_report?: InvestorDossierReportInput['preparation_report'];
|
||||
company_reports?: InvestorDossierReportInput['company_reports'];
|
||||
fiscal_reports?: InvestorDossierReportInput['fiscal_reports'];
|
||||
land_report?: InvestorDossierReportInput['land_report'];
|
||||
sector_references?: SectorResourceSelection[];
|
||||
funding_selections?: {
|
||||
id: string;
|
||||
@@ -117,6 +124,8 @@ type Path = {
|
||||
file_name: string;
|
||||
status: string;
|
||||
malware_scan_status: string;
|
||||
version_no: number;
|
||||
sha256: string;
|
||||
}[];
|
||||
requests: {
|
||||
id: string;
|
||||
@@ -995,7 +1004,8 @@ export default function InvestmentPath(path: Path) {
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
<section className="space-y-3">
|
||||
<InvestorDossierReports dossier={path} />
|
||||
<section id="resultados-financeiros" className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">
|
||||
Resultados devolvidos ao dossier
|
||||
</h2>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { createOperationKey } from '@/lib/operation-key';
|
||||
import { Management, button, control } from './shared';
|
||||
|
||||
type Plan = {
|
||||
mode: 'initial' | 'remaining' | 'zambezia';
|
||||
pending_count: number;
|
||||
aliases: Record<string,string>;
|
||||
alias_sources: string[];
|
||||
missing_province_source: string | null;
|
||||
hash: string;
|
||||
ready: boolean;
|
||||
installed: boolean;
|
||||
consistent: boolean;
|
||||
backup_verified: boolean;
|
||||
mfa_confirmed: boolean;
|
||||
target: { database: string; port: number };
|
||||
migration: string;
|
||||
code_sha256: string;
|
||||
files: Record<string, { sha256: string; records: number; issues?: Record<string,number> }>;
|
||||
new_districts: string[];
|
||||
notice: string;
|
||||
};
|
||||
|
||||
export default function DemographyInstallation({ plan }: { plan: Plan }) {
|
||||
const [reason, setReason] = useState(
|
||||
'Disponibilização autorizada pela Tempo em 13/11/2026, preservando valores, advertências e histórico.',
|
||||
);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [operation] = useState(createOperationKey);
|
||||
async function apply() {
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const cookie =
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')
|
||||
.slice(1)
|
||||
.join('=') ?? '';
|
||||
const response = await fetch('/management/demography/install', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(cookie),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
hash: plan.hash,
|
||||
mode: plan.mode,
|
||||
confirm: confirmed,
|
||||
operation_key: operation,
|
||||
reason,
|
||||
}),
|
||||
});
|
||||
const body = await response.text();
|
||||
if (!body.trim() || !response.headers.get('content-type')?.includes('application/json')) {
|
||||
throw new Error(`O servidor não devolveu um recibo JSON (HTTP ${response.status}). Consulte o estado antes de repetir a operação.`);
|
||||
}
|
||||
const result = JSON.parse(body);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
result.message ??
|
||||
'Não foi possível disponibilizar os dados.',
|
||||
);
|
||||
router.visit('/management/demography');
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Falha de comunicação. Consulte o estado antes de repetir.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Management title="Disponibilizar dados demográficos INE">
|
||||
<p>
|
||||
Destino:{' '}
|
||||
<strong>
|
||||
{plan.target.database}:{plan.target.port}
|
||||
</strong>
|
||||
. {plan.mode !== 'initial' ? 'Disponibiliza os ficheiros pendentes deste conjunto e acrescenta apenas os distritos autorizados, preservando todos os lotes já aprovados.' : 'Instala as tabelas demográficas e disponibiliza os três exemplares homologados.'} Mantém os dados existentes, as versões e os valores originais.
|
||||
</p>
|
||||
<dl className="grid gap-2 sm:grid-cols-2">
|
||||
<dt>Backup e restauro da entrega</dt>
|
||||
<dd>
|
||||
{plan.backup_verified
|
||||
? 'Conferidos'
|
||||
: 'Pendentes ou desactualizados'}
|
||||
</dd>
|
||||
<dt>Estrutura</dt>
|
||||
<dd>
|
||||
{plan.consistent
|
||||
? plan.installed
|
||||
? 'Instalada'
|
||||
: 'Preparada'
|
||||
: 'Divergente — reconciliar'}
|
||||
</dd>
|
||||
<dt>MFA da sessão</dt>
|
||||
<dd>
|
||||
{plan.mfa_confirmed
|
||||
? 'Confirmado'
|
||||
: 'Conclua o MFA da sua conta antes de executar'}
|
||||
</dd>
|
||||
</dl>
|
||||
<table className="w-full text-left">
|
||||
<caption className="text-left font-semibold">
|
||||
Pré-visualização aprovada para disponibilização
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Exemplar</th>
|
||||
<th>Observações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(plan.files).map(([name, file]) => (
|
||||
<tr key={name}>
|
||||
<td>{name}</td>
|
||||
<td>{file.records.toLocaleString('pt-MZ')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Distritos a acrescentar quando ausentes:{' '}
|
||||
{plan.new_districts.join(' e ')}. Não são criados limites
|
||||
geográficos, licenças ou direitos territoriais.
|
||||
</p>
|
||||
<p>{plan.notice}</p>
|
||||
{plan.mode !== 'initial' && <details><summary>Conferir advertências por ficheiro</summary><p>Nas células com fórmulas, são conservados apenas os valores numéricos guardados no original. O portal não executa nem recalcula fórmulas. Valores ausentes não se tornam zero.</p><ul>{Object.entries(plan.files).map(([name,file])=><li key={name}><strong>{name}</strong><ul>{Object.entries(file.issues ?? {}).map(([code,count])=><li key={code}>{({cached_formula_value:'Valores guardados em células com fórmula',missing_value:'Valores em falta',age_total_mismatch:'Divergências na soma de idades',sex_total_mismatch:'Divergências na soma por sexo',residence_total_mismatch:'Divergências entre urbano/rural',district_total_mismatch:'Divergências na soma de distritos'} as Record<string,string>)[code] ?? code}: {count.toLocaleString('pt-MZ')}</li>)}</ul></li>)}</ul></details>}
|
||||
{plan.mode !== 'initial' && <p>{plan.pending_count} ficheiros ainda por disponibilizar. {plan.missing_province_source && <>{plan.missing_province_source}: ficheiro provincial não fornecido; não se presume cobertura anual.</>}</p>}
|
||||
{Object.keys(plan.aliases).length > 0 && <details><summary>Conferir correspondências com o cadastro existente</summary><ul>{Object.entries(plan.aliases).map(([key,name])=><li key={key}>{key.split('|').slice(0,2).join(' — ')} → {name}</li>)}</ul><p>Designações originais preservadas; os identificadores do cadastro não são renomeados ou fundidos.</p><ul>{plan.alias_sources.map(url=><li key={url}><a href={url} target="_blank" rel="noopener noreferrer">Consultar fundamento da correspondência</a></li>)}</ul></details>}
|
||||
{plan.pending_count === 0 && <p role="status">Todos os ficheiros deste conjunto já estão aprovados. Não é necessário repetir a operação.</p>}
|
||||
<label className="block">
|
||||
Motivo
|
||||
<textarea
|
||||
className={control}
|
||||
value={reason}
|
||||
minLength={20}
|
||||
maxLength={1000}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
/>
|
||||
Confirmo o destino, os {Object.keys(plan.files).length} exemplares, as correspondências territoriais e a preservação das advertências apresentadas.
|
||||
</label>
|
||||
<button
|
||||
className={button}
|
||||
disabled={
|
||||
!plan.ready ||
|
||||
!plan.mfa_confirmed ||
|
||||
!confirmed ||
|
||||
reason.length < 20 ||
|
||||
busy
|
||||
}
|
||||
onClick={() => void apply()}
|
||||
>
|
||||
{busy ? 'A importar e conferir…' : plan.mode === 'zambezia' ? 'Disponibilizar Zambézia' : plan.mode !== 'initial' ? 'Disponibilizar restantes províncias' : 'Instalar e disponibilizar'}
|
||||
</button>
|
||||
{message && <p role="alert">{message}</p>}
|
||||
<p>
|
||||
<a className="underline" href="/localizacoes/demografia">
|
||||
Consultar os dados disponíveis
|
||||
</a>
|
||||
</p>
|
||||
</Management>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { Management, button, control } from './shared';
|
||||
|
||||
type Preview = {
|
||||
id: string;
|
||||
file_name: string;
|
||||
sha256: string;
|
||||
publication_version: string;
|
||||
status: string;
|
||||
report: {
|
||||
record_count: number;
|
||||
issue_counts: Record<string, number>;
|
||||
issues: { code: string; sheet: string; cell: string; delta?: string }[];
|
||||
};
|
||||
matches: {
|
||||
key: string;
|
||||
province: string;
|
||||
territory: string;
|
||||
level: string;
|
||||
geo_location_id: string | null;
|
||||
state: string;
|
||||
}[];
|
||||
territory_options: {
|
||||
id: string;
|
||||
name: string;
|
||||
location_type: string;
|
||||
parent_id: string | null;
|
||||
}[];
|
||||
events: {
|
||||
id: number;
|
||||
action: string;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
}[];
|
||||
};
|
||||
export default function DemographyManagement({
|
||||
batches,
|
||||
preview,
|
||||
qa = true,
|
||||
}: {
|
||||
qa?: boolean;
|
||||
batches: {
|
||||
id: string;
|
||||
file_name: string;
|
||||
publication_version: string;
|
||||
status: string;
|
||||
}[];
|
||||
preview: Preview | null;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [version, setVersion] = useState('');
|
||||
const [year, setYear] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function execute(path: string, body: FormData | object) {
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const token =
|
||||
document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('XSRF-TOKEN='))
|
||||
?.split('=')
|
||||
.slice(1)
|
||||
.join('=') ?? '';
|
||||
const response = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-XSRF-TOKEN': decodeURIComponent(token),
|
||||
...(body instanceof FormData
|
||||
? {}
|
||||
: { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
body: body instanceof FormData ? body : JSON.stringify(body),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
data.message ?? 'Não foi possível executar a operação.',
|
||||
);
|
||||
setMapping({});
|
||||
router.get('/management/demography', { batch: data.id });
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error ? error.message : 'Erro de comunicação.',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Management
|
||||
title={
|
||||
qa ? 'Dados demográficos INE — QA' : 'Dados demográficos INE'
|
||||
}
|
||||
>
|
||||
<p>
|
||||
Preparar não grava observações. Confirmar importa para consulta
|
||||
administrativa. Aprovar disponibiliza a série nas análises deste
|
||||
ambiente; desactivar conserva dados e histórico.
|
||||
</p>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
body.append('publication_version', version);
|
||||
if (year) body.append('baseline_year', year);
|
||||
void execute('/management/demography/prepare', body);
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
Excel original
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
required
|
||||
className={control}
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
Publicação / identificação do exemplar
|
||||
<input
|
||||
required
|
||||
maxLength={255}
|
||||
className={control}
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
Ano de referência, apenas para folha sem ano
|
||||
<input
|
||||
type="number"
|
||||
min="1900"
|
||||
max="2100"
|
||||
className={control}
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button className={button} disabled={busy}>
|
||||
Preparar e verificar
|
||||
</button>
|
||||
</form>
|
||||
{message && <p role="alert">{message}</p>}
|
||||
<h2 className="text-xl font-semibold">Importações anteriores</h2>
|
||||
<ul>
|
||||
{batches.map((b) => (
|
||||
<li key={b.id}>
|
||||
<a
|
||||
className="underline"
|
||||
href={`/management/demography?batch=${b.id}`}
|
||||
>
|
||||
{b.file_name} · {b.publication_version} · {b.status}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{preview && (
|
||||
<section key={preview.id} className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{preview.file_name} — {preview.status}
|
||||
</h2>
|
||||
<p>
|
||||
{preview.report.record_count} observações extraídas.
|
||||
SHA256: {preview.sha256}
|
||||
</p>
|
||||
<details>
|
||||
<summary>Erros e advertências</summary>
|
||||
<pre>
|
||||
{JSON.stringify(
|
||||
preview.report.issue_counts,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
<div className="max-h-72 overflow-auto">
|
||||
<ul>
|
||||
{preview.report.issues.map((i, n) => (
|
||||
<li key={n}>
|
||||
{i.sheet}!{i.cell}: {i.code}{' '}
|
||||
{i.delta ?? ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<h3 className="font-semibold">
|
||||
Correspondências territoriais
|
||||
</h3>
|
||||
{preview.matches.map((m) => (
|
||||
<label className="block" key={m.key}>
|
||||
{m.province} / {m.territory} ({m.level}) — {m.state}
|
||||
<select
|
||||
className={control}
|
||||
disabled={preview.status !== 'prepared'}
|
||||
value={
|
||||
mapping[m.key] ?? m.geo_location_id ?? ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
setMapping({
|
||||
...mapping,
|
||||
[m.key]: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Por resolver</option>
|
||||
{preview.territory_options
|
||||
.filter((t) => t.location_type === m.level)
|
||||
.map((t) => (
|
||||
<option value={t.id} key={t.id}>
|
||||
{t.name} (
|
||||
{preview.territory_options.find(
|
||||
(p) => p.id === t.parent_id,
|
||||
)?.name ?? 'Província'}
|
||||
)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
<label className="block">
|
||||
Motivo e tratamento das advertências
|
||||
<textarea
|
||||
className={control}
|
||||
minLength={10}
|
||||
maxLength={2000}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{preview.status === 'prepared' ? (
|
||||
<button
|
||||
disabled={busy || reason.length < 10}
|
||||
className={button}
|
||||
onClick={() =>
|
||||
void execute(
|
||||
`/management/demography/${preview.id}/confirm`,
|
||||
{
|
||||
sha256: preview.sha256,
|
||||
mapping,
|
||||
reason,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Confirmar importação
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{preview.status !== 'approved' && (
|
||||
<button
|
||||
disabled={busy || reason.length < 10}
|
||||
className={button}
|
||||
onClick={() =>
|
||||
void execute(
|
||||
`/management/demography/${preview.id}/transition`,
|
||||
{ action: 'approved', reason },
|
||||
)
|
||||
}
|
||||
>
|
||||
{qa
|
||||
? 'Aprovar utilização em QA'
|
||||
: 'Aprovar utilização'}
|
||||
</button>
|
||||
)}
|
||||
{preview.status !== 'disabled' && (
|
||||
<button
|
||||
disabled={busy || reason.length < 10}
|
||||
className={button}
|
||||
onClick={() =>
|
||||
void execute(
|
||||
`/management/demography/${preview.id}/transition`,
|
||||
{ action: 'disabled', reason },
|
||||
)
|
||||
}
|
||||
>
|
||||
Desactivar série
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<details>
|
||||
<summary>Histórico</summary>
|
||||
<ul>
|
||||
{preview.events.map((e) => (
|
||||
<li key={e.id}>
|
||||
{e.created_at}: {e.action} — {e.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
)}
|
||||
</Management>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export function Management({
|
||||
Revisão da origem das oportunidades
|
||||
</Link>
|
||||
<Link href="/management/history">Histórico</Link>
|
||||
<Link href="/management/demography">Demografia INE</Link>
|
||||
<Link href="/management/crm/leads">CRM</Link>
|
||||
<Link href="/management/crm/investors">Investidores</Link>
|
||||
<Link href="/management/projects">Projectos</Link>
|
||||
|
||||
Reference in New Issue
Block a user