Files
Investidor-V2/resources/js/components/investment-map-indicators.tsx
T

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