Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e69ab1499f | ||
|
|
3258a25f76 | ||
|
|
0e7d51f18f | ||
|
|
fd341b627e | ||
|
|
1350b4df73 |
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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()}` : '');
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -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=',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user