diff --git a/app/Http/Controllers/InvestmentDiscoveryController.php b/app/Http/Controllers/InvestmentDiscoveryController.php index c09f4dcb..ca1f0360 100644 --- a/app/Http/Controllers/InvestmentDiscoveryController.php +++ b/app/Http/Controllers/InvestmentDiscoveryController.php @@ -26,6 +26,7 @@ class InvestmentDiscoveryController extends Controller } return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters), + 'corridorGroups' => ($filters['view'] ?? '') === 'corridors' ? app(\App\Services\OpportunityCorridorExplorer::class)->groups($filters) : [], 'territorySelection' => app(InvestmentGeography::class)->selection($filters), 'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()), 'results' => $catalogue->query($filters)->paginate(12)->withQueryString(), diff --git a/app/Services/InvestmentCatalogue.php b/app/Services/InvestmentCatalogue.php index 4b73e684..c6160869 100644 --- a/app/Services/InvestmentCatalogue.php +++ b/app/Services/InvestmentCatalogue.php @@ -26,7 +26,7 @@ class InvestmentCatalogue 'cluster_id' => ['nullable', 'uuid'], 'corridor_id' => ['nullable', 'uuid'], 'project_id' => ['nullable', 'uuid'], 'geo_location_id' => ['nullable', 'uuid'], 'education' => ['nullable', 'boolean'], 'zee_id' => ['nullable', 'uuid'], 'zfi_id' => ['nullable', 'uuid'], 'park_id' => ['nullable', 'uuid'], - 'dimension' => ['nullable', 'in:cluster,corridor,zee,zfi'], 'view' => ['nullable', 'in:grid,list,map,combined'], + 'dimension' => ['nullable', 'in:cluster,corridor,zee,zfi'], 'view' => ['nullable', 'in:grid,list,map,combined,corridors'], 'maturity_level' => ['nullable', 'integer', 'between:1,8'], 'facets' => ['nullable', 'array', 'max:20'], 'facets.*' => ['integer', 'min:1'], 'min_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999'], 'max_amount' => ['nullable', 'numeric', 'min:0', 'max:999999999999', ...($request->filled('min_amount') ? ['gte:min_amount'] : [])], diff --git a/app/Services/OpportunityCorridorExplorer.php b/app/Services/OpportunityCorridorExplorer.php new file mode 100644 index 00000000..d675a106 --- /dev/null +++ b/app/Services/OpportunityCorridorExplorer.php @@ -0,0 +1,55 @@ +query($filters)->get() as $record) { + $context = $catalogue->context($record->id); + $classification = $context['classification'] ?? []; + $corridors = collect($context['corridors'])->mapWithKeys(fn ($c) => ['corridor:'.$c->id => $c->name])->all(); + foreach ($classification as $relation) { + if ($relation['target_type'] === 'corridor' && ! in_array($relation['purpose'], ['proximity', 'administrative_boundary'], true)) { + $corridors['corridor:'.$relation['target_id']] = $relation['label']; + } + } + // The approved source names are not aliases for similarly named master records. + $source = data_get($record->workflow_context, 'corridor_agribusiness'); + $chain = null; + if (is_array($source) && ($source['source_sha256'] ?? null) === CorridorAgribusinessOpportunities::SOURCE_HASH) { + $corridors['source:'.hash('sha256', $source['corridor'])] = $source['corridor']; + $chain = $source['chain']; + } + $chains = collect($classification)->where('target_type', 'chain')->pluck('label')->all(); + if ($chain) $chains[] = $chain; + $stages = collect($classification)->pluck('production_stage')->filter()->unique()->values()->all(); + // Keep the source's actual activity description rather than inventing stage labels. + if ($stages === [] && $chain && ! empty($source['summary'])) $stages = [$source['summary']]; + $path = collect($context['primary_territory_path'] ?? []); + $province = $path->firstWhere('location_type', 'province')->name ?? $record->province; + $district = $path->firstWhere('location_type', 'district')->name ?? $record->district; + $item = ['id' => $record->id, 'title' => $record->title, 'slug' => $record->slug, 'summary' => $record->summary, + 'province' => $province, 'district' => $district, 'chains' => array_values(array_unique($chains)), + 'stages' => $stages, 'maturity_label' => $record->maturity_label]; + foreach ($corridors ?: ['other' => 'Outras localizações'] as $key => $name) { + $groups[$key] ??= ['id' => $key, 'name' => $name, 'opportunities' => []]; + $groups[$key]['opportunities'][$record->id] = $item; + } + } + foreach ($groups as &$group) { + $group['opportunities'] = array_values($group['opportunities']); + $group['count'] = count($group['opportunities']); + $group['locations'] = array_values(array_unique(array_filter(array_map(fn ($r) => implode(' / ', array_filter([$r['province'], $r['district']])), $group['opportunities'])))); + } + unset($group); + uasort($groups, fn ($a, $b) => ($a['id'] === 'other' ? 1 : 0) <=> ($b['id'] === 'other' ? 1 : 0) ?: strnatcasecmp($a['name'], $b['name'])); + return array_values($groups); + } +} diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index c8d30645..3b4a2ce5 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -1,5 +1,17 @@ # Rastreabilidade, utilização e formação +## Explorar oportunidades por corredor — 23/10/2026 + +Decisão expressa da Tempo: acrescentar o corredor como forma de explorar o catálogo, preservando vistas por sector, território e cadeia de valor, fichas únicas e percurso existente. Nova vista `/oportunidades?sector=agronegocio&view=corridors`, com acesso destacado no início de Agronegócio. Não há migration, cadastro duplicado ou alteração de publicações, associações, permissões, Turismo de Inhambane ou geometrias. + +Cada grupo apresenta nome, número de oportunidades publicadas, localizações identificadas, cadeias e etapas/actividades. O lote usa as seis designações exactas da fonte aceite; não as funde com mestres semelhantes. Outras fichas usam as relações públicas já disponibilizadas pelo catálogo; proximidade não é tratada como pertença. Sem associação pública identificada, a ficha aparece em **Outras localizações**. Uma ficha em vários grupos mantém o mesmo ID e a selecção é única. Contagens de grupos podem sobrepor-se; não significam cadastros distintos. + +Dentro do grupo, filtrar província, distrito e cadeia de valor; filtros mantêm escolhas anteriores, inclusive de outros grupos. **Comparar as escolhidas** reutiliza a comparação existente de até quatro fichas; essa capacidade não limita a escolha para preparar investimento. **Preparar investimento com as oportunidades escolhidas** envia todos os IDs ao mesmo dossier, onde se confirma a localização por ficha e se reutilizam os dados em licenciamento, terra e simulação. Filtrar a pesquisa não altera a localização da ficha nem escolhe silenciosamente uma implantação. + +Lacunas reais: as 34 fichas não têm província/distrito de implantação identificados; a vista indica abrangência por indicar e não transforma nomes de corredores em limites territoriais. As actividades produtivas são apresentadas com o texto da fonte, sem inventar uma classificação de etapa. Etapas adicionais vêm apenas das relações públicas existentes. Os originais e a rastreabilidade permanecem internos. + +Delta conferido: dois testes, 26 asserções (seis grupos e 34 IDs sem truncar à primeira página; relação múltipla com um ID; grupo residual; localizações conhecidas sem inventar faltantes). Build e tipos verificados. Navegador QA: escolha de duas fichas de Beira e Maputo, filtro Soja mantendo as escolhas e comparação das duas identidades. Reutilizadas as provas aceites da confirmação territorial e continuidade do dossier. + ## Apresentação sem notas internas — 23/10/2026 Correcção solicitada pela Tempo: a interface deve comunicar a tarefa e o conteúdo, sem narrar decisões de implementação. Simplificados o cabeçalho da gestão do lote, a entrada no roteiro e as escolhas de oportunidades. Na ficha pública, removido o alerta genérico de falta de data de validação; mantém-se o estado **Em preparação** e a indicação de que a execução depende das condições e autorizações aplicáveis. diff --git a/resources/js/components/opportunity-corridor-groups.tsx b/resources/js/components/opportunity-corridor-groups.tsx new file mode 100644 index 00000000..67a3b42e --- /dev/null +++ b/resources/js/components/opportunity-corridor-groups.tsx @@ -0,0 +1,51 @@ +import { Link } from '@inertiajs/react'; +import { useState } from 'react'; +import { button, card, input } from '@/pages/investment/shared'; + +type Item = {id:number;title:string;slug:string;summary:string|null;province:string|null;district:string|null;chains:string[];stages:string[];maturity_label:string|null}; +export type CorridorGroup = {id:string;name:string;count:number;locations:string[];opportunities:Item[]}; +type Props = {groups:CorridorGroup[];selected:number[];onSelect:(id:number,checked:boolean)=>void;onCompare:(ids:number[])=>void;onClear:()=>void}; + +function Group({group,selected,onSelect}: {group:CorridorGroup} & Pick) { + const [province,setProvince]=useState(''); + const [district,setDistrict]=useState(''); + const [chain,setChain]=useState(''); + const provinces=[...new Set(group.opportunities.map(r=>r.province).filter((s):s is string=>!!s))].sort(); + const districts=[...new Set(group.opportunities.filter(r=>!province||r.province===province).map(r=>r.district).filter((s):s is string=>!!s))].sort(); + const chains=[...new Set(group.opportunities.flatMap(r=>r.chains))].sort(); + const rows=group.opportunities.filter(r=>(!province||r.province===province)&&(!district||r.district===district)&&(!chain||r.chains.includes(chain))); + return
+

{group.name}

{group.count} oportunidades publicadas

+

{group.locations.length ? 'Localizações identificadas: '+group.locations.join(' · ') : 'Abrangência territorial por indicar.'}

+
+ + + +
+

{rows.length} oportunidades com estes filtros

+
{rows.map(r=>
+

{r.title}

+

{[r.province,r.district].filter(Boolean).join(' / ')||'Localização por escolher'}

+

Cadeia de valor: {r.chains.join(' · ')||'Por indicar'}

+ {r.summary && !r.stages.includes(r.summary) &&

{r.summary}

} +

Etapas e actividades: {r.stages.join(' · ')||'Por indicar'}

{r.maturity_label}

+ + Ver ficha +
)}
+
; +} + +export default function OpportunityCorridorGroups({groups,selected,onSelect,onCompare,onClear}:Props) { + return
+
+

Explore por corredor e escolha onde investir

+

Pode escolher oportunidades de vários corredores e confirmar a localização de cada uma durante a preparação.

+

{selected.length} oportunidades escolhidas

+ {!!selected.length&&
'opportunities[]='+id).join('&')}>Preparar investimento com as oportunidades escolhidas + + + {selected.length>4&&

A comparação apresenta até quatro fichas de cada vez. Pode preparar o investimento com todas as escolhidas.

}
} +
+ {groups.map(group=>)} +
; +} diff --git a/resources/js/pages/investment/catalogue.tsx b/resources/js/pages/investment/catalogue.tsx index 1846092c..c5427b6b 100644 --- a/resources/js/pages/investment/catalogue.tsx +++ b/resources/js/pages/investment/catalogue.tsx @@ -1,4 +1,5 @@ import EducationalGuides from '@/components/educational-guides'; +import OpportunityCorridorGroups, { type CorridorGroup } from '@/components/opportunity-corridor-groups'; import { searchTerritoryHref } from '@/lib/search-territory'; import type { EducationalArticle } from '@/components/educational-article-card'; import OpportunitySearchFilters from '@/components/opportunity-search-filters'; @@ -25,8 +26,10 @@ export default function Catalogue({ territorial, territorySelection, catalogueUrl, + corridorGroups = [], }: { guides?: EducationalArticle[]; + corridorGroups?: CorridorGroup[]; filters: Record; references: References; results: { @@ -123,6 +126,10 @@ export default function Catalogue({ const [saved, setSaved] = useState(favourites); const [selected, setSelected] = useState([]); const [comparison, setComparison] = useState<{ record: Opportunity }[]>([]); + const comparisonRef = useRef(null); + useEffect(() => { + if (view === 'corridors' && comparison.length) comparisonRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [comparison, view]); const [points, setPoints] = useState(null); const [areas, setAreas] = useState([]); const [unlocated, setUnlocated] = useState< @@ -418,7 +425,7 @@ export default function Catalogue({ Alargar pesquisa (remover localização) )} - {['grid', 'list', 'map', 'combined'].map((value, i) => ( + {['grid', 'list', 'map', 'combined', 'corridors'].map((value, i) => ( ))} )} - {!searchLoading && + {view === 'corridors' && !searchLoading && !searchError && {setInvestmentSelection([]);sessionStorage.removeItem('investment-opportunity-selection');}} onCompare={ids=>void api('/api/v1/investment/compare?'+ids.map(id=>'ids[]='+id).join('&')).then(data=>setComparison(data.records)).catch(e=>setError(e.message))}/>} + {view !== 'corridors' && !searchLoading && !searchError && (view !== 'map' || error || points?.length === 0) && (
)} -