fix: preserve territorial search across investment catalogues

This commit is contained in:
2026-10-22 12:52:52 +02:00
parent 5f4eb5cfaa
commit 9ac623a433
14 changed files with 118 additions and 24 deletions
@@ -2151,3 +2151,30 @@ Autorização expressa da Tempo executada. Main passou de `57fc9148` por avanço
**Estado fiscal preservado:** nenhuma publicação/activação de nova regra e nenhuma operação na base activa. O consumidor IVA geral previamente autorizado manteve a mesma disponibilidade, cobertura2026 e mensagem antes/depois. As exclusões sectoriais, forma indirecta/dimensão monetária não determinada, benefícios territoriais e apuramento fiscal completo continuam explícitos e não bloqueiam o lote procedimental concluído.
**Suspensão/recuperação:** desligar somente a chave do novo percurso e reconstruir cache/reiniciar graciosamente os workers mantém dossiers, documentos, respostas, versões, relatórios e históricos. Para reverter configuração, usar o valor anterior apenas depois de conferir o hash actual e preservar alterações posteriores; não substituir todo o .env sem conferir concorrência. A reversão de código/build segue o procedimento de releases já existente, sem restaurar dados antigos. A release anterior foi efectivamente conservada na primeira tentativa recusada. Recibo completo local em `outputs/investmoz-adaptive-path-delivery-20261022/delivery-receipt.json`; a presente actualização documental fica na branch de registo, sem novo push main ou nova disponibilização desnecessária.
## Revis?o das 30 fichas de Inhambane e cat?logo vazio ? 22/10/2026
Leitura remota autenticada, transac??o READ ONLY, base teste_investidor: 30 oportunidades em draft; zero publicadas em toda a base. As consultas independentes de Cabo Delgado e Inhambane e a consulta exacta Inhambane/Vilankulo devolveram zero sem falha de consulta. A alega??o de oportunidades publicadas de Cabo Delgado n?o se confirma neste destino; n?o inventar associa??es para preencher a lista.
As 30 localiza??es permanecem nos recursos privados opportunity_location_drafts, referidos no cadastro can?nico; os campos e v?nculos p?blicos ainda n?o s?o a projec??o dessas fichas. A inexist?ncia desses v?nculos p?blicos em rascunhos n?o prova perda da localiza??o. Na publica??o, projectar somente a abrang?ncia documentada e conferir a pesquisa territorial.
Decis?o Tempo nesta conversa: incompletude e autoriza??es necess?rias ? execu??o n?o s?o impedimentos autom?ticos ? divulga??o como oportunidade em prepara??o. Proposta: divulgar resumos das 30 fichas em prepara??o com fonte, edi??o, data-base e lacunas vis?veis; n?o declarar financiamento, terreno, licen?as, promotor autorizado ou execu??o confirmados. Valores hist?ricos, agregados e divergentes devem ser identificados sem somas indevidas. Nenhum impedimento concreto de divulga??o do resumo foi demonstrado nesta leitura. Isto n?o declara o conte?do privado integral public?vel: originais, contactos e evid?ncias continuam separados. N?o houve publica??o, altera??o de pap?is ou escrita remota.
Prepara??o por ficha: outputs/investmoz-territory-recovery-20261022/publication-review.json (30 identidades reais, localiza??o privada, lacunas e valores). Evid?ncia: read-catalogue-remote.py e remote-read.log no mesmo direct?rio. A tabela hist?rica de lacunas de prepara??o ? reutilizada, sem a converter em bloqueios legais. A prepara??o t?cnica do fluxo de publica??o ainda deve reconciliar essas condi??es com o contrato existente; n?o marcar publica??o como pronta ou executada.
O URL recebido ? do cat?logo/iniciar por territ?rio, n?o do dossier afectado. N?o permite ler o contexto inicial, a altera??o e a ?ltima ac??o desse processo. A falha da pr?via territorial e do t?rmino sem resposta continua aberta e ainda n?o foi reproduzida no dossier real; n?o encerrar pela disponibiliza??o anterior ou por esta leitura de cat?logo.
<!-- search-territory-recovery-20261022 -->
## Continuidade territorial da pesquisa — correcção em QA, 22/10/2026
Esclarecimento da Tempo: o percurso reportado foi navegação pública entre carteira e catálogo, sem clicar em iniciar/continuar ideia. Não existe evidência de alteração de um dossier pessoal neste caso. Retira-se a exigência de URL do dossier e de confirmação de impacto; a alteração é de filtros de pesquisa.
Causa verificada: ligações da carteira para oportunidades e zonas e ligações dos menus públicos usavam destinos sem província/distrito. O contexto transportado para detalhes por `catalogue_query` também não acompanhava as ligações cruzadas. No ecrã de zonas, os parâmetros de exportação/detalhe omitiam a hierarquia territorial.
Correcção: `searchTerritoryHref` transporta exclusivamente os quatro níveis territoriais na URL entre carteira, oportunidades, zonas e educação, incluindo os respectivos detalhes. Reutiliza o `catalogue_query` existente e preserva os filtros próprios do destino. Uma selecção explícita de outro ascendente não herda descendentes incompatíveis. Sem armazenamento global, mistura de contas ou alteração de dossiers. Menus, navegação relacionada, breadcrumbs e ligações cruzadas usam a mesma função. O selector partilhado mantém a limpeza dos descendentes; não há confirmação adicional para pesquisar.
Verificação dirigida: seis testes Node (incluindo o selector partilhado), TypeScript e build isolado. No navegador QA: carteira Cabo Delgado → selecção Inhambane/Vilankulo → pesquisar → catálogo com ambos os campos preenchidos; recarregamento e regresso preservam a selecção; zonas herdam os dois níveis; mudar para Cabo Delgado limpa Vilankulo. Contagem zero, filtros activos e mensagem de ausência coerentes na base QA consultada. Não foram repetidos cálculos ou exportadores fiscais nem publicados registos para produzir resultados artificiais.
Provas: `outputs/investmoz-territory-recovery-20261022/before-carteira.png` e `after-catalogue.png` no workspace de entrega; testes em `tests/frontend/search-territory.test.mjs` e `territory-path.test.mjs`; logs QA `outputs/territory-types.log` e `territory-build.log`. O router privado QA selecciona o build isolado; não integra a candidata.
Entrega limitada a branch/QA. A homologação não foi alterada por esta correcção. Publicação das 30 fichas de Inhambane permanece separada: revisão por ficha preparada, nenhuma publicação executada; ainda por reconciliar a preparação técnica da divulgação em preparação com o fluxo existente. A correcção da pesquisa não depende dessa publicação.
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
import { Link, usePage } from '@inertiajs/react';
import { searchTerritoryHref } from '@/lib/search-territory';
export default function CatalogueNavigation({
dimensionUrl,
}: {
@@ -21,7 +22,7 @@ export default function CatalogueNavigation({
)
return dimensionUrl(u.searchParams.get('dimension')!);
if (context) u.searchParams.set('context', context);
return u.pathname + u.search;
return searchTerritoryHref(u.pathname + u.search, url);
};
const sections = {
'Enquadramento e filtros': [
@@ -1,4 +1,5 @@
import PortalProductIdentity from '@/components/portal-product-identity';
import { searchTerritoryHref } from '@/lib/search-territory';
import { Link, router, usePage } from '@inertiajs/react';
import {
ChevronDown,
@@ -110,7 +111,7 @@ export function PortalGroupedMenu() {
className="tempo-menu-item"
>
<Link
href={item.href!}
href={searchTerritoryHref(item.href!, url)}
aria-current={
currentItem === item.id ? 'page' : undefined
}
+2 -1
View File
@@ -1,4 +1,5 @@
import SearchFeedback from '@/components/search-feedback';
import { searchTerritoryHref } from '@/lib/search-territory';
import { Link, usePage } from '@inertiajs/react';
import { createContext, useContext } from 'react';
import { PortalGroupedMenu } from '@/components/portal-grouped-menu';
@@ -148,7 +149,7 @@ export default function PortalNavigation({
{parent && (
<>
<Link
href={parent}
href={searchTerritoryHref(parent, url)}
className="underline"
>
{names[parent]}
+2 -1
View File
@@ -1,3 +1,4 @@
import { searchTerritoryHref } from './search-territory';
/** Only a query string is carried; the return destination is always local. */
export function catalogueDetailHref(path: string, query: string): string {
return query
@@ -8,5 +9,5 @@ export function catalogueReturnHref(base: string, pageUrl: string): string {
const query = new URL(pageUrl, 'https://portal.invalid').searchParams.get(
'catalogue_query',
);
return base + (query ? `?${new URLSearchParams(query).toString()}` : '');
return searchTerritoryHref(base + (query ? `?${new URLSearchParams(query).toString()}` : ''), pageUrl);
}
+24
View File
@@ -0,0 +1,24 @@
const levels = ['province_id', 'district_id', 'admin_post_id', 'locality_id'];
const searchRoutes = ['/carteira', '/oportunidades', '/zonas', '/educacao'];
/** URL-scoped search context, never a saved investment or another browser tab. */
export function searchTerritoryHref(href: string, pageUrl: string): string {
const base = 'https://portal.invalid';
const source = new URL(pageUrl, base);
const target = new URL(href, base);
const supported = (path: string) => searchRoutes.some((route) => path === route || path.startsWith(`${route}/`));
if (target.origin !== base || !supported(source.pathname) || !supported(target.pathname)) return href;
const inherited = source.searchParams.has('catalogue_query')
? new URLSearchParams(source.searchParams.get('catalogue_query')!)
: source.searchParams;
// An explicit ancestor, including an explicit empty value, overrides its descendants.
let changed = false;
for (const field of levels) {
if (target.searchParams.has(field)) {
changed ||= target.searchParams.get(field) !== inherited.get(field);
} else if (!changed && inherited.get(field)) {
target.searchParams.set(field, inherited.get(field)!);
}
}
return target.pathname + target.search + target.hash;
}
+4 -2
View File
@@ -1,4 +1,5 @@
import EducationalGuides from '@/components/educational-guides';
import { searchTerritoryHref } from '@/lib/search-territory';
import type { EducationalArticle } from '@/components/educational-article-card';
import OpportunitySearchFilters from '@/components/opportunity-search-filters';
import SearchState from '@/components/search-state';
@@ -46,7 +47,8 @@ export default function Catalogue({
corridor_id: string | null;
}[];
}) {
const { auth } = usePage<{ auth: { user: unknown } }>().props;
const page = usePage<{ auth: { user: unknown } }>();
const { auth } = page.props;
const form = useForm({
association_type: String(filters.association_type ?? ''),
association_target: String(filters.association_target ?? ''),
@@ -696,7 +698,7 @@ export default function Catalogue({
<div className="grid gap-4 md:grid-cols-3">
{territorial.map((d) => (
<Link
href={`/carteira/${d.id}`}
href={searchTerritoryHref(`/carteira/${d.id}`, page.url)}
key={d.id}
className={card}
>
+3 -2
View File
@@ -1,4 +1,5 @@
import EducationalGuides from '@/components/educational-guides';
import { searchTerritoryHref } from '@/lib/search-territory';
import type { EducationalArticle } from '@/components/educational-article-card';
import { ClassificationSummary } from '@/components/opportunity-classification';
import { Link, router, usePage } from '@inertiajs/react';
@@ -45,7 +46,7 @@ export default function Detail({
<p>
<Link
className="underline"
href={`/carteira/${dossierOrigin.id}`}
href={searchTerritoryHref(`/carteira/${dossierOrigin.id}`, page.url)}
>
Voltar ao dossier: {dossierOrigin.title}
</Link>
@@ -253,7 +254,7 @@ export default function Detail({
<span key={dossier.id}>
<Link
className="underline"
href={`/carteira/${dossier.id}`}
href={searchTerritoryHref(`/carteira/${dossier.id}`, page.url)}
>
Consultar dossier: {dossier.title}
</Link>
+3 -2
View File
@@ -5,6 +5,7 @@ import ParkDetailsEditor, {
} from '@/components/investment-zone-operational-details';
import { Link, router, useForm, usePage } from '@inertiajs/react';
import { catalogueReturnHref } from '@/lib/catalogue-context';
import { searchTerritoryHref } from '@/lib/search-territory';
import InvestmentTerritory from '@/components/investment-territory';
import InvestmentMapPanel from '@/components/investment-map-panel';
import {
@@ -1772,7 +1773,7 @@ function Editor({
<p key={o.id}>
<Link
className="underline"
href={`/oportunidades/${o.slug}?zone_id=${z.id}`}
href={searchTerritoryHref(`/oportunidades/${o.slug}?zone_id=${z.id}`, pageUrl)}
>
{o.title}
</Link>
@@ -1829,7 +1830,7 @@ function Editor({
<p key={r.id}>
<Link
className="underline"
href={`/carteira/${r.id}`}
href={searchTerritoryHref(`/carteira/${r.id}`, pageUrl)}
>
{r.title}
</Link>
+10 -5
View File
@@ -1,7 +1,8 @@
import InvestmentZoneInstallation, {
type ParkInstallationPlan,
} from '@/components/investment-zone-installation';
import { Link, router } from '@inertiajs/react';
import { Link, router, usePage } from '@inertiajs/react';
import { searchTerritoryHref } from '@/lib/search-territory';
import { useState } from 'react';
import { catalogueDetailHref } from '@/lib/catalogue-context';
import {
@@ -90,9 +91,13 @@ export default function Zones({
zoneExtensionsAvailable?: boolean;
territorySelection?: TerritoryOption[];
}) {
const pageUrl = usePage().url;
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const exportParams = new URLSearchParams();
['province_id', 'district_id', 'admin_post_id', 'locality_id'].forEach((field, index) => {
if (territorySelection[index]) exportParams.set(field, territorySelection[index].id);
});
for (const key of [
'q',
'category',
@@ -148,9 +153,9 @@ export default function Zones({
>
<Link href="/clusters">Por Cluster</Link>
<Link href="/corredores">Por Corredor</Link>
<Link href="/zonas?category=zee">Por ZEE</Link>
<Link href="/zonas?category=zfi">Por ZFI</Link>
<Link href="/zonas?category=industrial_park">
<Link href={searchTerritoryHref('/zonas?category=zee', pageUrl)}>Por ZEE</Link>
<Link href={searchTerritoryHref('/zonas?category=zfi', pageUrl)}>Por ZFI</Link>
<Link href={searchTerritoryHref('/zonas?category=industrial_park', pageUrl)}>
Parques industriais
</Link>
</nav>
@@ -343,7 +348,7 @@ export default function Zones({
{!management && (
<Link
className="ml-4 underline"
href={`/oportunidades?${new URLSearchParams({ [zone.category === 'zee' ? 'zee_id' : zone.category === 'zfi' ? 'zfi_id' : 'park_id']: zone.id })}`}
href={searchTerritoryHref(`/oportunidades?${new URLSearchParams({ [zone.category === 'zee' ? 'zee_id' : zone.category === 'zfi' ? 'zfi_id' : 'park_id']: zone.id })}`, pageUrl)}
>
Ver oportunidades associadas
</Link>
+6 -4
View File
@@ -1,4 +1,5 @@
import { Link, useForm } from '@inertiajs/react';
import { Link, useForm, usePage } from '@inertiajs/react';
import { searchTerritoryHref } from '@/lib/search-territory';
import { catalogueDetailHref } from '@/lib/catalogue-context';
import { Management } from '../management/shared';
import type { Paginated } from '../management/shared';
@@ -35,6 +36,7 @@ export default function Index({
locality_id: filters.locality_id ?? '',
});
const base = management ? '/management/portfolio' : '/carteira';
const { url } = usePage();
const Frame = management ? Management : Layout;
return (
<Frame title="Corredores, clusters e projectos âncora">
@@ -151,7 +153,7 @@ export default function Index({
(r.corridor_id || r.cluster_id || r.project_id) && (
<Link
className="ml-4 underline"
href={`/oportunidades?${new URLSearchParams(r.corridor_id ? { corridor_id: r.corridor_id } : r.cluster_id ? { cluster_id: r.cluster_id } : { project_id: r.project_id! })}`}
href={searchTerritoryHref(`/oportunidades?${new URLSearchParams(r.corridor_id ? { corridor_id: r.corridor_id } : r.cluster_id ? { cluster_id: r.cluster_id } : { project_id: r.project_id! })}`, url)}
>
Ver oportunidades associadas
</Link>
@@ -174,10 +176,10 @@ export default function Index({
aria-label="Alternativas de exploração"
className="flex flex-wrap gap-4"
>
<Link className="underline" href="/oportunidades">
<Link className="underline" href={searchTerritoryHref('/oportunidades', url)}>
Ver oportunidades publicadas
</Link>
<Link className="underline" href="/zonas">
<Link className="underline" href={searchTerritoryHref('/zonas', url)}>
Consultar ZEE, ZFI e parques
</Link>
<Link className="underline" href="/Simulador">
+4 -3
View File
@@ -1,6 +1,7 @@
import { Link, useForm, router, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { catalogueReturnHref } from '@/lib/catalogue-context';
import { searchTerritoryHref } from '@/lib/search-territory';
import InvestmentMapPanel from '@/components/investment-map-panel';
import StructureGeographyPanel, {
type StructureGeography,
@@ -190,7 +191,7 @@ function Editor({
</h2>
{zones.map((z) => (
<p key={z.id}>
<Link className="underline" href={`/zonas/${z.id}`}>
<Link className="underline" href={searchTerritoryHref(`/zonas/${z.id}`, pageUrl)}>
{z.name}
</Link>{' '}
· {z.association_type} · Fonte: {z.source_reference}
@@ -297,7 +298,7 @@ function Editor({
<article key={opportunity.id}>
<Link
className="font-semibold underline"
href={`/oportunidades/${opportunity.slug}?from_dossier=${record.id}`}
href={searchTerritoryHref(`/oportunidades/${opportunity.slug}?from_dossier=${record.id}`, pageUrl)}
>
{opportunity.title}
</Link>
@@ -485,7 +486,7 @@ function Editor({
{kind(r)}:{' '}
<Link
className="underline"
href={`${base}/${r.id}${management ? '' : `?from=${record.id}`}`}
href={searchTerritoryHref(`${base}/${r.id}${management ? '' : `?from=${record.id}`}`, pageUrl)}
>
{r.title}
</Link>
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { searchTerritoryHref } from '../../resources/js/lib/search-territory.ts';
test('portfolio to catalogue to related screen retains only territorial search filters', () => {
const start = '/carteira?province_id=inh&district_id=vil&q=airport&kind=project';
const catalogue = searchTerritoryHref('/oportunidades?view=grid', start);
assert.equal(catalogue, '/oportunidades?view=grid&province_id=inh&district_id=vil');
const detail = searchTerritoryHref('/carteira/id', catalogue);
assert.equal(searchTerritoryHref('/carteira', detail), '/carteira?province_id=inh&district_id=vil');
assert.equal(searchTerritoryHref('/zonas?category=zee', start), '/zonas?category=zee&province_id=inh&district_id=vil');
});
test('nested historical return query restores territory without importing unrelated filters', () => {
assert.equal(searchTerritoryHref('/oportunidades', '/carteira/id?catalogue_query='+encodeURIComponent('province_id=inh&district_id=vil&q=airport')), '/oportunidades?province_id=inh&district_id=vil');
});
test('explicit parent changes or clearing never import incompatible descendants', () => {
const current = '/oportunidades?province_id=inh&district_id=vil&admin_post_id=post&locality_id=loc';
assert.equal(searchTerritoryHref('/carteira?province_id=cabo', current), '/carteira?province_id=cabo');
assert.equal(searchTerritoryHref('/carteira?district_id=other', current), '/carteira?district_id=other&province_id=inh');
assert.equal(searchTerritoryHref('/carteira?province_id=', current), '/carteira?province_id=');
});
test('no cross-tab, project, account, external or unrelated route inheritance', () => {
assert.equal(searchTerritoryHref('/oportunidades','/carteira'), '/oportunidades');
assert.equal(searchTerritoryHref('/oportunidades','/investimentos/id?province_id=private'), '/oportunidades');
assert.equal(searchTerritoryHref('/investimentos/id','/carteira?province_id=inh'), '/investimentos/id');
assert.equal(searchTerritoryHref('https://other.test/carteira','/carteira?province_id=inh'), 'https://other.test/carteira');
});