Compare commits

..
Author SHA1 Message Date
nala e69ab1499f docs: comprovar guias na biblioteca QA existente
tests / ci (push) Canceled after 0s
2026-10-20 19:49:55 +02:00
nala 3258a25f76 feat: apresentar guias educativos com imagem resumo e contexto 2026-10-20 19:45:45 +02:00
nala 0e7d51f18f fix: concluir apresentacao publica dos negocios e zonas
tests / ci (push) Canceled after 0s
2026-10-20 19:00:32 +02:00
nala fd341b627e fix: harmonizar pesquisa e navegacao dos catalogos em QA 2026-10-20 18:53:41 +02:00
nala 1350b4df73 autenticacao 1
tests / ci (push) Canceled after 0s
2026-10-20 17:51:31 +02:00
42 changed files with 17770 additions and 10371 deletions
@@ -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')
@@ -27,6 +27,7 @@ class InvestmentDiscoveryController extends Controller
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
'territorial' => $this->territorial($filters)]);
@@ -53,7 +54,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 +76,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
@@ -106,6 +107,7 @@ class InvestmentDiscoveryController extends Controller
unset($returnFilters['zone_id'], $returnFilters['from_dossier']);
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
'guides' => app(\App\Services\SectorInvestment::class)->guides($record->sector),
'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null,
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
}
@@ -131,7 +133,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)) {
+89 -11
View File
@@ -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<string,mixed> */
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();
}
+32 -3
View File
@@ -35,7 +35,7 @@ final class LegacyContent
}
/** @return array<string, mixed> */
public function article(string $slug = 'agronegocio'): array
public function article(string $slug = 'agronegocio', bool $includeRelated = true): array
{
abort_unless($this->available(), 404);
$identity = config('legacy_content.articles.'.$slug);
@@ -80,13 +80,26 @@ final class LegacyContent
}
preg_match_all('~href="([^"]+)"~i', $localHtml, $existingLinks);
$alreadyLinked = array_map(fn ($href) => html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'), $existingLinks[1]);
$relatedArticles = array_values(array_filter($this->related($identity['group'], $slug), fn ($item) => !in_array($item['href'], $alreadyLinked, true)));
$relatedArticles = [];
if ($includeRelated) {
foreach ($this->related($identity['group'], $slug) as $item) {
if (in_array($item['href'], $alreadyLinked, true)) continue;
try {
$relatedArticles[] = $this->article($item['slug'], false);
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) {
if ($error->getStatusCode() !== 404) throw $error;
}
}
}
return ['id' => (int) $row->resource_id, 'document_id' => $row->id, 'version' => (int) $row->metadata_version,
'sha256' => trim($row->sha256), 'title' => $source['title'], 'author' => $source['author'],
'legacy_id' => $source['legacy_id'], 'legacy_category' => $source['legacy_category'],
'published_at' => $source['published_at'], 'modified_at' => $source['modified_at'], 'source_url' => $source['source_url'],
'href' => '/'.$slug, 'html' => $localHtml,
'href' => '/'.$slug, 'html' => $localHtml, 'summary' => self::excerpt($localHtml),
'opportunities_href' => $sector ? '/oportunidades?sector='.rawurlencode($sector->slug) : null,
'opportunities_label' => $sector ? 'Ver oportunidades de '.$sector->name : null,
'has_opportunities' => count($opportunities) > 0,
'landing_href' => '/explorar/'.$identity['group'], 'start_href' => '/investimentos/a-partir-de/'.$slug,
'group' => config('legacy_content.groups.'.$identity['group']),
'related_articles' => $relatedArticles,
@@ -101,6 +114,22 @@ final class LegacyContent
'consultation_state' => 'public', 'legal_state' => 'not_applicable', 'calculation_use' => false];
}
/** An excerpt of the displayed edition, never a claim about returns or availability. */
public static function excerpt(string $html): string
{
$plain = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
if (str_contains($plain, 'Hotelaria e resorts') && str_contains($plain, 'Ecoturismo')
&& str_contains($plain, 'Infraestruturas turísticas') && str_contains($plain, 'Oportunidades Regionais')) {
return 'Conheça os temas de turismo e hospitalidade abordados no guia: hotelaria, ecoturismo, infraestruturas de apoio e referências regionais. Use este enquadramento para preparar a sua ideia de investimento.';
}
preg_match_all('~<p\b[^>]*>(.*?)</p>~isu', $html, $paragraphs);
foreach ([...($paragraphs[1] ?? []), $html] as $paragraph) {
$text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($paragraph), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if (mb_strlen($text) >= 60) return \Illuminate\Support\Str::limit($text, 320);
}
return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
/** Resolve a library identity, preserving the already stored source URLs and versions. */
/** @return array<string, mixed> */
public function articleForResource(int $resourceId): array
+1 -1
View File
@@ -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;
+26
View File
@@ -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<string,mixed> $data
* @return array<string,mixed> */
public function normalize(array $data): array
+21 -13
View File
@@ -37,19 +37,7 @@ final class SectorInvestment
public function publicView(Sector $sector): array
{
$query = SectorOrganization::opportunities($sector);
$articles = [];
if (app(LegacyContent::class)->available()) {
foreach (self::CONTENT[$sector->slug] ?? [] as $slug) {
try {
$a = app(LegacyContent::class)->article($slug);
$articles[] = ['title' => $a['title'], 'href' => $a['href'], 'image' => $a['image'], 'sha256' => $a['sha256'], 'scope' => $slug === 'tech-fintech' && $sector->slug !== 'fintech' ? 'Especialização Fintech; não representa todo o setor.' : 'Conteúdo educativo relacionado; não é uma oportunidade concreta.'];
} catch (HttpException $e) {
if ($e->getStatusCode() !== 404) {
throw $e;
}
}
}
}
$articles = $this->guides($sector);
$relations = [];
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $table => $foreign) {
$ids = DB::table('opportunity_'.$table)->whereIn('opportunity_id', (clone $query)->select('id'))->pluck($foreign);
@@ -74,6 +62,26 @@ final class SectorInvestment
'content_notice' => $sector->slug === 'mercado-financeiro' ? 'O artigo geral de Mercado Financeiro permanece vazio e não foi publicado. A preparação de negócios neste setor está disponível.' : null];
}
/** Published guides for an explicit sector; never inferred from proximity. */
public function guides(?Sector $sector): array
{
if (! $sector) return [];
$articles = [];
if (app(LegacyContent::class)->available()) {
foreach (self::CONTENT[$sector->slug] ?? [] as $slug) {
try {
$a = app(LegacyContent::class)->article($slug, false);
$articles[] = \Illuminate\Support\Arr::only($a, ['title', 'href', 'image', 'sha256', 'summary', 'author', 'opportunities_href', 'opportunities_label', 'has_opportunities']);
} catch (HttpException $e) {
if ($e->getStatusCode() !== 404) {
throw $e;
}
}
}
}
return $articles;
}
public function attach(User $actor, string $id, array $input): void
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'sector_slug' => 'required|string|max:150'])->validate();
@@ -0,0 +1,84 @@
{
"branch": "codex/educational-content-cards",
"base": "0e7d51f18fb424857fdaad48498a2fc43c044749",
"scope": "New QA delta; separate Tempo review before main",
"files": [
{
"path": "app/Http/Controllers/InvestmentDiscoveryController.php",
"sha256": "693d1217ead894747c266a7295774b26a8b987f8faf06003ed60a6d3e15aca6c"
},
{
"path": "app/Services/LegacyContent.php",
"sha256": "e856ba2a042e971933a401fd9544a50f6bc5c1541c28062719d840a8277da7b8"
},
{
"path": "app/Services/SectorInvestment.php",
"sha256": "a0ee1c9bf864ef765ed70fc1bcdc839781d0bed7d705d991c801bfaa9bf7e4a0"
},
{
"path": "docs/investmoz/unification/traceability-and-manual.md",
"sha256": "bbbb79e84cae659d1921dfa4440e3fcc192521e0976cace0fa2694828219ec00"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv",
"sha256": "420369f562508f68ea50e78d386721d6b62fa730893c4e6ccd06817f4dfb8a27"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog.md",
"sha256": "d77e4931e8f1a0fcbf0f1d80be83a54b22682ffdc0286852d3ad167652056156"
},
{
"path": "docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
"sha256": "757d457a295eb9a516abd1eec6002a52aa1d9a35faea291046e3c99681a1cdcb"
},
{
"path": "resources/js/components/educational-article-card.tsx",
"sha256": "03a298406702cd3211c44fad1fdf79d455ad029d997d417a416f450d6d420b6e"
},
{
"path": "resources/js/components/educational-guides.tsx",
"sha256": "2a2a52e50694e336f05544d51fc776a3bbc2b06d2ab61a1b3cdf1d261afca474"
},
{
"path": "resources/js/components/sector-entry.tsx",
"sha256": "39004abf04f096b987d852d834ce9b27fdf557d2283db3b5868678d3ff0bff1a"
},
{
"path": "resources/js/pages/investment/catalogue.tsx",
"sha256": "ced5e8d4b2910aeeaeb709d6ff0eb2d492f96d9b671d6ff832eea66d022a1f70"
},
{
"path": "resources/js/pages/investment/detail.tsx",
"sha256": "719b3c008887676dd0b69ffe48bd5a64426de92362d92da8ad39412a578c51bc"
},
{
"path": "resources/js/pages/portal/legacy.tsx",
"sha256": "dcd8462e940614f5935efa19d9c9444fe3432fbc704e9c05144508d5c944c90d"
},
{
"path": "tests/Feature/EducationalGuidesTest.php",
"sha256": "aa9e3f62890f1d75f639ad4e7e05713c18112ec96a7fd49c864e5e007807d3cf"
}
],
"build_manifest_sha256": "89c3ce0c67d9faf8cb3c5f7e9d74b3ad5d0ad70dff81ca2e37ca5cac7a471620",
"migrations": [],
"imports": [],
"publication": false,
"remote_operations": [],
"ai": false,
"evidence": {
"backend": "1 test, 8 assertions; exact isolated QA guard and transactions",
"types": "passed",
"build": "passed",
"desktop_width": 2048,
"mobile_width": 390,
"mobile_document_width": 375,
"keyboard": "visible focus and Enter opened guide",
"visual_data": "Read-only copy of existing public Tourism article/image; not synthetic",
"real_library": "Normal sector -> local image 25 and source summary -> normal article /turismo -> library resource 10; existing isolated QA; no import"
},
"limitations": [
"Tempo visual review pending before main integration; prior accepted dossier proof retained, not repeated"
],
"rollback": "Restore previous sources/build through existing deployment chain, preserving database/uploads/permissions and later data"
}
@@ -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": "5596aa8edbbeff34a13613ec11f00f49c62e76a8e2185e5762d972fc70b667e7"
},
{
"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": "f45a56b250bbb5da19613c6b7ad94ba89ea501929f97636c797d92b4a2251b51"
},
{
"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": "5fecb308c3800f6e2ee266fd8559d2b14bea3214f2ffbeb4b651b86f2bed7ea7"
},
{
"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": "da35948622e0754e4ecc25247b8926dfcafa63c4fcb78a94da91a3c6e8b47451",
"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."
}
@@ -1286,3 +1286,260 @@ Interfaces QA: [atribuir interesses](http://127.0.0.1:18555/management/interests
| P6 | Conferência do delta de conteúdos/consumidores após P3–P5; reutilizar provas actuais. Vínculo com dossier legítimo quando disponível. | 2–4 h |
**Saldo de disponibilização do produto existente: 11–20 h**, condicionado ao lote P4 e às pré-condições do destino; não inclui espera por decisão/acesso nem desenvolvimento dos contratos especializados. Os 27–46 h são referência inicial do conjunto, não saldo. Não há base para estimar de forma responsável em bloco o desenvolvimento das oito modalidades: concluir por modalidade exige contrato explícito, âmbito sem acesso indevido, gravação/recuperação e submissão/revisão compatíveis. Recuperação e cessão exigem primeiro identificar a operação concreta; não constituem automaticamente novos módulos. **P7 permanece fechado e separado**, sem bloquear P1–P6. Incrementos 36–39 e instalação remota continuam para decisão expressa. Importação privada e publicação mantêm-se separadas.
## P1/P2 — prova complementar de actualização, 20/10/2026
Mantém-se a revisão QA `6508c44b9fc1` (56 fontes/177 assets, hashes conferidos). Origem `167b5db97653`; não reconstruir nem repetir provas aceites. Pela interface e login/MFA sintético: interesse actualizado de Recebido para Em análise, nota preservada no histórico após recarregar; pedido do Balcão actualizado de Submetido para Em análise pelo analista atribuído, resposta fundamentada e histórico recuperados. Reutilizadas as provas de atribuição, recuperação noutra sessão e recusa de outros registos. Zero alterações remotas, importações, publicações ou grants reais. Contas sintéticas novamente retiradas da allowlist HTTP.
Limite concreto: o agendamento por calendário não foi certificado — a entrada automatizada não actualizou o estado React e o validador existente recusou a falta de prazo. Esse erro ainda aparece em inglês. Não inferir falha de gravação do estado/nota, nem declarar o calendário aprovado. A relação entre próxima acção e prazo já existia; não foi criada exigência organizacional.
P1: 13 das 21 modalidades reconciliadas, oito lacunas de contrato/âmbito individualizadas na tabela anterior; não encerrar todos os contratos especializados. P2: composição QA concluída; sem nova alteração de código. P7 assistência fechada e separada. Saldo P1–P6: 11–20 h para disponibilização do recorte existente (P3 3–5, P4 4–8 condicionado ao lote, P5 2–3, P6 2–4); desenvolvimento especializado sem estimativa fechada e sem bloquear os restantes componentes. O delta de actualização agora concluído não justifica repetir nem voltar a somar horas de P1/P2. Manifesto actualizado apenas nas provas, mesma revisão de fontes.
## P3/P4 preparados e proposta P5/P6 — 20/10/2026
Revisão QA `3fd1f15bd01c`, sucessora de `6508c44b9fc1`: 56 fontes e 177 assets do build preservado. Duas fontes PHP corrigidas: mensagens do prazo em português e limite funcional no MNE reutilizando OpportunityCanonicalContract::deliveryBlockers. ExistingBusinessIntake não consultava esse limite; agora submissão e publicação de financiamento/concessão/fornecimento não são habilitadas por correspondência de acesso. A preparação permanece guardável. Cinco modalidades verificadas por transacção/rollback: capital_increase, financial_investor, financing_request, concession_opportunity, supply_opportunity. Nenhum módulo experimental foi ligado. As seis categorias base mantêm os bloqueios anteriores. As oito lacunas contratuais e P7 permanecem separados.
Calendário: mensagem de falta de prazo traduzida; edição por teclado, gravação e recuperação de 21/10/2026 com a próxima acção passaram no navegador QA. Não se alterou o componente de data: o preenchimento automático isolado não actualizava o estado React, enquanto a edição por teclado o actualiza. Prova anterior de actualização e limites reutilizada; sem repetir importações, scans, restauros ou percursos aceites. Sintaxe PHP passou; hashes dos 177 assets preservados, sem recompilação desnecessária por alteração exclusivamente PHP.
P3, manifesto privado: 30 fichas com identidade e hash actual do QA técnico, secções da fonte e relações; dois originais identificados por SHA256, versão e correspondência por bytes no clone do destino. As fichas continuam privadas; IDs de actores/organizações QA não são transportáveis. Definições project-change e project-follow-up extraídas de config/virtual-desk.php, incluindo campos e hash, comparadas com as versões do clone. Não substituir project-preparation v5 nem reassociar pedidos existentes. Nenhuma importação foi executada. O manifesto de identidades não é ainda um payload de aplicação autenticada.
P4, lista nominal: 27 entradas de referência (quatro edições de catálogo, 12 sectores e 11 actividades) e 24 referências territoriais com origem/identidade/versão e correspondências no clone. São inventário para reconciliação; **zero referências novas autorizadas para escrita e zero geometrias novas ou substituídas incluídas**. Não transportar catálogos de exemplo ou fonte pendente apenas por existirem localmente. Referências existentes são reutilizáveis por identidade/edição; divergências ficam por item e nunca se resolve por coincidência de ID local. A lista completa está no MANIFESTO-P4-REFERENCIAS-GEOMETRIAS.json. A base é um clone local, não prova fresca do servidor.
| Ordem P5/P6 | Operação e condição verificável | Reversão / limite |
|---|---|---|
| 1 — preflight por leitura | Conferir revisão/hash dos ficheiros nominais, schema usado por P1/P2 e P3, flags, operador/MFA, scanner Unix, worker e armazenamento. Comparar cada identidade/edição/hash dos manifestos com o destino actual. Parar apenas a operação divergente e apresentar diferença. | Não executar pacote de migrations. Esta candidata contém zero migrations. Não usar o clone como prova de estado remoto fresco. |
| 2 — backup fresco | Suspender escritas HTTP, tarefas agendadas e consumo de filas no recorte durante cópia consistente da base e ficheiros privados; registar inventário, hashes, UID/GID/modos. Incluir configuração e chaves de desencriptação no envelope protegido com recuperação fora do perfil Windows. Retomar só após fecho consistente. | Reutilizar prova aceite de restauro; nesta execução não criar backup nem repetir restauro. Recuperação total é contingência, não reversão normal. |
| 3 — código/build | Guardar cópia anterior por caminho/hash; aplicar apenas payload da revisão identificada, incluindo um build; efectuar limpeza/recriação de caches compatível com destino e processo existente. | Repor só ficheiros ainda com hash aplicado. Ficheiro modificado posteriormente gera conflito para reconciliação; nunca sobrescrever. |
| 4 — dados privados P3 | Produzir prévia fresca pelo importador existente; reusar originais por hash, confirmar scan real no destino, resolver custódia/permissões por identidade autorizada, criar apenas fichas inexistentes e referências compatíveis. Registar journal de IDs/criação/hash/versão/execução. | Não publicar recursos nem oportunidades; preservar edições posteriores. Sem apagar ficheiros partilhados, histórico ou relações. Corrigir/desactivar só registos deste lote pela recuperação suportada; versões usadas por dossiers permanecem. |
| 5 — serviços internos | VirtualDeskInstallationController, fluxo autenticado de instalação: registar apenas project-change/project-follow-up ausentes; comparar hashes se já existirem. Versionar sem substituir versões anteriores. Activação interna do serviço deve constar da decisão de disponibilização; não equivale a publicação de conteúdo. | Conservar versões referidas por pedidos posteriores. Retirar da escolha para novos pedidos apenas por mecanismo suportado; não eliminar versões ou pedidos. Se esse mecanismo não estiver disponível, conservar definição instalada e reverter só apresentação/código. |
| 6 — referências P4 | Nesta prévia não há operação nova de escrita de geometrias/catálogos. Revalidar correspondências e reutilizar destino. Nova edição real exige identificação nominal e revisão expressa do manifesto. | Nenhuma geometria existente é substituída. Não converter desconhecido em confirmado. |
| 7 — verificações do delta | Entrada autorizada; guardar/recuperar próxima acção e data; limites MNE sem activar contratos; fichas privadas/originais e duas definições recuperáveis; pesquisa/filtros/contexto apenas onde alterados. Vínculo positivo com dossier legítimo quando existir. | Provas aceites reutilizadas; não criar processo fictício no destino. Se faltar dossier legítimo, registar essa única prova pendente, sem bloquear os restantes componentes. |
| 8 — fecho | Retomar escritas/worker e entregar estado por componente com URLs, alterações nominais e impedimentos. Conservar manifesto de execução e reversão. | Reversão normal conserva dados posteriores e estruturas/definições aditivas utilizadas, sem down migrations nem dump antigo sobre dados recentes. |
**Saldo delimitado revisto: 7–13 h** — P3 2–4 h (staging privado/resolução de identidades/aplicação nominal); P4 1–2 h (reconciliação fresca da lista, sem novas geometrias); P5 2–3 h; P6 2–4 h. O calendário, tradução e protecção do limite funcional foram concluídos nesta passagem e estão contabilizados como trabalho realizado, não como horas adicionais futuras. Esta faixa substitui 11–20 h para este recorte delimitado; não cobre concluir todas as modalidades nem um futuro lote geográfico não identificado. Não inclui espera por acesso/decisão. P1/P2 ficam preparados em QA com os limites acima; P3/P4 preparados por leitura, não importados. Instalação, incrementos 36–39 e activação interna das duas definições precisam da decisão expressa identificada. Sem alterações remotas ou publicação nesta execução.
## Pacote executável P3/P4/P5/P6 — c11a0c74766c, 20/10/2026
Staging privado concluído: 30 fichas actuais sem actores QA, dois originais com hash, duas definições internas. Destino conferido por SSH só leitura em 2026-10-20T08:37:24Z: fontes sem divergência, fichas/originais/duas definições ausentes, project-preparation v5 preservado. 27 referências e 25 territórios reconciliados (inclui Inhambane); zero escrita de catálogo/geometria. Aplicação QA 3fd1f15bd01c; pacote c11a0c74766c, com adaptador de prévia/aplicação autenticada, backup consistente, instalação nominal e reversão de apresentação. Após uso só o manifesto de build anterior é reposto automaticamente; 56 fontes compatíveis, todos os assets, dados, definições usadas e histórico permanecem. O novo adaptador passou sintaxe/integridade/bindings, sem repetir importação; aplicação desse adaptador ainda não ensaiada. Saldo de aplicação/verificação 4–7 h; oito lacunas/P7 separados. Decisão pendente: executar revisão exacta, inclusão expressa 36–39/P3 e activação interna das duas definições. Não publicar fichas/documentos nem conceder novos papéis. Ver proposta única na mesma Page e RECIBO-PACOTE-EXECUTAVEL.json. Sem alteração remota nesta passagem.
## Pacote executável P3/P4/P5/P6 — 82dab3719ffc, 20/10/2026
Staging privado concluído: 30 fichas actuais sem actores QA, dois originais com hash, duas definições internas. Destino conferido por SSH só leitura em 2026-10-20T08:37:24Z: fontes sem divergência, fichas/originais/duas definições ausentes, project-preparation v5 preservado. 27 referências e 25 territórios reconciliados (inclui Inhambane); zero escrita de catálogo/geometria. Aplicação QA 3fd1f15bd01c; pacote 82dab3719ffc, com adaptador de prévia/aplicação autenticada, backup consistente, instalação nominal e reversão de apresentação. Após uso só o manifesto de build anterior é reposto automaticamente; 56 fontes compatíveis, todos os assets, dados, definições usadas e histórico permanecem. O adaptador foi aplicado apenas em clone isolado: 30 identidades/30 localizações/31 suportes/11 relações, login e MFA sintéticos normais; rollback antes do commit e recuperação após commit comprovados, repetição sem escritas. UUID de auditoria e recibo transaccional corrigidos; aplicação de 56 fontes inalterada. As questões de acesso público e diferenças visuais/login continuam separadas e abertas. Saldo de aplicação/verificação 4–7 h; oito lacunas/P7 separados. Decisão pendente: executar revisão exacta, inclusão expressa 36–39/P3 e activação interna das duas definições. Não publicar fichas/documentos nem conceder novos papéis. Ver proposta única na mesma Page e RECIBO-PACOTE-EXECUTAVEL.json. Sem alteração remota nesta passagem.
## Pacote executável P3/P4/P5/P6 — 312965f2c653, 20/10/2026
Staging privado concluído: 30 fichas actuais sem actores QA, dois originais com hash, duas definições internas. Destino conferido por SSH só leitura em 2026-10-20T08:37:24Z: fontes sem divergência, fichas/originais/duas definições ausentes, project-preparation v5 preservado. 27 referências e 25 territórios reconciliados (inclui Inhambane); zero escrita de catálogo/geometria. Aplicação QA 3fd1f15bd01c; pacote 312965f2c653, com adaptador de prévia/aplicação autenticada, backup consistente, instalação nominal e reversão de apresentação. Após uso só o manifesto de build anterior é reposto automaticamente; 56 fontes compatíveis, todos os assets, dados, definições usadas e histórico permanecem. O adaptador foi aplicado apenas em clone isolado: 30 identidades/30 localizações/31 suportes/11 relações, login e MFA sintéticos normais; rollback antes do commit e recuperação após commit comprovados, repetição sem escritas. UUID de auditoria e recibo transaccional corrigidos; aplicação de 56 fontes inalterada. As questões de acesso público e diferenças visuais/login continuam separadas e abertas. Saldo de aplicação/verificação 4–7 h; oito lacunas/P7 separados. Decisão pendente: executar revisão exacta, inclusão expressa 36–39/P3 e activação interna das duas definições. Não publicar fichas/documentos nem conceder novos papéis. Ver proposta única na mesma Page e RECIBO-PACOTE-EXECUTAVEL.json. Sem alteração remota nesta passagem.
## Execução autorizada 312965f2c653 em homologação — 2026-10-20T10:01:37Z
Autorização expressa da Tempo nesta conversa; aplicada exclusivamente em teste-investidor.tempo.co.mz. 56 fontes/177 assets (233 caminhos) conferidos; backup fresco consistente de 171 tabelas, privados, fontes/build e configuração, seis artefactos cifrados e recuperação existente preservada. Não foi repetido restauro aceite.
30 fichas privadas, 30 localizações, 31 suportes, 11 relações; dois originais internos com scan real limpo (uma tentativa cada), sem publicar. project-change/project-follow-up v1 internos activados; project-preparation v5 preservado. Reutilizadas 27 referências/25 territórios; zero escritas de catálogos/geometrias, migrations ou permissões. 160 tabelas integralmente iguais; registos anteriores nas tabelas acrescentadas conferidos por hash; 361 ficheiros anteriores fora do delta intactos. HTTP/fila activos. Recibo e acessos: outputs/investmoz-revisao-integrada-QA-20261019/p1-p2/modalidades-e-interface/RECIBO-EXECUCAO-312965.json.
Saldo: execução delimitada P1–P6 concluída; não somar outra vez as 4–7 h já executadas. Mantêm-se vínculo positivo com dossier legítimo, dados em falta/revisão independente, oito lacunas contratuais, capacidades por desenvolver, P7 e SuiteCRM. Reversão disponível só da apresentação: manter 56 fontes compatíveis, ambos os builds, dados, definições usadas e histórico. Não repor dump antigo sobre dados posteriores. As questões de autenticação prematura e diferenças visuais/login continuam separadas, com análise retomada; sem alteração remota adicional.
### Retoma separada de autenticação e apresentação após 312965
Diagnóstico apenas em clone isolado: com a reserva ligada, GET /documentos devolve 302 para login; desligada só no processo QA, /documentos e /oportunidades devolvem 200 anónimo, enquanto /management/portal-services e /minhas-oportunidades continuam 302. A reserva global tem origem na decisão expressa da Tempo, não constitui uma nova obrigação pública de MFA e não foi desactivada no destino. Questão permanece aberta para distinguir claramente reserva do ambiente e percurso público.
Manifestos de build local principal e QA instalada são diferentes; app.tsx/layout AuthSimple iguais, login.tsx difere no reaproveitamento do email. O ecrã local já aberto conserva título Laravel e rodapé anterior; não foi recarregada ou alterada a base local activa. Comparação visual por composição/configuração/sessão continua aberta. Nenhuma correcção adicional de código foi incluída silenciosamente no pacote. Prova: RETOMADA-AUTH-VISUAL-QA.json na mesma pasta do recibo; sem novo backlog.
## Consulta pública e coerência visual em QA — b0fdd083ca56, 20/10/2026
Origem: pedido expresso da Tempo de 20/10/2026. A reserva do ambiente permanece obrigatória; a dependência de uma identidade do portal para qualquer consulta era escolha de implementação. O middleware global confundia ambas. Modo `perimeter` preparado e demonstrado apenas no QA loopback: autorização do ambiente pelo servidor, consulta pública sem conta e autenticação nas operações identificadas. Ausência de autorização e cabeçalho HTTP forjado recusados. O modo `reviewers` continua como valor predefinido compatível até configurar uma reserva independente no destino por decisão expressa. Não basta activar a flag nem aceitar um cabeçalho do cliente.
Base visual: composição aprovada 3fd1f15bd01c, preservando início, navegação, editorial e rodapé. Builds diferentes e conteúdo/configuração em execução explicam a divergência observada com o local; não se copiou `.env` ou segredos. Login/registo ainda continham textos ingleses e logótipo Laravel. QA utiliza a marca Tempo existente, textos portugueses, formulário sem salto de foco e aviso da reserva. A ligação de entrada na ficha pública passa pelo fluxo existente `/participar/entrar`, conservando URL/contexto.
Provas: 13 fronteiras de acesso e seis verificações adicionais passaram; TypeScript e build com código 0. No navegador: consulta anónima → rascunho sintético → autenticação → mesma oportunidade, nome, país, sector e mensagem recuperados. Paragem antes do envio final; zero manifestações desta conta. Registo normal por HTTP em QA, sem confirmação artificial de email/MFA e sem email externo. Gestão privada continua protegida; a conta de participação não recebe permissões administrativas. Imagens antes/depois de início, login e registo guardadas no mesmo conjunto de provas. Não repetir importação, recibos completos, scans ou restauros.
Revisão QA `b0fdd083ca56`: 10 fontes e 177 ficheiros de build no manifesto `MANIFESTO-PUBLICO-VISUAL-QA.json`; pacote sem ambiente, chaves, actores, dados ou harness. QA: http://127.0.0.1:18561/ ; /oportunidades ; /login ; /register. Só acessível nesta máquina. Homologação remota mantém 312965f2c653; não instalada esta correcção e nenhuma abertura externa. Para futura disponibilização será necessário definir e conferir o perímetro independente, antes de activar `perimeter`. Oito lacunas contratuais, vínculo documental positivo ao dossier e P7 separados. Reversão desta preparação: restaurar as dez fontes/build anteriores e manter o modo reservado anterior; nenhum schema ou dado de negócio alterado por este delta.
## Proposta consolidada após reconciliação dos programadores — 423bd512d8c5, 20/10/2026
Preservada b0fdd083ca56 integralmente por hash, sem a renomear nem substituir. É revisão de conteúdo do pacote, não commit Git. Origin/main foi consultado nesta passagem: 507387502c6ef887433e8e9b5cf20ea39d4f17d4, com dois commits posteriores ao HEAD local a128c5a1. A nova composição mantém a aplicação aprovada 3fd1f15bd01c/312965 e acrescenta 97a08df2 (cabeçalho/menu responsivo, institucional/contacto, rodapé e formulário) e 50738750 (compilação/carregamento diferido dos exportadores). As alterações de 14ad2f60, cb438b2b, becb211f e 9bc450e8 já estavam incorporadas na base e foram conservadas. 6e57d1f6 e a128c5a1 são documentação/reconciliação, não implementação de autenticação.
Inventário: 760 ficheiros runtime do Git presentes, 677 exactos após normalizar finais de linha e 83 sobreposições QA enumeradas. Presença não comprova funcionamento de todos os componentes. Cinco conflitos de composição resolvidos; preservados menus novos, editorial, email institucional configurável, acesso/contexto e corpo dos exportadores aceites. A refactorização dos programadores só deslocou o corpo de geração; foi conservada com o corpo QA aceite. Ferramentas de arranque local e respectivo comando/composer dev-check não precisam de instalação no servidor. Não aplicar checkout/reset/pull nem substituir uma árvore inteira.
Identidade: a expressão anterior «marca Tempo» referia o logótipo da proprietária usado na autenticação, não autorização para mudar a identidade do produto. A composição agora identifica explicitamente InvesteMoz, propriedade da Tempo, Lda.; reutiliza o logótipo existente apenas para a proprietária. Não foi inventado outro logótipo de produto nem substituído um exemplar aprovado. Não foi localizado outro logótipo InvesteMoz entre os recursos públicos do repositório.
Reserva exacta: Nginx HTTPS com auth_basic independente, ficheiro protegido /etc/nginx/private/investmoz-homologation.htpasswd, snippet nominal e marcador FastCGI INVESTMOZ_HOMOLOGATION_ACCESS=granted só após autorização do ambiente. INV...HOMOLOGATION_ONLY=true mantido e ACCESS_MODE=perimeter. Document root /var/www/html/teste-investidor.tempo.co.mz/public, socket /run/php/php8.4-fpm.sock, vhost /etc/nginx/sites-available/teste-investidor.tempo.co.mz.conf. Configuração completa e hashes no pacote. /deploy e assets igualmente reservados; compatibilizar o cliente dos programadores antes da troca. Credenciais independentes por revisor autorizado, transmitidas por via protegida, sem reutilizar palavras-passe do portal ou alterar papéis/MFA.
Novas provas: oito casos HTTPS/Nginx/PHP-FastCGI passaram (401 sem autorização, 200 para convidado do portal autorizado no ambiente, 302 na operação privada), três passos da reversão da reserva e quatro casos do contacto com transporte simulado. Navegador: abrir/fechar/Escape do menu, navegação para contacto, erro do formulário e identidade em início/login/registo. Build/TypeScript passaram. Reutilizadas as 19 provas b0 e anteriores; sem nova importação, publicação, scan ou restauro. Clone Windows não comprova por si a instalação Linux. Última evidência remota continua a captura aceite de 20/10/2026; tentativa SSH desta passagem não autenticou (verificação da chave do host), sem ignorar essa verificação. Preflight fresco obrigatório antes da execução, com comparação também dos fontes que alimentam o build e detecção de ficheiros adicionais. Qualquer divergência bloqueia a troca e deve ser reconciliada.
Proposta única 423bd512d8c5: 40 fontes/recursos estáticos e 187 ficheiros de build, configurações e preflight somente leitura; zero migrations/importação/publicação. Reversão deste delta por ficheiro/hash e sequência da reserva, mantendo as 56 fontes compatíveis e dados introduzidos por 312965 e posteriores. Não é reposição integral da aplicação; não repor dump antigo. Oito lacunas contratuais, vínculo positivo ao dossier, geometrias e P7 separados. Estimativa operacional 90–150 minutos com preflight limpo e credenciais prontas, sem desenvolvimento das modalidades pendentes. Sem alterações remotas nesta passagem.
## Preflight SSH e compatibilidade da reserva — 20/10/2026
Revisão 423bd512d8c5 preservada, ZIP SHA256 43b68aaf44daec2db0251f43a8ae254f038fbc0bb0157839327190f578d5d1af. Esta leitura substitui a limitação SSH do registo anterior: 669 verificações do preflight exacto passaram, incluindo 665 entradas de fontes; 125 ficheiros de dependências/base adicionais coincidem com a base aceite. HEAD remoto 9bc450e8; origin/main permanece 50738750. Build servido e vhost coincidem com as pré-condições do manifesto. Não apareceu conflito novo de código dos programadores.
Preservar um comando adicional de diagnóstico idêntico à base aceite, 149 auxiliares gerados (11 diferenças de comentários e três auxiliares antigos sem algumas rotas; nenhuma operação nova desconhecida) e 566 recursos adicionais do build. Não apagar nem regenerar no servidor. A aplicação recebe o build QA já aceite, não uma compilação remota.
Compatibilidade /deploy: ensaio isolado com o webhook efectivo confirmou 401 com HMAC válido mas sem credencial ambiental; 403 com credencial ambiental e HMAC inválido; 202 com ambos válidos, despacho interceptado, zero comandos reais e zero chamadas remotas. Nginx QA reposto. Não repetir as provas funcionais aceites.
O script remoto já recusa árvore Git alterada antes de fetch/reset/migrations; o estado failed resulta dessa guarda. A primeira leitura por palavras-chave sobre reset era incompleta: nenhuma guarda nova é necessária. Conservar script/serviço/HMAC. Não chamar /deploy para instalar esta revisão: o fluxo genérico inclui migrations automáticas, fora deste manifesto nominal de zero migrations.
Execução proposta: após decisão, backup fresco consistente; criar credenciais ambientais por via protegida; configurar no webhook Gitea existente Authorization Header = Basic <base64(utilizador:segredo ambiental)> ANTES de ligar a reserva, sem mudar URL/eventos/ramo/Secret HMAC; conferir novamente as pré-condições; aplicar nominalmente 40 fontes/recursos e 187 ficheiros de build; configuração Nginx exacta com /deploy igualmente reservado. Não usar Test Delivery ou push para testar, pois pode disparar o deploy genérico. O cabeçalho é configuração operacional, não credencial de conta do portal, e não entra em chat, URL ou recibo.
Avaliadores introduzem credencial ambiental e navegam no conteúdo público sem conta InvesteMoz. Operações identificadas continuam a exigir login/políticas/MFA aplicáveis. A futura abertura pública retirará a reserva por decisão própria, sem retirar a protecção privada ou a assinatura/guarda de /deploy. Identidade preservada: InvesteMoz, propriedade da Tempo, Lda.
Reversão: repor selectivamente o delta por hash e a reserva anterior, e só depois o cabeçalho anterior do cliente, sem alterar o Secret HMAC. Preservar alterações posteriores, 56 fontes compatíveis, dados, relações, definições e assets. Não é reposição integral da aplicação nem restauro de dump. Nenhuma alteração remota efectuada. Execução continua sujeita à decisão da Tempo; estimativa operacional 90–150 min com acessos prontos, excluindo esperas/divergências. Oito lacunas contratuais, vínculo documental legítimo, geometrias e P7 separados.
Provas: RECIBO-PREFLIGHT-423bd512d8c5.json, PREFLIGHT-EXACTO-423bd512d8c5.json e PROVA-COMPATIBILIDADE-DEPLOY-QA.json na pasta da mesma proposta. O manifesto/ZIP congelados não foram alterados; este recibo actualiza o preflight e a ordem operacional.
## Execução autorizada 423bd512d8c5 — acesso pendente em 20/10/2026
A Tempo autorizou a execução nominal e a reserva independente. O preflight fresco passou (669 verificações). Staging protegido contém as mesmas 40 fontes/recursos e 187 ficheiros de build, sem alterar o ZIP. Dois hashes APR1 ambientais preparados em staging, sem activar a reserva. Credencial de Nala preparada em ficheiro local privado, restrito ao titular Windows/SYSTEM; nenhum valor em relatórios ou repositório.
Impedimento: o Gitea recusa a autenticação e o Authorization Header do webhook existente ainda não foi configurado. Não activar a reserva antes desta dependência. Pedido de autenticação/configuração local pendente; não falta nova autorização. Aplicação anterior preservada, sem manutenção, instalação, backup consistente iniciado, migrations, importação ou publicação. Retomar com acesso Gitea, backup fresco consistente e execução nominal já autorizada. Provas de consulta sem conta, conta simples e retoma por realizar após instalação. Disponibilização automática por Git não implementada e separada. Estado: ESTADO-EXECUCAO-AUTORIZADA-423.json na pasta da mesma proposta.
## Disponibilização nominal 423bd512d8c5 — 20/10/2026
Executada exclusivamente na homologação reservada por autorização expressa da Tempo. Gitea: utilizador confirmou gravação do Authorization Header; presença Basic e Secret HMAC conferida sem expor valores. Não foi chamado /deploy, Test Delivery ou fluxo Git genérico. Disponibilização automática por Git não implementada e separada.
Backup fresco consistente concluído: manutenção HTTP, fila suspensa, bloqueios SHARE nas 171 tabelas públicas, hashes dos ficheiros estáveis antes/depois. Seis artefactos cifrados e conferidos: base, ficheiros privados/públicos de armazenamento e configuração/chaves da aplicação, fontes/build, configuração de servidor, ACL Linux e metadados. Envelope portátil de recuperação existente preservado, independente do perfil Windows; proprietário/grupo/permissões/ACL/xattrs registados. Não repetido o restauro integral aceite. Cópia anterior do delta e diário de hashes sob custódia root no destino.
Aplicadas 40 fontes/recursos e 187 ficheiros de build, exactamente pelos hashes do pacote congelado. Manifesto efectivamente servido: 41e9bdbf13dcd95865eefa8af25e2b1e40a5c607d293ad4755152dcea83f9d5d. HEAD Git permanece 9bc450e8 e não identifica sozinho a composição servida. Restantes fontes, comando adicional, 149 auxiliares gerados e todos os assets anteriores não substituídos pelo manifesto preservados (741 ficheiros antigos inventariados, zero perdas/alterações imprevistas). Vhost/snippet correspondem aos bytes do pacote; apenas cinco chaves nominais alteradas; restantes valores .env e proprietário/permissões preservados. Nginx validado antes de reload; PHP-FPM/scanner/base não reiniciados. Manutenção terminada e fila no estado anterior activo. Migrations registadas 88 antes/depois; zero migrations, importação, publicação, alterações de contas/grants/MFA.
13 verificações HTTPS do delta passaram: sem credencial ambiental, início/documentos/build e /deploy recusados (401); com credencial ambiental e sem conta do portal, início, documentos, oportunidades, sobre e contacto 200; áreas privadas redireccionam para login; manifesto HTTP igual ao instalado. Navegador: utilizador concluiu a entrada ambiental; início e os 29 documentos abrem com Entrar visível, sem conta InvesteMoz; Continuar no meu Balcão redireccionou para login. Retoma após login real e consulta com conta simples aguardam a sessão apropriada; não certificar esses dois passos pela prova anónima. Sem actores sintéticos ou impersonação no destino.
Credencial ambiental entregue por ficheiro local privado, restrito ao titular Windows/SYSTEM; não consta do repositório, Page ou recibos. Abertura pública continua separada e retirará a reserva por decisão própria, mantendo protecções privadas e /deploy.
Reversão aprovada: modo reviewers enquanto reserva independente permanece; repor selectivamente apresentação/rotas e configuração deste delta apenas se os hashes ainda coincidirem; preservar alterações posteriores; só depois repor reserva/cabeçalho anteriores. Manter 56 fontes compatíveis, dados/relações/definições e assets posteriores. Não é reposição integral nem restauro de dump. Oito lacunas contratuais, dossier legítimo, geometrias, P7 e automatização Git separados. Instalação concluída; saldo desta execução: 15–30 min de verificação com sessão real adequada, excluindo esperas; nenhum novo desenvolvimento incluído. Provas e recibo: RECIBO-EXECUCAO-423bd512d8c5.json e PROVA-HTTP/CONFIGURACAO/PRESERVACAO-DESTINO-423.json na mesma pasta da proposta.
## Conferência da sessão real 423bd512d8c5 — 20/10/2026
Depois da entrada ambiental, o navegador apresentou início e biblioteca com 29 documentos sem conta do portal. Continuar no meu Balcão conduziu a /login. O titular concluiu a autenticação real e regressou automaticamente a /balcao-virtual/painel, com o título Balcão Virtual — acompanhamento e conta visível Osmane Nala. A página inicial voltou a abrir nessa sessão autenticada, sem pedir nova credencial. Esta evidência encerra a retoma no delta; não é prova de uma conta simples, pois a conta usada tem funções já autorizadas.
Não pedir novas contas para encerrar a instalação nem certificar a conta simples sem a observar. Única conferência específica restante deste recorte: consulta com uma conta simples real adequada, 5–10 min quando disponível, excluindo esperas. Não houve criação de conta, impersonação ou alteração de MFA/grants. Credencial ambiental, conta InvesteMoz e conta Gitea são independentes; para o trabalho corrente no portal, usar nala@tempo.co.mz. Não voltar a pedir autenticação nesta passagem.
Os seis directórios novos da instalação foram alinhados com o proprietário do repositório (mario:www-data, 0755), comprovadamente ausentes no arquivo fresco; permissões dos directórios existentes intactas. Sem alteração do pacote ou revisão. A sessão SSH encerrou pelo limite anterior; não foi reaberta apenas para actualizar o diário operacional remoto. Recibo final e provas actualizados localmente e na mesma Page. Revisão efectivamente servida continua 423bd512d8c5. Git automático, oito lacunas contratuais, dossier legítimo, geometrias e P7 separados.
## Consulta pública sem autenticação ambiental — 20/10/2026
Decisão expressa da Tempo: retirar a autenticação ambiental das páginas públicas, preservando login, permissões e MFA das operações privadas e a protecção própria de /deploy. Esta decisão substitui a exigência de credencial ambiental para consultar os conteúdos públicos nesta homologação; não publica novos conteúdos nem altera contas ou funções.
Executado apenas o delta de configuração: removida a reserva Basic global do vhost; Basic mantido explicitamente na localização exacta /deploy com o mesmo htpasswd, e HMAC/Secret/cabeçalho Gitea preservados. Mantidos noindex/nofollow/noarchive e private/no-store no Nginx. INVESTMOZ_HOMOLOGATION_ONLY=false desactiva a reserva global do portal e a indicação desactualizada de ambiente reservado. O modo anterior permanece configurado mas inactivo para eventual reversão; marcador FastCGI de acesso ambiental retirado. Nenhuma alteração de código/build: revisão servida 423bd512d8c5, manifesto SHA256 41e9bdbf13dcd95865eefa8af25e2b1e40a5c607d293ad4755152dcea83f9d5d. Apenas reload Nginx após nginx -t; sem manutenção, migrations, imports, publicação ou reinício de base/fila/scanner.
Backup fresco apenas da configuração alterada, com hashes revalidados e metadados Linux, cifrado sob a custódia e envelope portátil existentes. Valores secretos não constam dos recibos. Cópias anteriores root-only no destino. Reversão: restaurar primeiro o vhost anterior condicionado aos hashes, validar/recarregar para repor a barreira ambiental; depois repor a flag anterior com conferência de conflitos/cache. Preservar edições posteriores e dados; não restaurar base antiga nem build.
16 verificações HTTPS passaram com CookieJar novo e sem Authorization na consulta: início, pesquisa /oportunidades?q=agronegocio (termo recuperado), documentos, ficha pública /documentos/13, artigo /agronegocio, sobre e contacto respondem 200, com componentes públicos e utilizador do portal ausente. Seis áreas privadas (/management/portal-services, /management/profile-install, /minhas-oportunidades, /investimentos, /investidor/manifestacoes e /balcao-virtual/painel) redireccionam para o login do portal sem desafio ambiental. /deploy continua 401 sem Basic e 403 com Basic válido mas HMAC inválido em JSON válido; serviço de deploy não disparado. Uma primeira sondagem com corpo vazio produziu 400 (JSON inválido); corrigida somente a sondagem negativa, sem alterar a aplicação ou repetir as restantes provas aceites.
A ausência do bloqueio é distinta de apenas esconder uma caixa do navegador: os componentes públicos efectivamente renderizam sem formulário de login e a pesquisa conserva o termo. Login num formulário InvesteMoz continua correcto nas operações privadas; se surgir na consulta pública, é falha a registar. Conferência visual em janela InPrivate nova solicitada ao utilizador, ainda pendente neste registo. Não declarar concluída essa parte pela prova HTTP isolada.
Recibo: RECIBO-CONFIGURACAO-PUBLICA-20261020.json; prova: PROVA-CONSULTA-SEM-CREDENCIAL-20261020.json, na pasta da mesma proposta. Oito lacunas contratuais, conta simples específica, dossier legítimo, geometrias, P7 e disponibilização automática por Git continuam separados. Não houve novas contas, concessões, desactivação de MFA ou publicação adicional.
## Fecho da consulta pública sem credencial — 20/10/2026
O utilizador confirmou em janela Edge InPrivate nova: início, pesquisa /oportunidades?q=agronegocio e documentos abrem sem qualquer pedido de credenciais («ja funciona nestes 3»). Confirmação visual manual, distinta da prova automática HTTPS. A prova automática foi alargada apenas aos três consumidores adicionais afectados — zonas, sector Turismo e legislação — e passou em 19 verificações totais. Não repetidos scans, restauros, importações ou percursos anteriores.
Este delta de configuração fica concluído. Consulta pública não requer credencial ambiental nem conta InvesteMoz; seis operações privadas verificadas continuam a pedir login do portal, com permissões/MFA inalterados. /deploy conserva Basic + HMAC, com recusas 401/403 comprovadas e sem disparar o serviço. Prova de limites confirma que apenas /deploy contém Basic, o antigo include global e marcador FastCGI foram removidos, outros valores .env e proprietário/permissões estão intactos, webhook inalterado. Revisão de código/build continua 423bd512d8c5. Documentação anterior que exige credencial para a consulta pública fica superada por esta decisão expressa da Tempo.
Recibo e prova finais: RECIBO-CONFIGURACAO-PUBLICA-20261020.json e PROVA-CONSULTA-SEM-CREDENCIAL-20261020.json. Sem pendência neste recorte. Conta simples específica, oito lacunas contratuais, dossier legítimo, geometrias, P7 e automatização Git mantêm os saldos separados anteriores. Não se declara a aplicação inteira concluída por este ajuste.
## Git main automático — 20/10/2026 — 8cf820ef
Disponibilização automática activa em main; revisão servida 8cf820ef497218d9adebe6a6a022b4c80d694dc3. Push real recebido pelo webhook às 17:28:44 e instalado às 17:29:56 (Maputo), sem despacho manual. Preservadas composição 423bd512d8c5 e alterações dos programadores até 02d4e6b2; conflito do menu resolvido mantendo layout novo, identidade InvesteMoz e administração delimitada. Build, TypeScript, 47 testes PHP, 156 frontend e 3106 asserções de schema passaram; nove testes Linux do executor e ensaio do webhook também passaram. Demonstração visual: Consultar oportunidades. 171 tabelas e 88 migrations permanecem; zero migrations no destino, importações, publicação ou novos grants. .env com bytes/proprietário/modo intactos; armazenamento partilhado e 869 assets anteriores preservados. Consulta pública 200 sem credenciais, gestão 302 para login e /deploy 401 sem Basic. Fila serial, recusa de drift e de alterações de migrations, releases separadas, logs/recibos protegidos e reversão de código/build com dados posteriores conservados. Oito lacunas contratuais, dossier legítimo, geometrias, conta simples específica e P7 mantêm saldo separado.
Procedimento dos programadores: trabalhar numa branch, rever segundo as designações da Tempo e integrar em `main`. O webhook existente dispara a verificação e compilação da revisão completa. Confirmar o commit em https://teste-investidor.tempo.co.mz/build/revision.json. Uma falha conserva a versão servida e deixa recibo/log protegido; não editar a pasta servida para a contornar.
O executor genérico com migrations automáticas foi substituído. O contrato nominal de 88 fontes de migrations fica protegido fora do Git; uma alteração exige compatibilidade/decisão específica. A CI usa PostGIS oficial fixado por digest, apenas em loopback 55441, base descartável `*_test` e identidade conferida. Não usa contas ou dados da aplicação. A guarda nominal e os testes de lifecycle existentes são preservados; não se contornam as instalações autenticadas.
Recuperação: parar a path unit, aguardar o executor e usar `python3 /var/www/deploy/investmoz-pipeline.py --config /etc/investmoz-deploy/delivery.json --rollback` como operador existente. Conferem-se hashes e saúde; a release anterior fica servida e o worker recebe reinício gracioso. Mantêm-se base, uploads, configuração e dados posteriores. Não é restauro integral. O checkout físico anterior e a cópia cifrada de configuração/metadados Linux permanecem disponíveis; a recuperação portátil existente foi reutilizada. Não foi repetido o restauro integral aceite.
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.
Conferência final dos consumidores: negócios também conferido no navegador com pesquisa vazia; retirado o resumo local repetido, conservando o resumo partilhado. Fintech é identificado como especialização do Mercado Financeiro também nesse selector. Os cartões de zonas traduzem categoria, desenvolvimento, disponibilidade e estado de actividade, preservando os códigos e significados persistidos. A vista combinada não repete na lista de não localizados a ficha que já aparece na lista principal.
## Guias educativos relacionados — 20/10/2026
Delta independente de `0e7d51f18fb4`, que já foi integrado e confirmado na homologação. Este incremento permanece na branch `codex/educational-content-cards`, em QA, sujeito a revisão própria. Origem: pedido expresso da Tempo; nenhuma nova regra organizacional.
O componente `EducationalArticleCard` usa imagem pública autorizada, título original, resumo da edição apresentada, autoria e «Ler o guia». Turismo descreve hotelaria, ecoturismo, infraestruturas e referências regionais presentes no artigo, sem repetir metas financeiras como promessa. Outros artigos usam um excerto do texto efectivo. A apresentação não altera originais, fontes, autoria, direitos, versões, publicação ou scan. A imagem continua sujeita aos filtros públicos existentes. A acção secundária só surge quando a consulta de oportunidades públicas do sector tem resultados; não se infere disponibilidade por menção no artigo.
Consumidores: sector (`SectorEntry`), exploração editorial e leituras relacionadas (`portal/legacy`), catálogo de oportunidades com sector explícito (incluindo vista territorial/mapa) e ficha de oportunidade com sector. Um filtro de localização sem sector não determina um artigo; não se inventaram associações territoriais. Nos restantes catálogos territoriais não foi encontrado um cartão editorial equivalente a substituir. Não se alteraram cartões de anúncios, documentos ou entidades que não sejam recomendações de artigos.
Após a leitura, mantém-se «Iniciar ou continuar uma ideia», com `/investimentos/a-partir-de/{slug}` e o contexto autorizado do dossier quando existente. Não há novo percurso, conta ou privilégio. As provas anteriores de gravação documental mantêm-se; não foram repetidas.
Provas novas: `EducationalGuidesTest`, isolado por guarda QA, transacções e armazenamento fictício do teste, 1 teste / 8 asserções (excerto, autoria, destino, percurso, ausência de resultados, fonte não publicada e scan pendente). TypeScript, sintaxe PHP e build passaram. Capturas a 2048 px e 390 px; a 390 px, conteúdo com 375 px, sem transbordo horizontal, foco visível e Enter no botão «Ler o guia». Imagem real carregada com 900 px de largura natural. A prévia HTTP `http://127.0.0.1:18570/qa/guias/turismo` usa uma cópia por leitura do artigo/imagem já públicos no destino; não usa texto ou imagem sintéticos nem escreve registos. O router privado e a cópia ficam fora do Git. O destino não foi alterado.
Limitação: o artigo real não existe na base QA actual. A prévia permite conferir cartão e leitura; não comprova recuperação desse artigo num dossier autenticado nessa base. O URL original do percurso foi preservado e conferido pelo teste. Não se importaram dados ou fabricaram resultados para preencher essa lacuna. Capturas em `outputs/educational-guides-20261020/desktop.jpg` e `mobile.jpg` (ficheiros locais de prova).
Gestão: editar e publicar pelos ecrãs existentes da biblioteca/editorial; o resumo acompanha a edição apresentada. A acção de oportunidades é calculada pelos registos publicados, não pelo editor. Disponibilização futura: revisão própria e integração em `main` pelo processo automático, após decisão. Sem migrations, importação ou publicação neste delta. Reversão pelas fontes/build anteriores, conservando base, ficheiros, permissões e dados posteriores. IA, oito lacunas contratuais e P7 separados.
### Library integration evidence supplement - 20/10/2026
Functional code remains 3258a25f7659. Existing QA database legacy_trio_20261015_test and its original local storage were selected by an isolated loopback harness; no import, publication or application route override. Normal /sectores/turismo rendered the source-derived summary and image /documentos/25/view (HTTP 200; 900px natural width). Keyboard Enter on Ler o guia opened normal /turismo; the article links to library resource /documentos/10 (source SHA256 8cab862be245382dd4c1fd5fcb423c47aed7be3a22e0a7f8d5521510cc36bc72). Thus the real-library integration limitation of the earlier copied preview is resolved. URLs: http://127.0.0.1:18572/sectores/turismo and /turismo. Evidence: outputs/educational-guides-20261020/real-library-receipt.json, real-library-sector.jpg and real-library-guide.jpg. No dossier path/context change; previous accepted dossier proof retained, not repeated. Tempo visual review remains pending before main integration. AI excluded. This supplement changes evidence/documentation only, not functional code.
File diff suppressed because one or more lines are too long
@@ -2806,3 +2806,194 @@ Interfaces QA: [atribuir interesses](http://127.0.0.1:18555/management/interests
| P6 | Conferência do delta de conteúdos/consumidores após P3–P5; reutilizar provas actuais. Vínculo com dossier legítimo quando disponível. | 2–4 h |
**Saldo de disponibilização do produto existente: 11–20 h**, condicionado ao lote P4 e às pré-condições do destino; não inclui espera por decisão/acesso nem desenvolvimento dos contratos especializados. Os 27–46 h são referência inicial do conjunto, não saldo. Não há base para estimar de forma responsável em bloco o desenvolvimento das oito modalidades: concluir por modalidade exige contrato explícito, âmbito sem acesso indevido, gravação/recuperação e submissão/revisão compatíveis. Recuperação e cessão exigem primeiro identificar a operação concreta; não constituem automaticamente novos módulos. **P7 permanece fechado e separado**, sem bloquear P1–P6. Incrementos 36–39 e instalação remota continuam para decisão expressa. Importação privada e publicação mantêm-se separadas.
## P1/P2 — prova complementar de actualização, 20/10/2026
Mantém-se a revisão QA `6508c44b9fc1` (56 fontes/177 assets, hashes conferidos). Origem `167b5db97653`; não reconstruir nem repetir provas aceites. Pela interface e login/MFA sintético: interesse actualizado de Recebido para Em análise, nota preservada no histórico após recarregar; pedido do Balcão actualizado de Submetido para Em análise pelo analista atribuído, resposta fundamentada e histórico recuperados. Reutilizadas as provas de atribuição, recuperação noutra sessão e recusa de outros registos. Zero alterações remotas, importações, publicações ou grants reais. Contas sintéticas novamente retiradas da allowlist HTTP.
Limite concreto: o agendamento por calendário não foi certificado — a entrada automatizada não actualizou o estado React e o validador existente recusou a falta de prazo. Esse erro ainda aparece em inglês. Não inferir falha de gravação do estado/nota, nem declarar o calendário aprovado. A relação entre próxima acção e prazo já existia; não foi criada exigência organizacional.
P1: 13 das 21 modalidades reconciliadas, oito lacunas de contrato/âmbito individualizadas na tabela anterior; não encerrar todos os contratos especializados. P2: composição QA concluída; sem nova alteração de código. P7 assistência fechada e separada. Saldo P1–P6: 11–20 h para disponibilização do recorte existente (P3 3–5, P4 4–8 condicionado ao lote, P5 2–3, P6 2–4); desenvolvimento especializado sem estimativa fechada e sem bloquear os restantes componentes. O delta de actualização agora concluído não justifica repetir nem voltar a somar horas de P1/P2. Manifesto actualizado apenas nas provas, mesma revisão de fontes.
## P3/P4 preparados e proposta P5/P6 — 20/10/2026
Revisão QA `3fd1f15bd01c`, sucessora de `6508c44b9fc1`: 56 fontes e 177 assets do build preservado. Duas fontes PHP corrigidas: mensagens do prazo em português e limite funcional no MNE reutilizando OpportunityCanonicalContract::deliveryBlockers. ExistingBusinessIntake não consultava esse limite; agora submissão e publicação de financiamento/concessão/fornecimento não são habilitadas por correspondência de acesso. A preparação permanece guardável. Cinco modalidades verificadas por transacção/rollback: capital_increase, financial_investor, financing_request, concession_opportunity, supply_opportunity. Nenhum módulo experimental foi ligado. As seis categorias base mantêm os bloqueios anteriores. As oito lacunas contratuais e P7 permanecem separados.
Calendário: mensagem de falta de prazo traduzida; edição por teclado, gravação e recuperação de 21/10/2026 com a próxima acção passaram no navegador QA. Não se alterou o componente de data: o preenchimento automático isolado não actualizava o estado React, enquanto a edição por teclado o actualiza. Prova anterior de actualização e limites reutilizada; sem repetir importações, scans, restauros ou percursos aceites. Sintaxe PHP passou; hashes dos 177 assets preservados, sem recompilação desnecessária por alteração exclusivamente PHP.
P3, manifesto privado: 30 fichas com identidade e hash actual do QA técnico, secções da fonte e relações; dois originais identificados por SHA256, versão e correspondência por bytes no clone do destino. As fichas continuam privadas; IDs de actores/organizações QA não são transportáveis. Definições project-change e project-follow-up extraídas de config/virtual-desk.php, incluindo campos e hash, comparadas com as versões do clone. Não substituir project-preparation v5 nem reassociar pedidos existentes. Nenhuma importação foi executada. O manifesto de identidades não é ainda um payload de aplicação autenticada.
P4, lista nominal: 27 entradas de referência (quatro edições de catálogo, 12 sectores e 11 actividades) e 24 referências territoriais com origem/identidade/versão e correspondências no clone. São inventário para reconciliação; **zero referências novas autorizadas para escrita e zero geometrias novas ou substituídas incluídas**. Não transportar catálogos de exemplo ou fonte pendente apenas por existirem localmente. Referências existentes são reutilizáveis por identidade/edição; divergências ficam por item e nunca se resolve por coincidência de ID local. A lista completa está no MANIFESTO-P4-REFERENCIAS-GEOMETRIAS.json. A base é um clone local, não prova fresca do servidor.
| Ordem P5/P6 | Operação e condição verificável | Reversão / limite |
|---|---|---|
| 1 — preflight por leitura | Conferir revisão/hash dos ficheiros nominais, schema usado por P1/P2 e P3, flags, operador/MFA, scanner Unix, worker e armazenamento. Comparar cada identidade/edição/hash dos manifestos com o destino actual. Parar apenas a operação divergente e apresentar diferença. | Não executar pacote de migrations. Esta candidata contém zero migrations. Não usar o clone como prova de estado remoto fresco. |
| 2 — backup fresco | Suspender escritas HTTP, tarefas agendadas e consumo de filas no recorte durante cópia consistente da base e ficheiros privados; registar inventário, hashes, UID/GID/modos. Incluir configuração e chaves de desencriptação no envelope protegido com recuperação fora do perfil Windows. Retomar só após fecho consistente. | Reutilizar prova aceite de restauro; nesta execução não criar backup nem repetir restauro. Recuperação total é contingência, não reversão normal. |
| 3 — código/build | Guardar cópia anterior por caminho/hash; aplicar apenas payload da revisão identificada, incluindo um build; efectuar limpeza/recriação de caches compatível com destino e processo existente. | Repor só ficheiros ainda com hash aplicado. Ficheiro modificado posteriormente gera conflito para reconciliação; nunca sobrescrever. |
| 4 — dados privados P3 | Produzir prévia fresca pelo importador existente; reusar originais por hash, confirmar scan real no destino, resolver custódia/permissões por identidade autorizada, criar apenas fichas inexistentes e referências compatíveis. Registar journal de IDs/criação/hash/versão/execução. | Não publicar recursos nem oportunidades; preservar edições posteriores. Sem apagar ficheiros partilhados, histórico ou relações. Corrigir/desactivar só registos deste lote pela recuperação suportada; versões usadas por dossiers permanecem. |
| 5 — serviços internos | VirtualDeskInstallationController, fluxo autenticado de instalação: registar apenas project-change/project-follow-up ausentes; comparar hashes se já existirem. Versionar sem substituir versões anteriores. Activação interna do serviço deve constar da decisão de disponibilização; não equivale a publicação de conteúdo. | Conservar versões referidas por pedidos posteriores. Retirar da escolha para novos pedidos apenas por mecanismo suportado; não eliminar versões ou pedidos. Se esse mecanismo não estiver disponível, conservar definição instalada e reverter só apresentação/código. |
| 6 — referências P4 | Nesta prévia não há operação nova de escrita de geometrias/catálogos. Revalidar correspondências e reutilizar destino. Nova edição real exige identificação nominal e revisão expressa do manifesto. | Nenhuma geometria existente é substituída. Não converter desconhecido em confirmado. |
| 7 — verificações do delta | Entrada autorizada; guardar/recuperar próxima acção e data; limites MNE sem activar contratos; fichas privadas/originais e duas definições recuperáveis; pesquisa/filtros/contexto apenas onde alterados. Vínculo positivo com dossier legítimo quando existir. | Provas aceites reutilizadas; não criar processo fictício no destino. Se faltar dossier legítimo, registar essa única prova pendente, sem bloquear os restantes componentes. |
| 8 — fecho | Retomar escritas/worker e entregar estado por componente com URLs, alterações nominais e impedimentos. Conservar manifesto de execução e reversão. | Reversão normal conserva dados posteriores e estruturas/definições aditivas utilizadas, sem down migrations nem dump antigo sobre dados recentes. |
**Saldo delimitado revisto: 7–13 h** — P3 2–4 h (staging privado/resolução de identidades/aplicação nominal); P4 1–2 h (reconciliação fresca da lista, sem novas geometrias); P5 2–3 h; P6 2–4 h. O calendário, tradução e protecção do limite funcional foram concluídos nesta passagem e estão contabilizados como trabalho realizado, não como horas adicionais futuras. Esta faixa substitui 11–20 h para este recorte delimitado; não cobre concluir todas as modalidades nem um futuro lote geográfico não identificado. Não inclui espera por acesso/decisão. P1/P2 ficam preparados em QA com os limites acima; P3/P4 preparados por leitura, não importados. Instalação, incrementos 36–39 e activação interna das duas definições precisam da decisão expressa identificada. Sem alterações remotas ou publicação nesta execução.
## Pacote executável P3/P4/P5/P6 — c11a0c74766c, 20/10/2026
Staging privado concluído: 30 fichas actuais sem actores QA, dois originais com hash, duas definições internas. Destino conferido por SSH só leitura em 2026-10-20T08:37:24Z: fontes sem divergência, fichas/originais/duas definições ausentes, project-preparation v5 preservado. 27 referências e 25 territórios reconciliados (inclui Inhambane); zero escrita de catálogo/geometria. Aplicação QA 3fd1f15bd01c; pacote c11a0c74766c, com adaptador de prévia/aplicação autenticada, backup consistente, instalação nominal e reversão de apresentação. Após uso só o manifesto de build anterior é reposto automaticamente; 56 fontes compatíveis, todos os assets, dados, definições usadas e histórico permanecem. O novo adaptador passou sintaxe/integridade/bindings, sem repetir importação; aplicação desse adaptador ainda não ensaiada. Saldo de aplicação/verificação 4–7 h; oito lacunas/P7 separados. Decisão pendente: executar revisão exacta, inclusão expressa 36–39/P3 e activação interna das duas definições. Não publicar fichas/documentos nem conceder novos papéis. Ver proposta única na mesma Page e RECIBO-PACOTE-EXECUTAVEL.json. Sem alteração remota nesta passagem.
## Pacote executável P3/P4/P5/P6 — 82dab3719ffc, 20/10/2026
Staging privado concluído: 30 fichas actuais sem actores QA, dois originais com hash, duas definições internas. Destino conferido por SSH só leitura em 2026-10-20T08:37:24Z: fontes sem divergência, fichas/originais/duas definições ausentes, project-preparation v5 preservado. 27 referências e 25 territórios reconciliados (inclui Inhambane); zero escrita de catálogo/geometria. Aplicação QA 3fd1f15bd01c; pacote 82dab3719ffc, com adaptador de prévia/aplicação autenticada, backup consistente, instalação nominal e reversão de apresentação. Após uso só o manifesto de build anterior é reposto automaticamente; 56 fontes compatíveis, todos os assets, dados, definições usadas e histórico permanecem. O adaptador foi aplicado apenas em clone isolado: 30 identidades/30 localizações/31 suportes/11 relações, login e MFA sintéticos normais; rollback antes do commit e recuperação após commit comprovados, repetição sem escritas. UUID de auditoria e recibo transaccional corrigidos; aplicação de 56 fontes inalterada. As questões de acesso público e diferenças visuais/login continuam separadas e abertas. Saldo de aplicação/verificação 4–7 h; oito lacunas/P7 separados. Decisão pendente: executar revisão exacta, inclusão expressa 36–39/P3 e activação interna das duas definições. Não publicar fichas/documentos nem conceder novos papéis. Ver proposta única na mesma Page e RECIBO-PACOTE-EXECUTAVEL.json. Sem alteração remota nesta passagem.
## Pacote executável P3/P4/P5/P6 — 312965f2c653, 20/10/2026
Staging privado concluído: 30 fichas actuais sem actores QA, dois originais com hash, duas definições internas. Destino conferido por SSH só leitura em 2026-10-20T08:37:24Z: fontes sem divergência, fichas/originais/duas definições ausentes, project-preparation v5 preservado. 27 referências e 25 territórios reconciliados (inclui Inhambane); zero escrita de catálogo/geometria. Aplicação QA 3fd1f15bd01c; pacote 312965f2c653, com adaptador de prévia/aplicação autenticada, backup consistente, instalação nominal e reversão de apresentação. Após uso só o manifesto de build anterior é reposto automaticamente; 56 fontes compatíveis, todos os assets, dados, definições usadas e histórico permanecem. O adaptador foi aplicado apenas em clone isolado: 30 identidades/30 localizações/31 suportes/11 relações, login e MFA sintéticos normais; rollback antes do commit e recuperação após commit comprovados, repetição sem escritas. UUID de auditoria e recibo transaccional corrigidos; aplicação de 56 fontes inalterada. As questões de acesso público e diferenças visuais/login continuam separadas e abertas. Saldo de aplicação/verificação 4–7 h; oito lacunas/P7 separados. Decisão pendente: executar revisão exacta, inclusão expressa 36–39/P3 e activação interna das duas definições. Não publicar fichas/documentos nem conceder novos papéis. Ver proposta única na mesma Page e RECIBO-PACOTE-EXECUTAVEL.json. Sem alteração remota nesta passagem.
## Execução autorizada 312965f2c653 em homologação — 2026-10-20T10:01:37Z
Autorização expressa da Tempo nesta conversa; aplicada exclusivamente em teste-investidor.tempo.co.mz. 56 fontes/177 assets (233 caminhos) conferidos; backup fresco consistente de 171 tabelas, privados, fontes/build e configuração, seis artefactos cifrados e recuperação existente preservada. Não foi repetido restauro aceite.
30 fichas privadas, 30 localizações, 31 suportes, 11 relações; dois originais internos com scan real limpo (uma tentativa cada), sem publicar. project-change/project-follow-up v1 internos activados; project-preparation v5 preservado. Reutilizadas 27 referências/25 territórios; zero escritas de catálogos/geometrias, migrations ou permissões. 160 tabelas integralmente iguais; registos anteriores nas tabelas acrescentadas conferidos por hash; 361 ficheiros anteriores fora do delta intactos. HTTP/fila activos. Recibo e acessos: outputs/investmoz-revisao-integrada-QA-20261019/p1-p2/modalidades-e-interface/RECIBO-EXECUCAO-312965.json.
Saldo: execução delimitada P1–P6 concluída; não somar outra vez as 4–7 h já executadas. Mantêm-se vínculo positivo com dossier legítimo, dados em falta/revisão independente, oito lacunas contratuais, capacidades por desenvolver, P7 e SuiteCRM. Reversão disponível só da apresentação: manter 56 fontes compatíveis, ambos os builds, dados, definições usadas e histórico. Não repor dump antigo sobre dados posteriores. As questões de autenticação prematura e diferenças visuais/login continuam separadas, com análise retomada; sem alteração remota adicional.
### Retoma separada de autenticação e apresentação após 312965
Diagnóstico apenas em clone isolado: com a reserva ligada, GET /documentos devolve 302 para login; desligada só no processo QA, /documentos e /oportunidades devolvem 200 anónimo, enquanto /management/portal-services e /minhas-oportunidades continuam 302. A reserva global tem origem na decisão expressa da Tempo, não constitui uma nova obrigação pública de MFA e não foi desactivada no destino. Questão permanece aberta para distinguir claramente reserva do ambiente e percurso público.
Manifestos de build local principal e QA instalada são diferentes; app.tsx/layout AuthSimple iguais, login.tsx difere no reaproveitamento do email. O ecrã local já aberto conserva título Laravel e rodapé anterior; não foi recarregada ou alterada a base local activa. Comparação visual por composição/configuração/sessão continua aberta. Nenhuma correcção adicional de código foi incluída silenciosamente no pacote. Prova: RETOMADA-AUTH-VISUAL-QA.json na mesma pasta do recibo; sem novo backlog.
## Consulta pública e coerência visual em QA — b0fdd083ca56, 20/10/2026
Origem: pedido expresso da Tempo de 20/10/2026. A reserva do ambiente permanece obrigatória; a dependência de uma identidade do portal para qualquer consulta era escolha de implementação. O middleware global confundia ambas. Modo `perimeter` preparado e demonstrado apenas no QA loopback: autorização do ambiente pelo servidor, consulta pública sem conta e autenticação nas operações identificadas. Ausência de autorização e cabeçalho HTTP forjado recusados. O modo `reviewers` continua como valor predefinido compatível até configurar uma reserva independente no destino por decisão expressa. Não basta activar a flag nem aceitar um cabeçalho do cliente.
Base visual: composição aprovada 3fd1f15bd01c, preservando início, navegação, editorial e rodapé. Builds diferentes e conteúdo/configuração em execução explicam a divergência observada com o local; não se copiou `.env` ou segredos. Login/registo ainda continham textos ingleses e logótipo Laravel. QA utiliza a marca Tempo existente, textos portugueses, formulário sem salto de foco e aviso da reserva. A ligação de entrada na ficha pública passa pelo fluxo existente `/participar/entrar`, conservando URL/contexto.
Provas: 13 fronteiras de acesso e seis verificações adicionais passaram; TypeScript e build com código 0. No navegador: consulta anónima → rascunho sintético → autenticação → mesma oportunidade, nome, país, sector e mensagem recuperados. Paragem antes do envio final; zero manifestações desta conta. Registo normal por HTTP em QA, sem confirmação artificial de email/MFA e sem email externo. Gestão privada continua protegida; a conta de participação não recebe permissões administrativas. Imagens antes/depois de início, login e registo guardadas no mesmo conjunto de provas. Não repetir importação, recibos completos, scans ou restauros.
Revisão QA `b0fdd083ca56`: 10 fontes e 177 ficheiros de build no manifesto `MANIFESTO-PUBLICO-VISUAL-QA.json`; pacote sem ambiente, chaves, actores, dados ou harness. QA: http://127.0.0.1:18561/ ; /oportunidades ; /login ; /register. Só acessível nesta máquina. Homologação remota mantém 312965f2c653; não instalada esta correcção e nenhuma abertura externa. Para futura disponibilização será necessário definir e conferir o perímetro independente, antes de activar `perimeter`. Oito lacunas contratuais, vínculo documental positivo ao dossier e P7 separados. Reversão desta preparação: restaurar as dez fontes/build anteriores e manter o modo reservado anterior; nenhum schema ou dado de negócio alterado por este delta.
## Proposta consolidada após reconciliação dos programadores — 423bd512d8c5, 20/10/2026
Preservada b0fdd083ca56 integralmente por hash, sem a renomear nem substituir. É revisão de conteúdo do pacote, não commit Git. Origin/main foi consultado nesta passagem: 507387502c6ef887433e8e9b5cf20ea39d4f17d4, com dois commits posteriores ao HEAD local a128c5a1. A nova composição mantém a aplicação aprovada 3fd1f15bd01c/312965 e acrescenta 97a08df2 (cabeçalho/menu responsivo, institucional/contacto, rodapé e formulário) e 50738750 (compilação/carregamento diferido dos exportadores). As alterações de 14ad2f60, cb438b2b, becb211f e 9bc450e8 já estavam incorporadas na base e foram conservadas. 6e57d1f6 e a128c5a1 são documentação/reconciliação, não implementação de autenticação.
Inventário: 760 ficheiros runtime do Git presentes, 677 exactos após normalizar finais de linha e 83 sobreposições QA enumeradas. Presença não comprova funcionamento de todos os componentes. Cinco conflitos de composição resolvidos; preservados menus novos, editorial, email institucional configurável, acesso/contexto e corpo dos exportadores aceites. A refactorização dos programadores só deslocou o corpo de geração; foi conservada com o corpo QA aceite. Ferramentas de arranque local e respectivo comando/composer dev-check não precisam de instalação no servidor. Não aplicar checkout/reset/pull nem substituir uma árvore inteira.
Identidade: a expressão anterior «marca Tempo» referia o logótipo da proprietária usado na autenticação, não autorização para mudar a identidade do produto. A composição agora identifica explicitamente InvesteMoz, propriedade da Tempo, Lda.; reutiliza o logótipo existente apenas para a proprietária. Não foi inventado outro logótipo de produto nem substituído um exemplar aprovado. Não foi localizado outro logótipo InvesteMoz entre os recursos públicos do repositório.
Reserva exacta: Nginx HTTPS com auth_basic independente, ficheiro protegido /etc/nginx/private/investmoz-homologation.htpasswd, snippet nominal e marcador FastCGI INVESTMOZ_HOMOLOGATION_ACCESS=granted só após autorização do ambiente. INV...HOMOLOGATION_ONLY=true mantido e ACCESS_MODE=perimeter. Document root /var/www/html/teste-investidor.tempo.co.mz/public, socket /run/php/php8.4-fpm.sock, vhost /etc/nginx/sites-available/teste-investidor.tempo.co.mz.conf. Configuração completa e hashes no pacote. /deploy e assets igualmente reservados; compatibilizar o cliente dos programadores antes da troca. Credenciais independentes por revisor autorizado, transmitidas por via protegida, sem reutilizar palavras-passe do portal ou alterar papéis/MFA.
Novas provas: oito casos HTTPS/Nginx/PHP-FastCGI passaram (401 sem autorização, 200 para convidado do portal autorizado no ambiente, 302 na operação privada), três passos da reversão da reserva e quatro casos do contacto com transporte simulado. Navegador: abrir/fechar/Escape do menu, navegação para contacto, erro do formulário e identidade em início/login/registo. Build/TypeScript passaram. Reutilizadas as 19 provas b0 e anteriores; sem nova importação, publicação, scan ou restauro. Clone Windows não comprova por si a instalação Linux. Última evidência remota continua a captura aceite de 20/10/2026; tentativa SSH desta passagem não autenticou (verificação da chave do host), sem ignorar essa verificação. Preflight fresco obrigatório antes da execução, com comparação também dos fontes que alimentam o build e detecção de ficheiros adicionais. Qualquer divergência bloqueia a troca e deve ser reconciliada.
Proposta única 423bd512d8c5: 40 fontes/recursos estáticos e 187 ficheiros de build, configurações e preflight somente leitura; zero migrations/importação/publicação. Reversão deste delta por ficheiro/hash e sequência da reserva, mantendo as 56 fontes compatíveis e dados introduzidos por 312965 e posteriores. Não é reposição integral da aplicação; não repor dump antigo. Oito lacunas contratuais, vínculo positivo ao dossier, geometrias e P7 separados. Estimativa operacional 90–150 minutos com preflight limpo e credenciais prontas, sem desenvolvimento das modalidades pendentes. Sem alterações remotas nesta passagem.
## Preflight SSH e compatibilidade da reserva — 20/10/2026
Revisão 423bd512d8c5 preservada, ZIP SHA256 43b68aaf44daec2db0251f43a8ae254f038fbc0bb0157839327190f578d5d1af. Esta leitura substitui a limitação SSH do registo anterior: 669 verificações do preflight exacto passaram, incluindo 665 entradas de fontes; 125 ficheiros de dependências/base adicionais coincidem com a base aceite. HEAD remoto 9bc450e8; origin/main permanece 50738750. Build servido e vhost coincidem com as pré-condições do manifesto. Não apareceu conflito novo de código dos programadores.
Preservar um comando adicional de diagnóstico idêntico à base aceite, 149 auxiliares gerados (11 diferenças de comentários e três auxiliares antigos sem algumas rotas; nenhuma operação nova desconhecida) e 566 recursos adicionais do build. Não apagar nem regenerar no servidor. A aplicação recebe o build QA já aceite, não uma compilação remota.
Compatibilidade /deploy: ensaio isolado com o webhook efectivo confirmou 401 com HMAC válido mas sem credencial ambiental; 403 com credencial ambiental e HMAC inválido; 202 com ambos válidos, despacho interceptado, zero comandos reais e zero chamadas remotas. Nginx QA reposto. Não repetir as provas funcionais aceites.
O script remoto já recusa árvore Git alterada antes de fetch/reset/migrations; o estado failed resulta dessa guarda. A primeira leitura por palavras-chave sobre reset era incompleta: nenhuma guarda nova é necessária. Conservar script/serviço/HMAC. Não chamar /deploy para instalar esta revisão: o fluxo genérico inclui migrations automáticas, fora deste manifesto nominal de zero migrations.
Execução proposta: após decisão, backup fresco consistente; criar credenciais ambientais por via protegida; configurar no webhook Gitea existente Authorization Header = Basic <base64(utilizador:segredo ambiental)> ANTES de ligar a reserva, sem mudar URL/eventos/ramo/Secret HMAC; conferir novamente as pré-condições; aplicar nominalmente 40 fontes/recursos e 187 ficheiros de build; configuração Nginx exacta com /deploy igualmente reservado. Não usar Test Delivery ou push para testar, pois pode disparar o deploy genérico. O cabeçalho é configuração operacional, não credencial de conta do portal, e não entra em chat, URL ou recibo.
Avaliadores introduzem credencial ambiental e navegam no conteúdo público sem conta InvesteMoz. Operações identificadas continuam a exigir login/políticas/MFA aplicáveis. A futura abertura pública retirará a reserva por decisão própria, sem retirar a protecção privada ou a assinatura/guarda de /deploy. Identidade preservada: InvesteMoz, propriedade da Tempo, Lda.
Reversão: repor selectivamente o delta por hash e a reserva anterior, e só depois o cabeçalho anterior do cliente, sem alterar o Secret HMAC. Preservar alterações posteriores, 56 fontes compatíveis, dados, relações, definições e assets. Não é reposição integral da aplicação nem restauro de dump. Nenhuma alteração remota efectuada. Execução continua sujeita à decisão da Tempo; estimativa operacional 90–150 min com acessos prontos, excluindo esperas/divergências. Oito lacunas contratuais, vínculo documental legítimo, geometrias e P7 separados.
Provas: RECIBO-PREFLIGHT-423bd512d8c5.json, PREFLIGHT-EXACTO-423bd512d8c5.json e PROVA-COMPATIBILIDADE-DEPLOY-QA.json na pasta da mesma proposta. O manifesto/ZIP congelados não foram alterados; este recibo actualiza o preflight e a ordem operacional.
## Execução autorizada 423bd512d8c5 — acesso pendente em 20/10/2026
A Tempo autorizou a execução nominal e a reserva independente. O preflight fresco passou (669 verificações). Staging protegido contém as mesmas 40 fontes/recursos e 187 ficheiros de build, sem alterar o ZIP. Dois hashes APR1 ambientais preparados em staging, sem activar a reserva. Credencial de Nala preparada em ficheiro local privado, restrito ao titular Windows/SYSTEM; nenhum valor em relatórios ou repositório.
Impedimento: o Gitea recusa a autenticação e o Authorization Header do webhook existente ainda não foi configurado. Não activar a reserva antes desta dependência. Pedido de autenticação/configuração local pendente; não falta nova autorização. Aplicação anterior preservada, sem manutenção, instalação, backup consistente iniciado, migrations, importação ou publicação. Retomar com acesso Gitea, backup fresco consistente e execução nominal já autorizada. Provas de consulta sem conta, conta simples e retoma por realizar após instalação. Disponibilização automática por Git não implementada e separada. Estado: ESTADO-EXECUCAO-AUTORIZADA-423.json na pasta da mesma proposta.
## Disponibilização nominal 423bd512d8c5 — 20/10/2026
Executada exclusivamente na homologação reservada por autorização expressa da Tempo. Gitea: utilizador confirmou gravação do Authorization Header; presença Basic e Secret HMAC conferida sem expor valores. Não foi chamado /deploy, Test Delivery ou fluxo Git genérico. Disponibilização automática por Git não implementada e separada.
Backup fresco consistente concluído: manutenção HTTP, fila suspensa, bloqueios SHARE nas 171 tabelas públicas, hashes dos ficheiros estáveis antes/depois. Seis artefactos cifrados e conferidos: base, ficheiros privados/públicos de armazenamento e configuração/chaves da aplicação, fontes/build, configuração de servidor, ACL Linux e metadados. Envelope portátil de recuperação existente preservado, independente do perfil Windows; proprietário/grupo/permissões/ACL/xattrs registados. Não repetido o restauro integral aceite. Cópia anterior do delta e diário de hashes sob custódia root no destino.
Aplicadas 40 fontes/recursos e 187 ficheiros de build, exactamente pelos hashes do pacote congelado. Manifesto efectivamente servido: 41e9bdbf13dcd95865eefa8af25e2b1e40a5c607d293ad4755152dcea83f9d5d. HEAD Git permanece 9bc450e8 e não identifica sozinho a composição servida. Restantes fontes, comando adicional, 149 auxiliares gerados e todos os assets anteriores não substituídos pelo manifesto preservados (741 ficheiros antigos inventariados, zero perdas/alterações imprevistas). Vhost/snippet correspondem aos bytes do pacote; apenas cinco chaves nominais alteradas; restantes valores .env e proprietário/permissões preservados. Nginx validado antes de reload; PHP-FPM/scanner/base não reiniciados. Manutenção terminada e fila no estado anterior activo. Migrations registadas 88 antes/depois; zero migrations, importação, publicação, alterações de contas/grants/MFA.
13 verificações HTTPS do delta passaram: sem credencial ambiental, início/documentos/build e /deploy recusados (401); com credencial ambiental e sem conta do portal, início, documentos, oportunidades, sobre e contacto 200; áreas privadas redireccionam para login; manifesto HTTP igual ao instalado. Navegador: utilizador concluiu a entrada ambiental; início e os 29 documentos abrem com Entrar visível, sem conta InvesteMoz; Continuar no meu Balcão redireccionou para login. Retoma após login real e consulta com conta simples aguardam a sessão apropriada; não certificar esses dois passos pela prova anónima. Sem actores sintéticos ou impersonação no destino.
Credencial ambiental entregue por ficheiro local privado, restrito ao titular Windows/SYSTEM; não consta do repositório, Page ou recibos. Abertura pública continua separada e retirará a reserva por decisão própria, mantendo protecções privadas e /deploy.
Reversão aprovada: modo reviewers enquanto reserva independente permanece; repor selectivamente apresentação/rotas e configuração deste delta apenas se os hashes ainda coincidirem; preservar alterações posteriores; só depois repor reserva/cabeçalho anteriores. Manter 56 fontes compatíveis, dados/relações/definições e assets posteriores. Não é reposição integral nem restauro de dump. Oito lacunas contratuais, dossier legítimo, geometrias, P7 e automatização Git separados. Instalação concluída; saldo desta execução: 15–30 min de verificação com sessão real adequada, excluindo esperas; nenhum novo desenvolvimento incluído. Provas e recibo: RECIBO-EXECUCAO-423bd512d8c5.json e PROVA-HTTP/CONFIGURACAO/PRESERVACAO-DESTINO-423.json na mesma pasta da proposta.
## Conferência da sessão real 423bd512d8c5 — 20/10/2026
Depois da entrada ambiental, o navegador apresentou início e biblioteca com 29 documentos sem conta do portal. Continuar no meu Balcão conduziu a /login. O titular concluiu a autenticação real e regressou automaticamente a /balcao-virtual/painel, com o título Balcão Virtual — acompanhamento e conta visível Osmane Nala. A página inicial voltou a abrir nessa sessão autenticada, sem pedir nova credencial. Esta evidência encerra a retoma no delta; não é prova de uma conta simples, pois a conta usada tem funções já autorizadas.
Não pedir novas contas para encerrar a instalação nem certificar a conta simples sem a observar. Única conferência específica restante deste recorte: consulta com uma conta simples real adequada, 5–10 min quando disponível, excluindo esperas. Não houve criação de conta, impersonação ou alteração de MFA/grants. Credencial ambiental, conta InvesteMoz e conta Gitea são independentes; para o trabalho corrente no portal, usar nala@tempo.co.mz. Não voltar a pedir autenticação nesta passagem.
Os seis directórios novos da instalação foram alinhados com o proprietário do repositório (mario:www-data, 0755), comprovadamente ausentes no arquivo fresco; permissões dos directórios existentes intactas. Sem alteração do pacote ou revisão. A sessão SSH encerrou pelo limite anterior; não foi reaberta apenas para actualizar o diário operacional remoto. Recibo final e provas actualizados localmente e na mesma Page. Revisão efectivamente servida continua 423bd512d8c5. Git automático, oito lacunas contratuais, dossier legítimo, geometrias e P7 separados.
## Consulta pública sem autenticação ambiental — 20/10/2026
Decisão expressa da Tempo: retirar a autenticação ambiental das páginas públicas, preservando login, permissões e MFA das operações privadas e a protecção própria de /deploy. Esta decisão substitui a exigência de credencial ambiental para consultar os conteúdos públicos nesta homologação; não publica novos conteúdos nem altera contas ou funções.
Executado apenas o delta de configuração: removida a reserva Basic global do vhost; Basic mantido explicitamente na localização exacta /deploy com o mesmo htpasswd, e HMAC/Secret/cabeçalho Gitea preservados. Mantidos noindex/nofollow/noarchive e private/no-store no Nginx. INVESTMOZ_HOMOLOGATION_ONLY=false desactiva a reserva global do portal e a indicação desactualizada de ambiente reservado. O modo anterior permanece configurado mas inactivo para eventual reversão; marcador FastCGI de acesso ambiental retirado. Nenhuma alteração de código/build: revisão servida 423bd512d8c5, manifesto SHA256 41e9bdbf13dcd95865eefa8af25e2b1e40a5c607d293ad4755152dcea83f9d5d. Apenas reload Nginx após nginx -t; sem manutenção, migrations, imports, publicação ou reinício de base/fila/scanner.
Backup fresco apenas da configuração alterada, com hashes revalidados e metadados Linux, cifrado sob a custódia e envelope portátil existentes. Valores secretos não constam dos recibos. Cópias anteriores root-only no destino. Reversão: restaurar primeiro o vhost anterior condicionado aos hashes, validar/recarregar para repor a barreira ambiental; depois repor a flag anterior com conferência de conflitos/cache. Preservar edições posteriores e dados; não restaurar base antiga nem build.
16 verificações HTTPS passaram com CookieJar novo e sem Authorization na consulta: início, pesquisa /oportunidades?q=agronegocio (termo recuperado), documentos, ficha pública /documentos/13, artigo /agronegocio, sobre e contacto respondem 200, com componentes públicos e utilizador do portal ausente. Seis áreas privadas (/management/portal-services, /management/profile-install, /minhas-oportunidades, /investimentos, /investidor/manifestacoes e /balcao-virtual/painel) redireccionam para o login do portal sem desafio ambiental. /deploy continua 401 sem Basic e 403 com Basic válido mas HMAC inválido em JSON válido; serviço de deploy não disparado. Uma primeira sondagem com corpo vazio produziu 400 (JSON inválido); corrigida somente a sondagem negativa, sem alterar a aplicação ou repetir as restantes provas aceites.
A ausência do bloqueio é distinta de apenas esconder uma caixa do navegador: os componentes públicos efectivamente renderizam sem formulário de login e a pesquisa conserva o termo. Login num formulário InvesteMoz continua correcto nas operações privadas; se surgir na consulta pública, é falha a registar. Conferência visual em janela InPrivate nova solicitada ao utilizador, ainda pendente neste registo. Não declarar concluída essa parte pela prova HTTP isolada.
Recibo: RECIBO-CONFIGURACAO-PUBLICA-20261020.json; prova: PROVA-CONSULTA-SEM-CREDENCIAL-20261020.json, na pasta da mesma proposta. Oito lacunas contratuais, conta simples específica, dossier legítimo, geometrias, P7 e disponibilização automática por Git continuam separados. Não houve novas contas, concessões, desactivação de MFA ou publicação adicional.
## Fecho da consulta pública sem credencial — 20/10/2026
O utilizador confirmou em janela Edge InPrivate nova: início, pesquisa /oportunidades?q=agronegocio e documentos abrem sem qualquer pedido de credenciais («ja funciona nestes 3»). Confirmação visual manual, distinta da prova automática HTTPS. A prova automática foi alargada apenas aos três consumidores adicionais afectados — zonas, sector Turismo e legislação — e passou em 19 verificações totais. Não repetidos scans, restauros, importações ou percursos anteriores.
Este delta de configuração fica concluído. Consulta pública não requer credencial ambiental nem conta InvesteMoz; seis operações privadas verificadas continuam a pedir login do portal, com permissões/MFA inalterados. /deploy conserva Basic + HMAC, com recusas 401/403 comprovadas e sem disparar o serviço. Prova de limites confirma que apenas /deploy contém Basic, o antigo include global e marcador FastCGI foram removidos, outros valores .env e proprietário/permissões estão intactos, webhook inalterado. Revisão de código/build continua 423bd512d8c5. Documentação anterior que exige credencial para a consulta pública fica superada por esta decisão expressa da Tempo.
Recibo e prova finais: RECIBO-CONFIGURACAO-PUBLICA-20261020.json e PROVA-CONSULTA-SEM-CREDENCIAL-20261020.json. Sem pendência neste recorte. Conta simples específica, oito lacunas contratuais, dossier legítimo, geometrias, P7 e automatização Git mantêm os saldos separados anteriores. Não se declara a aplicação inteira concluída por este ajuste.
## Git main automático — 20/10/2026 — 8cf820ef
Disponibilização automática activa em main; revisão servida 8cf820ef497218d9adebe6a6a022b4c80d694dc3. Push real recebido pelo webhook às 17:28:44 e instalado às 17:29:56 (Maputo), sem despacho manual. Preservadas composição 423bd512d8c5 e alterações dos programadores até 02d4e6b2; conflito do menu resolvido mantendo layout novo, identidade InvesteMoz e administração delimitada. Build, TypeScript, 47 testes PHP, 156 frontend e 3106 asserções de schema passaram; nove testes Linux do executor e ensaio do webhook também passaram. Demonstração visual: Consultar oportunidades. 171 tabelas e 88 migrations permanecem; zero migrations no destino, importações, publicação ou novos grants. .env com bytes/proprietário/modo intactos; armazenamento partilhado e 869 assets anteriores preservados. Consulta pública 200 sem credenciais, gestão 302 para login e /deploy 401 sem Basic. Fila serial, recusa de drift e de alterações de migrations, releases separadas, logs/recibos protegidos e reversão de código/build com dados posteriores conservados. Oito lacunas contratuais, dossier legítimo, geometrias, conta simples específica e P7 mantêm saldo separado.
Procedimento dos programadores: trabalhar numa branch, rever segundo as designações da Tempo e integrar em `main`. O webhook existente dispara a verificação e compilação da revisão completa. Confirmar o commit em https://teste-investidor.tempo.co.mz/build/revision.json. Uma falha conserva a versão servida e deixa recibo/log protegido; não editar a pasta servida para a contornar.
O executor genérico com migrations automáticas foi substituído. O contrato nominal de 88 fontes de migrations fica protegido fora do Git; uma alteração exige compatibilidade/decisão específica. A CI usa PostGIS oficial fixado por digest, apenas em loopback 55441, base descartável `*_test` e identidade conferida. Não usa contas ou dados da aplicação. A guarda nominal e os testes de lifecycle existentes são preservados; não se contornam as instalações autenticadas.
Recuperação: parar a path unit, aguardar o executor e usar `python3 /var/www/deploy/investmoz-pipeline.py --config /etc/investmoz-deploy/delivery.json --rollback` como operador existente. Conferem-se hashes e saúde; a release anterior fica servida e o worker recebe reinício gracioso. Mantêm-se base, uploads, configuração e dados posteriores. Não é restauro integral. O checkout físico anterior e a cópia cifrada de configuração/metadados Linux permanecem disponíveis; a recuperação portátil existente foi reutilizada. Não foi repetido o restauro integral aceite.
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.
## Guias educativos relacionados — delta QA após 0e7d51f18fb4
Verificação complementar concluída: código funcional `3258a25f7659` ligado ao acervo QA existente `legacy_trio_20261015_test`, sem importação. Página normal `/sectores/turismo` apresenta imagem local `/documentos/25/view` (HTTP 200; 900 px naturais) e resumo; «Ler o guia» abre `/turismo`, ligado ao exemplar real `/documentos/10`. A limitação de integração com biblioteca indicada na primeira prévia fica superada por esta prova. Mantém-se a prova anterior do dossier, pois código/contexto não mudaram. Falta apenas revisão visual da Tempo antes de integrar. IA fora do lote.
AUD-GUIDE-CARD-20261020: componente comum com imagem autorizada, título, resumo do artigo publicado, autoria e «Ler o guia». Reutilizado em sectores, exploração editorial e leituras relacionadas; recomendado no catálogo por sector explícito e nas fichas de oportunidades. Sem associação territorial inferida. Botão secundário apenas com oportunidades públicas. QA: teste isolado 1/8, TypeScript/build e exemplos computador/móvel. Falta decisão sobre integração deste novo delta; a prévia visual de Turismo usa uma cópia por leitura do conteúdo público, sem importação. Recuperação documental num dossier com este acervo não demonstrada, pois o artigo não existe na base QA actual. Percurso e regras anteriores preservados; IA fora do lote.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,140 @@
import { Link, usePage } from '@inertiajs/react';
const labels: Record<string, string> = {
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<string, string> = {
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<string, unknown> | 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 (
<nav
aria-label="Filtros activos"
className="my-3 flex flex-wrap items-center gap-3"
>
<span className="font-semibold">Filtros activos:</span>
{active.map(([key, value]) => (
<Link
key={key}
href={href(key)}
className="rounded border px-3 py-2 underline"
aria-label={`Remover ${labels[key] ?? 'critério'}`}
>
{labels[key] ?? 'Critério seleccionado'}:{' '}
{describe(key, value)} ×
</Link>
))}
<Link href={href()} className="underline">
Limpar filtros
</Link>
</nav>
);
}
@@ -73,34 +73,32 @@ export default function CatalogueNavigation({
],
};
return (
<nav
className="my-4 grid gap-3 rounded-xl border bg-white p-4 sm:grid-cols-2 lg:grid-cols-4"
aria-label="Explorar dentro do catálogo"
>
{Object.entries(sections).map(([title, items]) => (
<section key={title}>
<h2 className="mb-2 font-semibold">{title}</h2>
<ul className="space-y-2">
{items.map(([label, href]) => (
<li key={label}>
<Link
className="underline"
href={withContext(href)}
>
{label}
</Link>
</li>
))}
</ul>
</section>
))}
<p className="text-sm sm:col-span-2 lg:col-span-4">
Filtros e vistas reutilizam os catálogos existentes.
Comprar/vender continua no consumidor /negocios; não duplica
anúncios nem unifica contagens. Selecionar um parque ou projeto
específico usa os filtros existentes; a ficha relacionada não é
uma oportunidade.
</p>
</nav>
<details className="my-4 rounded-xl border bg-white p-4">
<summary className="cursor-pointer font-semibold">
Catálogos relacionados
</summary>
<nav
className="my-4 grid gap-3 rounded-xl border bg-white p-4 sm:grid-cols-2 lg:grid-cols-4"
aria-label="Explorar dentro do catálogo"
>
{Object.entries(sections).map(([title, items]) => (
<section key={title}>
<h2 className="mb-2 font-semibold">{title}</h2>
<ul className="space-y-2">
{items.map(([label, href]) => (
<li key={label}>
<Link
className="underline"
href={withContext(href)}
>
{label}
</Link>
</li>
))}
</ul>
</section>
))}
</nav>
</details>
);
}
+8 -1
View File
@@ -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() && <p role="status" aria-live="polite">{matches.length ? `${matches.length} resultados encontrados` : "Não foram encontrados resultados para esta pesquisa"}</p>}
{search.trim() && (
<p role="status" aria-live="polite">
Países:{' '}
{matches.length
? `${matches.length} opções disponíveis`
: 'Nenhuma opção encontrada'}
</p>
)}
<select
disabled={disabled}
id={id ?? generatedId}
@@ -0,0 +1,73 @@
import { Link } from '@inertiajs/react';
export type EducationalArticle = {
title: string;
href: string;
summary: string;
author?: string;
image: { href: string; alt: string } | null;
opportunities_href?: string | null;
opportunities_label?: string | null;
has_opportunities?: boolean;
};
export default function EducationalArticleCard({
article,
}: {
article: EducationalArticle;
}) {
const focus =
'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-800';
return (
<article className="flex min-w-0 flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white text-slate-900 shadow-sm">
{article.image && (
<img
className="aspect-[16/9] w-full object-cover"
src={article.image.href}
alt={article.image.alt}
loading="lazy"
width={800}
height={450}
/>
)}
<div className="flex flex-1 flex-col items-start gap-4 p-5 sm:p-6">
<p className="text-sm font-medium text-emerald-800">
Guia sectorial
</p>
<h3 className="text-2xl leading-tight font-bold">
<Link href={article.href} className={focus}>
{article.title}
</Link>
</h3>
{article.summary && (
<p className="text-base leading-relaxed text-slate-700">
{article.summary}
</p>
)}
{article.author && (
<p className="text-sm text-slate-600">
Por {article.author}
</p>
)}
<div className="mt-auto flex flex-wrap items-center gap-x-5 gap-y-3 pt-2">
<Link
href={article.href}
className={`inline-flex min-h-11 items-center rounded-lg bg-emerald-900 px-5 py-3 font-semibold text-white hover:bg-emerald-800 ${focus}`}
>
Ler o guia
</Link>
{article.has_opportunities &&
article.opportunities_href && (
<Link
href={article.opportunities_href}
className={`inline-flex min-h-11 items-center py-2 font-semibold text-emerald-900 underline underline-offset-4 ${focus}`}
>
{article.opportunities_label ??
'Ver oportunidades'}
</Link>
)}
</div>
</div>
</article>
);
}
@@ -0,0 +1,27 @@
import EducationalArticleCard, {
type EducationalArticle,
} from './educational-article-card';
export default function EducationalGuides({
articles,
}: {
articles: EducationalArticle[];
}) {
if (!articles.length) return null;
return (
<section
aria-label="Guias para preparar o investimento"
className="space-y-5"
>
<h2 className="text-2xl font-bold">Descubra onde investir</h2>
<div className="grid gap-6 md:grid-cols-2">
{articles.map((article) => (
<EducationalArticleCard
key={article.href}
article={article}
/>
))}
</div>
</section>
);
}
@@ -24,7 +24,9 @@ export default function InvestmentMapPanel({
useEffect(() => {
const controller = new AbortController();
const params = new URLSearchParams(JSON.parse(key));
setPoints(null); setError(''); setMissing([]);
setPoints(null);
setError('');
setMissing([]);
setAreas([]);
fetch(`/api/v1/investment/map?${params}`, {
signal: controller.signal,
@@ -46,13 +48,21 @@ export default function InvestmentMapPanel({
);
})
.catch(() => {
if (!controller.signal.aborted) setError('Não foi possível concluir a pesquisa.');
if (!controller.signal.aborted)
setError('Não foi possível concluir a pesquisa.');
});
return () => controller.abort();
}, [key, retry]);
return (
<section className="space-y-3">
<SearchState loading={points === null && !error} error={error} total={(points?.length ?? 0) + missing.length} retry={() => setRetry(value => value + 1)} />
<SearchState
scope="Registos consultados pelo mapa"
countLabel="registos, incluindo os que não têm coordenadas"
loading={points === null && !error}
error={error}
total={(points?.length ?? 0) + missing.length}
retry={() => setRetry((value) => value + 1)}
/>
<InteractiveInvestmentMap points={points} areas={areas} />
<TerritoryGeometryPreview
territoryId={
@@ -113,9 +113,7 @@ export function ProvinceSelect({
})
.catch(() => {
if (!controller.signal.aborted)
setError(
'Não foi possível concluir a pesquisa.',
);
setError('Não foi possível concluir a pesquisa.');
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
@@ -130,7 +128,13 @@ export function ProvinceSelect({
return (
<>
<SearchState loading={loading} error={error} total={options.length} retry={() => setRetry(value => value + 1)} />
<SearchState
scope="Localizações"
loading={loading}
error={error}
total={options.length}
retry={() => setRetry((value) => value + 1)}
/>
<input
className={className}
aria-label={`Pesquisar ${label}`}
@@ -300,9 +304,7 @@ export function TerritoryLevel({
setMore(Boolean(data.next_page_url));
} catch {
if (!controller.signal.aborted)
setError(
'Não foi possível concluir a pesquisa.',
);
setError('Não foi possível concluir a pesquisa.');
} finally {
if (!controller.signal.aborted) setLoading(false);
}
@@ -314,7 +316,15 @@ export function TerritoryLevel({
}, [index, parent, page, enabled, endpoint, retry, search]);
return (
<div className="space-y-2">
{enabled && <SearchState loading={loading} error={error} total={options.length} retry={() => setRetry(value => value + 1)} />}
{enabled && (
<SearchState
scope={labels[index]}
loading={loading}
error={error}
total={options.length}
retry={() => setRetry((value) => value + 1)}
/>
)}
<label htmlFor={id} className="font-medium">
{labels[index]}
</label>
@@ -831,7 +831,11 @@ export function ClassificationSummary({
<h2 className="text-xl font-bold">
Associações territoriais e produtivas
</h2>
<p>{help}</p>
<p>
As relações preliminares permanecem por confirmar. Uma
associação não comprova disponibilidade, incentivos ou direitos
concedidos.
</p>
{Object.entries({
territory: 'Enquadramento territorial',
production: 'Organização produtiva',
@@ -867,10 +871,7 @@ export function ClassificationSummary({
</p>
))
) : (
<p>
Sem informação tipada publicada nesta dimensão;
consulte também as relações anteriores abaixo.
</p>
<p>Sem associações publicadas neste grupo.</p>
)}
</section>
))}
@@ -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<string, string> = {
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<string, string | boolean | number[]>;
const labels: Record<string, string> = {
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<string, string>;
}) {
const select = (
key: string,
choices: { value: string; label: string }[],
hint?: string,
) => (
<Field key={key} label={labels[key]}>
<select
className={input}
value={String(data[key] ?? '')}
onChange={(e) =>
change({
[key]: e.target.value,
...(key === 'sector'
? { subsector_id: '', chain_id: '' }
: {}),
})
}
>
<option value="">Todos</option>
{choices.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{hint && <p className="text-sm text-slate-600">{hint}</p>}
</Field>
);
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<string, string>
)[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 (
<form
className="space-y-5 rounded-xl border bg-white p-4 sm:p-6"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
aria-label="Pesquisar oportunidades"
>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Field label="O que procura">
<input
className={input}
value={String(data.q)}
placeholder="Palavras do título ou da descrição"
onChange={(e) => change({ q: e.target.value })}
/>
</Field>
{select('sector', choicesFor('sector'))}
{select('category', choicesFor('category'))}
</div>
<fieldset className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<legend className="mb-2 font-semibold">Localização</legend>
{(
[
'province_id',
'district_id',
'admin_post_id',
'locality_id',
] as const
).map((key, index) => (
<TerritoryLevel
key={`${key}-${path[index - 1]?.id ?? ''}`}
index={index}
parent={path[index - 1]?.id ?? ''}
selected={path[index]}
endpoint="/api/v1/investment/territory-options"
onChange={(value) =>
territory([
...path.slice(0, index),
...(value ? [value] : []),
])
}
/>
))}
</fieldset>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field label="Estado de preparação">
<select
className={input}
value={String(data.preparation ?? '')}
onChange={(e) =>
change({
preparation: e.target.value,
maturity_level: '',
})
}
>
<option value="">Todos os estados</option>
{[
'Etapas de preparação',
'Classificações históricas',
].map((group) => (
<optgroup key={group} label={group}>
{(references.preparationOptions ?? [])
.filter((o) => o.group === group)
.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</optgroup>
))}
</select>
<p className="text-sm text-slate-600">
Os registos históricos são apresentados separadamente e
conservam a classificação indicada na ficha.
</p>
</Field>
{(['min_amount', 'max_amount'] as const).map((key) => (
<Field key={key} label={labels[key]}>
<input
className={input}
type="number"
min="0"
step="0.01"
value={String(data[key])}
onChange={(e) => change({ [key]: e.target.value })}
/>
</Field>
))}
{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.',
)}
</div>
<details>
<summary className="cursor-pointer rounded border p-3 font-semibold focus-visible:outline-2">
Mais filtros
</summary>
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[
'corridor_id',
'cluster_id',
'zee_id',
'zfi_id',
'park_id',
'project_id',
'subsector_id',
'chain_id',
].map((key) => select(key, choicesFor(key)))}
<Field label="Data-alvo até">
<input
className={input}
type="date"
value={String(data.before)}
onChange={(e) => change({ before: e.target.value })}
/>
</Field>
</div>
<p className="my-3 text-sm">
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.
</p>
<label className="flex gap-2">
<input
type="checkbox"
checked={Boolean(data.include_national)}
onChange={(e) =>
change({ include_national: e.target.checked })
}
/>
Incluir oportunidades de âmbito nacional
</label>
<details className="mt-3">
<summary className="cursor-pointer font-medium">
Outras características
</summary>
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{[
'education',
'infrastructure',
'labour',
'qualification',
'support',
'impact',
'partnership',
]
.filter((group) =>
references.options.some(
(o) => o.group === group,
),
)
.map((group) => (
<fieldset
key={group}
className="rounded border p-3"
>
<legend>{groupLabels[group]}</legend>
{references.options
.filter((o) => o.group === group)
.map((option) => (
<label
key={option.id}
className="flex gap-2 py-1"
>
<input
type="checkbox"
checked={
Array.isArray(
data.facets,
) &&
data.facets.includes(
option.id,
)
}
onChange={(e) => {
const selected =
Array.isArray(
data.facets,
)
? data.facets
: [];
change({
facets: e.target
.checked
? [
...selected,
option.id,
]
: selected.filter(
(id) =>
id !==
option.id,
),
});
}}
/>
{option.label}
</label>
))}
</fieldset>
))}
</div>
</details>
</details>
<section
aria-label="Filtros activos"
className="flex flex-wrap gap-2"
>
{path.map((node, index) => (
<button
className="rounded border px-3 py-2"
type="button"
key={node.id}
aria-label={`Remover localização ${node.name}`}
onClick={() => territory(path.slice(0, index))}
>
{node.name} ×
</button>
))}
{active.flatMap(([key, value]) =>
key === 'facets' && Array.isArray(value)
? value.map((id) => (
<button
key={`facet-${id}`}
type="button"
className="rounded border px-3 py-2"
onClick={() =>
change({
facets: value.filter((x) => x !== id),
})
}
>
{' '}
{references.options.find((o) => o.id === id)
?.label ?? 'Critério adicional'}{' '}
×
</button>
))
: [
<button
key={key}
type="button"
className="rounded border px-3 py-2 text-left"
aria-label={`Remover ${labels[key] ?? 'critério adicional'}`}
onClick={() => remove(key)}
>
{labels[key] ?? 'Critério adicional'}:{' '}
{typeof value === 'boolean'
? 'Sim'
: (choicesFor(key).find(
(o) => o.value === String(value),
)?.label ??
([
'q',
'min_amount',
'max_amount',
'currency',
'before',
'province',
'district',
].includes(key)
? String(value)
: 'seleccionado'))}{' '}
×
</button>,
],
)}
</section>
{Object.entries(errors).map(([key, value]) => (
<p key={key} role="alert">
{value}
</p>
))}
<div className="flex flex-wrap items-end gap-4">
{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',
},
])}
<button className={button}>Pesquisar</button>
<button
type="button"
className="rounded border px-4 py-2 underline"
onClick={clear}
>
Limpar filtros
</button>
</div>
</form>
);
}
+169 -26
View File
@@ -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<string, unknown>): 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<string, unknown>): number | null {
}
export default function SearchFeedback() {
const page = usePage();
const [state, setState] = useState<'idle'|'loading'|'error'>('idle');
const latest = useRef<{url: string; data: Record<string, unknown>} | null>(null);
const handledLocally = page.component === 'investment/catalogue';
const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle');
const latest = useRef<{
url: string;
data: Record<string, unknown>;
} | 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<string, [string, string]> = {
'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<string, unknown>;
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<string, unknown>;
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 <aside data-search-feedback role={state === 'error' ? 'alert' : 'status'} aria-live="polite" aria-atomic="true" className="sticky top-0 z-50 border-b bg-white p-3 text-center text-slate-950 shadow">
{state === 'loading' ? 'A pesquisar…' : state === 'error' ? <><span>Não foi possível concluir a pesquisa. Os resultados anteriores mantêm-se abaixo. </span><button type="button" className="underline" onClick={() => { const request = latest.current; if (request) router.get(request.url, request.data as never, {preserveState:true, preserveScroll:true}); }}>Tentar novamente</button></> : total === 0 ? 'Não foram encontrados resultados para esta pesquisa' : `${total} resultados encontrados`}
</aside>;
return (
<>
<aside
data-search-feedback
role={state === 'error' ? 'alert' : 'status'}
aria-live="polite"
aria-atomic="true"
className="border-b bg-white p-3 text-center text-slate-950"
>
{label && <strong>{label}: </strong>}
{state === 'loading' ? (
'A pesquisar…'
) : state === 'error' ? (
<>
<span>
Não foi possível concluir a pesquisa. Os resultados
anteriores mantêm-se abaixo.{' '}
</span>
<button
type="button"
className="underline"
onClick={() => {
const request = latest.current;
if (request)
router.get(
request.url,
request.data as never,
{
preserveState: true,
preserveScroll: true,
},
);
}}
>
Tentar novamente
</button>
</>
) : total === 0 ? (
'Não foram encontrados resultados para esta pesquisa'
) : (
`${total} ${total === 1 ? 'resultado encontrado' : 'resultados encontrados'}`
)}
</aside>
{descriptor && <CatalogueFilterContext />}
</>
);
}
+47 -4
View File
@@ -1,5 +1,48 @@
export default function SearchState({loading, error, total, retry}: {loading:boolean; error?:string; total:number; retry:()=>void}) {
return <div role={error ? 'alert' : 'status'} aria-live="polite" aria-atomic="true" className="my-3 rounded border p-3">
{loading ? 'A pesquisar…' : error ? <>Não foi possível concluir a pesquisa. <button type="button" className="underline" onClick={retry}>Tentar novamente</button></> : total === 0 ? 'Não foram encontrados resultados para esta pesquisa' : `${total} resultados encontrados`}
</div>;
export default function SearchState({
loading,
error,
total,
retry,
scope,
countLabel,
}: {
loading: boolean;
error?: string;
total: number;
retry: () => void;
scope?: string;
countLabel?: string;
}) {
return (
<div
role={error ? 'alert' : 'status'}
aria-live="polite"
aria-atomic="true"
className="my-3 rounded border p-3"
>
{scope && <span className="font-medium">{scope}: </span>}
{loading ? (
'A pesquisar…'
) : error ? (
<>
Não foi possível concluir a pesquisa.{' '}
<button type="button" className="underline" onClick={retry}>
Tentar novamente
</button>
</>
) : 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'}`
)}
</div>
);
}
+6 -25
View File
@@ -1,11 +1,9 @@
import { Link } from '@inertiajs/react';
import EducationalArticleCard, {
type EducationalArticle,
} from './educational-article-card';
export type SectorEntryData = {
articles: {
title: string;
href: string;
image: { href: string; alt: string } | null;
scope: string;
}[];
articles: EducationalArticle[];
relations: {
id: string;
title: string;
@@ -66,9 +64,7 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
perfil e a modalidade já autorizados; se ainda não tem perfil,
pode preparar a ideia e pedir o perfil de promotor.
</p>
<h2 className="text-xl font-bold">
Conteúdo e possibilidades de negócio
</h2>
<h2 className="text-xl font-bold">Descubra onde investir</h2>
{data.content_notice && <p>{data.content_notice}</p>}
{!data.articles.length && (
<p>
@@ -78,22 +74,7 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
)}
<div className="grid gap-4 md:grid-cols-2">
{data.articles.map((a) => (
<article
className="min-w-0 rounded border p-4"
key={a.href}
>
{a.image && (
<img
className="mb-3 h-36 w-full object-cover"
src={a.image.href}
alt={a.image.alt}
/>
)}
<Link className="font-semibold underline" href={a.href}>
{a.title}
</Link>
<p>{a.scope}</p>
</article>
<EducationalArticleCard key={a.href} article={a} />
))}
</div>
{data.specialization_href && (
+12
View File
@@ -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()}` : '');
}
+37
View File
@@ -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',
@@ -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<Record<string, string>>(() => 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<string, string>
>(() =>
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<Record<string, string>>({
@@ -124,6 +143,37 @@ export default function BusinessMarketplace({
)}
</select>
</label>
{classificationOptions && (
<label className="grid gap-1">
<span>Sector</span>
<select
className={input}
value={classification.sector}
onChange={(event) =>
setClassification((current) => ({
...current,
sector: event.target.value,
subsector_id: '',
chain_id: '',
}))
}
>
<option value="">Todos</option>
{(classificationOptions.targets.sector ?? []).map(
(option) => (
<option
key={option.id}
value={String(option.id)}
>
{/fintech/i.test(option.label)
? 'Fintech — especialização do Mercado Financeiro'
: option.label}
</option>
),
)}
</select>
</label>
)}
<TerritoryFields
initial={territorySelection}
onChange={(path) =>
@@ -135,39 +185,117 @@ export default function BusinessMarketplace({
})
}
/>
{classificationOptions && <fieldset className="w-full rounded border p-3">
<legend>Classificação multidimensional — filtros combinados</legend>
<p>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.</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{([
['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<string, string>)[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]) => <label key={key} className="grid gap-1"><span>{label}</span><select className={input} value={classification[key]} onChange={(e) => setClassification((old) => ({ ...old, [key]: e.target.value, ...(key === 'association_type' ? { association_target: '' } : {}) }))}><option value="">Todos</option>{options.map((o) => <option key={o.id} value={String(o.id)}>{o.label}</option>)}</select></label>)}
</div>
</fieldset>}
{classificationOptions && (
<details className="w-full rounded border p-3">
<summary className="cursor-pointer font-semibold">
Mais filtros
</summary>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{(
[
[
'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<string, string>
)[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]) => (
<label key={key} className="grid gap-1">
<span>{label}</span>
<select
className={input}
value={classification[key]}
onChange={(e) =>
setClassification((old) => ({
...old,
[key]: e.target.value,
...(key === 'association_type'
? { association_target: '' }
: {}),
}))
}
>
<option value="">Todos</option>
{options.map((o) => (
<option
key={o.id}
value={String(o.id)}
>
{o.label}
</option>
))}
</select>
</label>
))}
</div>
</details>
)}
<button className={button}>Pesquisar</button>
</form>
<p role="status">
{results.meta?.total ?? results.data.length} anúncios de
negócios publicados
</p>
{!results.data.length && (
<p role="status">
Não existem oportunidades publicadas com estes critérios.
</p>
)}
<ul className="grid gap-4 md:grid-cols-2">
{results.data.map((record) => (
<li className={card} key={record.id}>
<p>
{businessModalities[record.modality] ??
record.modality}
'Modalidade por indicar'}
</p>
<h2 className="text-xl font-bold">
<Link
@@ -192,7 +320,11 @@ export default function BusinessMarketplace({
{record.amount_max ?? '—'} {record.currency}
</p>
)}
{!!record.multidimensional?.length && <ClassificationSummary rows={record.multidimensional} />}
{!!record.multidimensional?.length && (
<ClassificationSummary
rows={record.multidimensional}
/>
)}
{record.objective && (
<p className="mt-3 line-clamp-3">
{record.objective}
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -1,3 +1,5 @@
import EducationalGuides from '@/components/educational-guides';
import type { EducationalArticle } from '@/components/educational-article-card';
import { ClassificationSummary } from '@/components/opportunity-classification';
import { Link, router, usePage } from '@inertiajs/react';
import { useState } from 'react';
@@ -10,12 +12,14 @@ import TerritorialSources from '@/components/territorial-source-evidence';
import { Frame, card, button, money, groupLabels } from './shared';
import type { Opportunity, Context } from './shared';
export default function Detail({
guides = [],
record: r,
context: c,
zone_origin,
returnUrl = '/oportunidades',
dossierOrigin,
}: {
guides?: EducationalArticle[];
record: Opportunity;
context: Context;
zone_origin?: { id: string; name: string } | null;
@@ -110,7 +114,10 @@ export default function Detail({
Preparar manifestação de interesse
</button>
) : (
<Link className={button} href={`/participar/entrar?return=${encodeURIComponent(page.url)}`}>
<Link
className={button}
href={`/participar/entrar?return=${encodeURIComponent(page.url)}`}
>
Entrar para preparar manifestação
</Link>
)}
@@ -182,9 +189,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',
@@ -306,6 +313,7 @@ export default function Detail({
</p>
)}
</section>
<EducationalGuides articles={guides} />
</Frame>
);
}
+1
View File
@@ -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[];
+18 -3
View File
@@ -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<TerritoryOption[]>([]);
@@ -347,14 +349,27 @@ function Editor({
}
return (
<Frame title={z.name}>
<Link
className="underline"
href={catalogueReturnHref(
management ? '/management/investment/zones' : '/zonas',
pageUrl,
)}
>
Voltar à pesquisa
</Link>
<TerritorialSources sources={territorialSources} />
{!management && z.category === 'industrial_park' && (
<ParkDetailsPublic value={z.operational_details_public} />
)}
{!management && <PartnershipSourceLinks type="zone" id={z.id} />}
<p>
{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}
</p>
<p>
A ficha é informativa. A associação territorial não atribui
+218 -117
View File
@@ -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<string,string> = { 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<string, string> = {
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 && <InvestmentZoneInstallation initial={installation} />}
{management && detailsInstallation && <InvestmentZoneInstallation initial={detailsInstallation} endpoint="/management/investment/zones/operational-install" title="Complemento operacional L1 dos parques" summary="Uma coluna de detalhes, com dados privados cifrados e projeção pública separada. Sem importação ou publicação de fichas." />}
{management && installation && (
<InvestmentZoneInstallation initial={installation} />
)}
{management && detailsInstallation && (
<InvestmentZoneInstallation
initial={detailsInstallation}
endpoint="/management/investment/zones/operational-install"
title="Complemento operacional L1 dos parques"
summary="Uma coluna de detalhes, com dados privados cifrados e projeção pública separada. Sem importação ou publicação de fichas."
/>
)}
<p>
A informação territorial não atribui benefícios nem substitui
certificados ou decisões das entidades competentes.
</p>
<nav
aria-label="Dimensões de investimento"
className="flex flex-wrap gap-4 underline"
<details className="rounded border p-3">
<summary className="cursor-pointer font-semibold">
Catálogos relacionados
</summary>
<nav
aria-label="Dimensões de investimento"
className="flex flex-wrap gap-4 underline"
>
<Link href="/clusters">Por Cluster</Link>
<Link href="/corredores">Por Corredor</Link>
<Link href="/zonas?category=zee">Por ZEE</Link>
<Link href="/zonas?category=zfi">Por ZFI</Link>
<Link href="/zonas?category=industrial_park">
Parques industriais
</Link>
</nav>
</details>
<form
method="get"
onSubmit={(event) => {
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'}
>
<Link href="/clusters">Por Cluster</Link>
<Link href="/corredores">Por Corredor</Link>
<Link href="/zonas?category=zee">Por ZEE</Link>
<Link href="/zonas?category=zfi">Por ZFI</Link>
<Link href="/zonas?category=industrial_park">
Parques industriais
</Link>
</nav>
<form method="get" onSubmit={event => {
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'}>
<Field label="Pesquisar por nome ou código">
<input
className={input}
@@ -140,7 +182,7 @@ export default function Zones({
maxLength={120}
/>
</Field>
<Field label="Categoria">
<Field label="Grupo">
<select
className={input}
name="category"
@@ -154,93 +196,115 @@ export default function Zones({
</option>
</select>
</Field>
<Field label="Categoria física">
<select
className={input}
name="physical_category"
defaultValue={filters.physical_category ?? ''}
>
<option value="">Todas</option>
<option value="industrial_park">
Parque industrial
</option>
<details className="w-full rounded border p-3">
<summary className="cursor-pointer font-semibold">
Mais filtros
</summary>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Field label="Tipo de espaço">
<select
className={input}
name="physical_category"
defaultValue={filters.physical_category ?? ''}
>
<option value="">Todas</option>
<option value="industrial_park">
Parque industrial
</option>
{zoneExtensionsAvailable && (
<>
<option value="agro_industrial_park">
Parque agro-industrial
</option>
<option value="industrial_logistics_park">
Parque industrial e logístico
</option>
</>
)}
<option value="industrial_free_zone">
ZFI
</option>
<option value="economic_zone">ZEE</option>
</select>
</Field>
<Field label="Actividade documentada">
<input
className={input}
name="activity"
defaultValue={filters.activity}
maxLength={120}
/>
</Field>
<Field label="Regime jurídico">
<select
className={input}
name="legal_regime"
defaultValue={filters.legal_regime ?? ''}
>
<option value="">Todos</option>
<option value="zee">ZEE</option>
<option value="zfi">ZFI</option>
<option value="general">Regime geral</option>
<option value="pending">Por confirmar</option>
</select>
</Field>
{zoneExtensionsAvailable && (
<>
<option value="agro_industrial_park">
Parque agro-industrial
</option>
<option value="industrial_logistics_park">
Parque industrial e logístico
</option>
</>
<Field label="Desenvolvimento">
<select
className={input}
name="development_status"
defaultValue={
filters.development_status ?? ''
}
>
<option value="">Todos</option>
<option value="announced">Anunciado</option>
<option value="planned">Planeado</option>
<option value="in_development">
Em desenvolvimento
</option>
<option value="partially_operational">
Parcialmente operacional
</option>
<option value="operational">
Operacional
</option>
<option value="unknown">
Por confirmar
</option>
</select>
</Field>
)}
<option value="industrial_free_zone">ZFI</option>
<option value="economic_zone">ZEE</option>
</select>
</Field>
<Field label="Actividade documentada">
<input
className={input}
name="activity"
defaultValue={filters.activity}
maxLength={120}
/>
</Field>
<Field label="Regime jurídico">
<select
className={input}
name="legal_regime"
defaultValue={filters.legal_regime ?? ''}
>
<option value="">Todos</option>
<option value="zee">ZEE</option>
<option value="zfi">ZFI</option>
<option value="general">Regime geral</option>
<option value="pending">Por confirmar</option>
</select>
</Field>
{zoneExtensionsAvailable && (
<Field label="Desenvolvimento">
<select
className={input}
name="development_status"
defaultValue={filters.development_status ?? ''}
>
<option value="">Todos</option>
<option value="announced">Anunciado</option>
<option value="planned">Planeado</option>
<option value="in_development">
Em desenvolvimento
</option>
<option value="partially_operational">
Parcialmente operacional
</option>
<option value="operational">Operacional</option>
<option value="unknown">Por confirmar</option>
</select>
</Field>
)}
{zoneExtensionsAvailable && (
<Field label="Disponibilidade">
<select
className={input}
name="availability_status"
defaultValue={filters.availability_status ?? ''}
>
<option value="">Todas</option>
<option value="confirmed">Confirmada</option>
<option value="unavailable">Indisponível</option>
<option value="reported_unverified">
Reportada, por verificar
</option>
<option value="not_informed">Não informada</option>
</select>
</Field>
)}
{zoneExtensionsAvailable && (
<Field label="Disponibilidade">
<select
className={input}
name="availability_status"
defaultValue={
filters.availability_status ?? ''
}
>
<option value="">Todas</option>
<option value="confirmed">
Confirmada
</option>
<option value="unavailable">
Indisponível
</option>
<option value="reported_unverified">
Reportada, por verificar
</option>
<option value="not_informed">
Não informada
</option>
</select>
</Field>
)}
</div>
</details>
<TerritoryFields initial={territorySelection} />
<button className={button}>Pesquisar</button>
</form>
<p role="status">{records.total} registos disponíveis</p>
{!management && (
<a
className="underline"
@@ -251,8 +315,7 @@ export default function Zones({
)}
{!records.total && (
<p className={card}>
Sem registos disponíveis para estes filtros. As zonas
aguardam cadastro e validação documental.
Não foram encontrados resultados para esta pesquisa.
</p>
)}
{records.data.map((zone) => (
@@ -260,32 +323,62 @@ export default function Zones({
<h2 className="text-xl font-bold">
<Link
className="underline"
href={`${management ? '/management/investment/zones' : '/zonas'}/${zone.id}`}
href={catalogueDetailHref(
`${management ? '/management/investment/zones' : '/zonas'}/${zone.id}`,
exportParams.toString(),
)}
>
{zone.name}
</Link>
</h2>
<Link
className="underline"
href={catalogueDetailHref(
`${management ? '/management/investment/zones' : '/zonas'}/${zone.id}`,
exportParams.toString(),
)}
>
Consultar ficha
</Link>
{!management && (
<Link
className="ml-4 underline"
href={`/oportunidades?${new URLSearchParams({ [zone.category === 'zee' ? 'zee_id' : zone.category === 'zfi' ? 'zfi_id' : 'park_id']: zone.id })}`}
>
Ver oportunidades associadas
</Link>
)}
<p>
{zone.code} · {zone.category.toUpperCase()} ·{' '}
{zone.code} ·{' '}
{zone.category === 'industrial_park'
? 'Parque industrial'
: zone.category.toUpperCase()}{' '}
·{' '}
{[zone.district, zone.province]
.filter(Boolean)
.join(', ') || 'Localização por confirmar'}
</p>
{zone.legal_regime && (
<p>
Regime jurídico: {zone.category === 'industrial_park' ? (parkLabels[zone.legal_regime] ?? zone.legal_regime.toUpperCase()) : zone.legal_regime.toUpperCase()}
Regime jurídico:{' '}
{parkLabels[zone.legal_regime] ??
(['zee', 'zfi'].includes(zone.legal_regime)
? zone.legal_regime.toUpperCase()
: 'Por confirmar')}
</p>
)}
{zoneExtensionsAvailable && zone.development_status && (
<p>
Desenvolvimento:{' '}
{zone.category === 'industrial_park' ? (parkLabels[zone.development_status] ?? 'Por confirmar') : zone.development_status.replaceAll('_', ' ')}
{parkLabels[zone.development_status] ??
'Por confirmar'}
</p>
)}
{zoneExtensionsAvailable && zone.availability_status && (
<p>
Disponibilidade para instalação:{' '}
{zone.category === 'industrial_park' ? (parkLabels[zone.availability_status] ?? 'Não informada') : zone.availability_status.replaceAll('_', ' ')}
{parkLabels[zone.availability_status] ??
'Não informada'}
</p>
)}
{zone.area_value && (
@@ -307,7 +400,7 @@ export default function Zones({
{zone.activities
.map(
(activity) =>
`${activity.name} (${activity.status})`,
`${activity.name} (${({ potential: 'Potencial referido pela fonte', proposed: 'Proposta', authorized: 'Autorizada por documento', conditional: 'Condicionada', excluded: 'Excluída' } as Record<string, string>)[activity.status] ?? 'Por confirmar'})`,
)
.join(' · ')}
</p>
@@ -334,9 +427,17 @@ export default function Zones({
))}
</p>
)}
{zone.category === 'industrial_park' && <p>{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.'}</p>}
{zone.category === 'industrial_park' && (
<p>
{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.'}
</p>
)}
<p>{zone.description}</p>
<p>Validação: {zone.validation_status}</p>
{management && <p>Validação: {zone.validation_status}</p>}
{management && (
<p>
Versão {zone.version_no} · {zone.publication_status}
+12 -31
View File
@@ -1,6 +1,13 @@
import { Head, Link, useForm } from '@inertiajs/react';
import EducationalArticleCard, {
type EducationalArticle,
} from '@/components/educational-article-card';
export type LegacyArticle = {
summary: string;
opportunities_href?: string | null;
opportunities_label?: string | null;
has_opportunities?: boolean;
id: number;
title: string;
author: string;
@@ -13,12 +20,7 @@ export type LegacyArticle = {
landing_href: string;
start_href: string;
group: { label: string; heading: string };
related_articles: {
id: number;
slug: string;
title: string;
href: string;
}[];
related_articles: (EducationalArticle & { id: number })[];
html: string;
library_href: string;
image: { href: string; alt: string; source_url: string } | null;
@@ -153,29 +155,10 @@ export default function LegacySector({
{match ? (
<div className="grid gap-4 sm:grid-cols-2">
{articles.map((item) => (
<article
<EducationalArticleCard
key={item.id}
className="space-y-3 rounded-xl border bg-white p-5"
>
<h3 className="text-xl font-semibold">
<Link
className="underline"
href={item.href}
>
{item.title}
</Link>
</h3>
<p>
Conteúdo editorial preservado do portal
Tempo.
</p>
<Link
className="inline-block rounded bg-[#10352b] px-5 py-3 text-white"
href={item.href}
>
Ler o artigo
</Link>
</article>
article={item}
/>
))}
</div>
) : (
@@ -199,9 +182,7 @@ export default function LegacySector({
<ul className="grid gap-3 sm:grid-cols-2">
{article.related_articles.map((item) => (
<li key={item.id}>
<Link className="underline" href={item.href}>
{item.title}
</Link>
<EducationalArticleCard article={item} />
</li>
))}
</ul>
+146 -173
View File
@@ -481,32 +481,6 @@ export default function Services(p: Props) {
p.resources?.data.findIndex((resource) =>
formTypes.includes(resource.document_type),
) ?? -1;
const activeFilterNames: Record<string, string> = {
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 (
<div className="min-h-screen bg-slate-50 text-slate-900">
<Head title={title} />
@@ -677,63 +651,80 @@ export default function Services(p: Props) {
}
/>
</label>
<label className="block min-w-0">
Tema
<select
className={input}
value={filter.data.topic}
onChange={(e) =>
filter.setData('topic', e.target.value)
}
>
<option value="">Todos</option>
{p.topics?.map((topic) => (
<option key={topic}>{topic}</option>
))}
</select>
</label>
<label className="block min-w-0">
Categoria
<select
className={input}
value={filter.data.category}
onChange={(e) =>
filter.setData(
'category',
e.target.value,
)
}
>
<option value="">Todas</option>
{p.categories?.map((category) => (
<option key={category} value={category}>
{documentTypeLabels[category] ??
category}
</option>
))}
</select>
</label>
<label className="block min-w-0">
Tipo documental
<select
className={input}
value={filter.data.document_type}
onChange={(e) =>
filter.setData(
'document_type',
e.target.value,
)
}
>
<option value="">Todos</option>
{p.documentTypes?.map((type) => (
<option key={type} value={type}>
{documentTypeLabels[type] ??
'Documento'}
</option>
))}
</select>
</label>
{!!(p.topics?.length || filter.data.topic) && (
<label className="block min-w-0">
Tema
<select
className={input}
value={filter.data.topic}
onChange={(e) =>
filter.setData(
'topic',
e.target.value,
)
}
>
<option value="">Todos</option>
{p.topics?.map((topic) => (
<option key={topic}>{topic}</option>
))}
</select>
</label>
)}
{!!(
p.categories?.length || filter.data.category
) && (
<label className="block min-w-0">
Categoria
<select
className={input}
value={filter.data.category}
onChange={(e) =>
filter.setData(
'category',
e.target.value,
)
}
>
<option value="">Todas</option>
{p.categories?.map((category) => (
<option
key={category}
value={category}
>
{documentTypeLabels[category] ??
category}
</option>
))}
</select>
</label>
)}
{!!(
p.documentTypes?.length ||
filter.data.document_type
) && (
<label className="block min-w-0">
Tipo documental
<select
className={input}
value={filter.data.document_type}
onChange={(e) =>
filter.setData(
'document_type',
e.target.value,
)
}
>
<option value="">Todos</option>
{p.documentTypes?.map((type) => (
<option key={type} value={type}>
{documentTypeLabels[type] ??
'Documento'}
</option>
))}
</select>
</label>
)}
{!!p.activities?.length && (
<fieldset className="min-w-0 rounded border p-3">
<legend className="px-1">
@@ -866,62 +857,78 @@ export default function Services(p: Props) {
</div>
</fieldset>
)}
<label className="block min-w-0">
Entidade emitente
<select
className={input}
value={filter.data.issuer}
onChange={(e) =>
filter.setData('issuer', e.target.value)
}
>
<option value="">Todas</option>
{p.issuers?.map((issuer) => (
<option key={issuer}>{issuer}</option>
))}
</select>
</label>
<label className="block min-w-0">
Idioma
<select
className={input}
value={filter.data.language}
onChange={(e) =>
filter.setData(
'language',
e.target.value,
)
}
>
<option value="">Todos</option>
{p.languages?.map((language) => (
<option key={language}>
{language}
</option>
))}
</select>
</label>
<label className="block min-w-0">
Situação jurídica indicada
<select
className={input}
value={filter.data.legal_status}
onChange={(e) =>
filter.setData(
'legal_status',
e.target.value,
)
}
>
<option value="">Todas</option>
{p.legalStatuses?.map((status) => (
<option key={status} value={status}>
{legalStatusLabels[status] ??
'Por confirmar'}
</option>
))}
</select>
</label>
{!!(p.issuers?.length || filter.data.issuer) && (
<label className="block min-w-0">
Entidade emitente
<select
className={input}
value={filter.data.issuer}
onChange={(e) =>
filter.setData(
'issuer',
e.target.value,
)
}
>
<option value="">Todas</option>
{p.issuers?.map((issuer) => (
<option key={issuer}>
{issuer}
</option>
))}
</select>
</label>
)}
{!!(
p.languages?.length || filter.data.language
) && (
<label className="block min-w-0">
Idioma
<select
className={input}
value={filter.data.language}
onChange={(e) =>
filter.setData(
'language',
e.target.value,
)
}
>
<option value="">Todos</option>
{p.languages?.map((language) => (
<option key={language}>
{language}
</option>
))}
</select>
</label>
)}
{!!(
p.legalStatuses?.length ||
filter.data.legal_status
) && (
<label className="block min-w-0">
Situação jurídica indicada
<select
className={input}
value={filter.data.legal_status}
onChange={(e) =>
filter.setData(
'legal_status',
e.target.value,
)
}
>
<option value="">Todas</option>
{p.legalStatuses?.map((status) => (
<option key={status} value={status}>
{legalStatusLabels[status] ??
'Por confirmar'}
</option>
))}
</select>
</label>
)}
<label className="block min-w-0">
Confirmação da fonte
<select
@@ -985,30 +992,6 @@ export default function Services(p: Props) {
Limpar filtros
</Link>
</form>
{activeFilters.length > 0 && (
<section
aria-label="Filtros activos"
className="rounded border bg-white p-3"
>
<h2 className="font-semibold">
Filtros activos
</h2>
<ul className="flex flex-wrap gap-2">
{activeFilters.map((item) => (
<li
key={item}
className="rounded-full bg-slate-100 px-3 py-1 text-sm"
>
{item}
</li>
))}
</ul>
</section>
)}
<p role="status" aria-live="polite">
{p.resources?.total ?? 0} documentos correspondem
aos filtros activos.
</p>
{!!p.topics?.length && (
<nav
aria-label="Navegar por assunto"
@@ -1307,16 +1290,6 @@ export default function Services(p: Props) {
</>
)}
</main>
<footer className="border-t px-5 py-8 text-center text-sm">
Investe Moçambique ·{' '}
<Link href="/apoio" className="underline">
Apoio ao Investidor
</Link>{' '}
·{' '}
<Link href="/documentos" className="underline">
Documentos e Formulários
</Link>
</footer>
</div>
);
}
+30 -3
View File
@@ -1,4 +1,5 @@
import { Link, useForm } from '@inertiajs/react';
import { catalogueDetailHref } from '@/lib/catalogue-context';
import { Management } from '../management/shared';
import type { Paginated } from '../management/shared';
import { Layout, Field, control, button, Pagination, kind } from './shared';
@@ -102,7 +103,14 @@ export default function Index({
<h2 className="text-xl font-semibold">
<Link
className="underline"
href={`${base}/${r.id}`}
href={catalogueDetailHref(
`${base}/${r.id}`,
new URLSearchParams(
Object.entries(filters).filter(
([, value]) => Boolean(value),
) as [string, string][],
).toString(),
)}
>
{r.title}
</Link>
@@ -126,9 +134,28 @@ export default function Index({
</span>
</p>
)}
<Link className="underline" href={`${base}/${r.id}`}>
Explorar relações e próximos passos
<Link
className="underline"
href={catalogueDetailHref(
`${base}/${r.id}`,
new URLSearchParams(
Object.entries(filters).filter(
([, value]) => Boolean(value),
) as [string, string][],
).toString(),
)}
>
Consultar ficha
</Link>
{!management &&
(r.corridor_id || r.cluster_id || r.project_id) && (
<Link
className="ml-4 underline"
href={`/oportunidades?${new URLSearchParams(r.corridor_id ? { corridor_id: r.corridor_id } : r.cluster_id ? { cluster_id: r.cluster_id } : { project_id: r.project_id! })}`}
>
Ver oportunidades associadas
</Link>
)}
</article>
))}
</div>
+1 -11
View File
@@ -1,4 +1,4 @@
import { Head, Link, usePage } from '@inertiajs/react';
import { Head, usePage } from '@inertiajs/react';
import type { ReactNode } from 'react';
export { button, control, Field, Pagination } from '../management/shared';
export type Dossier = {
@@ -47,16 +47,6 @@ export function Layout({
return (
<Container className="mx-auto w-full max-w-6xl min-w-0 space-y-6 p-4 break-words md:p-8">
<Head title={title} />
<nav
className="flex flex-wrap gap-5 text-sm underline"
aria-label="Investimento"
>
<Link href="/">InvestMoz</Link>
<Link href="/carteira?kind=corridor">Corredores</Link>
<Link href="/carteira?kind=cluster">Clusters</Link>
<Link href="/carteira?kind=project">Projectos âncora</Link>
<Link href="/investidor">O meu painel</Link>
</nav>
<h1 className="text-3xl font-semibold">{title}</h1>
{flash?.success && (
<p role="status" className="rounded border p-3">
+8
View File
@@ -1,5 +1,6 @@
import { Link, useForm, router, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { catalogueReturnHref } from '@/lib/catalogue-context';
import InvestmentMapPanel from '@/components/investment-map-panel';
import StructureGeographyPanel, {
type StructureGeography,
@@ -76,8 +77,15 @@ function Editor({
const save = useForm({ origin_id: origin?.id ?? null });
const Frame = management ? Management : Layout;
const base = management ? '/management/portfolio' : '/carteira';
const pageUrl = usePage().url;
return (
<Frame title={record.title}>
<Link
className="underline"
href={catalogueReturnHref(base, pageUrl)}
>
Voltar à pesquisa
</Link>
<p>
{kind(record)} · {record.content.code}
</p>
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Tests\Feature;
use App\Services\LegacyContent;
use App\Services\SectorInvestment;
use App\Models\Sector;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class EducationalGuidesTest extends TestCase
{
use DatabaseTransactions;
public function createApplication(): Application
{
$app = parent::createApplication();
if (! IsolatedQaDatabase::allowsSchemaChanges()) throw new \LogicException('Isolated QA required');
return $app;
}
public function test_guide_uses_public_edition_and_hides_unpublished_or_unscanned_sources(): void
{
Storage::fake('local');
config(['portal.legacy_content_qa_enabled' => true]);
$identity = config('legacy_content.articles.turismo');
$body = '<p>Conteúdo sintético para conferir a apresentação do guia e preservar o texto da edição consultada.</p>';
$bytes = json_encode(['format' => 'investmoz-legacy-article-v1', 'source_url' => $identity['source_url'], 'content_html' => $body,
'title' => 'Guia sintético', 'author' => 'Autor QA', 'legacy_id' => 171990, 'legacy_category' => 'Turismo',
'published_at' => '2026-06-16', 'modified_at' => '2026-06-16'], JSON_THROW_ON_ERROR);
$id = (string) Str::uuid();
Storage::disk('local')->put('guides/'.$id, $bytes);
DB::table('documents')->insert(['id' => $id, 'external_id' => $identity['external_id'], 'source_system' => 'synthetic-test',
'file_name' => 'guide.json', 'media_type' => 'application/json', 'storage_key' => 'guides/'.$id, 'size_bytes' => strlen($bytes),
'sha256' => hash('sha256', $bytes), 'malware_scanned_sha256' => hash('sha256', $bytes), 'malware_scan_status' => 'clean',
'status' => 'active', 'rights_status' => 'authorized', 'data_classification' => 'public']);
$resource = DB::table('portal_resources')->insertGetId(['document_id' => $id, 'title' => 'Guia sintético', 'category' => 'education',
'topic' => 'turismo', 'published' => true, 'rights_status' => 'authorized', 'source_url' => $identity['source_url'], 'source_reference' => 'Synthetic isolated QA fixture', 'metadata_version' => 1]);
$sector = Sector::where('slug', 'turismo')->firstOrFail();
$guides = app(SectorInvestment::class)->guides($sector);
$this->assertSame(strip_tags($body), $guides[0]['summary']);
$this->assertSame('Autor QA', $guides[0]['author']);
$this->assertSame('/turismo', $guides[0]['href']);
$this->assertSame('/oportunidades?sector=turismo', $guides[0]['opportunities_href']);
$this->assertSame('/investimentos/a-partir-de/turismo', app(LegacyContent::class)->article('turismo')['start_href']);
DB::table('opportunities')->where('sector_id', $sector->id)->update(['status' => 'draft']);
$this->assertFalse(app(SectorInvestment::class)->guides($sector)[0]['has_opportunities']);
DB::table('portal_resources')->where('id', $resource)->update(['published' => false]);
$this->assertSame([], app(SectorInvestment::class)->guides($sector));
DB::table('portal_resources')->where('id', $resource)->update(['published' => true]);
DB::table('documents')->where('id', $id)->update(['malware_scan_status' => 'pending']);
$this->assertSame([], app(SectorInvestment::class)->guides($sector));
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace Tests\Feature;
use App\Services\InvestmentCatalogue;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Support\OpportunitySearchFixture;
use Tests\TestCase;
class OpportunitySearchTest extends TestCase
{
use DatabaseTransactions;
public function createApplication(): Application
{
$app=parent::createApplication();
if (! IsolatedQaDatabase::allowsSchemaChanges()) throw new \LogicException('Isolated QA required before test transaction');
return $app;
}
public function test_one_park_selector_matches_public_associations_not_proximity_or_shared_territory(): void
{
$f=OpportunitySearchFixture::create();
$q=['park_id'=>$f['zone']->id,'q'=>$f['query']];
$this->getJson('/api/v1/investment/search?'.http_build_query($q))->assertOk()
->assertJsonPath('total',1)->assertJsonPath('data.0.id',$f['associated']->id);
$this->assertSame(1,app(InvestmentCatalogue::class)->map($q)['unlocated_count']??count(app(InvestmentCatalogue::class)->map($q)['unlocated']));
$this->getJson('/api/v1/investment/search?'.http_build_query([...$q,'currency'=>'MZN']))->assertOk()->assertJsonPath('total',0);
$this->getJson('/api/v1/investment/search?'.http_build_query(['association_type'=>'park','association_target'=>$f['zone']->id]))
->assertOk()->assertJsonPath('total',1)->assertJsonPath('data.0.id',$f['associated']->id);
}
public function test_historical_and_current_preparation_with_equal_numbers_are_not_equivalent(): void
{
$f=OpportunitySearchFixture::create();
$this->getJson('/api/v1/investment/search?'.http_build_query(['q'=>$f['query'],'preparation'=>'historical:2']))
->assertOk()->assertJsonPath('total',1)->assertJsonPath('data.0.id',$f['associated']->id);
$this->getJson('/api/v1/investment/search?'.http_build_query(['q'=>$f['query'],'preparation'=>'stage:concept']))
->assertOk()->assertJsonPath('total',1)->assertJsonPath('data.0.id',$f['nearby']->id);
}
public function test_amount_requires_currency_and_combines_with_sector_location_and_modality(): void
{
$f=OpportunitySearchFixture::create();
$this->getJson('/api/v1/investment/search?max_amount=500000')->assertUnprocessable()->assertJsonValidationErrors('currency');
$q=['q'=>$f['query'],'sector'=>'turismo','province_id'=>$f['province']->id,
'category'=>'investment_project','preparation'=>'historical:2','max_amount'=>'500000','currency'=>'USD'];
$this->getJson('/api/v1/investment/search?'.http_build_query($q))->assertOk()->assertJsonPath('total',1)->assertJsonPath('data.0.id',$f['associated']->id);
$this->getJson('/api/v1/investment/search?'.http_build_query([...$q,'max_amount'=>'1']))->assertOk()->assertJsonPath('total',0);
$this->getJson('/api/v1/investment/search?'.http_build_query([...$q,'category'=>'business_sale']))->assertOk()->assertJsonPath('total',0);
}
public function test_zone_options_are_not_filtered_by_opportunity_text_or_modality(): void
{
$f=OpportunitySearchFixture::create();
$this->getJson('/api/v1/investment/references?q=turismo&category=investment_project')->assertOk()
->assertJsonFragment(['id'=>$f['zone']->id,'name'=>$f['zone']->name,'category'=>'industrial_park']);
$this->getJson('/api/v1/investment/search?'.http_build_query(['park_id'=>$f['zone']->id,'association_type'=>'park','association_target'=>$f['other_zone']->id]))
->assertUnprocessable()->assertJsonValidationErrors('park_id');
}
public function test_list_map_and_export_keep_the_same_public_park_results(): void
{
$f = OpportunitySearchFixture::create();
$query = http_build_query(['q' => $f['query'], 'park_id' => $f['zone']->id]);
$this->get('/oportunidades?'.$query)->assertOk()->assertInertia(fn ($page) => $page
->component('investment/catalogue')->where('results.total', 1)
->where('results.data.0.id', $f['associated']->id));
$csv = $this->get('/api/v1/investment/export?'.$query)->assertOk()->streamedContent();
$this->assertStringContainsString($f['associated']->title, $csv);
$this->assertStringNotContainsString($f['nearby']->title, $csv);
$this->assertStringNotContainsString($f['private']->title, $csv);
}
public function test_public_catalogue_consumers_keep_their_own_result_collections(): void
{
foreach ([
'/carteira?q=SINTETICO-INEXISTENTE' => ['portfolio/index', 'records.total'],
'/zonas?q=SINTETICO-INEXISTENTE' => ['investment/zones', 'records.total'],
'/documentos?q=SINTETICO-INEXISTENTE' => ['portal/services', 'resources.total'],
'/legislacao?q=SINTETICO-INEXISTENTE' => ['portal/services', 'resources.total'],
'/educacao?q=SINTETICO-INEXISTENTE' => ['investment/catalogue', 'results.total'],
'/negocios?q=SINTETICO-INEXISTENTE' => ['investment/business-marketplace', 'results.meta.total'],
] as $url => [$component, $total]) {
$this->get($url)->assertOk()->assertInertia(fn ($page) => $page->component($component)->where($total, 0));
}
$this->get('/clusters')->assertRedirect('/carteira?kind=cluster');
$this->get('/corredores')->assertRedirect('/carteira?kind=corridor');
}
}
@@ -0,0 +1,49 @@
<?php
namespace Tests\Support;
use App\Models\InvestmentZone;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\InvestmentGeography;
use App\Services\OpportunityMaturity;
use App\Support\IsolatedQaDatabase;
use Illuminate\Support\Str;
final class OpportunitySearchFixture
{
public static function create(): array
{
if (! IsolatedQaDatabase::allowsSchemaChanges()) throw new \LogicException('Isolated QA only');
$province = app(InvestmentGeography::class)->publicReferences()->where('location_type', 'province')->where('name', 'Inhambane')->first();
if (! $province) throw new \LogicException('QA requires the existing public Inhambane reference');
$sector = Sector::where('slug', 'turismo')->firstOrFail();
$editor = User::factory()->create(); $reviewer = User::factory()->create();
$zone = InvestmentZone::query()->create(['id'=>(string)Str::uuid(), 'code'=>'SINT-QA-'.Str::random(8),
'name'=>'SINTÉTICO QA — Parque Industrial de Balama', 'category'=>'industrial_park', 'physical_category'=>'industrial_park',
'publication_status'=>'published','validation_status'=>'validated', 'editor_id'=>$editor->id,'reviewer_id'=>$reviewer->id,
'published_at'=>now(), 'source_evidence'=>[['source_type'=>'promoter','url'=>'https://example.invalid/synthetic-qa','supports'=>'SINTÉTICO QA: não constitui informação sobre o parque real.']]]);
$other = InvestmentZone::query()->create(['id'=>(string)Str::uuid(),'code'=>'SINT-QA-'.Str::random(8),'name'=>'SINTÉTICO QA — Outro parque',
'category'=>'industrial_park','physical_category'=>'industrial_park','publication_status'=>'published','validation_status'=>'validated','editor_id'=>$editor->id,'reviewer_id'=>$reviewer->id,
'published_at'=>now(),'source_evidence'=>[['source_type'=>'promoter','url'=>'https://example.invalid/synthetic-qa','supports'=>'SINTÉTICO QA: outra associação.']]]);
$prefix='SINTÉTICO QA turismo '.Str::upper(Str::random(8));
$create = function(string $name, string $purpose, string $currency, int $level, ?string $stage=null, ?string $target=null, bool $private=false) use ($sector,$province,$zone,$prefix) {
$row=['related_id'=>$target??$zone->id,'relation_type'=>'located_in','classification'=>['target_type'=>'park','purpose'=>$purpose,
'disclosure'=>'public','requested_state'=>'preliminary','source_reference'=>'SINTÉTICO QA — ensaio de pesquisa','citation'=>'Fixture local']];
return Opportunity::forceCreate(['reference'=>'SINT-QA-'.Str::random(10),'slug'=>'sintetico-qa-'.Str::uuid(),'title'=>$prefix.' '.$name,
'summary'=>'Dados sintéticos para ensaio exclusivo em QA.','sector_id'=>$sector->id,'province'=>'Inhambane','primary_territory_id'=>$province->id,
'maturity_level'=>$level,'maturity_label'=>$stage?OpportunityMaturity::STAGES[$stage]:OpportunityMaturity::LEGACY_LABELS[$level],
'estimated_investment_amount'=>'300000.00','estimated_investment_currency'=>$currency,'opportunity_category'=>'investment_project',
'status'=>$private?'draft':'published','published_at'=>$private?null:now(),
'workflow_context'=>['publication'=>['visibility'=>'public','classification'=>[$row],
'maturity'=>['catalogue_version'=>$stage?OpportunityMaturity::VERSION:OpportunityMaturity::LEGACY,'stage'=>$stage]]]]);
};
return ['query'=>$prefix,'zone'=>$zone,'other_zone'=>$other,'province'=>$province,
'associated'=>$create('associada','park_operation','USD',2),
'nearby'=>$create('apenas próxima','proximity','USD',2,'concept'),
'same_territory'=>$create('mesmo território','administrative_boundary','MZN',4,'feasibility'),
'other_park'=>$create('outro parque','park_operation','USD',4,'feasibility',$other->id),
'private'=>$create('privada','park_operation','USD',2,null,null,true)];
}
}
+63 -4
View File
@@ -1,5 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
catalogueDetailHref,
catalogueReturnHref,
} from '../../resources/js/lib/catalogue-context.ts';
import {
portalNavigation,
legacyPortalNavigation,
@@ -51,7 +55,10 @@ void test('management and optional modules follow permissions and feature flags'
void test('detail URLs and filtered catalogue pages retain the current group', () => {
// Preserve the retained catalogue's detail/filter matching independently
// of the reconciled shell's new grouping names.
const groups = legacyPortalNavigation({ authenticated: true, manager: true });
const groups = legacyPortalNavigation({
authenticated: true,
manager: true,
});
for (const url of [
'/carteira/project/fixture',
'/zonas?category=zee',
@@ -87,8 +94,60 @@ void test('detail URLs and filtered catalogue pages retain the current group', (
void test('reconciled navigation selects discovery, realization and private support correctly', () => {
const groups = portalNavigation({ authenticated: false, manager: false });
assert.equal(currentNavigationGroup(groups, '/oportunidades/fixture'), 'discovery');
assert.equal(currentNavigationGroup(groups, '/simulador-fiscal'), 'realization');
assert.equal(currentNavigationGroup(groups, '/balcao-virtual/dossiers/fixture'), 'support');
assert.equal(
currentNavigationGroup(groups, '/oportunidades/fixture'),
'discovery',
);
assert.equal(
currentNavigationGroup(groups, '/simulador-fiscal'),
'realization',
);
assert.equal(
currentNavigationGroup(groups, '/balcao-virtual/dossiers/fixture'),
'support',
);
assert.equal(currentNavigationGroup(groups, '/management/crm/leads'), null);
});
void test('discovery keeps four entry points and catalogue destinations without duplicates', () => {
const groups = portalNavigation({
authenticated: false,
manager: false,
businesses: true,
education: true,
});
const links = [];
const collect = (items) =>
items.forEach((item) => {
if (item.href) links.push(item.href);
if (item.children) collect(item.children);
});
collect(groups);
assert.equal(new Set(links).size, links.length);
for (const href of [
'/investimentos/iniciar',
'/oportunidades',
'/sectores',
'/oportunidades?view=map',
'/carteira',
'/zonas',
'/negocios',
'/educacao',
'/documentos',
'/legislacao',
])
assert.ok(links.includes(href), href);
});
void test('catalogue detail and return preserve the search inside the local catalogue', () => {
const query = 'q=turismo+em+Inhambane&kind=cluster&province_id=fixture';
const href = catalogueDetailHref('/carteira/fixture', query);
assert.equal(catalogueReturnHref('/carteira', href), '/carteira?' + query);
assert.equal(
catalogueReturnHref(
'/zonas',
'/zonas/fixture?catalogue_query=https%3A%2F%2Foutside.invalid',
),
'/zonas?https%3A%2F%2Foutside.invalid=',
);
});