Files
Investidor-V2/resources/js/components/investment-map-indicators.tsx
T
2026-11-13 22:54:49 +02:00

682 lines
30 KiB
TypeScript

import { useId, useState } from 'react';
export type QuantitativeMapContext = {
state: 'available' | 'unavailable' | 'not_included' | 'error';
records: {
id: string;
zone_id: string;
zone_name: string;
zone_version: number;
url: string;
theme: 'workforce' | 'economic_potential';
indicator: string;
label?: string | null;
description?: string | null;
value: string | null;
unit?: string | null;
period_start?: string | null;
period_end?: string | null;
scope?: string | null;
population?: string | null;
methodology?: string | null;
source_reference?: string | null;
source_date?: string | null;
knowledge: string;
measure_kind?: 'unknown' | 'observed' | 'planned' | 'projected';
missing_metadata: string[];
}[];
zones_consulted?: number;
zone_limit?: number;
record_limit?: number;
truncated: boolean;
};
const labels: Record<string, string> = {
employment: 'Emprego',
investment: 'Investimento',
production: 'Produção',
exports: 'Exportações',
local_procurement: 'Compras locais',
};
const metadataLabels: Record<string, string> = {
unit: 'unidade',
period_start: 'início do período',
period_end: 'fim do período',
scope: 'âmbito',
population: 'população/base',
methodology: 'metodologia',
source_reference: 'fonte',
source_date: 'data da fonte',
};
const text = (value?: string | null) => value?.trim() || 'Não indicado';
const measureKinds: Record<string, string> = {
unknown: 'Por confirmar',
observed: 'Observado / efectivo',
planned: 'Planeado / meta',
projected: 'Projectado / estimado',
};
const measureKind = (row: QuantitativeMapContext['records'][number]) =>
row.measure_kind &&
['observed', 'planned', 'projected'].includes(row.measure_kind)
? row.measure_kind
: 'unknown';
const completeDate = (value?: string | null) =>
Boolean(
value &&
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
!Number.isNaN(Date.parse(value)) &&
new Date(value).toISOString().slice(0, 10) === value,
);
type MapObservation = QuantitativeMapContext['records'][number];
// Calendar metadata only: inclusive source dates, never a rate or annualisation.
function periodDays(row: MapObservation): number | null {
if (
!completeDate(row.period_start) ||
!completeDate(row.period_end) ||
row.period_start! > row.period_end!
)
return null;
return (
(Date.parse(row.period_end!) - Date.parse(row.period_start!)) /
86_400_000 +
1
);
}
const observationKey = (row: MapObservation) =>
JSON.stringify([row.zone_id, row.id, row.zone_version]);
export function compareMapObservations(
first: MapObservation,
second: MapObservation,
) {
const issues: string[] = [];
if (observationKey(first) === observationKey(second))
issues.push('Escolha duas observações distintas.');
if (first.zone_id !== second.zone_id)
issues.push(
'Territórios distintos: os valores não representam uma série do mesmo local.',
);
for (const [label, row] of [
['Primeira', first],
['Segunda', second],
] as const) {
if (measureKind(row) === 'unknown')
issues.push(
`${label} observação: natureza do valor por confirmar na fonte.`,
);
if (row.knowledge !== 'documented')
issues.push(
`${label} observação: informação desconhecida ou em divergência.`,
);
if (
row.value === null ||
!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(row.value)
)
issues.push(
`${label} observação: valor quantitativo por confirmar.`,
);
const comparisonMetadata: Record<string, string> = {
...metadataLabels,
description: 'descrição/natureza da grandeza',
};
const missing = Object.keys(comparisonMetadata).filter(
(field) => !row[field as keyof MapObservation]?.toString().trim(),
);
if (missing.length)
issues.push(
`${label} observação sem ${missing.map((field) => comparisonMetadata[field]).join(', ')}.`,
);
if (
!completeDate(row.period_start) ||
!completeDate(row.period_end) ||
(row.period_start ?? '') > (row.period_end ?? '')
)
issues.push(`${label} observação: período incompleto ou inválido.`);
if (!completeDate(row.source_date))
issues.push(
`${label} observação: data da fonte incompleta ou inválida.`,
);
}
const firstDays = periodDays(first);
const secondDays = periodDays(second);
if (firstDays !== null && secondDays !== null) {
if (firstDays !== secondDays)
issues.push(
'Duração dos períodos diferente; não trate os valores como bases temporais equivalentes.',
);
if (
(first.period_start !== second.period_start ||
first.period_end !== second.period_end) &&
first.period_start! <= second.period_end! &&
second.period_start! <= first.period_end!
)
issues.push(
'Os períodos sobrepõem-se; as observações podem abranger os mesmos dias.',
);
}
if (measureKind(first) !== measureKind(second))
issues.push(
'Natureza do valor diferente: observado, planeado e projectado não são equivalentes.',
);
for (const [field, label] of Object.entries({
indicator: 'Indicador',
unit: 'Unidade',
scope: 'Âmbito',
population: 'População/base',
methodology: 'Metodologia',
})) {
if (
(first[field as keyof MapObservation] ?? '').toString().trim() !==
(second[field as keyof MapObservation] ?? '').toString().trim()
)
issues.push(`${label} diferente entre as observações.`);
}
return {
state: issues.length ? 'review_required' : 'metadata_aligned',
issues,
};
}
export function MapObservationComparison({
first,
second,
}: {
first?: MapObservation;
second?: MapObservation;
}) {
if (!first || !second)
return (
<p role="status">
Seleccione duas observações disponíveis nos filtros actuais.
</p>
);
const assessment = compareMapObservations(first, second);
const fields: [string, (row: MapObservation) => string][] = [
['Território', (row) => row.zone_name],
['Observação', (row) => text(row.label)],
['Descrição e natureza da grandeza', (row) => text(row.description)],
['Natureza do valor', (row) => measureKinds[measureKind(row)]],
['Indicador', (row) => labels[row.indicator] ?? row.indicator],
[
'Valor original',
(row) => (row.value === null ? 'Por confirmar' : row.value),
],
['Unidade', (row) => text(row.unit)],
[
'Período',
(row) => `${text(row.period_start)} — ${text(row.period_end)}`,
],
[
'Duração do período (dias, limites incluídos)',
(row) => {
const days = periodDays(row);
return days === null ? 'Por confirmar' : String(days);
},
],
['Âmbito', (row) => text(row.scope)],
['População/base', (row) => text(row.population)],
['Metodologia', (row) => text(row.methodology)],
[
'Situação da informação',
(row) =>
row.knowledge === 'documented'
? 'Documentada'
: row.knowledge === 'disputed'
? 'Em divergência'
: 'Por confirmar',
],
['Fonte', (row) => text(row.source_reference)],
['Data da fonte', (row) => text(row.source_date)],
['Versão da ficha territorial', (row) => String(row.zone_version)],
];
return (
<div className="space-y-3">
<p role="status">
{assessment.state === 'metadata_aligned'
? 'Metadados alinhados; confirme nas fontes se os valores podem ser comparados.'
: 'Há diferenças ou lacunas a conferir antes de comparar os valores.'}
</p>
{!!assessment.issues.length && (
<ul className="list-disc pl-5">
{assessment.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
<p>
Períodos distintos ou sobrepostos exigem conferência da duração
e da base na fonte. Esta apresentação não calcula evolução,
soma, conversão, pontuação ou estimativa regional. A natureza
declarada deve ser conferida com a descrição e a fonte.
</p>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<caption className="sr-only">
Conferência de duas observações quantitativas
</caption>
<thead>
<tr>
<th className="p-2">Campo</th>
<th className="p-2">Primeira observação</th>
<th className="p-2">Segunda observação</th>
</tr>
</thead>
<tbody>
{fields.map(([label, value]) => (
<tr key={label} className="border-t">
<th className="p-2" scope="row">
{label}
</th>
<td className="p-2 break-words">
{value(first)}
</td>
<td className="p-2 break-words">
{value(second)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function MapObservationComparisonPicker({ rows }: { rows: MapObservation[] }) {
const id = useId();
const [firstKey, setFirst] = useState('');
const [secondKey, setSecond] = useState('');
const first = rows.find((row) => observationKey(row) === firstKey);
const second = rows.find((row) => observationKey(row) === secondKey);
return (
<details className="space-y-3 rounded border p-3">
<summary>Comparar duas observações</summary>
<p>
Confronte os dados e as fontes antes de os usar na decisão.
Selecções fora dos filtros deixam de ser comparadas; uma versão
nova exige nova selecção.
</p>
<div className="grid gap-3 sm:grid-cols-2">
{(['Primeira', 'Segunda'] as const).map((label, index) => (
<label
className="grid gap-1"
key={label}
htmlFor={`${id}-${index}`}
>
{label} observação
<select
className="w-full min-w-0 rounded border p-2"
id={`${id}-${index}`}
value={
index === 0
? first
? firstKey
: ''
: second
? secondKey
: ''
}
onChange={(event) =>
(index === 0 ? setFirst : setSecond)(
event.target.value,
)
}
>
<option value="">Seleccionar observação</option>
{rows.map((row) => (
<option
key={observationKey(row)}
value={observationKey(row)}
>
{row.zone_name} —{' '}
{row.label ||
labels[row.indicator] ||
row.indicator}{' '}
— {row.period_start || '?'} /{' '}
{row.period_end || '?'} — v
{row.zone_version}
</option>
))}
</select>
</label>
))}
</div>
<MapObservationComparison first={first} second={second} />
</details>
);
}
export function filterMapIndicators(
records: QuantitativeMapContext['records'],
indicator: string,
start: string,
end: string,
) {
if (start && end && start > end) return [];
return records.filter((row) => {
if (indicator !== 'all' && row.indicator !== indicator) return false;
if (!start && !end) return true;
// Incomplete or inverted source periods cannot establish overlap.
if (
!completeDate(row.period_start) ||
!completeDate(row.period_end) ||
!row.period_start ||
!row.period_end ||
row.period_start > row.period_end
)
return false;
return (
(!start || row.period_end >= start) &&
(!end || row.period_start <= end)
);
});
}
export default function InvestmentMapIndicators({
context,
theme,
}: {
context: QuantitativeMapContext | null;
theme: string;
}) {
const filterId = useId();
const [indicator, setIndicator] = useState('all');
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
if (!['all', 'workforce', 'economic_potential'].includes(theme))
return null;
const themeRows =
context?.records.filter(
(row) => theme === 'all' || row.theme === theme,
) ?? [];
const rows = filterMapIndicators(themeRows, indicator, start, end);
const invalidPeriod = Boolean(start && end && start > end);
const filtered = indicator !== 'all' || Boolean(start || end);
return (
<section
className="space-y-2 rounded border p-3"
aria-label="Indicadores quantitativos do contexto territorial"
>
<h3 className="font-semibold">
Indicadores quantitativos do contexto territorial
</h3>
<p>
Observações públicas das zonas e parques devolvidos como
contexto territorial, independentes das evidências das
oportunidades. Emprego não mede trabalhadores disponíveis;
investimento, produção e exportações não constituem um índice de
potencial ou garantia de retorno. A natureza do valor é
declarada segundo a fonte; sem essa indicação permanece por
confirmar.
</p>
{!context ? (
<p role="status">A carregar indicadores quantitativos…</p>
) : context.state === 'error' ? (
<p role="alert">
Não foi possível consultar os indicadores. Pode repetir a
consulta do mapa.
</p>
) : context.state === 'not_included' ? (
<p>
O conjunto de zonas não está incluído com estes filtros
específicos de oportunidades. Não foi feita uma medição
quantitativa neste âmbito.
</p>
) : context.state === 'unavailable' ? (
<p>
Observações quantitativas indisponíveis nesta versão do
cadastro; os restantes dados do mapa mantêm-se.
</p>
) : (
<>
<fieldset className="grid gap-3 sm:grid-cols-3">
<legend>
Filtrar observações do contexto territorial
</legend>
<label
className="grid gap-1"
htmlFor={`${filterId}-indicator`}
>
Indicador
<select
className="bg-background w-full rounded border p-2"
id={`${filterId}-indicator`}
value={indicator}
onChange={(event) =>
setIndicator(event.target.value)
}
>
<option value="all">
Todos os indicadores
</option>
{Object.entries(labels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
<label
className="grid gap-1"
htmlFor={`${filterId}-start`}
>
Período de referência — desde
<input
className="w-full min-w-0 rounded border p-2"
id={`${filterId}-start`}
type="date"
value={start}
onInput={(event) =>
setStart(event.currentTarget.value)
}
aria-invalid={invalidPeriod}
aria-describedby={`${filterId}-help`}
/>
</label>
<label
className="grid gap-1"
htmlFor={`${filterId}-end`}
>
Período de referência — até
<input
className="w-full min-w-0 rounded border p-2"
id={`${filterId}-end`}
type="date"
value={end}
onInput={(event) =>
setEnd(event.currentTarget.value)
}
aria-invalid={invalidPeriod}
aria-describedby={`${filterId}-help`}
/>
</label>
<button
className="w-fit rounded border px-3 py-2 text-sm underline sm:col-span-3"
type="button"
onClick={() => {
setIndicator('all');
setStart('');
setEnd('');
}}
>
Limpar filtros dos indicadores
</button>
</fieldset>
<p id={`${filterId}-help`}>
O período selecciona observações com sobreposição de
datas, incluindo os limites. Datas incompletas ou
invertidas na fonte ficam fora do filtro temporal. A
data da fonte não substitui o período de referência.
Estes filtros actuam apenas nas observações recebidas;
não alteram oportunidades ou limites de cobertura.
</p>
{invalidPeriod && (
<p role="alert">
O início do período deve ser anterior ou igual ao
fim.
</p>
)}
<p role="status">
{rows.length}{' '}
{rows.length === 1 ? 'observação' : 'observações'} nesta
camada, de {themeRows.length} recebidas, em{' '}
{context.zones_consulted}{' '}
{context.zones_consulted === 1
? 'ficha territorial consultada.'
: 'fichas territoriais consultadas.'}{' '}
A contagem não mede cobertura nacional.
</p>
{context.truncated && (
<p role="note">
Cobertura incompleta: consulta limitada a{' '}
{context.zone_limit} fichas e {context.record_limit}{' '}
observações. Refine a localização.
</p>
)}
{rows.length > 1 && (
<MapObservationComparisonPicker rows={rows} />
)}
{rows.length === 0 ? (
<p>
{filtered
? 'Nenhuma observação corresponde aos filtros dos indicadores.'
: 'Sem observações públicas destes indicadores na resposta.'}{' '}
Valor desconhecido não equivale a zero.
</p>
) : (
<details>
<summary>
Consultar valores, unidades e fontes (
{rows.length})
</summary>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<caption>
Cada linha conserva o âmbito da fonte.
Sem somas, conversões de unidade ou
moeda, médias ou comparação automática
entre períodos e populações.
</caption>
<thead>
<tr>
<th scope="col">
Ficha e indicador
</th>
<th scope="col">Valor e unidade</th>
<th scope="col">Período e base</th>
<th scope="col">
Fonte e metodologia
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={`${row.zone_id}:${row.id}`}
className="border-t align-top"
>
<th scope="row" className="p-2">
<a
className="underline"
href={row.url}
>
{row.zone_name}
</a>
<p>
{labels[
row.indicator
] ?? row.indicator}
: {text(row.label)}
</p>
<p>
{text(row.description)}
</p>
<p>
Versão pública{' '}
{row.zone_version}
</p>
</th>
<td className="p-2">
<p>
{row.value ??
'Por determinar'}{' '}
· {text(row.unit)}
</p>
<p>
Natureza:{' '}
{
measureKinds[
measureKind(row)
]
}
</p>
<p>
{row.knowledge ===
'documented'
? 'Documentado na fonte'
: row.knowledge ===
'disputed'
? 'Em divergência'
: 'Desconhecido'}
</p>
</td>
<td className="p-2">
<p>
{text(row.period_start)}{' '}
a {text(row.period_end)}
</p>
<p>
Âmbito:{' '}
{text(row.scope)}
</p>
<p>
População/base:{' '}
{text(row.population)}
</p>
</td>
<td className="p-2">
<p>
Fonte:{' '}
{text(
row.source_reference,
)}{' '}
· Data:{' '}
{text(row.source_date)}
</p>
<p>
Metodologia:{' '}
{text(row.methodology)}
</p>
{row.missing_metadata
.length > 0 && (
<p role="note">
Informação em falta:{' '}
{row.missing_metadata
.map(
(key) =>
metadataLabels[
key
] ??
key,
)
.join(', ')}
. Não agregar ou
comparar como se
estivesse completa.
</p>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</>
)}
</section>
);
}