diff --git a/app/Http/Controllers/Api/V1/OpportunityController.php b/app/Http/Controllers/Api/V1/OpportunityController.php index 1308218f..bce21579 100644 --- a/app/Http/Controllers/Api/V1/OpportunityController.php +++ b/app/Http/Controllers/Api/V1/OpportunityController.php @@ -36,6 +36,7 @@ class OpportunityController extends Controller $term = $validated['q']; $query->where(fn ($q) => $q ->whereLike('title', "%{$term}%") + ->orWhereLike('description', "%{$term}%") ->orWhereLike('summary', "%{$term}%") ->orWhereLike('reference', "%{$term}%") ->orWhereLike('province', "%{$term}%") @@ -54,7 +55,7 @@ class OpportunityController extends Controller } if (! empty($validated['maturity_level'])) { - $query->where('maturity_level', $validated['maturity_level']); + \App\Services\OpportunityMaturity::constrainSearch($query, 'historical:'.$validated['maturity_level']); } $results = $query->orderByDesc('is_featured') diff --git a/app/Http/Controllers/InvestmentDiscoveryController.php b/app/Http/Controllers/InvestmentDiscoveryController.php index 25300c54..db9cd8ad 100644 --- a/app/Http/Controllers/InvestmentDiscoveryController.php +++ b/app/Http/Controllers/InvestmentDiscoveryController.php @@ -53,7 +53,7 @@ class InvestmentDiscoveryController extends Controller if (! empty($filters['geo_location_id'])) { app(InvestmentGeography::class)->constrainDossiersToTerritories($query, app(InvestmentGeography::class)->descendants($filters['geo_location_id'])); } - foreach (['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level'] as $field) { + foreach (['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'zee_id', 'zfi_id', 'park_id', 'education', 'facets', 'sector', 'province', 'district', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level', 'preparation'] as $field) { if (isset($filters[$field]) && $filters[$field] !== '' && $filters[$field] !== false && $filters[$field] !== []) { return $query->whereRaw('false')->get(); } @@ -75,9 +75,9 @@ class InvestmentDiscoveryController extends Controller return response()->json($catalogue->query($catalogue->filters($request))->paginate($request->integer('per_page', 12))->withQueryString()); } - public function references(InvestmentCatalogue $catalogue): JsonResponse + public function references(Request $request, InvestmentCatalogue $catalogue): JsonResponse { - return response()->json($catalogue->references()); + return response()->json($catalogue->references(false, $catalogue->filters($request))); } public function detail(Request $request, string $slug, InvestmentCatalogue $catalogue): Response @@ -131,7 +131,7 @@ class InvestmentDiscoveryController extends Controller { $filters = $catalogue->filters($request); $context = $this->territorial($filters)->map(fn ($d) => ['id' => 'dossier:'.$d->id, 'title' => $d->title, 'kind' => $d->cluster_id ? 'Cluster' : ($d->corridor_id ? 'Corredor' : 'Projeto âncora'), 'url' => '/carteira/'.$d->id]); - $opportunityOnlyFilters = array_filter(Arr::only($filters, ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'education', 'sector', 'facets', 'project_id', 'cluster_id', 'corridor_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level']), fn ($value) => $value !== null && $value !== '' && $value !== false && $value !== []); + $opportunityOnlyFilters = array_filter(Arr::only($filters, ['association_type', 'association_target', 'association_state', 'association_purpose', 'authority_id', 'chain_id', 'subsector_id', 'category', 'publication_state', 'education', 'sector', 'facets', 'project_id', 'cluster_id', 'corridor_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level', 'preparation']), fn ($value) => $value !== null && $value !== '' && $value !== false && $value !== []); if ($opportunityOnlyFilters === [] && ! in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) { $zoneFilters = $filters; if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) { diff --git a/app/Services/InvestmentCatalogue.php b/app/Services/InvestmentCatalogue.php index b8efa15c..0238cc1e 100644 --- a/app/Services/InvestmentCatalogue.php +++ b/app/Services/InvestmentCatalogue.php @@ -18,6 +18,7 @@ class InvestmentCatalogue public function filters(Request $request): array { $filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'], + 'preparation' => ['nullable', \Illuminate\Validation\Rule::in(array_column(OpportunityMaturity::searchOptions(), 'value'))], 'opportunity_id' => ['nullable', 'integer', 'min:1'], 'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'], 'include_national' => ['nullable', 'boolean'], 'territory_basis' => ['nullable', 'in:all,direct,context'], @@ -40,6 +41,17 @@ class InvestmentCatalogue 'publication_state' => ['nullable', 'in:published'], 'per_page' => ['nullable', 'integer', 'between:1,50'], 'page' => ['nullable', 'integer', 'min:1']]); app(InvestmentGeography::class)->selection($filters); + $aliases = ['park' => 'park_id', 'cluster' => 'cluster_id', 'corridor' => 'corridor_id', 'project' => 'project_id']; + $alias = $aliases[$filters['association_type'] ?? ''] ?? null; + if ($alias && ! empty($filters['association_target']) && empty($filters['association_purpose']) && empty($filters['association_state'])) { + if (! empty($filters[$alias]) && $filters[$alias] !== $filters['association_target']) { + throw \Illuminate\Validation\ValidationException::withMessages([$alias => 'Foram indicadas duas selecções diferentes para o mesmo filtro. Escolha apenas uma.']); + } + if (\Illuminate\Support\Str::isUuid($filters['association_target'])) { + $filters[$alias] = $filters['association_target']; + unset($filters['association_type'], $filters['association_target']); + } + } return $filters; } @@ -51,6 +63,7 @@ class InvestmentCatalogue public function query(array $filters): Builder { $query = Opportunity::published()->with('sector:id,name,slug'); + $associated = $this->publicAssociatedIds($filters); if (($filters['territory_basis'] ?? 'all') === 'direct') { $query->where(fn ($q) => $q->whereNotNull('opportunities.primary_territory_id')->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id'))); } elseif (($filters['territory_basis'] ?? 'all') === 'context') { @@ -92,15 +105,17 @@ class InvestmentCatalogue } foreach (['zee_id' => 'zee', 'zfi_id' => 'zfi', 'park_id' => 'industrial_park'] as $field => $category) { if (! empty($filters[$field])) { - $query->whereIn('opportunities.id', app(InvestmentZones::class)->links() + $legacy = app(InvestmentZones::class)->links()->where('zl.association_type', '<>', 'proximity') ->join('investment_item_opportunities as mapping', 'mapping.item_id', '=', 'zl.item_id') ->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')->where('z.category', $category) - ->where('zl.zone_id', $filters[$field])->select('mapping.opportunity_id')); + ->where('zl.zone_id', $filters[$field])->select('mapping.opportunity_id'); + $query->where(fn ($matches) => $matches->whereIn('opportunities.id', $legacy) + ->orWhereIn('opportunities.id', $associated[$field] ?? [])); } } if (! empty($filters['q'])) { $term = '%'.$filters['q'].'%'; - $query->where(fn ($q) => $q->where('title', 'ilike', $term)->orWhere('summary', 'ilike', $term)->orWhere('reference', 'ilike', $term) + $query->where(fn ($q) => $q->where('title', 'ilike', $term)->orWhere('description', 'ilike', $term)->orWhere('summary', 'ilike', $term)->orWhere('reference', 'ilike', $term) ->orWhere('province', 'ilike', $term)->orWhereHas('sector', fn ($s) => $s->where('name', 'ilike', $term)) ->orWhereExists(fn ($g) => $g->selectRaw('1')->from('geo_locations as search_location')->whereNull('search_location.deleted_at')->where('search_location.data_classification', 'public') ->where('search_location.name', 'ilike', $term)->whereIn('search_location.id', app(InvestmentGeography::class)->publicReferences()->select('id')) @@ -116,11 +131,21 @@ class InvestmentCatalogue if (! empty($filters['sector'])) { SectorOrganization::constrain($query, $filters['sector']); } - foreach (['province', 'district', 'maturity_level'] as $field) { + foreach (['province', 'district'] as $field) { if (! empty($filters[$field])) { $query->where($field, $filters[$field]); } } + if (! empty($filters['preparation'])) { + OpportunityMaturity::constrainSearch($query, $filters['preparation']); + } elseif (! empty($filters['maturity_level'])) { + // Legacy links predate the eight-stage catalogue: retain their original meaning. + OpportunityMaturity::constrainSearch($query, match ((int) $filters['maturity_level']) { + 7 => 'stage:implementation', + 8 => 'stage:operational', + default => 'historical:'.$filters['maturity_level'], + }); + } foreach (self::RELATIONS as $relation => [$foreign, $table]) { if (! empty($filters[$foreign])) { $locationIds = $relation === 'locations' @@ -141,13 +166,15 @@ class InvestmentCatalogue continue; } if (in_array($relation, ['clusters', 'corridors'], true)) { - $query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link') + $query->where(fn ($matches) => $matches->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link') ->whereColumn('link.opportunity_id', 'opportunities.id')->where('link.'.$foreign, $filters[$foreign]) - ->whereIn('link.'.$foreign, $this->publicStructureIds($relation, $foreign))); + ->whereIn('link.'.$foreign, $this->publicStructureIds($relation, $foreign))) + ->orWhereIn('opportunities.id', $associated[$foreign] ?? [])); } else { - $query->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link')->join($table.' as target', 'target.id', '=', 'link.'.$foreign) + $query->where(fn ($matches) => $matches->whereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_'.$relation.' as link')->join($table.' as target', 'target.id', '=', 'link.'.$foreign) ->whereColumn('link.opportunity_id', 'opportunities.id')->where('link.'.$foreign, $filters[$foreign])->whereNull('target.deleted_at') - ->whereIn('target.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign))); + ->whereIn('target.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign))) + ->orWhereIn('opportunities.id', $associated[$foreign] ?? [])); } } } @@ -187,7 +214,9 @@ class InvestmentCatalogue * @return array */ public function references(bool $management = false, array $territoryFilters = []): array { + $path = app(InvestmentGeography::class)->selection($territoryFilters); $result = ['classification' => app(OpportunityClassification::class)->options(), + 'preparationOptions' => OpportunityMaturity::searchOptions(), 'authorities' => DB::table('authorities')->where('status', 'published')->get(['id', 'label_pt_mz']), 'categories' => OpportunityManagement::CATEGORIES, 'options' => DB::table('investment_reference_options')->when(! $management, fn ($q) => $q->where('active', true))->orderBy('sort_order')->orderBy('label')->get(), @@ -205,18 +234,67 @@ class InvestmentCatalogue $query->whereIn('id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->pluck($foreign)); } } + if (! $management && $path !== [] && $relation !== 'locations') { + $dossiers = InvestmentPortfolioController::visible(); + app(InvestmentGeography::class)->constrainDossiersToTerritories($dossiers, + app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id)); + $query->whereIn('id', $dossiers->whereNotNull($foreign)->select($foreign)); + } $result[$relation] = $query->orderBy('name')->get(['id', 'name']); } $zones = app(InvestmentZones::class)->visible(); if (! $management && $territoryFilters !== []) { - $zones = app(InvestmentZones::class)->search($territoryFilters); + // Opportunity words/modalities are not names/categories of zones. + $zones = app(InvestmentZones::class)->search(\Illuminate\Support\Arr::only($territoryFilters, + ['province_id', 'district_id', 'admin_post_id', 'locality_id', 'province', 'district', 'geo_location_id'])); } $result['zones'] = $zones->orderBy('name')->get(['id', 'name', 'category']); + if (! $management && ($path !== [] || ! empty($territoryFilters['sector']))) { + $scope = \Illuminate\Support\Arr::only($territoryFilters, ['sector','province_id','district_id','admin_post_id','locality_id','include_national']); + $available = ['chain' => [], 'activity' => []]; + $this->query($scope)->reorder()->chunkById(200, function ($records) use (&$available) { + foreach ($records as $record) { + foreach (app(OpportunityClassification::class)->publicRows($record) as $row) { + if (isset($available[$row['target_type']])) $available[$row['target_type']][] = $row['target_id']; + } + foreach (['subsector_id','economic_activity_id'] as $key) { + $id = $record->workflow_context['publication']['taxonomy'][$key] ?? null; + if ($id) $available['activity'][] = $id; + } + } + }, 'opportunities.id', 'id'); + foreach ($available as $kind => $ids) { + $result['classification']['targets'][$kind] = collect($result['classification']['targets'][$kind])->whereIn('id', $ids)->values(); + } + } return $result; } + private function publicAssociatedIds(array $filters): array + { + $kinds = ['park_id' => ['park'], 'zee_id' => ['zone'], 'zfi_id' => ['zone'], + 'cluster_id' => ['cluster'], 'corridor_id' => ['corridor'], 'project_id' => ['project']]; + $selected = array_filter(array_intersect_key($filters, $kinds)); + foreach (['zee_id' => 'zee', 'zfi_id' => 'zfi', 'park_id' => 'industrial_park'] as $key => $category) { + if (isset($selected[$key]) && ! app(InvestmentZones::class)->visible()->whereKey($selected[$key])->where('category', $category)->exists()) unset($selected[$key]); + } + if ($selected === []) return []; + $ids = []; + Opportunity::published()->select('opportunities.*')->chunkById(200, function ($records) use ($selected, $kinds, &$ids) { + foreach ($records as $record) { + foreach (app(OpportunityClassification::class)->publicRows($record) as $row) { + if (in_array($row['purpose'], ['proximity', 'administrative_boundary'], true) || $row['state'] === 'rejected') continue; + foreach ($selected as $field => $target) { + if ($row['target_id'] === $target && in_array($row['target_type'], $kinds[$field], true)) $ids[$field][] = $record->id; + } + } + } + }); + return $ids; + } + private function publicStructureIds(string $relation, string $foreign): \Illuminate\Database\Query\Builder { $table = self::RELATIONS[$relation][1]; @@ -329,11 +407,11 @@ class InvestmentCatalogue DB::raw("CASE WHEN o.primary_territory_id=g.id OR EXISTS (SELECT 1 FROM opportunity_locations direct WHERE direct.opportunity_id=o.id AND direct.geo_location_id=g.id) THEN 'território declarado da oportunidade' ELSE 'contexto do projeto associado; implantação da oportunidade por confirmar' END AS basis")]) ->map(fn ($area) => ['geometry' => json_decode($area->geometry, true), 'title' => $area->title.' — '.$area->name.' ('.$area->basis.')', 'source_reference' => $area->source_system, 'source_date' => json_decode($area->properties ?? '{}', true)['source_date'] ?? null, - 'url' => '/oportunidades/'.$area->slug])->all(); + 'url' => '/oportunidades/'.$area->slug.($filters === [] ? '' : '?'.http_build_query($filters))])->all(); $located = 0; foreach ($records as $record) { $properties = ['id' => $record->id, 'title' => $record->title, 'category' => $record->sector?->name, - 'location' => trim($record->province.' '.$record->district), 'url' => '/oportunidades/'.$record->slug]; + 'location' => trim($record->province.' '.$record->district), 'url' => '/oportunidades/'.$record->slug.($filters === [] ? '' : '?'.http_build_query($filters))]; foreach ($relations as $relation => $groups) { $properties[$relation] = ($groups->get($record->id) ?? collect())->pluck('name')->unique()->values()->all(); } diff --git a/app/Services/OpportunityClassification.php b/app/Services/OpportunityClassification.php index d55f78d4..810aa8da 100644 --- a/app/Services/OpportunityClassification.php +++ b/app/Services/OpportunityClassification.php @@ -383,7 +383,7 @@ class OpportunityClassification $matches = $matches && collect($rows)->contains(fn ($r) => $r['authority_id'] === $filters['authority_id']); } if (! empty($filters['subsector_id'])) { - $matches = $matches && (($record->workflow_context['publication']['taxonomy']['subsector_id'] ?? '') === $filters['subsector_id'] || collect($rows)->contains(fn ($r) => $r['target_type'] === 'activity' && $r['target_id'] === $filters['subsector_id'])); + $matches = $matches && (in_array($filters['subsector_id'], array_intersect_key($record->workflow_context['publication']['taxonomy'] ?? [], array_flip(['subsector_id','economic_activity_id'])), true) || collect($rows)->contains(fn ($r) => $r['target_type'] === 'activity' && $r['target_id'] === $filters['subsector_id'])); } if ($matches) { $ids[] = $record->id; diff --git a/app/Services/OpportunityMaturity.php b/app/Services/OpportunityMaturity.php index 53a5ae73..e34975c5 100644 --- a/app/Services/OpportunityMaturity.php +++ b/app/Services/OpportunityMaturity.php @@ -24,6 +24,32 @@ final class OpportunityMaturity public const LEGACY_LABELS = [1 => 'Ideia identificada', 'Pré-estudo disponível', 'Estudo de viabilidade em curso', 'Projecto estruturado', 'Pronto para investimento', 'Em negociação ou implementação']; + /** Public selection is explicit; historical numbers never stand for current stages. */ + public static function searchOptions(): array + { + $options = []; + foreach (self::STAGES as $code => $label) { + $options[] = ['value' => 'stage:'.$code, 'label' => $label, 'group' => 'Etapas de preparação']; + } + foreach (self::LEGACY_LABELS as $level => $label) { + $options[] = ['value' => 'historical:'.$level, 'label' => $label.' — registo histórico', 'group' => 'Classificações históricas']; + } + return $options; + } + + public static function constrainSearch(\Illuminate\Database\Eloquent\Builder $query, string $selection): void + { + [$kind, $value] = explode(':', $selection, 2); + if ($kind === 'stage') { + $query->where('workflow_context->publication->maturity->catalogue_version', self::VERSION) + ->where('workflow_context->publication->maturity->stage', $value); + } else { + $query->where(fn ($q) => $q->where('workflow_context->publication->maturity->catalogue_version', self::LEGACY) + ->orWhere(fn ($old) => $old->whereNull('workflow_context->publication->maturity->catalogue_version')->whereNull('workflow_context->publication->maturity->stage'))) + ->where('maturity_level', (int) $value); + } + } + /** @param array $data * @return array */ public function normalize(array $data): array diff --git a/docs/investmoz/unification/opportunity-search-qa-manifest.json b/docs/investmoz/unification/opportunity-search-qa-manifest.json new file mode 100644 index 00000000..941280f6 --- /dev/null +++ b/docs/investmoz/unification/opportunity-search-qa-manifest.json @@ -0,0 +1,155 @@ +{ + "branch": "codex/opportunity-search", + "base_commit": "1350b4df73e70ab35a28d923402ded7d836ade38", + "date": "2026-10-20", + "scope": "QA only; review before main integration", + "qa_url": "http://127.0.0.1:18570/oportunidades", + "files": [ + { + "path": "app/Http/Controllers/Api/V1/OpportunityController.php", + "sha256": "04fd696ce73fae8660491976781ef1bd96898a30af96363460427e7a9d139fe1" + }, + { + "path": "app/Http/Controllers/InvestmentDiscoveryController.php", + "sha256": "a7301ce6c6285d8ec374b7013b8f0a0bb9951da83625acc47da223d2c4275bbf" + }, + { + "path": "app/Services/InvestmentCatalogue.php", + "sha256": "4b44e18ca12c890e0220913c972ca6092e29f8975dfbc6d292b579b95f1c5e21" + }, + { + "path": "app/Services/OpportunityClassification.php", + "sha256": "f9a24067807fa620142839ab9873dde465457fcfc61f501d1c8309534a6da175" + }, + { + "path": "app/Services/OpportunityMaturity.php", + "sha256": "1cd73acb486d8e3ffa52c7419e48b9d69b450bb343f04664cd1a2b51e3317246" + }, + { + "path": "docs/investmoz/unification/traceability-and-manual.md", + "sha256": "e5400717643bde98a3b1e1163c4a4ebd4c6a774a168a3f5430c46cc3f850f5d8" + }, + { + "path": "docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv", + "sha256": "5d1de3418c5b021a0520bc6494a6a5efcef665ad1d20a45b11ae6aa702bedb01" + }, + { + "path": "docs/investmoz/v2/auditoria-portal/backlog.md", + "sha256": "303b53346af2dad908f9d40d02c14a8f245155897dacf8b2538cdaae5a92bea7" + }, + { + "path": "docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv", + "sha256": "4bbf47f7b5bfe54d2dd3b891eca3c04555329e92064c3bc6722a60c2f8eab4ce" + }, + { + "path": "resources/js/components/catalogue-filter-context.tsx", + "sha256": "b155d5e812b52fc52677cb9a8d44068f64821732c90a82dabb521eed44af047e" + }, + { + "path": "resources/js/components/catalogue-navigation.tsx", + "sha256": "88eccc6a9c2803fd26b678081f5da244a9c4ac9227664e7d0f1b57050b437239" + }, + { + "path": "resources/js/components/country-select.tsx", + "sha256": "53ef3742e4c55668d0ad85139be742405f5d1524196b3b1b3f3700c7bc311742" + }, + { + "path": "resources/js/components/investment-map-panel.tsx", + "sha256": "cfdb99b7dfa405d740f68cecef2b0df38488d2f63dc623da47a000037f0ec01e" + }, + { + "path": "resources/js/components/investment-territory.tsx", + "sha256": "35cef961efb51709cba271556f5e7c717b673ebbca480b680e37654c38cde324" + }, + { + "path": "resources/js/components/opportunity-classification.tsx", + "sha256": "fb4d843c8c056f10c34baa5ab9f2ba324e9796456a20455303b7a3b0747ee6cd" + }, + { + "path": "resources/js/components/opportunity-search-filters.tsx", + "sha256": "85e48e41537a5932e0398b7bc4bddd5322431ae517a91de2d53e83ef7927e54d" + }, + { + "path": "resources/js/components/search-feedback.tsx", + "sha256": "23ed85878e5b55a40e01928236f870f6dff4c2a7e19547beaa81a0a7d59c9c06" + }, + { + "path": "resources/js/components/search-state.tsx", + "sha256": "45cc210783625bcf96712064a0e7dd97f5681c499cdf60cedbe0e4127cba7aab" + }, + { + "path": "resources/js/lib/catalogue-context.ts", + "sha256": "c499ebda27b7672b55ea3e3da98c63c7d2d2cd490dcde77df3cc38e09792af73" + }, + { + "path": "resources/js/lib/portal-navigation.ts", + "sha256": "0d3bae6d726538b064cd992e6d433245321951c18198de5db540993232e6daaf" + }, + { + "path": "resources/js/pages/investment/business-marketplace.tsx", + "sha256": "fb6917c095f5ade85f52adeaf1292486627097e4f0daf0211f2b3627620ccd85" + }, + { + "path": "resources/js/pages/investment/catalogue.tsx", + "sha256": "9ea5742950e2737bebb7bd7f04ee24129c7f9917c1986cf070a3e282e7a22981" + }, + { + "path": "resources/js/pages/investment/detail.tsx", + "sha256": "c40926782a183fcde27415e0eda276f73189f071ba6ce715e79d007eb6388634" + }, + { + "path": "resources/js/pages/investment/shared.tsx", + "sha256": "f71d13f0e0b5b0e152cf262f02b9e0770a58d32472ac878aa0adbca53d2edf3f" + }, + { + "path": "resources/js/pages/investment/zone.tsx", + "sha256": "8cd64fb62af5e2d4582c46298568429e61e45247fca1b072287ad35ae07d76db" + }, + { + "path": "resources/js/pages/investment/zones.tsx", + "sha256": "45244ce4be7e75b4d52c453f90d1764cd4d7a5ab4299d6f0af91c68fbf103ebd" + }, + { + "path": "resources/js/pages/portal/services.tsx", + "sha256": "6f26cace5d1808f6d563d0d8b0ebed6b44748abed7205e465d9658f8b9cb2d17" + }, + { + "path": "resources/js/pages/portfolio/index.tsx", + "sha256": "2662cfe05c387cb876f8c352bd83a5a1d68056c615333e238ecb4e7b5489eb00" + }, + { + "path": "resources/js/pages/portfolio/shared.tsx", + "sha256": "27b9d1c8dbced48532e7a57ce184810a32e3dea7cdc3a37a549dc7fbe8c04426" + }, + { + "path": "resources/js/pages/portfolio/show.tsx", + "sha256": "51883858d09c6b07f281426fa9ec149c4de9f12286cc88b285caee01e892c892" + }, + { + "path": "tests/Feature/OpportunitySearchTest.php", + "sha256": "b2d57a838849a7d1c9e5b43c3f471d3e5d78c59a7cdb8e0f81f74de5d886d7d3" + }, + { + "path": "tests/Support/OpportunitySearchFixture.php", + "sha256": "46804da2f403c031ad206211db07f41f3f20dbf4f789f838e278e3f241d021f1" + }, + { + "path": "tests/frontend/portal-navigation.test.mjs", + "sha256": "f19ff2d17a385ab1f28c65f69233a13d88d6b8b6ec7285848fcd2a945b883ff9" + } + ], + "build_manifest_sha256": "2e2113d46d7f89ff94851b88a8d023ca4427d57d4f1361bf9577aef8d3aa64af", + "migrations": [], + "data_operations": [], + "remote_operations": [], + "new_dependencies": [], + "permissions_changes": false, + "proofs": "outputs/opportunity-search-20261020/receipt.json", + "pending": [ + "Tempo review before main integration", + "Natural-language interpretation not implemented; evidence_only remains separate", + "Eight contractual gaps and P7 unchanged" + ], + "rollback": "Restore previous source/build using existing release chain; keep database, uploads, permissions and later data.", + "hash_basis": "Git index bytes; line endings normalized by repository attributes. Manifest itself excluded." +} diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 5c79c1ce..d1ea6d9d 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -1465,3 +1465,57 @@ Recuperação: parar a path unit, aguardar o executor e usar `python3 /var/www/d As correcções do ensaio limitaram-se à composição: diagnóstico mais recente dos programadores, teste de exemplo sem migração inútil, fixtures unitárias independentes das flags do destino, expectativas dos menus/relatórios já aceites e guarda do cookie fora do navegador, com prova de CSRF preservado. Nenhuma nova regra organizacional. Provas: outputs/investmoz-git-delivery-20261020/RECIBO-DISPONIBILIZACAO-GIT-MAIN.json; PROVA-ENSAIO-COMPLETO.json; PROVA-CONTROLO-FINAL-LINUX.json; PROVA-GITEA-EVENTO-E-INSTALACAO.json; PROVA-VISUAL-GIT-MAIN.png; deploy/homologacao/README.md; /build/revision.json. Manual versionado: `deploy/homologacao/README.md`. Esta entrega encerra o fluxo automático, não os restantes componentes da aplicação. As alterações documentais anteriores no checkout local foram preservadas. + + +## Pesquisa transversal QA — 20/10/2026 + +Origem: instruções expressas da Tempo nesta conversa. A classificação multidimensional, dados, permissões, histórico e URLs mantêm-se. Os ajustes de interface/consulta são implementação do pedido, não novas regras organizacionais. Branch `codex/opportunity-search`, base `1350b4df`, QA HTTP `http://127.0.0.1:18570`. Não integrar em `main` antes da revisão: a integração desencadeia disponibilização automática. + +**Causa reproduzida de 11 versus 0:** em `/oportunidades?sector=turismo` no destino, o selector provincial apresentava 11 opções através de `SearchState` como “11 resultados encontrados”, enquanto o paginador devolvia zero oportunidades públicas. São universos diferentes. Agora o primeiro diz “Província: 11 opções disponíveis”; o catálogo usa apenas o total do seu paginador. Não foram alterados dados para obter concordância. Foram ainda retirados o resumo global duplicado desta página, sugestões com critérios incompletos e resultados de parcerias que tinham pesquisa própria. + +| Ecrã/URL | Problema | Correcção | Prova | Pendência/limite | +|---|---|---|---|---| +| Oportunidades — /oportunidades | Filtros duplicados, códigos internos, maturidades cruzadas, contagens concorrentes e mapa em grelha. | Filtro único, Mais filtros, preparação explícita, moeda obrigatória para montantes, pedidos canceláveis, estados e contexto únicos. | 6 testes PHP/112 asserções; navegador: parque, erro/repetição, moeda, vazio, retorno, hierarquia e teclado. | Revisão visual da Tempo; NLP separado. | +| Negócios — /negocios | Classificação técnica visível, estados de auditoria e contagem territorial sem âmbito. | Sector básico, critérios especializados recolhidos em Mais filtros, estado de auditoria retirado do controlo público, contagem identificada e filtros removíveis. | HTTP/Inertia: universo results.meta.total=0; selector partilhado e código inspeccionados. | Nenhum contrato foi habilitado; oito lacunas contratuais mantidas fora deste lote. | +| Sectores — /sectores; /sectores/{slug} | Navegação relacionada dispersa e contagens genéricas sem nome do universo. | Contagem identificada como Sectores; navegação comum preserva a entrada por sector e Fintech como especialização. | Inspecção de PublicPortalController, SectorOrganization e portalNavigation; testes de navegação. | Sem pesquisa autónoma no catálogo de sectores: não foi inventado um novo filtro. | +| Localizações — /oportunidades?view=map | Mapa e contagem de oportunidades podiam usar universos diferentes sem explicação. | Mesmos filtros e permissões; locais com coordenadas e oportunidades sem coordenadas apresentados separadamente; fichas de contexto identificadas. | Teste de associação/lista/exportação; mapa do serviço exclui proximidade e preserva oportunidade sem coordenadas. | Nenhuma coordenada ou geometria criada. | +| Carteira — /carteira | Menu local repetido, ausência de retorno explícito e de distinção entre ficha e pesquisa associada. | Contagem Fichas da carteira, chips removíveis, menu local redundante retirado, Consultar ficha e Ver oportunidades associadas; retorno com query local. | HTTP/Inertia de vazio; navegador de vazio; teste de contexto e navegação. | Provas anteriores de edição/publicação conservadas; não repetidas. | +| Zonas e parques — /zonas; /zonas/{id} | Destinos repetidos, vazio atribuído indevidamente a falta de cadastro, retorno sem filtros. | Destinos relacionados recolhidos; vazio neutro; consultar ficha separado de oportunidades associadas; retorno e exportação preservam filtros. | HTTP/Inertia de vazio; inspecção da query/exportação e helpers de retorno. | Disponibilidade/incentivos continuam sujeitos aos dados de cada ficha. | +| Clusters — /clusters → /carteira?kind=cluster | Mesmo padrão da carteira; não é um catálogo independente de oportunidades. | Reutiliza carteira, contagem nomeada e tipo de ficha removível; URL anterior preservada. | Redireccionamento verificado no teste PHP; consumidor comum inspeccionado. | Nenhuma equivalência entre implantação e associação criada. | +| Corredores — /corredores → /carteira?kind=corridor | Mesmo padrão da carteira e risco de confundir ficha territorial com oportunidade. | Reutiliza carteira e ligações com finalidade explícita; URL anterior preservada. | Redireccionamento verificado no teste PHP; consumidor comum inspeccionado. | Nenhuma alteração de relações ou dados. | +| Educação — /educacao | Herda formulário e contagens concorrentes das oportunidades. | Reutiliza integralmente a pesquisa corrigida com âmbito educação mantido. | HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns. | Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões. | +| Biblioteca — /documentos | Dois resumos de contagem, filtros activos sem remoção individual, controlos sem opções e rodapé local repetido. | Resumo único nomeado Documentos, chips removíveis, controlos vazios ocultos sem apagar valores seleccionados, rodapé comum. | HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado. | Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva. | +| Legislação — /legislacao | Mesmo padrão da biblioteca; não deve expor um universo concorrente. | Reutiliza a correcção da biblioteca e preserva filtros jurídicos/documentais pertinentes. | HTTP/Inertia: resources.total=0; consumidor e query pública partilhados. | Situação jurídica, consulta e cálculo continuam independentes. | + + +### Utilização + +1. Em oportunidades, preencher palavras, sector, modalidade e localização até ao nível conhecido. Usar “Mais filtros” para estruturas, actividades e cadeias. As opções territoriais não são a contagem das oportunidades. +2. Para limites financeiros, escolher a moeda. Sem moeda, a pesquisa pede esse campo; não assume MZN nem converte montantes. Preparação tem etapas actuais e classificações históricas separadas. +3. Retirar um critério pelo seu botão/ligação ou usar “Limpar filtros”. Alterar o pai territorial limpa os descendentes incompatíveis. Abrir a ficha e usar “Voltar à pesquisa” recupera os critérios. +4. “Consultar ficha” consulta a estrutura territorial/documental; “Ver oportunidades associadas” pesquisa vínculos publicados. Proximidade ou o mesmo distrito não demonstram pertença. No mapa, registos sem coordenadas continuam identificados na lista, sem posições inventadas. +5. Durante o pedido aparece “A pesquisar…”. Um erro apresenta “Tentar novamente” e preserva critérios; não aparece como resultado vazio. Pedidos anteriores são cancelados/ignorados ao mudar os filtros. Os estados têm `status`/`alert` e `aria-live`. + +### Compatibilidade e provas + +- A pesquisa antiga `maturity_level=1..6` conserva a classificação histórica, sem misturar a posição com a etapa actual. As posições 7/8, exclusivas das oito etapas, correspondem explicitamente a implementação/operacional. Novas selecções usam código de etapa ou classificação histórica explícita, sem reescrever os registos. +- Balama real, ID `ed4d2fba-95d5-42e3-8ca7-1f520de1036a`: leitura pública do destino devolveu zero oportunidades associadas. Na cópia QA, essa ficha real está em rascunho; não foi publicada. A prova positiva usa um parque e oportunidades **SINTÉTICO QA**, incluindo associação preliminar, proximidade, território comum, outro parque e rascunho privado. Só a associação pública correspondente entra no filtro. +- QA isolado `opportunity_search_20261020_test`, PostgreSQL local porta 55441; guarda impede testes na base activa. Testes transaccionais revertem apenas fixtures próprias; não foi executado `migrate:fresh`. +- Seis testes PHP, 112 asserções: associação, confidencialidade, modalidades, maturidades distintas, moeda/combinações/vazio, referências, lista/exportação e HTTP dos consumidores/aliases. Seis testes frontend: navegação, flags, destinos únicos, quatro entradas e retorno local com query. Build e TypeScript conferidos no recibo final. +- Navegador: consulta → ficha → retorno; parque isolado; validação de moeda → vazio; falha HTTP sintética de um único pedido → nova tentativa bem-sucedida; Inhambane/Vilankulo → Gaza limpa distrito e postos. A falha existe apenas no router privado do servidor QA, fora do pacote. +- Móvel: dimensão real 390 px, largura de conteúdo 375 px, sem transbordo horizontal; teclado abre/fecha Mais filtros e Descoberta. A capacidade automática de viewport não aplicou a dimensão; a prova utilizou o modo responsivo do navegador. Não se atribui essa limitação à aplicação. +- Antes/depois e estados: artefactos locais `opportunity-search-artifacts/{before-search.png,before-search-ax.txt,qa-after.png,qa-mobile.png,qa-error.png,qa-zero.png,backend-tests.log}`. As capturas do destino e QA usam acervos diferentes; os totais entre ambientes não são uma comparação de dados. +- Inventário de reutilização de `investment-territory.tsx`: oportunidades, negócios, carteira, zonas, educação, análise educativa, formulário/recursos/editor de oportunidades, estruturas, associações territoriais, contexto do investimento, pedido do Balcão, terra/DUAT, formulários públicos e assistência existente. A alteração comum identifica o âmbito da contagem; não demonstra de novo edição/autorização desses consumidores privados. `CountrySelect` também identifica Países; `InvestmentMapPanel` identifica o universo cartográfico. +- “Catatálogo” não foi encontrado nas fontes actuais inspeccionadas. O título servido usa “Catálogo”. O menu local redundante da carteira que exibia “InvestMoz” foi retirado; a identidade comum continua InvesteMoz, propriedade da Tempo. + +### Assistência à pesquisa — saldo separado + +`config/investmoz_ai.php`, `AiGateway` e `AssistiveRules` mostram pesquisa lexical/evidências e regras sobre campos estruturados, com `model=null`, sem pesquisa semântica. Não interpretam a frase “turismo em Inhambane até 500 mil USD” como filtros. `evidence_only` não comprova NLP. Nenhum serviço externo, custo ou envio de dados foi activado. + +Incremento ainda necessário: interpretação delimitada de palavras/sector/geografia/montante/moeda; prévia editável dos filtros; pergunta apenas quando ambíguo; confirmação antes de executar exactamente a query manual; ausência de resultados sem alargamento silencioso. Estimativa de engenharia: 6–10 h para um interpretador local delimitado e casos de ambiguidade/testes; compreensão aberta com modelo externo exige proposta e decisão próprias. Esta estimativa não inclui as oito lacunas contratuais nem P7 e não bloqueia as correcções manuais. + +### Integração e reversão + +Rever a branch e o manifesto nominal; reconciliar novos commits dos programadores antes da integração. Após decisão da Tempo, a cadeia Git existente compila a revisão aprovada de fontes e build. Esta execução não envia para `main`, não escreve no servidor e não altera conteúdos, schema, contas, papéis, MFA ou configuração. + +Não há migrations, importação ou publicação neste pacote. A reversão repõe as fontes/build da revisão anterior através da cadeia existente, preservando a base, uploads, referências, permissões e dados posteriores. Os helpers de contexto só transportam query local; não aceitam URL externa como destino de retorno. O manifesto lista fontes e hash do build QA; não inclui `.env`, credenciais, dumps, vendor/node_modules ou fixtures persistentes. diff --git a/docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv b/docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv index effb733b..ba423088 100644 --- a/docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv +++ b/docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv @@ -7370,3 +7370,14 @@ P2,AUD-COMPANY-02,Sem evidência suficiente,Modelos societários específicos ai P2,AUD-CAPITAL-01,Não implementado,Guião estruturado de capitalização não implementado.,Biblioteca e passos existentes do percurso,"Documentos/entidades/fontes/parâmetros próprios antes de implementação; sem novos módulos nesta auditoria. Guião com opções, riscos, preparação/documentos e fonte aplicável; sem promessa de retorno. Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.","Consulta pública com fontes/versões, instrumentos distinguidos, riscos/documentos e próxima ação; guardar referência no dossier correto; sem recomendar sucesso nem assumir compromisso.",Preservar fechos; correções concretas→provas de consumidor→decisões isoladas→incrementos futuros,não implementado,"Não foi demonstrado guia estruturado de capitalização. O pedido não autoriza custódia, intermediação, promessa de retorno ou aprovação dos módulos de oferta.","Reutilizar biblioteca/guia público, dossier e parceiros autorizados; manter separado de ofertas experimentais. RF-CRM-009 e regras de facilitação são relações existentes, sem equivalência automática com este guia.",Exemplar v1(3) ainda não confirmado; fontes/dados/atos concretos próprios. Nenhuma nova regra ou divulgação inferida.,CAP-001 / incremento posterior ao workflow das seis categorias,[Segmentação bf26caeee043 — identidade confirmada; nome local v1.docx] Alias documental CAP-001; SEG-bf26caee-p153; SEG-bf26caee-p154; SEG-bf26caee-p155; SEG-bf26caee-p702,"Orientação sobre private equity e capitalização, preparação, riscos, documentos e percurso",Biblioteca e passos existentes do percurso,Guião estruturado de capitalização não implementado.,"Documentos/entidades/fontes/parâmetros próprios antes de implementação; sem novos módulos nesta auditoria. Guião com opções, riscos, preparação/documentos e fonte aplicável; sem promessa de retorno.",Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,"Nova funcionalidade/expansão; preparar execução, não implementar nesta auditoria",Estimativa partilhada por lote em visao-consolidada-modulos.md; não somar por frase. Grupo: incrementos futuros.,incrementos futuros,segmentacao-processos-reconciliacao-20261011.md; contratos/rotas inventariados atuais; não há prova de percurso completo deste incremento.,,,,,16–24 h após conteúdo/âmbito definidos; não inclui intermediação. P2,AUD-CAPITAL-02,Não implementado,"Guião da Bolsa por segmento, intervenientes e requisitos não implementado.","Biblioteca, checklist e percurso existentes","Documentos/entidades/fontes/parâmetros próprios antes de implementação; sem novos módulos nesta auditoria. Fonte por requisito, condições/períodos identificados e decisões externas separadas. Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.","Etapas, entidade, base/artigo/redação, documentos, requisitos por segmento e lacunas explícitas; recuperação no dossier e encaminhamento permitido, sem integração externa simulada.",Preservar fechos; correções concretas→provas de consumidor→decisões isoladas→incrementos futuros,não implementado,"Guião por segmento, documentos, condições e fonte aplicável ainda não demonstrado. Não inferir valores, prazos, prospectos, taxas ou aprovação pelos artigos1–3.",Reutilizar guia/biblioteca/dossier/checklists. Relacionar pedido de admissão e patrocínio às referências específicas; separar ação interna do portal e ato de entidade competente.,Exemplar v1(3) ainda não confirmado; fontes/dados/atos concretos próprios. Nenhuma nova regra ou divulgação inferida.,CAP-002 / incremento posterior ao workflow das seis categorias,[Segmentação bf26caeee043 — identidade confirmada; nome local v1.docx] Alias documental CAP-002; SEG-bf26caee-p153; SEG-bf26caee-p154; SEG-bf26caee-p155; SEG-bf26caee-p709,"Guião de admissão à Bolsa de Valores de Moçambique, distinguindo Bolsa, regulador e operadores","Biblioteca, checklist e percurso existentes","Guião da Bolsa por segmento, intervenientes e requisitos não implementado.","Documentos/entidades/fontes/parâmetros próprios antes de implementação; sem novos módulos nesta auditoria. Fonte por requisito, condições/períodos identificados e decisões externas separadas.",Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,"Nova funcionalidade/expansão; preparar execução, não implementar nesta auditoria",Estimativa partilhada por lote em visao-consolidada-modulos.md; não somar por frase. Grupo: incrementos futuros.,incrementos futuros,segmentacao-processos-reconciliacao-20261011.md; contratos/rotas inventariados atuais; não há prova de percurso completo deste incremento.,,,,,16–32 h após correspondências normativas; não preencher taxas/prazos ausentes. P2,AUD-CHECKLIST-01,Implementado parcialmente,Checklists existem em configuração; editor/versionamento administrativo sem código e adaptação de submissões antigas não demonstrados.,"Configurações existentes, schema, biblioteca e auditoria","Documentos/entidades/fontes/parâmetros próprios antes de implementação; sem novos módulos nesta auditoria. Publicar versão autorizada, novas submissões usam-na e anteriores preservam a versão aplicada. Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.","Gestor autorizado configura/previsualiza/publica versão, investidor recebe versão aplicável ao contexto/data, guarda incompleto e recupera; nova versão não modifica submissão anterior nem trata decisão interna como licença.",Preservar fechos; correções concretas→provas de consumidor→decisões isoladas→incrementos futuros,parcial,"Checklists e catálogos configurados existentes; editor sem alterar código/publicação versionada por entidade não demonstrados. Separar obrigações comerciais, registo/publicidade e licença setorial.","Reutilizar config/document-requirements.php, requisitos/formulários/documentos/tarefas e serviços do Balcão existentes. Não duplicar motor, tabelas ou as cinco fases/19 etapas.",Exemplar v1(3) ainda não confirmado; fontes/dados/atos concretos próprios. Nenhuma nova regra ou divulgação inferida.,INV-007 / incremento posterior ao workflow das seis categorias,[Segmentação bf26caeee043 — identidade confirmada; nome local v1.docx] Alias documental INV-007; SEG-bf26caee-p14; SEG-bf26caee-p15; SEG-bf26caee-p20; SEG-bf26caee-p23; SEG-bf26caee-p26; SEG-bf26caee-p29; SEG-bf26caee-p32; SEG-bf26caee-p35; SEG-bf26caee-p38; SEG-bf26caee-p41; SEG-bf26caee-p44; SEG-bf26caee-p47; SEG-bf26caee-p50; SEG-bf26caee-p53; SEG-bf26caee-p56; SEG-bf26caee-p59; SEG-bf26caee-p62; SEG-bf26caee-p65; SEG-bf26caee-p68; SEG-bf26caee-p71; SEG-bf26caee-p74; SEG-bf26caee-p77; SEG-bf26caee-p80; SEG-bf26caee-p83; SEG-bf26caee-p87; SEG-bf26caee-p104; SEG-bf26caee-p109; SEG-bf26caee-p114; SEG-bf26caee-p119; SEG-bf26caee-p124; SEG-bf26caee-p129; SEG-bf26caee-p134; SEG-bf26caee-p140; SEG-bf26caee-p144; SEG-bf26caee-p145; SEG-bf26caee-p146; SEG-bf26caee-p147; SEG-bf26caee-p160; SEG-bf26caee-p161; SEG-bf26caee-p163; SEG-bf26caee-p164; SEG-bf26caee-p165; SEG-bf26caee-p166; SEG-bf26caee-p167; SEG-bf26caee-p168; SEG-bf26caee-p169; SEG-bf26caee-p170; SEG-bf26caee-p176; SEG-bf26caee-p180; SEG-bf26caee-p184; SEG-bf26caee-p188; SEG-bf26caee-p192; SEG-bf26caee-p196; SEG-bf26caee-p200; SEG-bf26caee-p204; SEG-bf26caee-p208; SEG-bf26caee-p212; SEG-bf26caee-p216; SEG-bf26caee-p220; SEG-bf26caee-p226; SEG-bf26caee-p227; SEG-bf26caee-p228; SEG-bf26caee-p229; SEG-bf26caee-p230; SEG-bf26caee-p231; SEG-bf26caee-p232; SEG-bf26caee-p233; SEG-bf26caee-p234; SEG-bf26caee-p237; SEG-bf26caee-p242; SEG-bf26caee-p247; SEG-bf26caee-p252; SEG-bf26caee-p257; SEG-bf26caee-p262; SEG-bf26caee-p267; SEG-bf26caee-p272; SEG-bf26caee-p277; SEG-bf26caee-p282; SEG-bf26caee-p289; SEG-bf26caee-p293; SEG-bf26caee-p297; SEG-bf26caee-p301; SEG-bf26caee-p305; SEG-bf26caee-p309; SEG-bf26caee-p313; SEG-bf26caee-p317; SEG-bf26caee-p321; SEG-bf26caee-p325; SEG-bf26caee-p329; SEG-bf26caee-p338; SEG-bf26caee-p342; SEG-bf26caee-p346; SEG-bf26caee-p350; SEG-bf26caee-p354; SEG-bf26caee-p358; SEG-bf26caee-p362; SEG-bf26caee-p366; SEG-bf26caee-p370; SEG-bf26caee-p374; SEG-bf26caee-p378; SEG-bf26caee-p382; SEG-bf26caee-p386; SEG-bf26caee-p390; SEG-bf26caee-p394; SEG-bf26caee-p398; SEG-bf26caee-p402; SEG-bf26caee-p407; SEG-bf26caee-p410; SEG-bf26caee-p481; SEG-bf26caee-p565; SEG-bf26caee-p648; SEG-bf26caee-p681; SEG-bf26caee-p756; SEG-bf26caee-p759; SEG-bf26caee-p762; SEG-bf26caee-p765; SEG-bf26caee-p768; SEG-bf26caee-p771; SEG-bf26caee-p787; SEG-bf26caee-p788; SEG-bf26caee-p789; SEG-bf26caee-p790; SEG-bf26caee-p791; SEG-bf26caee-p792,"Editor de checklists e formulários configuráveis, publicação de versões e preservação de submissões anteriores","Configurações existentes, schema, biblioteca e auditoria",Checklists existem em configuração; editor/versionamento administrativo sem código e adaptação de submissões antigas não demonstrados.,"Documentos/entidades/fontes/parâmetros próprios antes de implementação; sem novos módulos nesta auditoria. Publicar versão autorizada, novas submissões usam-na e anteriores preservam a versão aplicada.",Nenhuma nova decisão identificada para reconciliar/demonstrar esta cláusula. Decisões específicas só após localizar a regra correspondente.,"Nova funcionalidade/expansão; preparar execução, não implementar nesta auditoria",Estimativa partilhada por lote em visao-consolidada-modulos.md; não somar por frase. Grupo: incrementos futuros.,incrementos futuros,segmentacao-processos-reconciliacao-20261011.md; contratos/rotas inventariados atuais; não há prova de percurso completo deste incremento.,,,,,"24–40 h após política de alteração; reaproveitar motor, sem catálogo paralelo." +P1,AUD-SEARCH-T01,QA — revisão Tempo pendente,Revisão visual da Tempo; NLP separado.,"Filtro único, Mais filtros, preparação explícita, moeda obrigatória para montantes, pedidos canceláveis, estados e contexto únicos.",,"6 testes PHP/112 asserções; navegador: parque, erro/repetição, moeda, vazio, retorno, hierarquia e teclado.",,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,investment/catalogue,Revisão visual da Tempo; NLP separado.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,"6 testes PHP/112 asserções; navegador: parque, erro/repetição, moeda, vazio, retorno, hierarquia e teclado.",,,,Apenas delta identificado; não certifica funcionalidades não alteradas., +P1,AUD-SEARCH-T02,QA — revisão Tempo pendente,Nenhum contrato foi habilitado; oito lacunas contratuais mantidas fora deste lote.,"Sector básico, critérios especializados recolhidos em Mais filtros, estado de auditoria retirado do controlo público, contagem identificada e filtros removíveis.",,HTTP/Inertia: universo results.meta.total=0; selector partilhado e código inspeccionados.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,investment/business-marketplace,Nenhum contrato foi habilitado; oito lacunas contratuais mantidas fora deste lote.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia: universo results.meta.total=0; selector partilhado e código inspeccionados.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas., +P1,AUD-SEARCH-T03,QA — revisão Tempo pendente,Sem pesquisa autónoma no catálogo de sectores: não foi inventado um novo filtro.,Contagem identificada como Sectores; navegação comum preserva a entrada por sector e Fintech como especialização.,,"Inspecção de PublicPortalController, SectorOrganization e portalNavigation; testes de navegação.",,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,portal/services,Sem pesquisa autónoma no catálogo de sectores: não foi inventado um novo filtro.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,"Inspecção de PublicPortalController, SectorOrganization e portalNavigation; testes de navegação.",,,,Apenas delta identificado; não certifica funcionalidades não alteradas., +P1,AUD-SEARCH-T04,QA — revisão Tempo pendente,Nenhuma coordenada ou geometria criada.,Mesmos filtros e permissões; locais com coordenadas e oportunidades sem coordenadas apresentados separadamente; fichas de contexto identificadas.,,Teste de associação/lista/exportação; mapa do serviço exclui proximidade e preserva oportunidade sem coordenadas.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,investment/catalogue,Nenhuma coordenada ou geometria criada.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,Teste de associação/lista/exportação; mapa do serviço exclui proximidade e preserva oportunidade sem coordenadas.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas., +P1,AUD-SEARCH-T05,QA — revisão Tempo pendente,Provas anteriores de edição/publicação conservadas; não repetidas.,"Contagem Fichas da carteira, chips removíveis, menu local redundante retirado, Consultar ficha e Ver oportunidades associadas; retorno com query local.",,HTTP/Inertia de vazio; navegador de vazio; teste de contexto e navegação.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,portfolio/index; portfolio/show,Provas anteriores de edição/publicação conservadas; não repetidas.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia de vazio; navegador de vazio; teste de contexto e navegação.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas., +P1,AUD-SEARCH-T06,QA — revisão Tempo pendente,Disponibilidade/incentivos continuam sujeitos aos dados de cada ficha.,Destinos relacionados recolhidos; vazio neutro; consultar ficha separado de oportunidades associadas; retorno e exportação preservam filtros.,,HTTP/Inertia de vazio; inspecção da query/exportação e helpers de retorno.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,investment/zones; investment/zone,Disponibilidade/incentivos continuam sujeitos aos dados de cada ficha.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia de vazio; inspecção da query/exportação e helpers de retorno.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas., +P1,AUD-SEARCH-T07,QA — revisão Tempo pendente,Nenhuma equivalência entre implantação e associação criada.,"Reutiliza carteira, contagem nomeada e tipo de ficha removível; URL anterior preservada.",,Redireccionamento verificado no teste PHP; consumidor comum inspeccionado.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,portfolio/index,Nenhuma equivalência entre implantação e associação criada.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,Redireccionamento verificado no teste PHP; consumidor comum inspeccionado.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas., +P1,AUD-SEARCH-T08,QA — revisão Tempo pendente,Nenhuma alteração de relações ou dados.,Reutiliza carteira e ligações com finalidade explícita; URL anterior preservada.,,Redireccionamento verificado no teste PHP; consumidor comum inspeccionado.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,portfolio/index,Nenhuma alteração de relações ou dados.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,Redireccionamento verificado no teste PHP; consumidor comum inspeccionado.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas., +P1,AUD-SEARCH-T09,QA — revisão Tempo pendente,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,Reutiliza integralmente a pesquisa corrigida com âmbito educação mantido.,,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,investment/catalogue,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas., +P1,AUD-SEARCH-T10,QA — revisão Tempo pendente,Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,"Resumo único nomeado Documentos, chips removíveis, controlos vazios ocultos sem apagar valores seleccionados, rodapé comum.",,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,portal/services; portal/document,Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas., +P1,AUD-SEARCH-T11,QA — revisão Tempo pendente,"Situação jurídica, consulta e cálculo continuam independentes.",Reutiliza a correcção da biblioteca e preserva filtros jurídicos/documentais pertinentes.,,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,portal/services; portal/document,"Situação jurídica, consulta e cálculo continuam independentes.",,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas., diff --git a/docs/investmoz/v2/auditoria-portal/backlog.md b/docs/investmoz/v2/auditoria-portal/backlog.md index cc392eb0..06d3c259 100644 --- a/docs/investmoz/v2/auditoria-portal/backlog.md +++ b/docs/investmoz/v2/auditoria-portal/backlog.md @@ -2985,3 +2985,8 @@ Recuperação: parar a path unit, aguardar o executor e usar `python3 /var/www/d As correcções do ensaio limitaram-se à composição: diagnóstico mais recente dos programadores, teste de exemplo sem migração inútil, fixtures unitárias independentes das flags do destino, expectativas dos menus/relatórios já aceites e guarda do cookie fora do navegador, com prova de CSRF preservado. Nenhuma nova regra organizacional. Provas: outputs/investmoz-git-delivery-20261020/RECIBO-DISPONIBILIZACAO-GIT-MAIN.json; PROVA-ENSAIO-COMPLETO.json; PROVA-CONTROLO-FINAL-LINUX.json; PROVA-GITEA-EVENTO-E-INSTALACAO.json; PROVA-VISUAL-GIT-MAIN.png; deploy/homologacao/README.md; /build/revision.json. Manual versionado: `deploy/homologacao/README.md`. Esta entrega encerra o fluxo automático, não os restantes componentes da aplicação. As alterações documentais anteriores no checkout local foram preservadas. + + +## Pesquisa transversal QA — 20/10/2026 + +Inventário finito AUD-SEARCH-T01–T11 na mesma matriz/CSV e comparação no manual. Correcções manuais na branch `codex/opportunity-search`, sem integração/remoto/publicação. Assistência NLP ainda não implementada: interpretador e prévia/confirmar da mesma query manual, 6–10 h para recorte local delimitado; modelo externo sujeito a decisão. As oito lacunas contratuais e P7 mantêm-se separados. Revisão Tempo pendente; as provas comuns não encerram requisitos de gestão ou publicação não alterados. diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv index 957832c0..3ad6e7dc 100644 --- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv +++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv @@ -10639,3 +10639,14 @@ AUD-FINTECH-01,"Caracterizar e acompanhar investimento Fintech no mesmo dossier/ [RECONCILIACAO-43-20261019] Código e critérios confrontados com leitura READ ONLY fresca; PROVA-RECONCILIACAO-43-LEITURA.json, RECONCILIACAO-43-QA-FONTES.json, RECONCILIACAO-43-SCHEMA-QA.json. Não encerra critério funcional por inventário. [PLANO-P1-P6-20261019] Plano Tempo: 27–46 h disponibilização; P7 e desenvolvimento separados. Inclusão 36–39 expressa para decisão; sem publicação automática. Revisão de proposta d87970e701f0. Roteiro no manual existente; não encerra critério por instalação.",Setores e Fintech — QA 15/10/2026,15/10/2026; preserva fechos e B.,,,,,,,,,,"Cenários sintéticos, não comprovam licença institucional/atividade financeira real ou correspondência jurídica específica; 12 critérios aceites não reabertos.",,,, +AUD-SEARCH-T01,Consistência transversal da pesquisa e navegação — Oportunidades,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/oportunidades,"investment/catalogue — Filtro único, Mais filtros, preparação explícita, moeda obrigatória para montantes, pedidos canceláveis, estados e contexto únicos.",Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,"6 testes PHP/112 asserções; navegador: parque, erro/repetição, moeda, vazio, retorno, hierarquia e teclado.",QA — delta delimitado; revisão Tempo pendente,Revisão visual da Tempo; NLP separado.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,"Filtros duplicados, códigos internos, maturidades cruzadas, contagens concorrentes e mapa em grelha.",Revisão visual da Tempo; NLP separado.,investment/catalogue; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,"6 testes PHP/112 asserções; navegador: parque, erro/repetição, moeda, vazio, retorno, hierarquia e teclado.",Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Revisão visual da Tempo; NLP separado.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. +AUD-SEARCH-T02,Consistência transversal da pesquisa e navegação — Negócios,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/negocios,"investment/business-marketplace — Sector básico, critérios especializados recolhidos em Mais filtros, estado de auditoria retirado do controlo público, contagem identificada e filtros removíveis.",Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,HTTP/Inertia: universo results.meta.total=0; selector partilhado e código inspeccionados.,QA — delta delimitado; revisão Tempo pendente,Nenhum contrato foi habilitado; oito lacunas contratuais mantidas fora deste lote.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,"Classificação técnica visível, estados de auditoria e contagem territorial sem âmbito.",Nenhum contrato foi habilitado; oito lacunas contratuais mantidas fora deste lote.,investment/business-marketplace; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,HTTP/Inertia: universo results.meta.total=0; selector partilhado e código inspeccionados.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Nenhum contrato foi habilitado; oito lacunas contratuais mantidas fora deste lote.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. +AUD-SEARCH-T03,Consistência transversal da pesquisa e navegação — Sectores,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/sectores; /sectores/{slug},portal/services — Contagem identificada como Sectores; navegação comum preserva a entrada por sector e Fintech como especialização.,Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,"Inspecção de PublicPortalController, SectorOrganization e portalNavigation; testes de navegação.",QA — delta delimitado; revisão Tempo pendente,Sem pesquisa autónoma no catálogo de sectores: não foi inventado um novo filtro.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,Navegação relacionada dispersa e contagens genéricas sem nome do universo.,Sem pesquisa autónoma no catálogo de sectores: não foi inventado um novo filtro.,portal/services; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,"Inspecção de PublicPortalController, SectorOrganization e portalNavigation; testes de navegação.",Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Sem pesquisa autónoma no catálogo de sectores: não foi inventado um novo filtro.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. +AUD-SEARCH-T04,Consistência transversal da pesquisa e navegação — Localizações,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/oportunidades?view=map,investment/catalogue — Mesmos filtros e permissões; locais com coordenadas e oportunidades sem coordenadas apresentados separadamente; fichas de contexto identificadas.,Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,Teste de associação/lista/exportação; mapa do serviço exclui proximidade e preserva oportunidade sem coordenadas.,QA — delta delimitado; revisão Tempo pendente,Nenhuma coordenada ou geometria criada.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,Mapa e contagem de oportunidades podiam usar universos diferentes sem explicação.,Nenhuma coordenada ou geometria criada.,investment/catalogue; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,Teste de associação/lista/exportação; mapa do serviço exclui proximidade e preserva oportunidade sem coordenadas.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Nenhuma coordenada ou geometria criada.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. +AUD-SEARCH-T05,Consistência transversal da pesquisa e navegação — Carteira,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/carteira,"portfolio/index; portfolio/show — Contagem Fichas da carteira, chips removíveis, menu local redundante retirado, Consultar ficha e Ver oportunidades associadas; retorno com query local.",Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,HTTP/Inertia de vazio; navegador de vazio; teste de contexto e navegação.,QA — delta delimitado; revisão Tempo pendente,Provas anteriores de edição/publicação conservadas; não repetidas.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,"Menu local repetido, ausência de retorno explícito e de distinção entre ficha e pesquisa associada.",Provas anteriores de edição/publicação conservadas; não repetidas.,portfolio/index; portfolio/show; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,HTTP/Inertia de vazio; navegador de vazio; teste de contexto e navegação.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Provas anteriores de edição/publicação conservadas; não repetidas.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. +AUD-SEARCH-T06,Consistência transversal da pesquisa e navegação — Zonas e parques,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/zonas; /zonas/{id},investment/zones; investment/zone — Destinos relacionados recolhidos; vazio neutro; consultar ficha separado de oportunidades associadas; retorno e exportação preservam filtros.,Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,HTTP/Inertia de vazio; inspecção da query/exportação e helpers de retorno.,QA — delta delimitado; revisão Tempo pendente,Disponibilidade/incentivos continuam sujeitos aos dados de cada ficha.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,"Destinos repetidos, vazio atribuído indevidamente a falta de cadastro, retorno sem filtros.",Disponibilidade/incentivos continuam sujeitos aos dados de cada ficha.,investment/zones; investment/zone; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,HTTP/Inertia de vazio; inspecção da query/exportação e helpers de retorno.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Disponibilidade/incentivos continuam sujeitos aos dados de cada ficha.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. +AUD-SEARCH-T07,Consistência transversal da pesquisa e navegação — Clusters,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/clusters → /carteira?kind=cluster,"portfolio/index — Reutiliza carteira, contagem nomeada e tipo de ficha removível; URL anterior preservada.",Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,Redireccionamento verificado no teste PHP; consumidor comum inspeccionado.,QA — delta delimitado; revisão Tempo pendente,Nenhuma equivalência entre implantação e associação criada.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,Mesmo padrão da carteira; não é um catálogo independente de oportunidades.,Nenhuma equivalência entre implantação e associação criada.,portfolio/index; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,Redireccionamento verificado no teste PHP; consumidor comum inspeccionado.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Nenhuma equivalência entre implantação e associação criada.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. +AUD-SEARCH-T08,Consistência transversal da pesquisa e navegação — Corredores,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/corredores → /carteira?kind=corridor,portfolio/index — Reutiliza carteira e ligações com finalidade explícita; URL anterior preservada.,Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,Redireccionamento verificado no teste PHP; consumidor comum inspeccionado.,QA — delta delimitado; revisão Tempo pendente,Nenhuma alteração de relações ou dados.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,Mesmo padrão da carteira e risco de confundir ficha territorial com oportunidade.,Nenhuma alteração de relações ou dados.,portfolio/index; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,Redireccionamento verificado no teste PHP; consumidor comum inspeccionado.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Nenhuma alteração de relações ou dados.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. +AUD-SEARCH-T09,Consistência transversal da pesquisa e navegação — Educação,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/educacao,investment/catalogue — Reutiliza integralmente a pesquisa corrigida com âmbito educação mantido.,Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,QA — delta delimitado; revisão Tempo pendente,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,Herda formulário e contagens concorrentes das oportunidades.,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,investment/catalogue; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. +AUD-SEARCH-T10,Consistência transversal da pesquisa e navegação — Biblioteca,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/documentos,"portal/services; portal/document — Resumo único nomeado Documentos, chips removíveis, controlos vazios ocultos sem apagar valores seleccionados, rodapé comum.",Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,QA — delta delimitado; revisão Tempo pendente,Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,"Dois resumos de contagem, filtros activos sem remoção individual, controlos sem opções e rodapé local repetido.",Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,portal/services; portal/document; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. +AUD-SEARCH-T11,Consistência transversal da pesquisa e navegação — Legislação,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/legislacao,portal/services; portal/document — Reutiliza a correcção da biblioteca e preserva filtros jurídicos/documentais pertinentes.,Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,QA — delta delimitado; revisão Tempo pendente,"Situação jurídica, consulta e cálculo continuam independentes.",,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,Mesmo padrão da biblioteca; não deve expor um universo concorrente.,"Situação jurídica, consulta e cálculo continuam independentes.",portal/services; portal/document; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,"Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Situação jurídica, consulta e cálculo continuam independentes.",,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local. diff --git a/resources/js/components/catalogue-filter-context.tsx b/resources/js/components/catalogue-filter-context.tsx new file mode 100644 index 00000000..d761cea6 --- /dev/null +++ b/resources/js/components/catalogue-filter-context.tsx @@ -0,0 +1,140 @@ +import { Link, usePage } from '@inertiajs/react'; + +const labels: Record = { + q: 'Pesquisa', + query: 'Pesquisa', + sector: 'Sector', + kind: 'Tipo de ficha', + modality: 'Modalidade', + category: 'Categoria', + physical_category: 'Tipo de espaço', + legal_regime: 'Regime', + development_status: 'Desenvolvimento', + availability_status: 'Disponibilidade', + province_id: 'Província', + district_id: 'Distrito', + admin_post_id: 'Posto administrativo', + locality_id: 'Localidade', + geo_location_id: 'Localização', + territory_id: 'Território', + province: 'Província', + activity: 'Actividade', + document_type: 'Tipo de documento', + topic: 'Tema', + issuer: 'Entidade emissora', + language: 'Idioma', + legal_status: 'Situação documental', + source_confirmation: 'Fonte', + stage: 'Etapa', + module: 'Área', + association_type: 'Associação', + association_target: 'Referência', + association_purpose: 'Relação', + association_state: 'Confirmação', + chain_id: 'Cadeia de valor', + subsector_id: 'Actividade', + authority_id: 'Entidade', +}; +const territoryKeys = [ + 'province_id', + 'district_id', + 'admin_post_id', + 'locality_id', +]; +const values: Record = { + corridor: 'Corredores', + cluster: 'Clusters', + project: 'Projectos âncora', + zee: 'ZEE', + zfi: 'ZFI', + industrial_park: 'Parque industrial', + investment_project: 'Projecto de investimento', + company_sale: 'Venda de empresa', + company_purchase: 'Compra de empresa', + real_estate: 'Imobiliário', + land_right: 'Direitos fundiários', + turismo: 'Turismo', + agronegocio: 'Agronegócio', + fintech: 'Fintech — especialização do Mercado Financeiro', + 'mercado-financeiro': 'Mercado Financeiro', + legislation: 'Legislação', + procedure: 'Procedimento oficial', + official_form: 'Formulário oficial', + guide: 'Guia', + study: 'Estudo', + unknown: 'Por confirmar', + pt: 'Português', + en: 'Inglês', + confirmed: 'Confirmada', + pending: 'Por confirmar', +}; +/** Name each active criterion without exposing IDs or taxonomy codes. */ +export default function CatalogueFilterContext() { + const page = usePage(); + const filters = page.props.filters as Record | undefined; + if (!filters) return null; + const active = Object.entries(filters).filter( + ([key, value]) => + !['page', 'per_page', 'sort', 'view', 'publication_state'].includes( + key, + ) && + value !== '' && + value != null && + value !== false && + (!Array.isArray(value) || value.length), + ); + if (!active.length) return null; + const describe = (key: string, value: unknown): string => { + const path = page.props.territorySelection as + | { id: string; name: string }[] + | undefined; + const place = path?.find((item) => item.id === value)?.name; + if (place) return place; + if (typeof value !== 'string') return 'seleccionado'; + return ( + values[value] ?? + (['q', 'query', 'province', 'topic', 'issuer'].includes(key) + ? value + : 'seleccionado') + ); + }; + const href = (remove?: string) => { + const url = new URL(page.url, 'https://portal.invalid'); + const keys = remove + ? [ + remove, + ...territoryKeys + .slice(territoryKeys.indexOf(remove) + 1) + .filter(() => territoryKeys.includes(remove)), + ] + : Object.keys(filters); + for (const key of keys) { + url.searchParams.delete(key); + url.searchParams.delete(`${key}[]`); + } + url.searchParams.delete('page'); + return url.pathname + url.search; + }; + return ( + + ); +} diff --git a/resources/js/components/catalogue-navigation.tsx b/resources/js/components/catalogue-navigation.tsx index c6fea856..94e82a24 100644 --- a/resources/js/components/catalogue-navigation.tsx +++ b/resources/js/components/catalogue-navigation.tsx @@ -73,34 +73,32 @@ export default function CatalogueNavigation({ ], }; return ( - +
+ + Catálogos relacionados + + +
); } diff --git a/resources/js/components/country-select.tsx b/resources/js/components/country-select.tsx index e21e7616..b794c9b9 100644 --- a/resources/js/components/country-select.tsx +++ b/resources/js/components/country-select.tsx @@ -50,7 +50,14 @@ export default function CountrySelect({ onChange={(event) => setSearch(event.target.value)} placeholder="Pesquisar nome ou código do país" /> - {search.trim() &&

{matches.length ? `${matches.length} resultados encontrados` : "Não foram encontrados resultados para esta pesquisa"}

} + {search.trim() && ( +

+ Países:{' '} + {matches.length + ? `${matches.length} opções disponíveis` + : 'Nenhuma opção encontrada'} +

+ )} - {enabled && setRetry(value => value + 1)} />} + {enabled && ( + setRetry((value) => value + 1)} + /> + )} diff --git a/resources/js/components/opportunity-classification.tsx b/resources/js/components/opportunity-classification.tsx index 387d1d84..873ea154 100644 --- a/resources/js/components/opportunity-classification.tsx +++ b/resources/js/components/opportunity-classification.tsx @@ -831,7 +831,11 @@ export function ClassificationSummary({

Associações territoriais e produtivas

-

{help}

+

+ As relações preliminares permanecem por confirmar. Uma + associação não comprova disponibilidade, incentivos ou direitos + concedidos. +

{Object.entries({ territory: 'Enquadramento territorial', production: 'Organização produtiva', @@ -867,10 +871,7 @@ export function ClassificationSummary({

)) ) : ( -

- Sem informação tipada publicada nesta dimensão; - consulte também as relações anteriores abaixo. -

+

Sem associações publicadas neste grupo.

)} ))} diff --git a/resources/js/components/opportunity-search-filters.tsx b/resources/js/components/opportunity-search-filters.tsx new file mode 100644 index 00000000..306591ed --- /dev/null +++ b/resources/js/components/opportunity-search-filters.tsx @@ -0,0 +1,480 @@ +import { Field, input, button, groupLabels } from '@/pages/investment/shared'; +import type { References } from '@/pages/investment/shared'; +import { TerritoryLevel, type TerritoryOption } from './investment-territory'; + +export const modalityLabels: Record = { + investment_project: 'Projecto de investimento', + real_estate: 'Imobiliário', + land_rights: 'DUAT e direitos fundiários', + business_sale: 'Venda de negócio', + business_partnership: 'Parceria empresarial', + franchise: 'Franquia', + concession: 'Concessão', + financing: 'Financiamento', + supply: 'Fornecimento', + other: 'Outra modalidade', +}; +type Values = Record; +const labels: Record = { + q: 'O que procura', + sector: 'Sector', + category: 'Modalidade da oportunidade', + preparation: 'Estado de preparação', + sort: 'Ordenar', + min_amount: 'Investimento mínimo', + max_amount: 'Investimento máximo', + currency: 'Moeda', + before: 'Data-alvo até', + cluster_id: 'Cluster', + corridor_id: 'Corredor', + project_id: 'Projecto âncora', + zee_id: 'ZEE', + zfi_id: 'ZFI', + park_id: 'Parque industrial', + chain_id: 'Cadeia de valor', + subsector_id: 'Actividade', + include_national: 'Incluir âmbito nacional', + education: 'Educação', + province: 'Província indicada', + district: 'Distrito indicado', + dimension: 'Grupo de oportunidades', + territory_basis: 'Localização considerada', + geo_location_id: 'Localização adicional', + maturity_level: 'Classificação histórica', + association_type: 'Associação herdada', + association_target: 'Referência herdada', + association_purpose: 'Relação indicada', + association_state: 'Critério herdado', + authority_id: 'Entidade', + publication_state: 'Critério herdado', +}; +export default function OpportunitySearchFilters({ + data, + references, + path, + change, + territory, + submit, + clear, + errors, +}: { + data: Values; + references: References; + path: TerritoryOption[]; + change: (values: Values) => void; + territory: (path: TerritoryOption[]) => void; + submit: () => void; + clear: () => void; + errors: Record; +}) { + const select = ( + key: string, + choices: { value: string; label: string }[], + hint?: string, + ) => ( + + + {hint &&

{hint}

} +
+ ); + const zoneChoices = (category: string) => + (references.zones ?? []) + .filter((z) => z.category === category) + .map((z) => ({ value: z.id, label: z.name })); + const choicesFor = (key: string) => { + if (key === 'sector') + return references.sectors.map((s) => ({ + value: s.slug, + label: + s.slug === 'fintech' + ? 'Fintech — especialização do Mercado Financeiro' + : s.name, + })); + if (key === 'category') + return (references.categories ?? []).map((c) => ({ + value: c, + label: modalityLabels[c] ?? 'Outra modalidade', + })); + if (key === 'preparation') return references.preparationOptions ?? []; + if (key === 'dimension') + return [ + { value: 'cluster', label: 'Clusters' }, + { value: 'corridor', label: 'Corredores' }, + { value: 'zee', label: 'ZEE' }, + { value: 'zfi', label: 'ZFI' }, + ]; + if (['zee_id', 'zfi_id', 'park_id'].includes(key)) + return zoneChoices( + ( + { + zee_id: 'zee', + zfi_id: 'zfi', + park_id: 'industrial_park', + } as Record + )[key], + ); + const group = ( + { + cluster_id: 'clusters', + corridor_id: 'corridors', + project_id: 'projects', + } as const + )[key as 'cluster_id']; + if (group) + return references[group].map((e) => ({ + value: e.id, + label: e.name, + })); + if (key === 'chain_id' || key === 'subsector_id') + return ( + references.classification?.targets[ + key === 'chain_id' ? 'chain' : 'activity' + ] ?? [] + ).map((e) => ({ value: String(e.id), label: e.label })); + return []; + }; + const remove = (key: string) => { + if (key === 'sector') + change({ sector: '', subsector_id: '', chain_id: '' }); + else if (key === 'facets') change({ facets: [] }); + else change({ [key]: typeof data[key] === 'boolean' ? false : '' }); + }; + const active = Object.entries(data).filter( + ([k, v]) => + ![ + 'view', + 'sort', + 'province_id', + 'district_id', + 'admin_post_id', + 'locality_id', + ].includes(k) && + v !== '' && + v !== false && + v !== null && + (!Array.isArray(v) || v.length > 0) && + !(k === 'territory_basis' && v === 'all'), + ); + return ( +
{ + e.preventDefault(); + submit(); + }} + aria-label="Pesquisar oportunidades" + > +
+ + change({ q: e.target.value })} + /> + + {select('sector', choicesFor('sector'))} + {select('category', choicesFor('category'))} +
+
+ Localização + {( + [ + 'province_id', + 'district_id', + 'admin_post_id', + 'locality_id', + ] as const + ).map((key, index) => ( + + territory([ + ...path.slice(0, index), + ...(value ? [value] : []), + ]) + } + /> + ))} +
+
+ + +

+ Os registos históricos são apresentados separadamente e + conservam a classificação indicada na ficha. +

+
+ {(['min_amount', 'max_amount'] as const).map((key) => ( + + change({ [key]: e.target.value })} + /> + + ))} + {select( + 'currency', + ['MZN', 'USD', 'EUR', 'ZAR'].map((c) => ({ + value: c, + label: c, + })), + 'Seleccione uma moeda para comparar montantes. Não se efectuam conversões cambiais.', + )} +
+
+ + Mais filtros + +
+ {[ + 'corridor_id', + 'cluster_id', + 'zee_id', + 'zfi_id', + 'park_id', + 'project_id', + 'subsector_id', + 'chain_id', + ].map((key) => select(key, choicesFor(key)))} + + change({ before: e.target.value })} + /> + +
+

+ Estes filtros procuram associações documentadas. Estar perto + de um parque ou no mesmo distrito não constitui associação, + nem confirma incentivos ou disponibilidade. +

+ +
+ + Outras características + +
+ {[ + 'education', + 'infrastructure', + 'labour', + 'qualification', + 'support', + 'impact', + 'partnership', + ] + .filter((group) => + references.options.some( + (o) => o.group === group, + ), + ) + .map((group) => ( +
+ {groupLabels[group]} + {references.options + .filter((o) => o.group === group) + .map((option) => ( + + ))} +
+ ))} +
+
+
+
+ {path.map((node, index) => ( + + ))} + {active.flatMap(([key, value]) => + key === 'facets' && Array.isArray(value) + ? value.map((id) => ( + + )) + : [ + , + ], + )} +
+ {Object.entries(errors).map(([key, value]) => ( +

+ {value} +

+ ))} +
+ {select('sort', [ + { value: 'recent', label: 'Mais recentes' }, + { value: 'title', label: 'Título' }, + { + value: 'amount_asc', + label: 'Montante crescente, por moeda', + }, + { + value: 'amount_desc', + label: 'Montante decrescente, por moeda', + }, + ])} + + +
+
+ ); +} diff --git a/resources/js/components/search-feedback.tsx b/resources/js/components/search-feedback.tsx index d6efca37..cc1d5f59 100644 --- a/resources/js/components/search-feedback.tsx +++ b/resources/js/components/search-feedback.tsx @@ -1,14 +1,45 @@ import { router, usePage } from '@inertiajs/react'; import { useEffect, useRef, useState } from 'react'; +import CatalogueFilterContext from './catalogue-filter-context'; // Shared feedback for existing Inertia search consumers; form state is never reset. -const collections = ['results','records','resources','partnerships','opportunities','interests','investors','leads','articles','services','items','entries','documents','requests','batches','users','assignments','zones','matches','businesses']; +const collections = [ + 'results', + 'records', + 'resources', + 'partnerships', + 'opportunities', + 'interests', + 'investors', + 'leads', + 'articles', + 'services', + 'items', + 'entries', + 'documents', + 'requests', + 'batches', + 'users', + 'assignments', + 'zones', + 'matches', + 'businesses', +]; function count(props: Record): number | null { - if (props.demands && props.capacities) return Number((props.demands as {total:number}).total) + Number((props.capacities as {total:number}).total); - if (props.plan && Array.isArray((props.plan as {rows:unknown[]}).rows)) return (props.plan as {rows:unknown[]}).rows.length; + if (props.demands && props.capacities) + return ( + Number((props.demands as { total: number }).total) + + Number((props.capacities as { total: number }).total) + ); + if (props.plan && Array.isArray((props.plan as { rows: unknown[] }).rows)) + return (props.plan as { rows: unknown[] }).rows.length; if (typeof props.total === 'number') return props.total; - if (typeof props.match === 'boolean' && Array.isArray(props.articles)) return props.match ? props.articles.length : 0; + if (typeof props.match === 'boolean' && Array.isArray(props.articles)) + return props.match ? props.articles.length : 0; for (const key of collections) { - const value = props[key] as {total?:number; meta?:{total?:number}; data?:unknown[]} | unknown[] | undefined; + const value = props[key] as + | { total?: number; meta?: { total?: number }; data?: unknown[] } + | unknown[] + | undefined; if (!value) continue; if (Array.isArray(value)) return value.length; if (typeof value.total === 'number') return value.total; @@ -19,28 +50,140 @@ function count(props: Record): number | null { } export default function SearchFeedback() { const page = usePage(); - const [state, setState] = useState<'idle'|'loading'|'error'>('idle'); - const latest = useRef<{url: string; data: Record} | null>(null); + const handledLocally = page.component === 'investment/catalogue'; + const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle'); + const latest = useRef<{ + url: string; + data: Record; + } | null>(null); const active = useRef(false); - const searchable = page.props.filters !== undefined || ['portal/legacy','desk/catalogue','investment/zones','partnerships/catalogue','education/index','management/territory-geometry-import'].includes(page.component); - const total = searchable ? count(page.props) : null; + const searchable = + page.props.filters !== undefined || + [ + 'portal/legacy', + 'desk/catalogue', + 'investment/zones', + 'partnerships/catalogue', + 'education/index', + 'management/territory-geometry-import', + ].includes(page.component); + // A page may contain several independent catalogues. Never infer a public + // result universe by whichever collection happens to occur first. + const descriptors: Record = { + 'portfolio/index': ['records', 'Fichas da carteira'], + 'investment/zones': ['records', 'Zonas e parques'], + 'investment/business-marketplace': ['results', 'Anúncios de negócios'], + 'portal/legacy': ['articles', 'Artigos'], + }; + const descriptor = + page.component === 'portal/services' + ? page.props.section === 'documents' || + page.props.section === 'legislation' + ? ['resources', 'Documentos'] + : page.props.section === 'sectors' + ? ['sectors', 'Sectores'] + : null + : descriptors[page.component]; + const total = descriptor + ? page.component === 'portal/legacy' && page.props.match === false + ? 0 + : count({ results: page.props[descriptor[0]] }) + : searchable + ? count(page.props) + : null; + const label = descriptor?.[1]; useEffect(() => { - const off = [router.on('before', event => { - const visit = event.detail.visit; - if (visit.method !== 'get') { active.current = false; return; } - const data = visit.data as Record; - const url = visit.url; - const isSearch = total !== null || ['q','query','search','filter','status','sector'].some(key => key in data || url.searchParams.has(key)); - active.current = isSearch; - if (isSearch) { latest.current = {url: url.toString(), data}; setState('loading'); } - }), router.on('success', () => { if (active.current) { active.current = false; setState('idle'); } }), - router.on('error', () => { if (active.current) setState('error'); }), - router.on('httpException', event => { if (active.current) { event.preventDefault(); setState('error'); } }), - router.on('networkError', event => { if (active.current) { event.preventDefault(); setState('error'); } })]; - return () => off.forEach(remove => remove()); - }, [total]); + if (handledLocally) return; + const off = [ + router.on('before', (event) => { + const visit = event.detail.visit; + if (visit.method !== 'get') { + active.current = false; + return; + } + const data = visit.data as Record; + const url = visit.url; + const isSearch = + total !== null || + ['q', 'query', 'search', 'filter', 'status', 'sector'].some( + (key) => key in data || url.searchParams.has(key), + ); + active.current = isSearch; + if (isSearch) { + latest.current = { url: url.toString(), data }; + setState('loading'); + } + }), + router.on('success', () => { + if (active.current) { + active.current = false; + setState('idle'); + } + }), + router.on('error', () => { + if (active.current) setState('error'); + }), + router.on('httpException', (event) => { + if (active.current) { + event.preventDefault(); + setState('error'); + } + }), + router.on('networkError', (event) => { + if (active.current) { + event.preventDefault(); + setState('error'); + } + }), + ]; + return () => off.forEach((remove) => remove()); + }, [total, handledLocally]); + if (handledLocally) return null; if (total === null && state === 'idle') return null; - return ; + return ( + <> + + {descriptor && } + + ); } diff --git a/resources/js/components/search-state.tsx b/resources/js/components/search-state.tsx index 8edec02d..5d84635e 100644 --- a/resources/js/components/search-state.tsx +++ b/resources/js/components/search-state.tsx @@ -1,5 +1,48 @@ -export default function SearchState({loading, error, total, retry}: {loading:boolean; error?:string; total:number; retry:()=>void}) { - return
- {loading ? 'A pesquisar…' : error ? <>Não foi possível concluir a pesquisa. : total === 0 ? 'Não foram encontrados resultados para esta pesquisa' : `${total} resultados encontrados`} -
; +export default function SearchState({ + loading, + error, + total, + retry, + scope, + countLabel, +}: { + loading: boolean; + error?: string; + total: number; + retry: () => void; + scope?: string; + countLabel?: string; +}) { + return ( +
+ {scope && {scope}: } + {loading ? ( + 'A pesquisar…' + ) : error ? ( + <> + Não foi possível concluir a pesquisa.{' '} + + + ) : total === 0 ? ( + scope ? ( + 'Nenhuma opção encontrada' + ) : ( + 'Não foram encontrados resultados para esta pesquisa' + ) + ) : countLabel ? ( + `${total} ${countLabel}` + ) : scope ? ( + `${total} ${total === 1 ? 'opção disponível' : 'opções disponíveis'}` + ) : ( + `${total} ${total === 1 ? 'resultado encontrado' : 'resultados encontrados'}` + )} +
+ ); } diff --git a/resources/js/lib/catalogue-context.ts b/resources/js/lib/catalogue-context.ts new file mode 100644 index 00000000..90709387 --- /dev/null +++ b/resources/js/lib/catalogue-context.ts @@ -0,0 +1,12 @@ +/** Only a query string is carried; the return destination is always local. */ +export function catalogueDetailHref(path: string, query: string): string { + return query + ? `${path}?catalogue_query=${encodeURIComponent(query)}` + : path; +} +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()}` : ''); +} diff --git a/resources/js/lib/portal-navigation.ts b/resources/js/lib/portal-navigation.ts index 663a5cb1..daa610ef 100644 --- a/resources/js/lib/portal-navigation.ts +++ b/resources/js/lib/portal-navigation.ts @@ -445,6 +445,11 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] { label: 'Descoberta', children: [ link('home', 'Início', '/'), + link( + 'idea-entry', + 'Partir de uma ideia', + '/investimentos/iniciar', + ), link( 'why', 'Porquê investir em Moçambique', @@ -457,6 +462,36 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] { ), link('sectors', 'Sectores de investimento', '/sectores'), link('locations', 'Localizações', '/oportunidades?view=map'), + { + id: 'related-catalogues', + label: 'Catálogos relacionados', + children: [ + link( + 'portfolio', + 'Carteira, clusters e corredores', + '/carteira', + ), + link('zones', 'Zonas e parques', '/zonas'), + ...(access.businesses + ? [ + link( + 'business-catalogue', + 'Negócios', + '/negocios', + ), + ] + : []), + ...(access.education + ? [ + link( + 'education-catalogue', + 'Oportunidades na educação', + '/educacao', + ), + ] + : []), + ], + }, ], }, { @@ -487,6 +522,8 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] { id: 'support', label: 'Suporte', children: [ + link('documents', 'Biblioteca de documentos', '/documentos'), + link('legislation', 'Legislação', '/legislacao'), link( 'services', 'Serviços ao investidor', diff --git a/resources/js/pages/investment/business-marketplace.tsx b/resources/js/pages/investment/business-marketplace.tsx index 8b94f546..a6f7f0fe 100644 --- a/resources/js/pages/investment/business-marketplace.tsx +++ b/resources/js/pages/investment/business-marketplace.tsx @@ -6,7 +6,11 @@ import { type TerritoryOption, } from '@/components/investment-territory'; import { businessModalities } from './business-types'; -import { ClassificationSummary, classificationStates, type PublicClassification, type ClassificationOptions } from '@/components/opportunity-classification'; +import { + ClassificationSummary, + type PublicClassification, + type ClassificationOptions, +} from '@/components/opportunity-classification'; type PublicBusiness = { multidimensional?: PublicClassification[]; @@ -57,7 +61,22 @@ export default function BusinessMarketplace({ classificationOptions, authorities = [], }: Props) { - const [classification, setClassification] = useState>(() => Object.fromEntries(['sector', 'association_type', 'association_target', 'association_purpose', 'association_state', 'chain_id', 'authority_id', 'subsector_id'].map((key) => [key, filters[key] ?? '']))); + const [classification, setClassification] = useState< + Record + >(() => + Object.fromEntries( + [ + 'sector', + 'association_type', + 'association_target', + 'association_purpose', + 'association_state', + 'chain_id', + 'authority_id', + 'subsector_id', + ].map((key) => [key, filters[key] ?? '']), + ), + ); const [query, setQuery] = useState(filters.q ?? ''); const [modality, setModality] = useState(filters.modality ?? ''); const [territory, setTerritory] = useState>({ @@ -124,6 +143,35 @@ export default function BusinessMarketplace({ )} + {classificationOptions && ( + + )} @@ -135,22 +183,109 @@ export default function BusinessMarketplace({ }) } /> - {classificationOptions &&
- Classificação multidimensional — filtros combinados -

Critérios diferentes combinam-se por «e». Tipo, referência, finalidade e estado têm de corresponder à mesma relação pública. O setor principal mantém o seu significado.

-
- {([ - ['sector', 'Setor principal do anúncio', classificationOptions.targets.sector ?? []], - ['association_type', 'Tipo de associação', Object.keys(classificationOptions.types).map((id) => ({ id, label: ({ corridor: 'Corredor', location: 'Localização', cluster: 'Cluster', project: 'Projeto âncora', chain: 'Cadeia de valor', park: 'Parque industrial', zone: 'Regime de zona', sector: 'Setor associado', activity: 'Subsector ou atividade' } as Record)[id] ?? id }))], - ['association_target', 'Referência da associação', classificationOptions.targets[classification.association_type] ?? []], - ['association_purpose', 'Finalidade', Object.entries(classificationOptions.purposes).map(([id, label]) => ({ id, label }))], - ['association_state', 'Estado da associação', Object.entries(classificationStates).filter(([id]) => !['archived', 'revoked'].includes(id)).map(([id, label]) => ({ id, label }))], - ['chain_id', 'Cadeia de valor', classificationOptions.targets.chain ?? []], - ['subsector_id', 'Subsector ou atividade', classificationOptions.targets.activity ?? []], - ['authority_id', 'Entidade competente', authorities.map((a) => ({ id: a.id, label: a.label_pt_mz }))], - ] as [string, string, { id: string | number; label: string }[]][]).map(([key, label, options]) => )} -
-
} + {classificationOptions && ( +
+ + Mais filtros + +
+ {( + [ + [ + 'association_type', + 'Tipo de associação', + Object.keys( + classificationOptions.types, + ).map((id) => ({ + id, + label: + ( + { + corridor: 'Corredor', + location: 'Localização', + cluster: 'Cluster', + project: + 'Projeto âncora', + chain: 'Cadeia de valor', + park: 'Parque industrial', + zone: 'Regime de zona', + sector: 'Setor associado', + activity: + 'Subsector ou atividade', + } as Record + )[id] ?? id, + })), + ], + [ + 'association_target', + 'Referência da associação', + classificationOptions.targets[ + classification.association_type + ] ?? [], + ], + [ + 'association_purpose', + 'Finalidade', + Object.entries( + classificationOptions.purposes, + ).map(([id, label]) => ({ id, label })), + ], + [ + 'chain_id', + 'Cadeia de valor', + classificationOptions.targets.chain ?? + [], + ], + [ + 'subsector_id', + 'Subsector ou atividade', + classificationOptions.targets + .activity ?? [], + ], + [ + 'authority_id', + 'Entidade competente', + authorities.map((a) => ({ + id: a.id, + label: a.label_pt_mz, + })), + ], + ] as [ + string, + string, + { id: string | number; label: string }[], + ][] + ).map(([key, label, options]) => ( + + ))} +
+
+ )}

@@ -192,7 +327,11 @@ export default function BusinessMarketplace({ {record.amount_max ?? '—'} {record.currency}

)} - {!!record.multidimensional?.length && } + {!!record.multidimensional?.length && ( + + )} {record.objective && (

{record.objective} diff --git a/resources/js/pages/investment/catalogue.tsx b/resources/js/pages/investment/catalogue.tsx index 335a88a2..57c7e258 100644 --- a/resources/js/pages/investment/catalogue.tsx +++ b/resources/js/pages/investment/catalogue.tsx @@ -1,28 +1,12 @@ -import { usePortalQuery } from '@/hooks/use-portal-query'; +import OpportunitySearchFilters from '@/components/opportunity-search-filters'; import SearchState from '@/components/search-state'; -import CatalogueNavigation from '@/components/catalogue-navigation'; -import { ClassificationFilters } from '@/components/opportunity-classification'; import { Link, router, useForm, usePage } from '@inertiajs/react'; import { useEffect, useRef, useState } from 'react'; import Map from '@/components/interactive-investment-map'; import TerritoryGeometryPreview from '@/components/territory-geometry-preview'; import type { InvestmentMapArea } from '@/components/interactive-investment-map'; -import { - TerritoryLevel, - type TerritoryOption, -} from '@/components/investment-territory'; -import { MATURITY_LEVELS } from '@/lib/investment-maturity'; -import PartnershipSearchResults from '@/components/partnership-search-results'; -import { - Frame, - Field, - input, - button, - card, - money, - api, - groupLabels, -} from './shared'; +import { type TerritoryOption } from '@/components/investment-territory'; +import { Frame, button, card, money, api, groupLabels } from './shared'; import type { Opportunity, References } from './shared'; type Feature = { @@ -95,6 +79,16 @@ export default function Catalogue({ max_amount: String(filters.max_amount ?? ''), currency: String(filters.currency ?? ''), maturity_level: String(filters.maturity_level ?? ''), + preparation: String( + filters.preparation ?? + (Number(filters.maturity_level) === 7 + ? 'stage:implementation' + : Number(filters.maturity_level) === 8 + ? 'stage:operational' + : filters.maturity_level + ? `historical:${filters.maturity_level}` + : ''), + ), before: String(filters.before ?? ''), sort: typeof filters.sort === 'string' ? filters.sort : 'recent', facets: (Array.isArray(filters.facets) ? filters.facets : []).map( @@ -120,35 +114,118 @@ export default function Catalogue({ const filterKey = JSON.stringify(filters); const draftFilterKey = JSON.stringify(form.data); const lastRequest = useRef(draftFilterKey); + const [searchLoading, setSearchLoading] = useState(false); + const [searchError, setSearchError] = useState(''); + const requestSequence = useRef(0); + const cancelSearch = useRef<(() => void) | undefined>(undefined); + const runSearch = (data = form.data) => { + cancelSearch.current?.(); + const sequence = ++requestSequence.current; + lastRequest.current = JSON.stringify(data); + form.clearErrors(); + if ((data.min_amount || data.max_amount) && !data.currency) { + form.setError( + 'currency', + 'Seleccione uma moeda antes de comparar os montantes.', + ); + setSearchLoading(false); + setSearchError('Indique a moeda para concluir a pesquisa.'); + return; + } + setSearchLoading(true); + setSearchError(''); + const payload = Object.fromEntries( + Object.entries(data) + .filter( + ([, value]) => + value !== '' && + value !== false && + (!Array.isArray(value) || value.length > 0), + ) + .map(([key, value]) => [ + key, + typeof value === 'boolean' ? 1 : value, + ]), + ); + router.get( + catalogueUrl === '/educacao' && !data.education + ? '/oportunidades' + : catalogueUrl, + payload, + { + preserveState: true, + preserveScroll: true, + replace: true, + onCancelToken: (token) => { + cancelSearch.current = () => token.cancel(); + }, + onError: (errors) => { + if (sequence === requestSequence.current) { + form.setError(errors); + setSearchError( + 'A pesquisa não foi concluída. Verifique os campos indicados.', + ); + } + }, + onHttpException: () => { + if (sequence === requestSequence.current) + setSearchError('A pesquisa não foi concluída.'); + return false; + }, + onNetworkError: () => { + if (sequence === requestSequence.current) + setSearchError('A pesquisa não foi concluída.'); + return false; + }, + onFinish: () => { + if (sequence === requestSequence.current) + setSearchLoading(false); + }, + }, + ); + }; + const changeTerritory = (path: TerritoryOption[]) => { + setTerritoryPath(path); + form.setData((d) => ({ + ...d, + province: '', + district: '', + geo_location_id: '', + province_id: path[0]?.id ?? '', + district_id: path[1]?.id ?? '', + admin_post_id: path[2]?.id ?? '', + locality_id: path[3]?.id ?? '', + park_id: '', + zee_id: '', + zfi_id: '', + cluster_id: '', + corridor_id: '', + project_id: '', + chain_id: '', + subsector_id: '', + association_type: '', + association_target: '', + association_state: '', + association_purpose: '', + })); + }; useEffect(() => { if (draftFilterKey === lastRequest.current) return; - let cancel: (() => void) | undefined; + cancelSearch.current?.(); + requestSequence.current++; + setSearchLoading(true); const timer = setTimeout(() => { + if (draftFilterKey === lastRequest.current) return; const data = JSON.parse(draftFilterKey); - lastRequest.current = draftFilterKey; - router.get( - catalogueUrl, - { - ...data, - education: data.education ? 1 : 0, - include_national: data.include_national ? 1 : 0, - }, - { - preserveState: true, - preserveScroll: true, - replace: true, - onCancelToken: (token) => { - cancel = () => token.cancel(); - }, - }, - ); + runSearch(data); }, 350); return () => { clearTimeout(timer); - cancel?.(); + cancelSearch.current?.(); }; }, [draftFilterKey, catalogueUrl]); useEffect(() => { + if (!['map', 'combined'].includes(view)) return; let active = true; const values = JSON.parse(filterKey) as Record< string, @@ -190,8 +267,6 @@ export default function Catalogue({ active = false; }; }, [view, filterKey, mapAttempt]); - const suggestionQuery = usePortalQuery<{opportunities:{title:string;slug:string}[]}>(`/api/v1/investment/suggest?q=${encodeURIComponent(form.data.q)}`, 300); - const currentSuggestions = form.data.q.length >= 2 ? suggestionQuery.data?.opportunities ?? [] : []; const query = () => { const params = new URLSearchParams(); for (const [key, value] of Object.entries(filters)) { @@ -221,20 +296,6 @@ export default function Catalogue({ setError((e as Error).message); } }; - const dimensionUrl = (dimension: string) => { - const params = new URLSearchParams(query()); - params.set('dimension', dimension); - [ - 'cluster_id', - 'corridor_id', - 'zee_id', - 'zfi_id', - 'park_id', - 'project_id', - ].forEach((key) => params.delete(key)); - params.delete('page'); - return `${catalogueUrl}?${params}`; - }; return ( - - {!!territoryPath.length && ( )}

- Combine contexto territorial, dimensão e necessidades. As fichas - documentais e as oportunidades mantêm a sua origem e natureza. + Combine os filtros para encontrar oportunidades publicadas. + Nenhum critério é alargado automaticamente.

-

- Negócios existentes, participações, imóveis e activos só surgem - como anúncio depois de revisão e publicação expressa. A pesquisa - de negócios é um catálogo separado; ausência de resultados não - confirma inexistência do activo. As referências governamentais - da carteira são separadas de anúncios privados. -

-
{ - e.preventDefault(); - setPoints(null); - setComparison([]); - setSelected([]); - form.transform((data) => ({ - ...data, - education: data.education ? 1 : 0, - include_national: data.include_national ? 1 : 0, - })); - form.get(catalogueUrl, { preserveState: true }); + form.setData((d) => ({ ...d, ...patch }))} + territory={changeTerritory} + submit={() => runSearch()} + errors={form.errors} + clear={() => { + setTerritoryPath([]); + form.setData( + (d) => + Object.fromEntries( + Object.entries(d).map(([key, value]) => [ + key, + key === 'view' + ? 'grid' + : key === 'sort' + ? 'recent' + : key === 'territory_basis' + ? 'all' + : Array.isArray(value) + ? [] + : typeof value === 'boolean' + ? false + : '', + ]), + ) as unknown as typeof d, + ); }} - > - {references.classification && ( - - form.setData((d) => ({ - ...d, - [key]: value, - ...(key === 'association_type' - ? { association_target: '' } - : {}), - })) - } - /> - )} -
- {( - [ - 'province_id', - 'district_id', - 'admin_post_id', - 'locality_id', - ] as const - ).map((key, index) => ( - { - const path = [ - ...territoryPath.slice(0, index), - ...(value ? [value] : []), - ]; - setTerritoryPath(path); - form.setData((data) => ({ - ...data, - province: '', - district: '', - geo_location_id: '', - province_id: path[0]?.id ?? '', - district_id: path[1]?.id ?? '', - admin_post_id: path[2]?.id ?? '', - locality_id: path[3]?.id ?? '', - zee_id: '', - zfi_id: '', - park_id: '', - })); - }} - /> - ))} -
- - form.setData('q', e.target.value)} - autoComplete="off" - aria-describedby="search-suggestions" - /> - - {form.data.q.length >= 2 && } - {currentSuggestions.length > 0 && ( -
    - {currentSuggestions.map((s) => ( -
  • - - {s.title} - -
  • - ))} -
- )} -
- {( - [ - ['zee_id', 'zee', 'ZEE'], - ['zfi_id', 'zfi', 'ZFI'], - ['park_id', 'industrial_park', 'Parque industrial'], - ] as const - ) - .filter( - ([field]) => - !form.data.dimension || - field === `${form.data.dimension}_id` || - !!form.data[field], - ) - .map(([field, category, label]) => ( - - - - ))} - {(['clusters', 'corridors', 'projects'] as const).map( - (group, index) => { - const key = ( - [ - 'cluster_id', - 'corridor_id', - 'project_id', - ] as const - )[index]; - const label = ( - [ - 'Cluster', - 'Corredor', - 'Projecto âncora', - ] as const - )[index]; - if ( - form.data.dimension && - key !== `${form.data.dimension}_id` && - !form.data[key] - ) - return null; - return ( - - - - ); - }, - )} - - - -
- {(form.data.zee_id || form.data.zfi_id || form.data.park_id) && - (() => { - const selectedId = - form.data.zee_id || - form.data.zfi_id || - form.data.park_id; - const selectedZone = references.zones?.find( - (zone) => zone.id === selectedId, - ); - return selectedZone ? ( -

- Enquadramento seleccionado: {selectedZone.name}.{' '} - - Abrir ficha e oportunidades associadas - -

- ) : null; - })()} -
- - -

- O filtro usa a posição registada. Os catálogos - histórico e V2 conservam significados distintos nas - posições 1–6; consulte a maturidade apresentada na - ficha. As posições 7–8 pertencem ao catálogo V2. -

-
- {(['min_amount', 'max_amount'] as const).map((key) => ( - - { - form.setData(key, e.target.value); - if (!form.data.currency) - form.setData('currency', 'MZN'); - }} - /> - - ))} - - - - - - form.setData('before', e.target.value) - } - /> - -
- - - - - -
- - Subsector, educação, infraestruturas, competências e - apoios - -
- {[ - 'subsector', - 'education', - 'infrastructure', - 'labour', - 'qualification', - 'support', - 'impact', - 'partnership', - ].map((group) => ( -
- - {groupLabels[group]} - - {references.options - .filter((o) => o.group === group) - .map((o) => ( - - ))} -
- ))} -
-
-
- - - - - - Limpar filtros - -
- {Object.entries(form.errors).map(([key, value]) => ( -

- {value} -

- ))} - - {(territoryPath.length > 0 || - form.data.q || - form.data.dimension || - form.data.sector || - form.data.cluster_id || - form.data.corridor_id || - form.data.project_id || - form.data.geo_location_id || - form.data.zee_id || - form.data.zfi_id || - form.data.park_id || - form.data.include_national || - form.data.education || - form.data.maturity_level || - form.data.min_amount || - form.data.max_amount || - form.data.currency || - form.data.before || - form.data.facets.length > 0) && ( -
- Filtros activos: - {territoryPath.map((node, index) => ( - - {node.name}{' '} - - - ))} - {form.data.sector && ( - - Sector:{' '} - {references.sectors.find( - (s) => s.slug === form.data.sector, - )?.name ?? form.data.sector} - - )} - {form.data.cluster_id && ( - - Cluster:{' '} - { - references.clusters.find( - (v) => v.id === form.data.cluster_id, - )?.name - }{' '} - - - )} - {form.data.corridor_id && ( - - Corredor:{' '} - { - references.corridors.find( - (v) => v.id === form.data.corridor_id, - )?.name - }{' '} - - - )} - {form.data.project_id && ( - - Projecto:{' '} - { - references.projects.find( - (v) => v.id === form.data.project_id, - )?.name - }{' '} - - - )} - {form.data.q && ( - - Pesquisa: {form.data.q}{' '} - - - )} - {form.data.dimension && ( - - Dimensão: {form.data.dimension}{' '} - - - )} - {form.data.geo_location_id && ( - - Localização adicional:{' '} - {references.locations.find( - (v) => v.id === form.data.geo_location_id, - )?.name ?? 'seleccionada'}{' '} - - - )} - {form.data.zee_id && ( - - ZEE:{' '} - {references.zones?.find( - (z) => z.id === form.data.zee_id, - )?.name ?? 'seleccionada'}{' '} - - - )} - {form.data.zfi_id && ( - - ZFI:{' '} - {references.zones?.find( - (z) => z.id === form.data.zfi_id, - )?.name ?? 'seleccionada'}{' '} - - - )} - {form.data.park_id && ( - - Parque:{' '} - {references.zones?.find( - (z) => z.id === form.data.park_id, - )?.name ?? 'seleccionado'}{' '} - - - )} - {form.data.education && ( - - Educação{' '} - - - )} - {form.data.maturity_level && ( - - Maturidade: {form.data.maturity_level}{' '} - - - )} - {(form.data.min_amount || form.data.max_amount) && ( - - Montante: {form.data.min_amount || 'sem mínimo'}– - {form.data.max_amount || 'sem máximo'}{' '} - {form.data.currency}{' '} - - - )} - {form.data.currency && - !form.data.min_amount && - !form.data.max_amount && ( - - Moeda: {form.data.currency}{' '} - - - )} - {form.data.before && ( - - Data até: {form.data.before}{' '} - - - )} - {form.data.facets.map((id) => { - const option = references.options.find( - (item) => item.id === id, - ); - return ( - - {option?.label ?? `Opção ${id}`}{' '} - - - ); - })} - {form.data.include_national && ( - - Âmbito nacional incluído - - )} - {territoryPath.length > 0 && ( - - )} - {form.data.sector && ( - - )} - {form.data.include_national && ( - - )} -
- )} + /> + runSearch()} + />
-

{results.total} oportunidades publicadas

- {results.total === 0 && + {!searchLoading && + !searchError && + results.total === 0 && (form.data.province_id || form.data.district_id || form.data.admin_post_id || @@ -1086,6 +387,7 @@ export default function Catalogue({ {['grid', 'list', 'map', 'combined'].map((value, i) => (
- setMapAttempt(value => value + 1)} /> - {!error && } - {['map', 'combined'].includes(view) && ( - - )} - {['map', 'combined'].includes(view) && + {!searchLoading && + !searchError && + ['map', 'combined'].includes(view) && ( +
+ {error ? ( + <> + Não foi possível apresentar o mapa. A lista + continua disponível.{' '} + + + ) : points === null ? ( + 'A carregar o mapa…' + ) : ( + `${points.length} ${points.length === 1 ? 'local' : 'locais'} no mapa; ${unlocated.length} ${unlocated.length === 1 ? 'oportunidade' : 'oportunidades'} sem coordenadas confirmadas.` + )} +
+ )} + {!searchLoading && + !searchError && + ['map', 'combined'].includes(view) && + !error && } + {!searchLoading && + !searchError && + ['map', 'combined'].includes(view) && ( + + )} + {!searchLoading && + !searchError && + ['map', 'combined'].includes(view) && !error && mapContext.length > 0 && (
)} - {truncated && ( -

- O mapa atingiu o limite de 1.000 registos; refine os - filtros. A cobertura não está completa. -

- )} - {unlocated.length > 0 && ( -
-

- {unlocated.length} investimentos sem coordenadas de - local confirmadas -

-

- Continuam nos resultados. Seleccionar uma província não - determina a posição exacta do investimento. + {!searchLoading && + !searchError && + ['map', 'combined'].includes(view) && + truncated && ( +

+ O mapa atingiu o limite de 1.000 registos; refine os + filtros. A cobertura não está completa.

-
    - {unlocated.map((record) => ( -
  • - - {record.title} - {' '} - —{' '} - {record.location || 'Localização por completar'} -
  • - ))} -
-
- )} - {(view !== 'map' || error || points?.length === 0) && ( -
- {results.data.map((r) => ( -
-

- {r.sector?.name} ·{' '} - {r.province || 'Localização não indicada'} -

-

- - {r.title} - -

- {!r.last_validated_at && ( -

- Esta ficha não regista data de validação - documental. Confirme a origem e as condições - antes de agir. + )} + {!searchLoading && + !searchError && + view === 'map' && + unlocated.length > 0 && ( +

+

+ {unlocated.length} investimentos sem coordenadas de + local confirmadas +

+

+ Continuam nos resultados. Seleccionar uma província + não determina a posição exacta do investimento. +

+
    + {unlocated.map((record) => ( +
  • + + {record.title} + {' '} + —{' '} + {record.location || + 'Localização por completar'} +
  • + ))} +
+
+ )} + {!searchLoading && + !searchError && + (view !== 'map' || error || points?.length === 0) && ( +
+ {results.data.map((r) => ( +
+

+ {r.sector?.name} ·{' '} + {r.province || 'Localização não indicada'}

- )} -

{r.summary}

-

{money(r)}

-

{r.maturity_label}

-
- - Ver ficha - - - -
-
- ))} -
- )} +

+ + {r.title} + +

+

{r.summary}

+

{money(r)}

+

{r.maturity_label}

+
+ + Ver ficha + + + +
+
+ ))} +
+ )} {!results.total && (

Sem oportunidades com estes filtros. Explore as fichas diff --git a/resources/js/pages/investment/detail.tsx b/resources/js/pages/investment/detail.tsx index a67c86f5..799b62ca 100644 --- a/resources/js/pages/investment/detail.tsx +++ b/resources/js/pages/investment/detail.tsx @@ -110,7 +110,10 @@ export default function Detail({ Preparar manifestação de interesse ) : ( - + Entrar para preparar manifestação )} @@ -182,9 +185,9 @@ export default function Detail({ ['Investimento', money(r)], ['Maturidade', r.maturity_label], [ - 'Catálogo de maturidade', + 'Classificação da preparação', r.maturity_catalogue_version === 'spec-v2' - ? 'Oito etapas (V2)' + ? 'Etapas de preparação' : r.maturity_catalogue_version === 'legacy-v1' ? 'Histórico — seis níveis' : 'Versão não registada', diff --git a/resources/js/pages/investment/shared.tsx b/resources/js/pages/investment/shared.tsx index b8f93532..ab2a99d5 100644 --- a/resources/js/pages/investment/shared.tsx +++ b/resources/js/pages/investment/shared.tsx @@ -23,6 +23,7 @@ export type Entity = { geography?: StructureGeography | null; }; export type References = { + preparationOptions?: { value: string; label: string; group: string }[]; classification?: import('@/components/opportunity-classification').ClassificationOptions; authorities?: { id: string; label_pt_mz: string }[]; categories?: string[]; diff --git a/resources/js/pages/investment/zone.tsx b/resources/js/pages/investment/zone.tsx index 25e72239..ff485423 100644 --- a/resources/js/pages/investment/zone.tsx +++ b/resources/js/pages/investment/zone.tsx @@ -3,7 +3,8 @@ import ParkDetailsEditor, { emptyParkDetails, type ParkDetails, } from '@/components/investment-zone-operational-details'; -import { Link, router, useForm } from '@inertiajs/react'; +import { Link, router, useForm, usePage } from '@inertiajs/react'; +import { catalogueReturnHref } from '@/lib/catalogue-context'; import InvestmentTerritory from '@/components/investment-territory'; import InvestmentMapPanel from '@/components/investment-map-panel'; import { @@ -276,6 +277,7 @@ function Editor({ attachments, opportunities, }: Props) { + const pageUrl = usePage().url; const [message, setMessage] = useState(''); const [busy, setBusy] = useState(false); const [locationPath, setLocationPath] = useState([]); @@ -347,14 +349,27 @@ function Editor({ } return ( + + Voltar à pesquisa + {!management && z.category === 'industrial_park' && ( )} {!management && }

- {z.code} · {z.category.toUpperCase()} · {z.validation_status} ·{' '} - {z.province} {z.district} + {z.code} ·{' '} + {z.category === 'industrial_park' + ? 'Parque industrial' + : z.category.toUpperCase()}{' '} + {management && `· ${z.validation_status}`} · {z.province}{' '} + {z.district}

A ficha é informativa. A associação territorial não atribui diff --git a/resources/js/pages/investment/zones.tsx b/resources/js/pages/investment/zones.tsx index 865f2fca..3c470beb 100644 --- a/resources/js/pages/investment/zones.tsx +++ b/resources/js/pages/investment/zones.tsx @@ -1,6 +1,9 @@ -import InvestmentZoneInstallation, { type ParkInstallationPlan } from '@/components/investment-zone-installation'; +import InvestmentZoneInstallation, { + type ParkInstallationPlan, +} from '@/components/investment-zone-installation'; import { Link, router } from '@inertiajs/react'; import { useState } from 'react'; +import { catalogueDetailHref } from '@/lib/catalogue-context'; import { TerritoryFields, type TerritoryOption, @@ -41,7 +44,22 @@ type Zone = { scope_type: string; }[]; }; -const parkLabels: Record = { pending:'Por confirmar', general:'Geral', not_applicable:'Não aplicável', unknown:'Não informado', not_informed:'Não informada', announced:'Anunciado', planned:'Planeado', in_development:'Em desenvolvimento', partially_operational:'Parcialmente operacional', operational:'Operacional segundo o cadastro', suspended:'Suspenso', confirmed:'Confirmada segundo o cadastro', unavailable:'Indisponível', reported_unverified:'Declarada, não confirmada' }; +const parkLabels: Record = { + pending: 'Por confirmar', + general: 'Geral', + not_applicable: 'Não aplicável', + unknown: 'Não informado', + not_informed: 'Não informada', + announced: 'Anunciado', + planned: 'Planeado', + in_development: 'Em desenvolvimento', + partially_operational: 'Parcialmente operacional', + operational: 'Operacional segundo o cadastro', + suspended: 'Suspenso', + confirmed: 'Confirmada segundo o cadastro', + unavailable: 'Indisponível', + reported_unverified: 'Declarada, não confirmada', +}; export default function Zones({ installation, detailsInstallation, @@ -105,33 +123,57 @@ export default function Zones({ : 'ZEE, ZFI e parques industriais' } > - {management && installation && } - {management && detailsInstallation && } + {management && installation && ( + + )} + {management && detailsInstallation && ( + + )}

A informação territorial não atribui benefícios nem substitui certificados ou decisões das entidades competentes.

- - { - event.preventDefault(); - const form = event.currentTarget; - const url = new URL(form.action); - const params = new URLSearchParams(); - for (const [name, value] of new FormData(form)) if (typeof value === 'string') params.append(name, value); - url.search = params.toString(); - router.get(url.toString(), {}, {preserveState: true, preserveScroll: true}); - }} className={card + ' grid gap-4 md:grid-cols-3'}> - Sem registos disponíveis para estes filtros. As zonas - aguardam cadastro e validação documental. + Não foram encontrados resultados para esta pesquisa.

)} {records.data.map((zone) => ( @@ -260,11 +301,31 @@ export default function Zones({

{zone.name}

+ + Consultar ficha + + {!management && ( + + Ver oportunidades associadas + + )}

{zone.code} · {zone.category.toUpperCase()} ·{' '} {[zone.district, zone.province] @@ -273,19 +334,29 @@ export default function Zones({

{zone.legal_regime && (

- Regime jurídico: {zone.category === 'industrial_park' ? (parkLabels[zone.legal_regime] ?? zone.legal_regime.toUpperCase()) : zone.legal_regime.toUpperCase()} + Regime jurídico:{' '} + {zone.category === 'industrial_park' + ? (parkLabels[zone.legal_regime] ?? + zone.legal_regime.toUpperCase()) + : zone.legal_regime.toUpperCase()}

)} {zoneExtensionsAvailable && zone.development_status && (

Desenvolvimento:{' '} - {zone.category === 'industrial_park' ? (parkLabels[zone.development_status] ?? 'Por confirmar') : zone.development_status.replaceAll('_', ' ')} + {zone.category === 'industrial_park' + ? (parkLabels[zone.development_status] ?? + 'Por confirmar') + : zone.development_status.replaceAll('_', ' ')}

)} {zoneExtensionsAvailable && zone.availability_status && (

Disponibilidade para instalação:{' '} - {zone.category === 'industrial_park' ? (parkLabels[zone.availability_status] ?? 'Não informada') : zone.availability_status.replaceAll('_', ' ')} + {zone.category === 'industrial_park' + ? (parkLabels[zone.availability_status] ?? + 'Não informada') + : zone.availability_status.replaceAll('_', ' ')}

)} {zone.area_value && ( @@ -334,7 +405,15 @@ export default function Zones({ ))}

)} - {zone.category === 'industrial_park' &&

{zone.resources?.length ? `${zone.resources.length} recursos descritos; consulte o âmbito e a fonte na ficha.` : 'Infraestruturas, serviços, acessos e capacidades: não informados.'} {!zone.activities?.length && 'Atividades: não informadas.'}

} + {zone.category === 'industrial_park' && ( +

+ {zone.resources?.length + ? `${zone.resources.length} recursos descritos; consulte o âmbito e a fonte na ficha.` + : 'Infraestruturas, serviços, acessos e capacidades: não informados.'}{' '} + {!zone.activities?.length && + 'Atividades: não informadas.'} +

+ )}

{zone.description}

Validação: {zone.validation_status}

{management && ( diff --git a/resources/js/pages/portal/services.tsx b/resources/js/pages/portal/services.tsx index 81e2ef7a..98cff894 100644 --- a/resources/js/pages/portal/services.tsx +++ b/resources/js/pages/portal/services.tsx @@ -481,32 +481,6 @@ export default function Services(p: Props) { p.resources?.data.findIndex((resource) => formTypes.includes(resource.document_type), ) ?? -1; - const activeFilterNames: Record = { - q: 'Texto', - topic: 'Assunto', - category: 'Categoria', - document_type: 'Tipo', - activity: 'Actividade', - stage: 'Etapa', - module: 'Módulo', - issuer: 'Emitente', - language: 'Idioma', - legal_status: 'Situação', - source_confirmation: 'Fonte', - territory_id: 'Território', - }; - const activeFilters = Object.entries(filter.data).flatMap( - ([key, value]) => { - const text = Array.isArray(value) - ? value.join(', ') - : typeof value === 'string' - ? value - : ''; - return activeFilterNames[key] && text.trim() - ? [`${activeFilterNames[key]}: ${text}`] - : []; - }, - ); return (
@@ -677,63 +651,80 @@ export default function Services(p: Props) { } /> - - - + {!!(p.topics?.length || filter.data.topic) && ( + + )} + {!!( + p.categories?.length || filter.data.category + ) && ( + + )} + {!!( + p.documentTypes?.length || + filter.data.document_type + ) && ( + + )} {!!p.activities?.length && (
@@ -866,62 +857,78 @@ export default function Services(p: Props) {
)} - - - + {!!(p.issuers?.length || filter.data.issuer) && ( + + )} + {!!( + p.languages?.length || filter.data.language + ) && ( + + )} + {!!( + p.legalStatuses?.length || + filter.data.legal_status + ) && ( + + )}