379 lines
18 KiB
TypeScript
379 lines
18 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;
|
|
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 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,
|
|
);
|
|
|
|
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 prevista ou
|
|
efectiva de cada valor depende da descrição da fonte, não é
|
|
determinada automaticamente.
|
|
</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 === 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>
|
|
{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>
|
|
);
|
|
}
|