Reconciliar fontes instaladas 423bd512d8c5 com main

This commit is contained in:
2026-10-20 14:45:19 +02:00
parent 507387502c
commit 9ab721c97f
108 changed files with 2766 additions and 494 deletions
@@ -5,24 +5,15 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Sleep;
use Throwable;
class CheckDevelopmentEnvironment extends Command
{
protected $signature = 'portal:dev-check {--wait=0 : Segundos para aguardar uma falha transitória do PostgreSQL}';
protected $signature = 'portal:dev-check';
protected $description = 'Verifica o arranque local sem alterar dados ou mostrar credenciais';
public function handle(): int
{
$wait = filter_var($this->option('wait'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 3600]]);
if ($wait === false) {
$this->error('--wait deve ser um inteiro entre 0 e 3600 segundos.');
return self::INVALID;
}
$checks = [
'PHP >= 8.4.1' => version_compare(PHP_VERSION, '8.4.1', '>='),
'Driver PostgreSQL activo' => extension_loaded('pdo_pgsql'),
@@ -34,17 +25,14 @@ class CheckDevelopmentEnvironment extends Command
];
if ($checks['Driver PostgreSQL activo'] && $checks['Base PostgreSQL configurada']) {
$checks['Ligação à base de dados'] = $this->checkConnection($wait);
}
if ($checks['Ligação à base de dados'] ?? false) {
try {
DB::selectOne('SELECT 1');
$checks['Ligação à base de dados'] = true;
$checks['Extensão PostGIS instalada'] = (bool) DB::selectOne("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'postgis') AS installed")->installed;
$checks['Tabelas principais instaladas'] = collect(['migrations', 'users', 'sectors', 'opportunities', 'sessions', 'cache', 'jobs'])
->every(fn (string $table): bool => Schema::hasTable($table));
} catch (Throwable) {
$checks['Esquema da base de dados verificável'] = false;
$this->warn('A ligação foi estabelecida, mas a verificação do esquema falhou. Verifique as permissões e a preparação QA.');
} catch (\Throwable) {
$checks['Ligação à base de dados'] = false;
}
}
@@ -60,61 +48,4 @@ class CheckDevelopmentEnvironment extends Command
return self::SUCCESS;
}
private function checkConnection(int $wait): bool
{
$deadline = microtime(true) + $wait;
while (true) {
try {
DB::selectOne('SELECT 1');
return true;
} catch (Throwable $error) {
[$reason, $retryable] = $this->connectionFailure($error);
$remaining = $deadline - microtime(true);
if (! $retryable || $remaining <= 0) {
$this->warn($reason);
if ($retryable) {
$this->warn('Inicie Docker Desktop e execute ./deploy/local/start.ps1. Consulte os registos com docker compose --env-file .env -f deploy/local/compose.yaml logs --tail 40 postgres.');
}
return false;
}
$this->line('[AGUARDA] '.$reason.' Nova tentativa; prazo restante: '.(int) ceil($remaining).' s.');
DB::purge();
Sleep::for(min(5, $remaining))->seconds();
}
}
}
/** @return array{string, bool} */
private function connectionFailure(Throwable $error): array
{
// Inspect the chain internally; never print exception messages, DSNs or credentials.
$messages = '';
do {
$messages .= ' '.strtolower($error->getMessage()).' '.strtolower((string) $error->getCode());
$error = $error->getPrevious();
} while ($error !== null);
if (str_contains($messages, '28p01') || str_contains($messages, '28000')
|| str_contains($messages, 'password authentication failed') || str_contains($messages, 'no pg_hba.conf entry')) {
return ['Autenticação PostgreSQL recusada. Verifique as credenciais e as regras de acesso; aguardar não corrige este erro.', false];
}
if (str_contains($messages, '3d000') || preg_match('/database .* does not exist/', $messages)) {
return ['A base PostgreSQL configurada não existe. Verifique a configuração e a preparação QA.', false];
}
if (str_contains($messages, '57p03') || str_contains($messages, 'recovery mode')
|| str_contains($messages, 'not yet accepting connections') || str_contains($messages, 'database system is starting up')) {
return ['PostgreSQL em arranque ou recuperação; aguarde a conclusão sem remover o volume.', true];
}
if (preg_match('/\b08[0-9a-z]{3}\b/', $messages) || str_contains($messages, 'connection refused')
|| str_contains($messages, '57p01') || str_contains($messages, '57p02')) {
return ['PostgreSQL indisponível. Verifique se Docker Desktop e o contentor local estão iniciados.', true];
}
return ['Falha PostgreSQL não transitória. Verifique a configuração e os registos do servidor.', false];
}
}
@@ -23,8 +23,9 @@ class InterestRequestController extends Controller
{
public function store(StoreInterestRequestRequest $request, InterestSubmission $submission): JsonResponse
{
$validated = $request->validated();
$validated = [...$request->validated(), '_actor_id' => $request->user()->id];
$key = $request->header('Idempotency-Key');
abort_unless(is_string($key), 422, 'Identificador do envio em falta.');
Validator::make(['idempotency_key' => $key], ['idempotency_key' => [$key === null ? 'nullable' : 'required', 'uuid']])->validate();
try {
@@ -53,7 +54,7 @@ class InterestRequestController extends Controller
'phone' => $validated['phone'] ?? null,
'investor_type' => $validated['investorType'],
'country' => $validated['country'],
'metadata' => ['declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null],
'metadata' => ['portal_user_id' => $request->user()->id, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null],
'sector_interest' => $validated['sectorInterest'],
'opportunity_reference' => $validated['opportunityReference'] ?? null,
'investment_range' => $validated['investmentRange'] ?? null,
@@ -77,6 +78,19 @@ class InterestRequestController extends Controller
}
}
$row = \App\Models\InvestmentSubmission::create([
'user_id' => $request->user()->id, 'opportunity_id' => $opportunity?->id,
'interest_request_id' => $interest->id, 'status' => 'submitted', 'version' => 1,
'payload' => ['simple_participation' => true, 'opportunity_title' => $opportunity?->title ?? $business?->title, 'opportunity_reference' => $opportunity?->reference, 'phone' => $validated['phone'] ?? null, 'declared_origin_country_code' => $validated['declared_origin_country_code'] ?? null, 'full_name' => $validated['fullName'], 'email' => $validated['email'],
'objective' => $validated['message'], 'sector' => $validated['sectorInterest'],
'country' => $validated['country'], 'existing_business_id' => $business?->id,
'modality' => $validated['modality'] ?? null],
]);
\Illuminate\Support\Facades\DB::table('investment_submission_events')->insert([
'submission_id' => $row->id, 'actor_id' => $request->user()->id,
'action' => 'submitted', 'version' => 1, 'created_at' => now(),
]);
$interest->forceFill(['metadata' => [...$interest->metadata, 'submission_id' => $row->id]])->save();
return $interest;
});
@@ -85,7 +99,8 @@ class InterestRequestController extends Controller
'message' => 'Manifestação de interesse registada com sucesso.',
'data' => [
'reference' => $interestRequest->reference,
'status' => 'received',
'status' => $interestRequest->status,
'tracking_url' => '/investidor/manifestacoes',
],
], 201);
} catch (IdempotencyConflict) {
@@ -11,8 +11,8 @@ class DocumentMalwareController extends Controller
{
public function retry(Request $request, string $id): RedirectResponse
{
DB::transaction(function () use ($id): void {
$document = DB::table('documents')->where('id', $id)->lockForUpdate()->first();
DB::transaction(function () use ($id, $request): void {
$document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->lockForUpdate()->first();
abort_unless($document && $document->status !== 'deleted' && $document->deleted_at === null, 404);
abort_unless(in_array($document->malware_scan_status, ['pending', 'inconclusive', 'failed'], true), 422,
'Só é possível repetir uma análise pendente, inconclusiva ou falhada. Para substituir um ficheiro, carregue uma nova versão.');
@@ -78,6 +78,7 @@ class EditorialArticleController extends Controller
public function edit(int $id)
{
$this->installed();
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource(request()->user(), $id);
$r = DB::table('portal_resources')->find($id);
abort_unless($r, 404);
$content = $r->editorial_content ? json_decode($r->editorial_content, true) : null;
@@ -108,6 +109,7 @@ class EditorialArticleController extends Controller
DB::transaction(function () use ($request, $id, $data) {
$r = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($r, 404);
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_unless((int) $r->metadata_version === (int) $data['metadata_version'], 409, 'O artigo foi alterado noutra sessão. Recarregue antes de editar.');
if (! empty($data['image_resource_id'])) {
abort_unless($this->publicResources()->where('r.id', $data['image_resource_id'])->whereIn('d.media_type', ['image/png', 'image/jpeg', 'image/webp'])->exists(), 422, 'Escolha uma imagem pública disponível na biblioteca.');
@@ -67,7 +67,7 @@ class ExistingBusinessController extends Controller
$business = app(BusinessRepository::class)->find($id);
Gate::authorize('view', $business);
app(CompatibilityShadow::class)->schedule($id);
$props = ['countries' => app(CountryCatalogue::class)->all(), 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'formSchema' => BusinessFormSchema::fields(), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business),
$props = ['countries' => app(CountryCatalogue::class)->all(), 'sectors' => Sector::choices()->orderBy('name')->get(['id', 'name', 'slug']), 'formSchema' => BusinessFormSchema::fields(), 'modalityFormType' => \App\Services\ExistingBusinessProfileCategory::formType($business->modality), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business),
'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business),
'canTriage' => $request->user()->can('triage', $business),
'canApproveLevelOne' => $request->user()->can('approveLevelOne', $business),
@@ -31,7 +31,14 @@ class InvestmentSubmissionController extends Controller
{
public function index(Request $request): Response|JsonResponse
{
$submissions = InvestmentSubmission::where('user_id', $request->user()->id)->latest()->paginate(20)->through(function ($row) {
$query = InvestmentSubmission::where('user_id', $request->user()->id);
if (! $request->user()->hasVerifiedEmail()) {
// The existing payload is encrypted; scope after decryption, never infer ownership from email.
$simpleIds = InvestmentSubmission::where('user_id', $request->user()->id)->where('status', 'submitted')->get(['id', 'payload'])
->filter(fn ($row) => ($row->payload['simple_participation'] ?? false) === true)->modelKeys();
$query->whereIn('id', $simpleIds);
}
$submissions = $query->latest()->paginate(20)->through(function ($row) {
$interest = $row->interest_request_id ? InterestRequest::find($row->interest_request_id) : null;
return ['id' => $row->id, 'status' => $row->status, 'version' => $row->version, 'created_at' => $row->created_at,
@@ -118,29 +125,32 @@ class InvestmentSubmissionController extends Controller
$target = $row->opportunity_id ? Opportunity::published()->find($row->opportunity_id) : null;
$dossier = $row->dossier_id ? InvestmentPortfolioController::visible()->find($row->dossier_id) : null;
$zoneOrigin = DB::table('investment_submission_zones')->where('submission_id', $id)->first(['zone_id', 'zone_version', 'intent']);
$boundCase = DB::table('desk_cases')->where('submission_id', $id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first();
$contextChoices = (int) $row->user_id === (int) $request->user()->id && $row->status === 'draft' && ! $boundCase
$contextAvailable = app(VirtualDesk::class)->installed();
$boundCase = $contextAvailable ? DB::table('desk_cases')->where('submission_id', $id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first() : null;
$contextChoices = $contextAvailable && (int) $row->user_id === (int) $request->user()->id && $row->status === 'draft' && ! $boundCase
? DB::table('desk_cases')->where('user_id', $request->user()->id)->whereNotNull('journey_id')->whereNull('closed_at')->whereNull('submission_id')->orderBy('title')->get(['id', 'title', 'version']) : [];
$data = ['contextDossierId' => $boundCase?->id, 'contextChoices' => $contextChoices, 'boundContextRevision' => app(InvestorContextConsumers::class)->submissionRevision($id), 'countries' => app(CountryCatalogue::class)->all(), 'submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin,
'target' => ($target ? $target->title : null) ?? ($dossier ? $dossier->title : null), 'references' => [
$data = ['contextDossierId' => $boundCase?->id, 'contextChoices' => $contextChoices, 'boundContextRevision' => $contextAvailable ? app(InvestorContextConsumers::class)->submissionRevision($id) : 0, 'countries' => app(CountryCatalogue::class)->all(), 'submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin,
'target' => ($target ? $target->title : null) ?? ($dossier ? $dossier->title : null) ?? ($row->payload['opportunity_title'] ?? null), 'references' => [
...$catalogue->references(), 'options' => DB::table('investment_reference_options')->orderBy('label')->get()],
'needs' => InvestmentSpecificNeed::where('submission_id', $id)->get(),
'attachments' => DB::table('investment_submission_attachments as a')->join('documents as d', 'd.id', '=', 'a.document_id')
->where('a.submission_id', $id)->whereNull('d.deleted_at')->get(['a.id', 'd.file_name']),
'events' => DB::table('investment_submission_events')->where('submission_id', $id)->orderBy('id')->get(['action', 'version', 'created_at']),
'events' => DB::table('investment_submission_events')->where('submission_id', $id)->orderBy('id')->get(['action', 'version', 'created_at'])
->concat($interest ? DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)->orderBy('created_at')->get(['to_status', 'created_at'])->map(fn ($event) => ['action' => $event->to_status, 'version' => $row->version, 'created_at' => $event->created_at]) : [])->sortBy('created_at')->values(),
'revisions' => DB::table('investment_submission_revisions')->where('submission_id', $id)->orderByDesc('version')->get(['version', 'created_at']),
'messages' => DB::table('investment_messages')->where('submission_id', $id)->orderBy('created_at')->get(['id', 'sender_role', 'body', 'created_at'])->map(function ($message) {
$message->body = Crypt::decryptString($message->body);
return $message;
}),
'receipt' => $interest?->only(['reference', 'status']), 'management' => (int) $row->user_id !== $request->user()->id];
'receipt' => $interest?->only(['reference', 'status', 'next_action', 'next_action_due_at']), 'management' => (int) $row->user_id !== $request->user()->id];
return $request->expectsJson() ? response()->json($data) : Inertia::render('investment/submission', $data);
}
public function bindContext(Request $request, string $id): JsonResponse
{
abort_unless(app(VirtualDesk::class)->installed(), 422, 'O vínculo ao percurso do investimento não está disponível neste destino. A manifestação e a sua origem permanecem guardadas.');
abort_if(array_diff(array_keys($request->all()), ['case_id', 'case_version', 'version', '_token']), 422, 'Parâmetros de associação inválidos.');
$data = $request->validate(['case_id' => 'required|uuid', 'case_version' => 'required|integer|min:1', 'version' => 'required|integer|min:1']);
$result = DB::transaction(function () use ($request, $id, $data) {
@@ -395,7 +405,7 @@ class InvestmentSubmissionController extends Controller
private function event(InvestmentSubmission $row, Request $request, string $action): void
{
$boundCase = DB::table('desk_cases')->where('submission_id', $row->id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first();
$boundCase = app(VirtualDesk::class)->installed() ? DB::table('desk_cases')->where('submission_id', $row->id)->where('user_id', $request->user()->id)->whereNotNull('journey_id')->first() : null;
$binding = null;
if ($boundCase) {
$binding = app(InvestorContextConsumers::class)->submissionRevision($row->id);
@@ -98,7 +98,7 @@ class InvestmentZoneController extends Controller
abort_if((! empty($filters['development_status']) || ! empty($filters['availability_status'])) && ! Schema::hasColumn('investment_zones', 'source_evidence'), 503,
'Os filtros de desenvolvimento e disponibilidade estarão disponíveis após a migração do catálogo de zonas.');
$path = app(InvestmentGeography::class)->selection($filters);
$publicColumns = ['version_no', 'id', 'code', 'name', 'name_en', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'data_as_of', 'sources_consulted_at', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'];
$publicColumns = ['primary_territory_id', 'version_no', 'id', 'code', 'name', 'name_en', 'category', 'physical_category', 'legal_regime', 'operational_status', 'development_status', 'availability_status', 'area_value', 'area_unit', 'area_components', 'source_evidence', 'activities', 'resources', 'aliases', 'data_as_of', 'sources_consulted_at', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'];
if(app(\App\Services\InvestmentZoneOperationalDetails::class)->installed()){$publicColumns[]='operational_details';}
$publicColumns = array_values(array_filter($publicColumns, fn ($column) => Schema::hasColumn('investment_zones', $column)));
$rows = $zones->search($filters, $management)->orderBy('name')->orderBy('id')
@@ -113,6 +113,8 @@ class InvestmentZoneController extends Controller
->when(! $management, fn ($q) => $q->where('zl.validation_status', 'validated')->where('g.data_classification', 'public'))
->orderBy('g.location_type')->orderBy('g.name')->get(['g.id', 'g.name', 'g.location_type', 'zl.scope_type']));
if (! $management) { $zone->makeHidden('primary_territory_id'); }
return $management ? $zone : [...app(InvestmentZoneOperationalProfile::class)->publicDetails($zone->toArray()),'operational_details_public'=>app(\App\Services\InvestmentZoneOperationalDetails::class)->publicData($zone)];
});
$props = ['detailsInstallation'=>$management ? app(\App\Services\InvestmentZoneOperationalDetailsInstallation::class)->preview($request->user()) : null, 'installation' => $management ? app(InvestmentZoneProfileInstallation::class)->preview($request->user()) : null, 'records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path,
@@ -98,7 +98,13 @@ class OpportunityResourceController extends Controller
}
}
return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'assessment' => $this->assessment($resources)]);
return Inertia::render('management/opportunity-resources', ['opportunity' => $opportunity, 'etag' => app(OpportunityManagement::class)->etag($opportunity), 'resources' => $resources, 'librarySources' => !app(\App\Services\PortalProfileAccess::class)->canEdit(request()->user(), $opportunity) ? [] : app(\App\Services\OpportunityLibrarySupport::class)->choices(request()->user()), 'existingLocations' => DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')->where('opportunity_id', $id)->get(['g.id', 'g.name', 'g.location_type']), 'locationReference' => app(\App\Services\OpportunityTerritorialPresentation::class)->describe($opportunity, $resources['locations']), 'assessment' => $this->assessment($resources)]);
}
public function reuseLibrary(Request $request, int $id): JsonResponse
{
$row = app(\App\Services\OpportunityLibrarySupport::class)->attach($request->user(), Opportunity::findOrFail($id), $request->all());
return response()->json(['id' => $row->id, 'message' => 'Original associado como suporte privado, sem copiar ficheiros ou publicar.']);
}
public function save(Request $request, int $id, string $kind, OpportunityResources $service, ?string $resourceId = null): JsonResponse
@@ -144,11 +150,15 @@ class OpportunityResourceController extends Controller
{
abort_unless(in_array($kind, ['documents', 'images'], true), 404);
$opportunity = Opportunity::findOrFail($id);
app(\App\Services\PortalProfileAccess::class)->authorize(request()->user(), 'view', $opportunity);
abort_if($opportunity->status === 'archived', 404);
$record = DB::table($service->table($kind).' as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.id', $resourceId)->where('r.opportunity_id', $id)->whereNull('r.deleted_at')->whereNull('d.deleted_at')->first(['d.*']);
abort_unless($record !== null, 404, 'Recurso indisponível ou excluído logicamente.');
abort_unless($record->status === 'active' && $record->malware_scan_status === 'clean' && hash_equals(trim($record->sha256), (string) $record->malware_scanned_sha256), 423, 'Scan pendente, inconclusivo ou inseguro. O ficheiro permanece em quarentena.');
$support = DB::table($service->table($kind))->where('id', $resourceId)->first();
app(\App\Services\OpportunityLibrarySupport::class)->assertAvailable($support, $record);
if (request()->filled('source_sha256')) abort_unless(hash_equals(trim($record->sha256), (string)request('source_sha256')), 409, 'O original já não corresponde à versão consultada.');
return app(VerifiedDocumentBinary::class)->respond($record);
}
@@ -53,6 +53,36 @@ class PortalManagementController extends Controller
return Inertia::render('management/history', ['events' => $events]);
}
private function interestAssignmentEtag(InterestRequest $interest): string
{
return hash('sha256',json_encode([$interest->id,$interest->status,$interest->assigned_to,$interest->updated_at?->format('Y-m-d H:i:s.u')],JSON_THROW_ON_ERROR));
}
public function interestAssignments(Request $request): Response|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
$rows=InterestRequest::whereNotIn('status',['cancelled','closed','refused','converted'])->orderBy('id')->get(['id','reference','status','assigned_to','updated_at'])
->map(fn($row)=>['id'=>$row->id,'protocol'=>$row->reference,'status'=>$row->status,'analyst_id'=>$row->assigned_to,'version'=>0,'etag'=>$this->interestAssignmentEtag($row),'due_at'=>null]);
$data=['kind'=>'interest','requests'=>$rows,'analysts'=>$this->interestAssignees()->get(['id','name'])];
return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data);
}
public function assignInterest(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
$data=$request->validate(['etag'=>'required|string|size:64','analyst_id'=>['required','integer',Rule::in($this->interestAssignees()->pluck('id'))],'reason'=>'required|string|min:10|max:2000'],['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 10 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.in'=>'Seleccione um responsável autorizado.']);
DB::transaction(function()use($request,$interest,$data){
$row=InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail();
if (!hash_equals($this->interestAssignmentEtag($row),$data['etag'])) throw ValidationException::withMessages(['etag'=>'A atribuição mudou. Recupere a lista actual e tente novamente.']);
if (in_array($row->status,['cancelled','closed','refused','converted'],true)) throw ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
$owner=(int)($row->metadata['portal_user_id']??0);
abort_if($owner===(int)$data['analyst_id'] || InvestmentSubmission::where('interest_request_id',$row->id)->where('user_id',$data['analyst_id'])->exists(),422,'O interessado não pode acompanhar o seu próprio pedido como equipa.');
PortalAudit::save($row,['assigned_to'=>(int)$data['analyst_id']],$request->user()->id);
app(\App\Services\PortalProfileAccess::class)->audit($request->user(),'interest_assigned',['interest_id'=>$row->id,'assigned_to'=>(int)$data['analyst_id'],'reason'=>$data['reason']]);
});
return $request->expectsJson()?response()->json(['ok'=>true]):back()->with('success','Responsável atribuído.');
}
public function interests(Request $request): Response
{
$filters = $request->validate([
@@ -65,6 +95,7 @@ class PortalManagementController extends Controller
$query = InterestRequest::query()->with('assignee:id,name')->select([
'id', 'reference', 'full_name', 'email', 'country', 'metadata', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at',
]);
app(\App\Services\ScopedPortalAdministration::class)->interestQuery($query, $request->user());
if ($country = $filters['country'] ?? null) {
$values = app(CountryCatalogue::class)->filterValues($country);
$query->whereIn(DB::raw('LOWER(BTRIM(country))'), $values);
@@ -111,6 +142,7 @@ class PortalManagementController extends Controller
public function interest(InterestRequest $interest): Response
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
$history = DB::table('business_workflow_transitions')->where('interest_request_id', $interest->id)
->orderByDesc('created_at')->limit(50)->get(['from_status', 'to_status', 'actor_id', 'reason', 'created_at'])
->map(function ($row) {
@@ -138,12 +170,18 @@ class PortalManagementController extends Controller
public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): RedirectResponse|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
$validated = $request->validate([
'status' => ['required', Rule::in(self::STATES)],
'note' => ['nullable', 'string', 'min:10', 'max:2000'],
'next_action' => ['nullable', 'required_with:next_action_due_at', 'string', 'max:180'],
'next_action_due_at' => ['nullable', 'required_with:next_action', 'date', 'after_or_equal:today'],
'assigned_to' => ['nullable', 'integer', Rule::in($this->interestAssignees()->pluck('id'))],
], [
'next_action_due_at.required_with' => 'Indique o prazo da próxima acção.',
'next_action_due_at.date' => 'Indique uma data válida para a próxima acção.',
'next_action_due_at.after_or_equal' => 'O prazo da próxima acção deve ser hoje ou uma data futura.',
'next_action.required_with' => 'Descreva a próxima acção correspondente ao prazo indicado.',
]);
$before = $interest->status;
$updated = $workflow->transition($request->user(), $interest, $validated['status'], $validated['note'] ?? null,
@@ -160,6 +198,7 @@ class PortalManagementController extends Controller
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
$this->withdrawConsent($request, $interest, $data['reason'], 'share_contact_with_proponent');
@@ -169,6 +208,7 @@ class PortalManagementController extends Controller
public function withdrawContactConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->interest(request()->user(), $interest),403);
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
$this->withdrawConsent($request, $interest, $data['reason'], 'respond_to_interest');
@@ -217,7 +257,9 @@ class PortalManagementController extends Controller
['triagers', 'reviewers', 'approvers_l1', 'approvers_l2', 'publishers'],
)));
return User::query()->whereNotNull('email_verified_at')->whereIn(DB::raw('lower(email)'), $emails)->orderBy('name');
return User::query()->whereNotNull('email_verified_at')->where(function ($q) use ($emails) {
$q->whereIn(DB::raw('lower(email)'), $emails)->orWhereIn('id', DB::table('portal_superuser_pair')->select('first_user_id'))->orWhereIn('id', DB::table('portal_superuser_pair')->select('second_user_id'));
})->orderBy('name');
}
public function catalog(Request $request, string $resource): Response
@@ -286,17 +286,47 @@ class PortalServicesController extends Controller
return app(VerifiedDocumentBinary::class)->respond($document, true);
}
public function downloadForManager(string $id): BinaryFileResponse|\Illuminate\Http\Response
public function downloadForManager(Request $request, string $id): BinaryFileResponse|\Illuminate\Http\Response
{
$document = DB::table('documents')->where('id', $id)->whereNull('deleted_at')->first();
$document = app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $id)->first();
abort_unless($document && $document->status === 'active' && $document->malware_scan_status === 'clean'
&& hash_equals(trim($document->sha256), trim((string) $document->malware_scanned_sha256)), 404);
return app(VerifiedDocumentBinary::class)->respond($document);
}
public function manage(MalwareScanner $scanner): Response
public function manage(Request $request, MalwareScanner $scanner): Response
{
$access = app(\App\Services\DocumentManagementAccess::class);
if (! $access->full($request->user())) {
$documents = $access->documents($request->user())->where('status', '!=', 'deleted');
$health = $scanner->health();
$health['signatures_at'] = $health['signatures_at']?->format(DATE_ATOM);
$editorial = $access->editorial($request->user());
$resources = $editorial ? $access->editorialResources($request->user())->orderByDesc('r.updated_at')
->get(['r.*', 'd.file_name', 'd.media_type', 'd.size_bytes', 'd.issuer', 'd.status as file_status', 'd.data_classification', 'd.version_no as file_version', 'd.rights_status as file_rights_status', 'd.malware_scan_status', 'd.malware_failure_reason']) : collect();
$links = DB::table('portal_resource_links')->whereIn('resource_id', $resources->pluck('id'))->orderBy('id')->get()->groupBy('resource_id');
$revisions = DB::table('portal_resource_revisions')->whereIn('resource_id', $resources->pluck('id'))->orderByDesc('revision_no')->get(['resource_id', 'revision_no', 'change_type', 'actor_user_id', 'created_at'])->groupBy('resource_id');
$resources->each(function ($resource) use ($links, $revisions) {
$resource->links = $links->get($resource->id, collect())->values();
$resource->revisions = $revisions->get($resource->id, collect())->values();
});
return Inertia::render('portal/manage', [
'documentAccessOnly' => ! $editorial, 'editorialScopeOnly' => $editorial, 'resources' => $resources, 'partnerships' => [], 'territories' => [],
'requirementCatalogue' => [], 'requirementAssociationsEnabled' => false,
'disclosureAuthorization' => null, 'incompleteCount' => $resources->filter(fn ($r) => ! $r->description || ! $r->issuer || ! $r->source_url || $r->source_confirmation === 'unconfirmed' || $r->legal_status === 'unknown' || $r->rights_status === 'unconfirmed')->count(), 'possibleDuplicates' => $resources->whereNotNull('duplicate_of_resource_id')->count(),
'documents' => (clone $documents)->latest()->limit(250)->get([
'id', 'file_name', 'media_type', 'size_bytes', 'issuer', 'issued_on', 'valid_until',
'uploaded_by', 'created_at', 'status', 'data_classification', 'version_no', 'sha256',
'rights_status', 'malware_scan_status', 'malware_scanned_at', 'malware_failure_reason',
'malware_engine_version', 'malware_signatures_at', 'malware_scan_attempts',
]),
'malwareCounts' => (clone $documents)->select('malware_scan_status', DB::raw('count(*) as total'))
->groupBy('malware_scan_status')->pluck('total', 'malware_scan_status'),
'malwareHealth' => $health,
]);
}
$disclosureAuthorization = DB::table('audit_events')->where('source_system', 'user-instruction')
->where('external_id', 'document-repository-disclosure-authorization-2026-10-06-v1')
->where('action', 'public_disclosure_authorized')->first(['external_id', 'occurred_at', 'after_data']);
@@ -378,6 +408,7 @@ class PortalServicesController extends Controller
$id = DB::transaction(function () use ($data, $request): int {
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
$identity = $this->identityKey($data);
if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
@@ -406,9 +437,11 @@ class PortalServicesController extends Controller
DB::transaction(function () use ($data, $request, $id, $expectedVersion): void {
$resource = DB::table('portal_resources')->where('id', $id)->lockForUpdate()->first();
abort_unless($resource !== null, 404);
app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
abort_if((int) $resource->metadata_version !== $expectedVersion, 409, 'O documento foi alterado por outra sessão. Recarregue a revisão antes de editar.');
$document = DB::table('documents')->where('id', $data['document_id'])->where('status', '!=', 'deleted')->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($document !== null, 422, 'Seleccione um documento existente e não eliminado.');
abort_unless(app(\App\Services\DocumentManagementAccess::class)->documents($request->user())->where('id', $document->id)->exists(), 403);
$identity = $this->identityKey($data);
if ($identity) {
DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['portal-resource-identity:'.$identity]);
@@ -437,6 +470,11 @@ class PortalServicesController extends Controller
public function moderate(Request $request, string $type, int $id): RedirectResponse
{
$access = app(\App\Services\DocumentManagementAccess::class);
if (! $access->full($request->user())) {
abort_unless($type === 'resources', 403);
$access->assertEditableResource($request->user(), $id);
}
if ($type === 'partnerships' && Schema::hasColumn('portal_partnerships', 'partnership_type')) {
return redirect('/parcerias/'.$id)->with('success', 'Reveja a proposta e registe uma decisão fundamentada na ficha da Bolsa.');
}
@@ -444,6 +482,7 @@ class PortalServicesController extends Controller
$table = $type === 'resources' ? 'portal_resources' : 'portal_partnerships';
DB::transaction(function () use ($request, $data, $table, $type, $id) {
abort_unless(DB::table($table)->where('id', $id)->lockForUpdate()->first() !== null, 404);
if ($type === 'resources') app(\App\Services\DocumentManagementAccess::class)->assertEditableResource($request->user(), $id);
if ($type === 'resources' && $data['published']) {
$resource = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->where('r.id', $id)->first(['r.document_type', 'r.legislation', 'r.source_confirmation', 'r.legal_status', 'r.rights_status',
+24 -7
View File
@@ -148,7 +148,7 @@ class VirtualDeskController extends Controller
$data = ['boundContextRevision' => app(InvestorContextConsumers::class)->requestRevision($row), 'countries' => app(CountryCatalogue::class)->all(), 'toolPrefill' => $desk->requestToolPrefill($request->user(), $row), 'request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)],
'case' => ['id' => $case->id, 'title' => $case->title], 'caseVersion' => $case->version, 'definition' => $definition, 'territoryPaths' => $territoryPaths,
'serviceSource' => json_decode(DB::table('desk_service_versions')->where('id', $row->service_version_id)->value('source'), true),
'owner' => $desk->owns($request->user(), $case), 'manager' => $request->user()->can('manage-portal'), 'analyst' => (int) $row->analyst_id === $request->user()->id,
'owner' => $desk->owns($request->user(), $case), 'manager' => app(\App\Services\ScopedPortalAdministration::class)->deskDecision($request->user(),$row), 'analyst' => (int) $row->analyst_id === $request->user()->id,
'fieldsEditable' => in_array($row->status, ['draft', 'returned'], true), 'states' => config('virtual-desk.request_states'), 'transitions' => config('virtual-desk.transitions.'.$row->status, []),
'findings' => $desk->findings($payload), 'ai' => config('virtual-desk.ai'), 'documents' => $this->documents($case->id, $request->user())->filter(fn ($d) => ! $d->request_id || $d->request_id === $id)->values(),
'messages' => DB::table('desk_messages as m')->join('users as u', 'u.id', '=', 'm.actor_id')->where('request_id', $id)->orderBy('m.created_at')->get(['m.id', 'm.actor_id', 'm.body', 'm.created_at', 'u.name'])->map(fn ($m) => [...(array) $m, 'body' => Crypt::decryptString($m->body),
@@ -176,14 +176,26 @@ class VirtualDeskController extends Controller
return $request->expectsJson() ? response()->json(['version' => $row->version, 'status' => $row->status]) : back()->with('success', 'Decisão interna registada.');
}
public function assignments(Request $request): JsonResponse|Response
{
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()),403);
// Triage projection deliberately excludes payloads, documents, investor identity and case titles.
$data=['requests'=>DB::table('desk_requests')->whereNotIn('status',['draft','cancelled','closed'])->orderBy('created_at')->get(['id','protocol','status','version','analyst_id','due_at']),
'analysts'=>User::whereNotNull('email_verified_at')->orderBy('name')->get(['id','name'])];
return $request->expectsJson()?response()->json($data):Inertia::render('management/desk-assignments',$data);
}
public function assign(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
{
abort_unless($request->user()->can('manage-portal'), 403);
$data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date']);
abort_unless(app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()), 403);
$data = $request->validate(['version' => 'required|integer', 'analyst_id' => 'required|integer|exists:users,id', 'reason' => 'required|string|min:3|max:3000', 'due_at' => 'nullable|date'], ['reason.required'=>'Explique o motivo da atribuição.','reason.min'=>'Explique o motivo da atribuição com pelo menos 3 caracteres.','analyst_id.required'=>'Seleccione o responsável.','analyst_id.exists'=>'Seleccione um responsável com conta disponível.']);
DB::transaction(function () use ($request, $id, $desk, $data) {
$row = $desk->request($request->user(), $id, false, true);
$case = $desk->case($request->user(), $row->case_id);
abort_unless((int) $row->version === (int) $data['version'] && ! in_array($row->status, ['draft', 'cancelled', 'closed'], true), 409);
$row = DB::table('desk_requests')->where('id',$id)->lockForUpdate()->first();
abort_unless($row,404);
$case = DB::table('desk_cases')->where('id',$row->case_id)->first();
abort_unless($case,404);
if ((int)$row->version !== (int)$data['version']) throw \Illuminate\Validation\ValidationException::withMessages(['version'=>'Este pedido foi actualizado. Recupere a lista e tente novamente.']);
if (in_array($row->status,['draft','cancelled','closed'],true)) throw \Illuminate\Validation\ValidationException::withMessages(['status'=>'Este pedido já não permite uma nova atribuição. Recupere a lista actual.']);
$analyst = User::where('id', $data['analyst_id'])->firstOrFail();
abort_unless($analyst->hasVerifiedEmail(), 422, 'O responsável deve ter conta verificada.');
abort_if($desk->owns($analyst, $case, false), 422, 'O proponente não pode ser o analista.');
@@ -217,13 +229,18 @@ class VirtualDeskController extends Controller
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Mensagem privada guardada.');
}
public function receipt(Request $request, string $id, VirtualDesk $desk): StreamedResponse
public function receipt(Request $request, string $id, VirtualDesk $desk): StreamedResponse|\Illuminate\Http\Response
{
$row = $desk->request($request->user(), $id);
abort_unless($row->protocol && $row->submitted_snapshot, 404);
$snapshot = $desk->decode($row->submitted_snapshot);
$format = $request->validate(['format' => 'nullable|in:html'])['format'] ?? null;
$this->auditRead($request, 'desk_requests', $id, 'receipt_downloaded');
if ($format === 'html') {
return response()->view('desk.receipt', ['record' => $row, 'snapshot' => $snapshot, 'definition' => $desk->definition($row)], 200, ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff']);
}
return response()->streamDownload(function () use ($row, $snapshot) {
echo 'Balcão Virtual — recibo de submissão interna'.PHP_EOL.$row->protocol.PHP_EOL.'Data: '.$row->submitted_at.PHP_EOL.'Versão: '.$row->version.PHP_EOL;
echo json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use App\Services\DocumentManagementAccess;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureDocumentManagement
{
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
abort_unless($actor, 403);
$access = app(DocumentManagementAccess::class);
if ($access->full($actor)) {
return $next($request); // Preserve existing management controls.
}
$allowed = $access->limited($actor) || $access->editorial($actor);
$access->audit($actor, $allowed ? 'document_access_allowed' : 'document_access_denied', [
'scope' => $access->editorial($actor) ? 'approved-editorial-catalogue-and-own-files' : 'own-portal-services-files', 'method' => $request->method(), 'path' => $request->path(),
]);
abort_unless($allowed, 403, 'O acesso documental exige autorização, email confirmado e autenticação MFA nesta sessão.');
return $next($request);
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/** Environment reservation is not a portal identity or a business permission. */
class EnsureHomologationAccess
{
public function handle(Request $request, Closure $next): Response
{
if (!config('portal.homologation_only', false)) return $next($request);
$mode=config('portal.homologation_access_mode', 'reviewers');
if ($mode==='perimeter') {
// Set only by the trusted server after its environment-access check.
// A client HTTP_INVESTMOZ_* header is deliberately not accepted.
abort_unless($request->server('INVESTMOZ_HOMOLOGATION_ACCESS')==='granted',403,
'Este ambiente de homologação tem acesso reservado à Tempo.');
$response=$next($request);
$response->headers->set('Cache-Control','private, no-store');
$response->headers->set('X-Robots-Tag','noindex, nofollow, noarchive');
return $response;
}
abort_unless($mode==='reviewers',503,'A reserva da homologação não está configurada.');
// Compatibility until an independent perimeter is explicitly configured.
if ($request->is('login','logout','register','forgot-password','reset-password','reset-password/*',
'email/verify','email/verify/*','email/verification-notification','two-factor-challenge',
'user/confirm-password','user/confirmed-password-status','user/two-factor-*',
'settings/security','settings/password','user/password','passkeys/*','user/passkeys/*','up')) return $next($request);
$actor=$request->user();
if (!$actor) return $request->expectsJson()
? response()->json(['message'=>'Entre na conta autorizada para homologação.'],401)
: redirect()->guest(route('login'));
abort_unless($actor->hasVerifiedEmail() && in_array(strtolower(trim($actor->email)),config('portal.homologation_reviewers',[]),true),403,
'Esta homologação está reservada às contas indicadas pela Tempo.');
$response=$next($request);$response->headers->set('Cache-Control','private, no-store');return $response;
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureScopedEditorial
{
public function handle(Request $request, Closure $next): Response
{
$actor = $request->user();
abort_unless($actor, 403);
$access = app(\App\Services\DocumentManagementAccess::class);
if ($access->full($actor)) {
return $next($request);
}
abort_unless($access->editorial($actor), 403);
$allowed = in_array($request->route()->getName(), ['editorial.edit', 'editorial.save', 'services.resource', 'services.resource.update'], true)
|| ($request->route()->getName() === 'services.moderate' && $request->route('type') === 'resources');
abort_unless($allowed, 403);
if ($request->route('id') !== null) {
$access->assertEditableResource($actor, (int) $request->route('id'));
}
return $next($request);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Services\ScopedPortalAdministration;
final class EnsureScopedPortalAdministration
{
public function handle(Request $request, Closure $next): Response
{
$actor=$request->user();abort_unless($actor,403);
$scope=app(ScopedPortalAdministration::class);
$path=$request->path();
$interests=$request->is('management/interests','management/interests/*','api/v1/investment/admin/interests/*');
// Exact areas of P1; unrelated management, fiscal rules and private records keep their gate.
$catalogue=$request->is('management/economic-references/lot-01','management/economic-references/lot-01/*',
'management/education','management/education/*','management/investment/references','management/investment/references/*','api/v1/investment/admin/references','api/v1/investment/admin/references/*',
'management/investment/zones','management/investment/zones/*','api/v2/investment/admin/zones','api/v2/investment/admin/zones/*',
'management/balcao/atribuicoes','management/balcao/atribuicoes/*','management/balcao/servicos',
'management/balcao/fontes') || ($request->isMethod('GET') && $path==='management/balcao-install');
$allowed=$interests ? $scope->interestOperator($actor) : ($catalogue && $scope->administrative($actor));
abort_unless($allowed || ($actor->can('manage-portal') && !app(\App\Services\PortalProfileAccess::class)->has($actor,'auditor')),403,'Acesso reservado ao âmbito já designado.');
$response=$next($request);
if (!$request->isMethod('GET') && $response->getStatusCode()<400) {
app(\App\Services\PortalProfileAccess::class)->audit($actor,'scoped_administration',[
'method'=>$request->method(),'route'=>$request->route()?->uri(),'scope'=>$interests?'assigned-interest':'catalogue-administration']);
}
return $response;
}
}
@@ -51,8 +51,10 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
'homologationReserved' => (bool) config('portal.homologation_only', false),
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
'portalOperator' => config('portal.operator_name'),
'portalInstitutionalContactEmail' => filter_var(config('portal.institutional_contact_email'), FILTER_VALIDATE_EMAIL) ?: null,
'legacyContentEnabled' => app(LegacyContent::class)->available(),
'educationEnabled' => (bool) config('education.enabled', false),
'aiEnabled' => (bool) config('investmoz_ai.enabled', false),
@@ -67,6 +69,15 @@ class HandleInertiaRequests extends Middleware
'installationHref' => '/management/opportunity-management-install',
] : null,
'flash' => ['success' => fn () => $request->session()->get('success'), 'warning' => fn () => $request->session()->get('warning')],
'scopedAdministration' => $request->user() && app(\App\Services\ScopedPortalAdministration::class)->administrative($request->user()) ? [
['label'=>'Zonas e parques','href'=>'/management/investment/zones'],
['label'=>'Referências económicas','href'=>'/management/economic-references/lot-01'],
['label'=>'Referências e formulários','href'=>'/management/investment/references'],
['label'=>'Educação e competências','href'=>'/management/education'],
['label'=>'Interesses atribuídos','href'=>'/management/interests'],
['label'=>'Atribuições de interesses','href'=>'/management/interests/atribuicoes'],
['label'=>'Atribuições do Balcão','href'=>'/management/balcao/atribuicoes'],
] : [],
'auth' => [
'user' => $request->user(),
'canManagePortal' => $request->user()?->can('manage-portal') ?? false,
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Middleware;
use App\Models\InvestmentSubmission;
use Closure;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/** Tempo 19/10/2026: exception for simple participation only; formal operations retain verification. */
class VerifiedOrSimpleParticipation
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user()->hasVerifiedEmail()) return $next($request);
if ($request->isMethod('GET') && $request->is('investidor/manifestacoes', 'api/v1/investment/submissions')) return $next($request);
$id = $request->route('id');
$show = $request->isMethod('GET') && $request->is('investidor/manifestacoes/'.$id, 'api/v1/investment/submissions/'.$id);
$message = $request->isMethod('POST') && $request->is('investidor/manifestacoes/'.$id.'/messages', 'api/v1/investment/submissions/'.$id.'/messages');
if ($id && ($show || $message)) {
$row = InvestmentSubmission::whereKey($id)->where('user_id', $request->user()->id)->firstOrFail();
if ($row->status === 'submitted' && ($row->payload['simple_participation'] ?? false) === true) return $next($request);
}
return app(EnsureEmailIsVerified::class)->handle($request, $next);
}
}
@@ -26,7 +26,7 @@ class StoreInterestRequestRequest extends FormRequest
return [
'fullName' => ['required', 'string', 'min:3', 'max:150'],
'email' => ['required', 'email:rfc,dns', 'max:150'],
'email' => ['required', 'email:rfc', 'max:150'],
'phone' => ['nullable', 'string', 'max:30'],
'investorType' => ['required', 'string', Rule::in([
'individual', 'empresa', 'fundo', 'entidade_publica', 'outro',
+7 -6
View File
@@ -91,7 +91,7 @@ class ExistingBusinessPolicy
public function scopeReviewQueue(Builder $query, User $user): void
{
if (app(PortalProfileAccess::class)->available()) {
$query->where('modality', 'company_sale');
$query->whereIn('modality',array_keys(\App\Services\ExistingBusinessProfileCategory::MAP));
$ids = (clone $query)->get()->filter(fn (ExistingBusiness $business) => $this->view($user, $business))->modelKeys();
$query->whereKey($ids);
}
@@ -156,9 +156,8 @@ class ExistingBusinessPolicy
{
$access = app(PortalProfileAccess::class);
if ($access->available()) {
// Only the existing company-sale consumer is reconciled in this increment.
// Do not translate other modalities or invent an organization from free text.
if (($business !== null && $business->modality !== 'company_sale') || ! $access->mfa($user)) {
$category=$business ? \App\Services\ExistingBusinessProfileCategory::category($business) : null;
if (($business !== null && $category === null) || ! $access->mfa($user) || $access->has($user,'auditor')) {
return false;
}
$role = $designation === 'publishers' ? 'publisher' : 'validator';
@@ -166,8 +165,10 @@ class ExistingBusinessPolicy
return false;
}
foreach ($access->grants($user, $role) as $grant) {
if ($grant->organization_id === null
&& in_array('business_sale', json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR), true)) {
$organizationMatches = $grant->organization_id === null
|| ($business !== null && $grant->organization_id === $business->organization_id);
if ($organizationMatches
&& ($category !== null ? in_array($category,json_decode($grant->opportunity_types,true,512,JSON_THROW_ON_ERROR),true) : array_intersect(array_values(\App\Services\ExistingBusinessProfileCategory::MAP),json_decode($grant->opportunity_types,true,512,JSON_THROW_ON_ERROR)) !== [])) {
return true;
}
}
+11 -4
View File
@@ -100,8 +100,11 @@ final class BusinessFormSchema
$rules['payload.amount_sought'][] = 'regex:/^\d{1,15}(\.\d{1,4})?$/';
$rules['payload.source_date'][] = 'date_format:Y-m-d';
$rules['payload.official_source_url'][] = 'url';
$rules['payload.equity_percentage'][] = 'numeric';
$rules['payload.equity_percentage'][] = 'between:0,100';
// The field limit is a text length, not an 8% ceiling.
$rules['payload.equity_percentage'] = ['nullable', 'numeric', 'between:0,100',
function ($attribute, $value, $fail) {
if (strlen((string) $value) > 8) $fail('Indique a percentagem com até 8 caracteres.');
}];
foreach (['valid_until', 'mandate_valid_until'] as $date) {
$rules['payload.'.$date][] = 'date_format:Y-m-d';
}
@@ -116,8 +119,12 @@ final class BusinessFormSchema
/** @param array<string,mixed> $payload */
public static function validateSubmission(array $payload, ?int $sectorId, string $modality): void
{
if (($payload['opportunity_type'] ?? null) !== $modality) {
throw ValidationException::withMessages(['opportunity_type' => 'O tipo da oportunidade não corresponde à classificação guardada.']);
$expectedType = ExistingBusinessProfileCategory::formType($modality);
if ($expectedType === null) {
throw ValidationException::withMessages(['opportunity_type' => 'Esta modalidade pode ser guardada em preparação. O formulário de submissão correspondente ainda não está definido; a modalidade não será alterada automaticamente.']);
}
if (($payload['opportunity_type'] ?? null) !== $expectedType) {
throw ValidationException::withMessages(['opportunity_type' => 'O tipo de oportunidade não corresponde à modalidade guardada. Recupere o formulário; a modalidade original foi preservada.']);
}
$rules = [];
+6 -4
View File
@@ -12,17 +12,19 @@ final class DeclaredOriginStatistics
/** @return array<string, list<array{code: string|null, name: string, count: int}>> */
public function forManager(User $actor, ?string $origin = null): array
{
abort_unless($actor->can('manage-portal'), 403);
abort_unless(app(ScopedPortalAdministration::class)->interestOperator($actor), 403);
$names = array_column(app(CountryCatalogue::class)->all(), 'name', 'code');
$groups = [];
foreach (DB::table('interest_requests')->whereNull('deleted_at')->select('metadata')->cursor() as $row) {
$interests=DB::table('interest_requests')->whereNull('deleted_at');
app(ScopedPortalAdministration::class)->interestQuery($interests,$actor);
foreach ($interests->select('metadata')->cursor() as $row) {
$metadata = json_decode($row->metadata ?? '{}', true);
$code = $metadata['declared_origin_country_code'] ?? null;
$key = is_string($code) && isset($names[$code]) ? $code : '';
$groups[$key] = ($groups[$key] ?? 0) + 1;
}
$desk = [];
if (Schema::hasTable('desk_requests')) {
if ($actor->can('manage-portal') && Schema::hasTable('desk_requests')) {
foreach (DB::table('desk_requests')->whereNotIn('status', ['draft', 'cancelled'])->select('payload')->cursor() as $row) {
$code = app(VirtualDesk::class)->decode($row->payload)['declared_origin_country_code'] ?? null;
$key = is_string($code) && isset($names[$code]) ? $code : '';
@@ -30,7 +32,7 @@ final class DeclaredOriginStatistics
}
}
$businesses = [];
if (Schema::hasTable('existing_businesses')) {
if ($actor->can('manage-portal') && Schema::hasTable('existing_businesses')) {
foreach (app(BusinessRepository::class)->query()->whereNotIn('status', ['draft', 'cancelled'])->cursor() as $business) {
$code = $business->payload['declared_origin_country_code'] ?? null;
$key = is_string($code) && isset($names[$code]) ? $code : '';
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
class DocumentManagementAccess
{
public function full(User $actor): bool
{
return Gate::forUser($actor)->allows('manage-portal');
}
public function limited(User $actor): bool
{
// Explicit Tempo authorisation; registration itself never grants access.
return $actor->hasVerifiedEmail()
&& in_array(strtolower(trim($actor->email)), config('portal.document_managers', []), true)
&& app(PortalProfileAccess::class)->mfa($actor);
}
/** Restore the approved editorial operation without a global management grant. */
public function editorial(User $actor): bool
{
return config('portal.homologation_only', false)
&& in_array(strtolower(trim($actor->email)), config('homologation_editorial.accounts', []), true)
&& app(PortalProfileAccess::class)->superuser($actor)
&& ! app(PortalProfileAccess::class)->has($actor, 'auditor')
&& app(PortalProfileAccess::class)->mfa($actor);
}
public function editorialResources(User $actor): Builder
{
abort_unless($this->editorial($actor), 403);
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->whereNull('d.deleted_at')->where('d.status', '!=', 'deleted')
->where(function ($query) use ($actor) {
$query->where(function ($own) use ($actor) {
$own->where('r.created_by', $actor->id)->where('d.uploaded_by', $actor->id)
->where('d.source_system', 'portal-services');
});
foreach (config('homologation_editorial.resources', []) as $item) {
$query->orWhere(function ($listed) use ($item) {
$listed->where('r.id', $item['id'])->where('d.sha256', $item['sha256'])
->where('d.external_id', $item['external_id']);
});
}
});
}
public function assertEditableResource(User $actor, int $id): void
{
abort_unless($this->full($actor) || $this->editorialResources($actor)->where('r.id', $id)->exists(), 403);
}
public function documents(User $actor): Builder
{
$query = DB::table('documents')->whereNull('deleted_at');
if (! $this->full($actor)) {
$editorial = $this->editorial($actor);
abort_unless($this->limited($actor) || $editorial, 403);
$query->where(function ($scope) use ($actor, $editorial) {
$scope->where(fn ($own) => $own->where('uploaded_by', $actor->id)->where('source_system', 'portal-services'));
if ($editorial) {
$scope->orWhereIn('id', $this->editorialResources($actor)->select('d.id'));
}
});
}
return $query;
}
public function audit(User $actor, string $action, array $data): void
{
DB::table('audit_events')->insert([
'source_system' => 'document-access-v1', 'entity_type' => 'authorization',
'actor_user_id' => $actor->id, 'action' => $action,
'data_classification' => 'restricted', 'correlation_id' => (string) Str::uuid(),
'after_data' => json_encode($data, JSON_THROW_ON_ERROR),
]);
}
}
@@ -47,7 +47,7 @@ class EconomicReferenceInstallation
private function authorize(User $actor, bool $write = false, string $operation = ''): void
{
abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'), 403, 'Acesso reservado à gestão autorizada.');
abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor), 403, 'Acesso reservado à gestão autorizada.');
if (! $write) { return; }
$access = app(PortalProfileAccess::class);
abort_if($access->has($actor, 'auditor'), 403, 'A restrição de Auditor permite apenas consulta.');
+9 -1
View File
@@ -37,7 +37,7 @@ final class ExistingBusinessIntake
return DB::transaction(function () use ($actor, $id, $version, $action, $note, $consent, $confirmationCode) {
$business = app(BusinessRepository::class)->find($id, true);
$profileWorkflow = $business->modality === 'company_sale' && app(PortalProfileAccess::class)->available();
$profileWorkflow = ExistingBusinessProfileCategory::category($business) !== null && app(PortalProfileAccess::class)->available();
$ability = match ($action) {
OpportunityStatus::Submitted->value, OpportunityStatus::Withdrawn->value => 'owns',
OpportunityStatus::Triage->value => 'triage',
@@ -65,6 +65,14 @@ final class ExistingBusinessIntake
OpportunityStatus::Triage->value, OpportunityStatus::InReview->value, OpportunityStatus::NeedsInformation->value],
];
abort_unless(in_array($business->status, $allowed[$action], true), 409, 'Transição indisponível neste estado.');
// Reuse the bounded delivery contract: profile correspondence is access, not functional enablement.
$category = ExistingBusinessProfileCategory::category($business);
if ($category !== null && in_array($action, ['submitted', 'published'], true)) {
$blockers = app(OpportunityCanonicalContract::class)->deliveryBlockers(['opportunity_category' => $category]);
if ($blockers) {
throw ValidationException::withMessages(['modality' => implode(' ', array_values($blockers))]);
}
}
if ($action === 'submitted') {
if (($business->payload['wizard_version'] ?? null) === '2') {
BusinessFormSchema::validateSubmission($business->payload, $business->sector_id, $business->modality);
@@ -0,0 +1,45 @@
<?php
namespace App\Services;
use App\Models\ExistingBusiness;
/** Access correspondence, never evidence of legality, title, finance or publication. */
final class ExistingBusinessProfileCategory
{
// Exact catalogue correspondences plus the explicit mappings already present
// in BusinessWizard and the transaction variants in OpportunityCategoryFields.
public const MAP = [
'company_sale'=>'business_sale', 'participation_sale'=>'business_sale',
'buyer_search'=>'business_sale', 'succession'=>'business_sale',
'franchise'=>'franchise', 'real_estate'=>'real_estate', 'land_right'=>'land_rights',
'investment_project'=>'investment_project', 'expansion'=>'investment_project',
'internationalization'=>'investment_project', 'agricultural_investment'=>'investment_project',
'business_partnership'=>'business_partnership', 'strategic_partner'=>'business_partnership',
'joint_venture'=>'business_partnership', 'capital_increase'=>'financing',
'financial_investor'=>'financing', 'financing_request'=>'financing',
'concession_opportunity'=>'concession', 'supply_opportunity'=>'supply',
];
// Native form types are separate from the persisted modality. No default to a project.
public const FORM_TYPES = [
'investment_project'=>'investment_project', 'company_sale'=>'company_sale',
'asset_sale'=>'asset_sale', 'business_partnership'=>'business_partnership',
'financing_request'=>'financing_request', 'concession_opportunity'=>'concession_opportunity',
'supply_opportunity'=>'supply_opportunity', 'participation_sale'=>'company_sale',
'asset_lease'=>'asset_sale', 'capital_increase'=>'financing_request',
'financial_investor'=>'financing_request', 'strategic_partner'=>'business_partnership',
'joint_venture'=>'business_partnership', 'expansion'=>'investment_project',
'agricultural_investment'=>'investment_project', 'internationalization'=>'investment_project',
'buyer_search'=>'company_sale', 'succession'=>'company_sale',
];
public static function category(ExistingBusiness $business): ?string
{
return self::MAP[$business->modality] ?? null;
}
public static function formType(string $modality): ?string
{
return self::FORM_TYPES[$modality] ?? null;
}
}
+1
View File
@@ -41,6 +41,7 @@ final class InterestWorkflow
return DB::transaction(function () use ($actor, $interest, $status, $note, $nextAction, $dueAt, $assigneeId, $assignmentProvided, $actionProvided) {
$locked = InterestRequest::query()->lockForUpdate()->findOrFail($interest->id);
abort_unless(app(ScopedPortalAdministration::class)->interest($actor,$locked),403);
$from = $locked->status;
$actionDate = $dueAt ? Carbon::parse($dueAt)->toDateString() : null;
$followUpChanged = ($actionProvided && ($locked->next_action !== $nextAction
@@ -26,7 +26,7 @@ class InvestmentZoneOperationalDetailsInstallation
}
private function authorize(User $actor,bool $write=false): void
{
abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'),403,'Gestão autorizada necessária.');
abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor),403,'Gestão autorizada necessária.');
if($write){app(PortalProfileInstallation::class)->authorize($actor,true);}
}
private function evidence(array $target,string $code): array
@@ -31,7 +31,7 @@ class InvestmentZoneProfileInstallation
}
private function authorize(User $actor,bool $write=false): void
{
abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'),403,'Gestão autorizada necessária.');
abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor),403,'Gestão autorizada necessária.');
if($write){app(PortalProfileInstallation::class)->authorize($actor,true);}
}
private function evidence(array $target,string $code): array
+20 -4
View File
@@ -50,13 +50,24 @@ final class InvestorContext
$href = '';
$title = '';
$version = null;
$documents = [];
$visibility = 'public';
$state = null;
if ($kind === 'opportunity') {
abort_unless(ctype_digit($id), 422, 'Identificador de oportunidade inválido.');
$o = Opportunity::published()->with('sector')->findOrFail((int) $id);
$title = $o->title;
$href = '/oportunidades/'.$o->slug;
$o = Opportunity::with('sector')->findOrFail((int) $id);
$published = Opportunity::published()->whereKey($o->id)->exists();
if (!$published) {
abort_unless(request()->user() && $o->status !== 'archived' && app(PortalProfileAccess::class)->canView(request()->user(), $o), 404, 'Ficha privada indisponível neste âmbito.');
$visibility = 'private';
$state = app(PortalProfileAccess::class)->state($o);
}
if (request()->user()) $documents = app(OpportunityLibrarySupport::class)->snapshot(request()->user(), $o);
$draft = !$published ? app(OpportunityManagement::class)->values($o) : [];
$title = $draft['canonical']['official_title'] ?? $o->title;
$href = $published ? '/oportunidades/'.$o->slug : '/minhas-oportunidades/'.$o->id.'/view';
$version = $o->management_version;
$fields = ['project_title' => $o->title, 'project_summary' => $o->summary];
$fields = ['project_title' => $title, 'project_summary' => $draft['canonical']['executive_summary'] ?? $o->summary];
if ($o->sector) {
$fields['sector_id'] = (string) $o->sector_id;
$fields['sector'] = $o->sector->name;
@@ -123,6 +134,7 @@ final class InvestorContext
}
$fields = array_filter($fields, fn ($v) => is_scalar($v) && trim((string) $v) !== '');
$record = ['key' => $key, 'kind' => $kind, 'id' => $id, 'title' => $title, 'version' => $version, 'href' => $href, 'fields' => $fields];
if ($documents || $visibility === 'private') $record += ['documents' => $documents, 'visibility' => $visibility, 'state' => $state];
return [...$record, 'sha256' => hash('sha256', json_encode($record, JSON_THROW_ON_ERROR))];
}
@@ -156,6 +168,10 @@ final class InvestorContext
foreach (Opportunity::published()->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'))->orderBy('title')->limit(51)->get(['id', 'title']) as $o) {
$items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title, 'kind' => 'opportunity'];
}
if (request()->user()) {
$private = app(PortalProfileAccess::class)->scope(Opportunity::query(), request()->user())->where('status', '!=', 'published')->where('status', '!=', 'archived')->when($query, fn ($q) => $q->where('title', 'ilike', '%'.$query.'%'))->orderBy('title')->limit(51)->get();
foreach ($private as $o) if (app(PortalProfileAccess::class)->canView(request()->user(), $o)) $items[] = ['key' => 'opportunity:'.$o->id, 'title' => $o->title.' — preparação privada', 'kind' => 'opportunity'];
}
if (config('existing-business.enabled')) {
foreach (ExistingBusiness::published()->when($query, fn ($q) => $q->where('public_title', 'ilike', '%'.$query.'%'))->orderBy('public_title')->limit(51)->get(['id', 'public_title']) as $o) {
$items[] = ['key' => 'business:'.$o->id, 'title' => $o->public_title, 'kind' => 'business'];
+37 -5
View File
@@ -40,7 +40,7 @@ final class LegacyContent
abort_unless($this->available(), 404);
$identity = config('legacy_content.articles.'.$slug);
abort_unless(is_array($identity) && ($identity['publishable'] ?? false), 404);
$row = $this->publicResources()->where('d.external_id', $identity['external_id'])->first();
$row = $this->identityResources()->get($identity['external_id']);
abort_unless($row !== null, 404);
$binary = app(VerifiedDocumentBinary::class)->respond($row);
$bytes = $binary instanceof BinaryFileResponse ? file_get_contents($binary->getFile()->getPathname()) : $binary->getContent();
@@ -106,7 +106,7 @@ final class LegacyContent
public function articleForResource(int $resourceId): array
{
abort_unless($this->available(), 404);
$externalId = $this->publicResources()->where('r.id', $resourceId)->first()?->external_id;
$externalId = $this->identityResources()->firstWhere('resource_id', $resourceId)?->legacy_identity;
foreach (config('legacy_content.articles', []) as $slug => $identity) {
if ($identity['external_id'] === $externalId && $identity['publishable']) {
return $this->article($slug);
@@ -132,7 +132,7 @@ final class LegacyContent
private function related(string $group, ?string $except = null): array
{
$identities = array_filter(config('legacy_content.articles', []), fn ($identity, $slug) => $identity['group'] === $group && $identity['publishable'] && $slug !== $except, ARRAY_FILTER_USE_BOTH);
$resources = $this->publicResources()->whereIn('d.external_id', array_column($identities, 'external_id'))->get()->keyBy('external_id');
$resources = $this->identityResources();
$result = [];
foreach ($identities as $slug => $identity) {
$row = $resources->get($identity['external_id']);
@@ -146,8 +146,7 @@ final class LegacyContent
private function localLinks(string $html): string
{
$public = $this->publicResources()->whereIn('d.external_id', array_column(config('legacy_content.articles', []), 'external_id'))
->get()->keyBy('external_id');
$public = $this->identityResources();
$links = [];
foreach (config('legacy_content.articles', []) as $slug => $identity) {
if ($identity['publishable'] && $public->get($identity['external_id'])?->source_url === $identity['source_url']) {
@@ -208,6 +207,39 @@ final class LegacyContent
});
}
/** Resolve explicit transport aliases without changing original IDs, versions or relations. */
private function identityResources(): \Illuminate\Support\Collection
{
$identities = array_column(config('legacy_content.articles', []), 'external_id');
$aliases = config('legacy_content_aliases', []);
$rows = $this->publicResources()->where(function ($query) use ($identities, $aliases) {
$query->whereIn('d.external_id', $identities);
if ($aliases !== []) {
$query->orWhereIn('r.id', array_column($aliases, 'resource_id'));
}
})->get();
$resolved = collect();
foreach ($rows as $row) {
$identity = in_array($row->external_id, $identities, true) ? $row->external_id : null;
foreach ($aliases as $original => $alias) {
if ((int) $row->resource_id === (int) $alias['resource_id']
&& $row->external_id === $alias['external_id']
&& hash_equals($alias['sha256'], trim($row->sha256))
&& $row->source_url === $alias['source_url']) {
abort_if($identity !== null && $identity !== $original, 409, 'Correspondência de artigo em reconciliação.');
$identity = $original;
}
}
if ($identity === null) {
continue;
}
abort_if($resolved->has($identity), 409, 'Correspondência de artigo em reconciliação.');
$row->legacy_identity = $identity;
$resolved->put($identity, $row);
}
return $resolved;
}
private function publicResources(): Builder
{
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
+14 -5
View File
@@ -169,14 +169,23 @@ final class ClamDScanner implements MalwareScanner
throw new RuntimeException('No production scanner is configured.');
}
$host = (string) config('services.malware.host', '127.0.0.1');
$port = (int) config('services.malware.port', 3310);
if ($host === '' || in_array($host, ['0.0.0.0', '::'], true) || $port < 1 || $port > 65535) {
throw new RuntimeException('The ClamD endpoint is invalid.');
$unix = config('services.malware.socket');
if ($unix !== null && $unix !== '') {
if (! is_string($unix) || ! str_starts_with($unix, '/') || str_contains($unix, "\0") || strlen($unix) > 107) {
throw new RuntimeException('The ClamD Unix socket is invalid.');
}
$endpoint = 'unix://'.$unix;
} else {
$host = (string) config('services.malware.host', '127.0.0.1');
$port = (int) config('services.malware.port', 3310);
if ($host === '' || in_array($host, ['0.0.0.0', '::'], true) || $port < 1 || $port > 65535) {
throw new RuntimeException('The ClamD endpoint is invalid.');
}
$endpoint = "tcp://{$host}:{$port}";
}
$timeout = max(1, (int) config('services.malware.timeout_seconds', 120));
$socket = @stream_socket_client("tcp://{$host}:{$port}", $errorCode, $errorMessage, min(10, $timeout));
$socket = @stream_socket_client($endpoint, $errorCode, $errorMessage, min(10, $timeout));
if (! is_resource($socket)) {
throw new RuntimeException('ClamD is unavailable.');
}
+24 -9
View File
@@ -45,7 +45,7 @@ class OpportunityCanonicalContract
public const VERSION = 'opportunity-canonical-adapter-20261010-semantic1';
/** @return array<string,mixed> */
public function schema(bool $draft = false): array
public function schema(bool $draft = false, array $payload = []): array
{
$bytes = file_get_contents(resource_path('data/investmoz-opportunity-modular.schema.json'));
if ($bytes === false) {
@@ -59,11 +59,14 @@ class OpportunityCanonicalContract
$schema = app(OpportunityCategoryFields::class)->enrich($schema);
$schema = app(OpportunityModularProfiles::class)->extend($schema);
$schema = app(OpportunityClassification::class)->extend($schema);
$schema = app(OpportunityInitiativeApplicability::class)->schema($schema, $payload);
$schema = $this->writeSchema($schema, $draft);
$properties = &$schema['$defs']['opportunityCreate']['properties'];
unset($properties['mutation_context']); // Actor, audit reason and revision are server-owned.
$properties['sector_id'] = ['type' => 'integer', 'minimum' => 1];
$properties['promoter_id'] = ['type' => ['string', 'integer']];
if (! app(OpportunityInitiativeApplicability::class)->enabled($payload)) {
$properties['promoter_id'] = ['type' => ['string', 'integer']];
}
// References point to the existing collections; their metadata is recovered on the server.
foreach (['locations', 'documents', 'images'] as $field) {
$properties[$field]['items'] = ['type' => 'string', 'format' => 'uuid'];
@@ -144,7 +147,7 @@ class OpportunityCanonicalContract
/** @param array<string,mixed> $payload */
private function validateSchema(array $payload, bool $draft): void
{
$schema = $this->schema($draft);
$schema = $this->schema($draft, $payload);
$validator = new CompliantValidator(null, 100, false);
$data = $this->schemaData($payload, $schema['$defs']['opportunityCreate'], $schema['$defs']);
$result = $validator->validate($data, json_decode(json_encode($schema, JSON_THROW_ON_ERROR)));
@@ -168,6 +171,11 @@ class OpportunityCanonicalContract
}
$prefix = rtrim('canonical.'.implode('.', $node->data()->fullPath()), '.');
$args = $node->args();
if ($node->keyword() === 'type' && in_array($prefix, ['canonical.initiative.responsible_name', 'canonical.initiative.coverage_statement'], true)) {
$errors[$prefix][] = 'Informação em falta: preencha este campo antes de submeter.';
return;
}
if ($node->keyword() === 'required') {
foreach ($args['missing'] ?? [] as $field) {
$errors[$prefix.'.'.$field][] = 'Preencha este campo antes de submeter.';
@@ -241,15 +249,21 @@ class OpportunityCanonicalContract
{
$values = app(OpportunityManagement::class)->values($record);
$payload = $values['canonical'] ?? [];
$this->validateSchema($payload, false);
$errors = [];
$initiative = app(OpportunityInitiativeApplicability::class);
$initiative->validate($payload, $errors);
try {
$this->validateSchema($payload, false);
} catch (ValidationException $failure) {
throw ValidationException::withMessages([...$failure->errors(), ...$errors]);
}
$check = function (bool $valid, string $path, string $message) use (&$errors): void {
if (! $valid) {
$errors['canonical.'.$path] = $message;
}
};
$check(($payload['opportunity_category'] ?? null) === $record->opportunity_category, 'opportunity_category', 'A categoria deve coincidir com o registo e a autorização.');
$check(($payload['promoter_id'] ?? null) === ($record->organization_id ?? (int) $record->owner_user_id), 'promoter_id', 'Identifique o promotor real da oportunidade; o operador do portal não o substitui.');
$check($initiative->enabled($payload) || ($payload['promoter_id'] ?? null) === ($record->organization_id ?? (int) $record->owner_user_id), 'promoter_id', 'Identifique o promotor real da oportunidade; o operador do portal não o substitui.');
$previous = app(OpportunityManagement::class)->values($record)['canonical'] ?? [];
$sameTaxonomy = ($previous['sector_id'] ?? null) === ($payload['sector_id'] ?? null)
&& ($previous['subsector_id'] ?? null) === ($payload['subsector_id'] ?? null)
@@ -284,6 +298,7 @@ class OpportunityCanonicalContract
&& hash_equals(trim($file->sha256), trim((string) $file->malware_scanned_sha256)), "$kind.$index", 'Original ativo com análise antimalware correspondente obrigatória.');
if ($file) {
// Verify actual bytes/cipher integrity as well as the recorded scan hash.
app(\App\Services\OpportunityLibrarySupport::class)->assertAvailable($row, $file);
app(VerifiedDocumentBinary::class)->respond($file);
$row->sha256 = trim($file->sha256);
}
@@ -296,7 +311,7 @@ class OpportunityCanonicalContract
$check(! $hasArea || $hasUnit, "locations.$index.area_unit", 'Indique a unidade da área antes de submeter; a preparação incompleta continua guardada nos recursos.');
$check(! $hasUnit || $hasArea, "locations.$index.area_value", 'Indique o valor da área antes de submeter; a unidade não determina o valor.');
$check(! $hasUnit || in_array($location['area_unit'], OpportunityResources::AREA_UNITS, true), "locations.$index.area_unit", 'Use uma unidade do catálogo existente: sqm, ha ou sqkm.');
$check(! empty($location['district_id']) && ! empty($location['role']) && array_key_exists('is_primary', $location), "locations.$index", 'Localização requer distrito, papel e indicação de principal.');
$check(! $initiative->applicable($payload, 'physical_site') || (! empty($location['district_id']) && ! empty($location['role']) && array_key_exists('is_primary', $location)), "locations.$index", 'Localização requer distrito, papel e indicação de principal.');
app(InvestmentGeography::class)->selection($location, true);
$country = $location['country_code'] ?? null;
$check($country === 'MZ', "locations.$index.country_code", 'A hierarquia administrativa moçambicana deve declarar MZ; outros países exigem localização própria.');
@@ -306,8 +321,8 @@ class OpportunityCanonicalContract
}
$locations = $resources['locations'] ?? [];
$primary = array_filter($locations, fn ($l) => $l['payload']['is_primary'] ?? false);
$check(count($primary) === 1, 'locations', 'Selecione exatamente uma localização principal.');
$check($payload['location_mode'] !== 'single' || count($locations) === 1, 'location_mode', 'Modo single exige uma localização.');
$check(! $initiative->applicable($payload, 'physical_site') || count($primary) === 1, 'locations', 'Selecione exatamente uma localização principal.');
$check(! $initiative->applicable($payload, 'physical_site') || ($payload['location_mode'] ?? null) !== 'single' || count($locations) === 1, 'location_mode', 'Modo single exige uma localização.');
foreach ($payload['evidences'] as $i => $evidence) {
$check(isset($resources['documents'][$evidence['document_id']]), "evidences.$i.document_id", 'Evidência deve usar um documento ativo desta oportunidade incluído no contrato.');
}
@@ -356,7 +371,7 @@ class OpportunityCanonicalContract
$errors['canonical.'.$module] = 'Módulo incompatível com a categoria. Preserve-o noutra versão antes de mudar de categoria.';
}
}
if ($category === 'investment_project') {
if ($category === 'investment_project' && app(OpportunityInitiativeApplicability::class)->applicable($p, 'unit_investment')) {
$total = $p['total_investment'];
$sum = array_sum(array_column($p['investment_breakdown'], 'amount'));
$secured = array_sum(array_column($p['secured_financing'], 'amount'));
+7 -2
View File
@@ -65,7 +65,12 @@ class OpportunityCategoryFields
// The extracted Create property was opaque, preventing completion through the UI.
$common['environmental_social'] = ['$ref' => '#/$defs/environmentalSocialCreate'];
$common['maturity_stage']['enum'] = [...array_keys(OpportunityMaturity::STAGES), null];
$common['financial_indicators'] = $this->object(['npv' => ['type' => 'number'], 'irr' => ['type' => 'number'], 'payback_years' => $n, 'scenario' => $s, 'horizon_years' => ['type' => 'integer', 'minimum' => 1], 'currency' => ['type' => 'string', 'pattern' => '^[A-Z]{3}$'], 'discount_rate' => $n, 'model_document_id' => $d], ['npv', 'irr', 'payback_years', 'scenario', 'horizon_years', 'currency', 'discount_rate', 'model_document_id']);
$common['financial_indicators'] = $this->object(['npv' => ['type' => 'number'], 'irr' => ['type' => 'number'], 'payback_years' => $n, 'scenario' => $s, 'horizon_years' => ['type' => 'integer', 'minimum' => 1], 'currency' => ['type' => 'string', 'pattern' => '^[A-Z]{3}$'], 'discount_rate' => $n, 'model_document_id' => $d]);
// OP-9f173ed3-0043: a historical scenario is not a completed feasibility model.
$defs['opportunityCreate']['allOf'][] = [
'if' => ['properties' => ['maturity_stage' => ['enum' => ['feasibility', 'structured', 'investment_ready', 'implementation', 'operational']]], 'required' => ['maturity_stage']],
'then' => ['required' => ['financial_indicators'], 'properties' => ['financial_indicators' => ['required' => ['npv', 'irr', 'payback_years', 'scenario', 'horizon_years', 'currency', 'discount_rate', 'model_document_id']]]],
];
$defs['milestoneCreate']['properties'] += ['start_date' => $date, 'end_date' => $date, 'responsible_name' => $s, 'dependencies' => $strings];
$defs['milestoneCreate']['required'] = array_values(array_unique([...$defs['milestoneCreate']['required'], 'start_date', 'end_date', 'responsible_name']));
$defs['riskCreate']['properties']['mitigation']['minLength'] = 1;
@@ -166,7 +171,7 @@ class OpportunityCategoryFields
foreach ($p['risks'] as $i => $risk) {
$fail(($risk['likelihood'] < 4 && $risk['severity'] < 4) || ! empty($risk['mitigation']), "risks.$i.mitigation", 'Risco com probabilidade ou gravidade alta exige mitigação.');
}
if (in_array($p['maturity_stage'], ['feasibility', 'structured', 'investment_ready', 'implementation', 'operational'], true)) {
if (app(OpportunityInitiativeApplicability::class)->applicable($p, 'unit_investment') && in_array($p['maturity_stage'], ['feasibility', 'structured', 'investment_ready', 'implementation', 'operational'], true)) {
$fail(! empty($p['financial_indicators']), 'financial_indicators', 'A partir de viabilidade, indique cenário, indicadores e modelo financeiro.');
$fail(count(array_filter($p['evidences'], fn ($e) => in_array($e['evidence_type'], ['feasibility_study', 'feasibility'], true))) > 0, 'evidences', 'Associe o estudo de viabilidade desta etapa.');
}
+1 -1
View File
@@ -11,7 +11,7 @@ class OpportunityCompleteness
* @return array<string,mixed> */
public function calculate(array $payload): array
{
$schema = app(OpportunityCanonicalContract::class)->schema();
$schema = app(OpportunityCanonicalContract::class)->schema(false, $payload);
$validator = new Validator;
$paths = [];
$walk = function (mixed $value, array $node, string $path) use (&$walk, &$paths, $schema, $validator): void {
@@ -0,0 +1,94 @@
<?php
namespace App\Services;
/** Tempo decision: programme/portfolio applicability; persisted in existing canonical JSON. */
class OpportunityInitiativeApplicability
{
public const GROUPS = [
'physical_site' => ['location_mode', 'licenses', 'environmental_social'],
'individual_business' => ['promoter_id', 'promoter_type', 'opportunity_type', 'investor_profiles', 'products', 'assets', 'inputs', 'markets', 'competitive_advantage', 'employment', 'impact_indicators', 'enterprise_size'],
'unit_investment' => ['total_investment', 'currency', 'investment_breakdown', 'secured_financing', 'funding_sought', 'financial_indicators'],
];
public function enabled(array $p): bool
{
return ($p['opportunity_category'] ?? null) === 'investment_project'
&& in_array($p['initiative']['kind'] ?? null, ['program', 'portfolio'], true);
}
public function applicable(array $p, string $group): bool
{
return ! $this->enabled($p) || ($p['initiative']['applicability'][$group] ?? 'missing') !== 'not_applicable';
}
public function schema(array $schema, array $payload): array
{
$base = &$schema['$defs']['opportunityCreate'];
$text = ['type' => ['string', 'null'], 'maxLength' => 4000];
$base['properties']['initiative'] = ['type' => 'object', 'additionalProperties' => false,
'required' => ['kind', 'responsible_name', 'coverage_statement', 'applicability'],
'properties' => [
'kind' => ['enum' => ['program', 'portfolio']],
'responsible_name' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 500],
'coverage_statement' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 4000],
'aggregate_budget_statement' => $text,
'applicability' => ['type' => 'object', 'additionalProperties' => false,
'required' => array_keys(self::GROUPS),
'properties' => array_fill_keys(array_keys(self::GROUPS), ['enum' => ['applicable', 'not_applicable', 'missing']])],
]];
if (! $this->enabled($payload)) {
// A profile cannot relax requirements of another category.
if (isset($payload['initiative'])) {
$base['allOf'][] = ['properties' => ['opportunity_category' => ['const' => 'investment_project']]];
}
return $schema;
}
$ignored = [];
foreach (self::GROUPS as $group => $fields) {
if (! $this->applicable($payload, $group)) {
$ignored = [...$ignored, ...$fields];
}
}
// Program responsibility is distinct from component promoter identity, in all cases.
$ignored = array_unique([...$ignored, 'promoter_id', 'promoter_type']);
$base['required'] = array_values(array_diff($base['required'], $ignored));
$base['required'][] = 'initiative';
foreach ($ignored as $field) {
$base['properties'][$field] = ['description' => 'Not applicable; retained without alteration']; // Retain old bytes; do not validate an inapplicable placeholder.
}
foreach ($base['allOf'] as &$condition) {
if (isset($condition['then']['required'])) {
$condition['then']['required'] = array_values(array_diff($condition['then']['required'], $ignored));
}
if (isset($condition['then']['properties'])) {
$condition['then']['properties'] = array_diff_key($condition['then']['properties'], array_flip($ignored));
}
// Location counts apply to implantation, not to the coverage resources.
if (! $this->applicable($payload, 'physical_site') && isset($condition['if']['properties']['location_mode'])) {
$condition = ['description' => 'Coverage is distinct from physical implantation'];
}
}
unset($condition);
if (! $this->applicable($payload, 'physical_site')) {
unset($base['properties']['locations']['minItems']);
}
return $schema;
}
public function validate(array $p, array &$errors): void
{
if (! $this->enabled($p)) {
return;
}
if (! in_array($p['maturity_stage'] ?? null, array_keys(OpportunityMaturity::STAGES), true)) {
$errors['canonical.maturity_stage'] = 'Indique a etapa conhecida do programa/carteira; a classificação não confirma preparação ou viabilidade.';
}
foreach (self::GROUPS as $group => $fields) {
if (($p['initiative']['applicability'][$group] ?? 'missing') === 'missing') {
$errors['canonical.initiative.applicability.'.$group] = 'Informação em falta: indique se este grupo é aplicável ou não aplicável à ficha; não invente dados das componentes.';
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
/** An existing opportunity resource references the original document. No binary or permission is copied. */
class OpportunityLibrarySupport
{
public function choices(User $actor): array
{
return $this->sources($actor)->orderBy('r.id')->limit(100)->get(['r.id', 'r.title', 'r.version_label', 'r.metadata_version', 'r.published', 'd.sha256'])->all();
}
private function sources(User $actor)
{
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
->whereNull('d.deleted_at')->where('d.status', '!=', 'deleted')
->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
->whereIn('d.data_classification', ['internal', 'public'])
->when(! $actor->can('manage-portal'), function ($query) use ($actor) {
$query->where(function ($scope) use ($actor) {
$scope->where(fn ($public) => $public->where('r.published',true)->where('d.data_classification','public')->where('d.status','active')->where('d.malware_scan_status','clean')->whereColumn('d.sha256','d.malware_scanned_sha256'));
if ($actor->hasVerifiedEmail()) {
$scope->orWhere(fn ($own) => $own->where('r.created_by',$actor->id)->where('d.uploaded_by',$actor->id));
}
$editorial=app(DocumentManagementAccess::class);
if ($editorial->editorial($actor)) {
$scope->orWhereIn('r.id',$editorial->editorialResources($actor)->select('r.id'));
}
});
});
}
public function attach(User $actor, Opportunity $opportunity, array $input): \stdClass
{
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity);
$data = Validator::make($input, ['resource_id' => 'required|integer|min:1', 'creation_key' => 'required|uuid', 'etag' => 'required|string|size:64', 'reason' => 'required|string|min:20|max:1000', 'section' => 'nullable|string|max:255', 'supported_statement' => 'nullable|string|max:2000'])->validate();
return DB::transaction(function () use ($actor, $opportunity, $data) {
$opportunity = Opportunity::whereKey($opportunity->id)->lockForUpdate()->firstOrFail();
app(PortalProfileAccess::class)->authorize($actor, 'edit', $opportunity);
$source = $this->sources($actor)->where('r.id', $data['resource_id'])->lockForUpdate()->first(['r.*', 'd.id as original_id', 'd.sha256', 'd.version_no']);
abort_unless($source, 403, 'O acesso actual à biblioteca não permite reutilizar este original.');
$existing = DB::table('opportunity_documents')->where('opportunity_id', $opportunity->id)->whereNull('deleted_at')->where('document_id', $source->original_id)->first();
if ($existing) {
$payload = json_decode($existing->payload, true);
abort_unless(($payload['library_source']['resource_id'] ?? null) === $source->id, 409, 'Original já associado por outro percurso; recuperar o suporte existente.');
abort_unless(($payload['section'] ?? null) === ($data['section'] ?? null) && ($payload['supported_statement'] ?? null) === ($data['supported_statement'] ?? null), 409, 'Original já associado com outra referência; editar explicitamente o suporte existente.');
return $existing;
}
abort_unless(hash_equals(app(OpportunityManagement::class)->etag($opportunity), $data['etag']), 409, 'A ficha mudou; preserve a selecção e recupere a versão actual.');
abort_if(DB::table('opportunity_documents')->where('id', $data['creation_key'])->exists(), 409, 'Chave já utilizada noutro suporte.');
abort_if(DB::table('opportunity_documents')->where('opportunity_id', $opportunity->id)->whereNull('deleted_at')->count() >= 100, 422, 'Limite de documentos atingido.');
$payload = ['title' => $source->title, 'category' => $source->category, 'issuer' => null, 'edition' => $source->version_label,
'classification' => 'internal', 'visibility' => 'internal', 'source_reference' => $source->source_reference,
'section' => $data['section'] ?? null, 'supported_statement' => $data['supported_statement'] ?? null,
'library_source' => ['resource_id' => $source->id, 'document_id' => $source->original_id, 'metadata_version' => $source->metadata_version,
'document_version' => $source->version_no, 'edition' => $source->version_label, 'sha256' => trim($source->sha256), 'source_reference' => $source->source_reference]];
$now = now();
DB::table('opportunity_documents')->insert(['id' => $data['creation_key'], 'opportunity_id' => $opportunity->id, 'document_id' => $source->original_id,
'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'version' => 1, 'created_by' => $actor->id, 'created_at' => $now, 'updated_at' => $now]);
$after = DB::table('opportunity_documents')->where('id', $data['creation_key'])->first();
DB::table('audit_events')->insert(['source_system' => 'opportunity-resources', 'entity_type' => 'documents', 'actor_user_id' => $actor->id,
'action' => 'library_original_reused', 'data_classification' => 'restricted', 'before_data' => json_encode(['snapshot' => Crypt::encryptString('null')]),
'after_data' => json_encode(['opportunity_id' => $opportunity->id, 'resource_id' => $after->id, 'version' => 1,
'reason' => Crypt::encryptString($data['reason']), 'snapshot' => Crypt::encryptString(json_encode($after, JSON_THROW_ON_ERROR))])]);
$opportunity->management_version++;
$opportunity->save();
return $after;
});
}
public function assertAvailable(\stdClass $resource, \stdClass $document): void
{
$payload = is_string($resource->payload) ? json_decode($resource->payload, true) : $resource->payload;
$pin = $payload['library_source'] ?? null;
if (! $pin) return;
$source = DB::table('portal_resources')->where('id', $pin['resource_id'])->first();
abort_unless($source && $source->rights_status !== 'restricted' && $document->rights_status !== 'restricted'
&& in_array($document->data_classification, ['internal', 'public'], true)
&& $document->id === $pin['document_id'] && hash_equals($pin['sha256'], trim($document->sha256)), 403, 'O original está retirado, restrito ou já não corresponde à versão guardada.');
}
public function snapshot(User $actor, Opportunity $opportunity): array
{
$access = app(PortalProfileAccess::class);
if (! $access->canView($actor, $opportunity)) return [];
return collect(app(OpportunityResources::class)->listing($opportunity->id, 'documents'))->filter(fn ($r) => ! $r->deleted_at && isset($r->payload['library_source']))->map(function ($r) use ($opportunity) {
$pin = $r->payload['library_source'];
return [...$pin, 'title' => $r->payload['title'], 'support_id' => $r->id, 'support_version' => $r->version,
'section' => $r->payload['section'] ?? null, 'opportunity_id' => $opportunity->id,
'download_url' => '/minhas-oportunidades/'.$opportunity->id.'/resources/documents/'.$r->id.'/download?source_sha256='.$pin['sha256']];
})->values()->all();
}
}
+9
View File
@@ -62,6 +62,11 @@ class OpportunityResources
'payload' => 'present|array',
])->validate();
$payload = $this->validatePayload($kind, $data['payload'], $opportunityId);
if ($kind === 'documents' && $id && $file) {
$linked = DB::table($table)->where('id', $id)->where('opportunity_id', $opportunityId)->first();
abort_if($linked && isset(json_decode($linked->payload, true)['library_source']), 422,
'Este suporte reutiliza um original da biblioteca; seleccionar outra versão pela biblioteca.');
}
$stored = null;
$documentId = null;
if ($file) {
@@ -114,6 +119,10 @@ class OpportunityResources
abort_unless(Storage::disk('local')->put($stored, Crypt::encryptString($original)) === true, 500, 'Falha ao cifrar original.');
DB::table('documents')->insert(['id' => $documentId, 'source_system' => 'opportunity-resources', 'file_name' => basename($file->getClientOriginalName()), 'media_type' => $file->getMimeType(), 'storage_key' => $stored, 'storage_encryption' => 'laravel', 'sha256' => $hash, 'status' => 'quarantined', 'data_classification' => 'restricted', 'size_bytes' => $file->getSize(), 'uploaded_by' => $actor->id]);
}
if ($record && $kind === 'documents' && isset(json_decode($record->payload, true)['library_source'])) {
abort_if($file !== null, 422, 'Este suporte reutiliza um original da biblioteca; seleccionar outra versão pela biblioteca.');
$payload['library_source'] = json_decode($record->payload, true)['library_source'];
}
$values = ['payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'version' => $record ? $record->version + 1 : 1, 'updated_at' => now()];
if ($kind !== 'locations' && $documentId) {
$values['document_id'] = $documentId;
@@ -0,0 +1,28 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
/** Read-only presentation of existing references. Does not reconcile, confirm or save data. */
class OpportunityTerritorialPresentation
{
public function describe(Opportunity $record, array $locations): array
{
$path = array_values(array_filter(app(InvestmentGeography::class)->path($record->primary_territory_id, true), fn ($node) => in_array($node->location_type, ['province', 'district', 'admin_post', 'locality'], true)));
$mainIds = array_column($path, 'id', 'location_type');
$differences = [];
foreach ($locations as $location) {
if ($location->deleted_at || empty($location->payload['is_primary']) || !$path) continue;
$p = $location->payload;
$different = false;
foreach ($mainIds as $level => $id) if (!empty($p[$level.'_id']) && $p[$level.'_id'] !== $id) $different = true;
$foreign = !empty($p['country_code']) && $p['country_code'] !== 'MZ';
if ($foreign || $different) {
$differences[] = ['resource_id' => $location->id, 'title' => $p['title'] ?? 'Localização sem título',
'message' => 'A referência principal da ficha e esta localização documentada apontam para territórios diferentes. Confirme se a distinção é intencional; nenhum valor foi substituído.'];
}
}
return ['path' => $path, 'differences' => $differences];
}
}
+19 -5
View File
@@ -25,7 +25,18 @@ class PortalProfileInstallation
return hash('sha256', json_encode(array_map(fn ($path) => hash_file('sha256', $path), array_unique($files)), JSON_THROW_ON_ERROR));
}
public function authorize(User $actor, bool $write = false): void
public function designatedBootstrap(User $actor): bool
{
$emails = config('portal_profiles.bootstrap_designated_emails', []);
if (! config('portal.homologation_only', false) || count(array_unique($emails)) !== 2
|| ! in_array(strtolower(trim($actor->email)), $emails, true)) {
return false;
}
$target = config('portal_profiles.bootstrap_designated_database', '');
return $target !== '' && DB::selectOne('SELECT current_database() AS database')->database === $target;
}
public function authorize(User $actor, bool $write = false, bool $bootstrapOnly = false): void
{
abort_unless($actor->hasVerifiedEmail(), 403);
if (Schema::hasTable('portal_role_assignments')) {
@@ -33,7 +44,7 @@ class PortalProfileInstallation
->where('starts_at', '<=', now())->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->exists(), 403, 'A restrição do Auditor prevalece.');
}
$pair = Schema::hasTable('portal_superuser_pair') ? DB::table('portal_superuser_pair')->where('id', 1)->first() : null;
abort_unless($pair ? in_array($actor->id, [(int) $pair->first_user_id, (int) $pair->second_user_id], true) : $actor->can('manage-portal'), 403, 'Gestor de instalação ou titular da dupla necessário.');
abort_unless($pair ? in_array($actor->id, [(int) $pair->first_user_id, (int) $pair->second_user_id], true) : ($actor->can('manage-portal') || ($bootstrapOnly && $this->access->installed() && $this->designatedBootstrap($actor))), 403, 'Gestor de instalação ou titular designado necessário.');
if ($write) {
abort_unless($this->access->mfa($actor), 403, 'Conclua MFA nesta sessão; configuração de MFA isoladamente não autoriza a operação.');
}
@@ -42,7 +53,7 @@ class PortalProfileInstallation
/** @return array<string,mixed> */
public function preview(User $actor): array
{
$this->authorize($actor);
$this->authorize($actor, false, true);
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
$schema = [];
@@ -126,7 +137,7 @@ class PortalProfileInstallation
public function bootstrap(User $actor, array $input): array
{
$this->authorize($actor, true);
$this->authorize($actor, true, true);
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted', 'idempotency_key' => 'required|uuid',
'first_user_id' => 'required|integer|exists:users,id', 'second_user_id' => 'required|integer|different:first_user_id|exists:users,id', 'reason' => 'required|string|min:20|max:1000'])->validate();
abort_unless((int) $data['first_user_id'] === $actor->id, 403, 'O primeiro titular deve apresentar o seu próprio pedido; não é permitido inicializar terceiros.');
@@ -135,6 +146,9 @@ class PortalProfileInstallation
abort_unless(hash_equals($p['hash'], $data['hash']), 409, 'Plano alterado.');
abort_unless($p['installed'] && $p['consistent'] && ! in_array(false, $p['dependencies'], true) && ! $p['bootstrap']['initialized'], 409, 'Schema consistente instalado e dupla não inicializada necessários.');
$second = User::findOrFail($data['second_user_id']);
if ($this->designatedBootstrap($actor)) {
abort_unless($this->designatedBootstrap($second), 403, 'Use os dois titulares expressamente designados para este destino.');
}
foreach ([$actor, $second] as $user) app(PortalSuperusers::class)->validateBootstrapHolder($user);
abort_if(DB::table('portal_security_approvals')->where('kind', 'bootstrap')->whereNull('consumed_at')->where('expires_at', '>', now())->exists(), 409, 'Já existe pedido pendente. Recupere-o; não duplique a cerimónia.');
$id = (string) \Illuminate\Support\Str::uuid();
@@ -180,7 +194,7 @@ class PortalProfileInstallation
abort_unless(hash_equals($view['hash'], $data['hash']), 409, 'Pedido alterado; consulte novamente.');
abort_unless($view['can_confirm'], 409, 'Pedido expirado, já consumido ou revisão/destino alterados.');
$first = User::findOrFail($row->requested_by);
$this->authorize($first);
$this->authorize($first, false, true);
$p = $this->preview($first);
abort_unless($p['installed'] && $p['consistent'] && ! in_array(false, $p['dependencies'], true) && ! $p['bootstrap']['initialized'], 409, 'Dependências ou dupla alteradas.');
app(PortalSuperusers::class)->validateBootstrapHolder($first);
@@ -0,0 +1,50 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Models\InterestRequest;
use Illuminate\Support\Facades\DB;
/** Reuses approved administration; never changes the global gate or private-record ACL. */
final class ScopedPortalAdministration
{
public function administrative(?User $actor): bool
{
if (!$actor || !$actor->hasVerifiedEmail()) return false;
$access = app(PortalProfileAccess::class);
if ($access->has($actor, 'auditor')) return false;
return ($access->superuser($actor) && $access->mfa($actor))
|| ($access->legacyWriteAllowed($actor) && $access->mfa($actor));
}
public function interestOperator(?User $actor): bool
{
if (!$actor || !$actor->hasVerifiedEmail()) return false;
$access=app(PortalProfileAccess::class);
return !$access->has($actor,'auditor') && $access->mfa($actor)
&& ($this->administrative($actor) || DB::table('interest_requests')->where('assigned_to',$actor->id)->whereNull('deleted_at')->exists());
}
public function interest(User $actor, InterestRequest $record): bool
{
return $this->interestOperator($actor)
&& ($actor->can('manage-portal') || (int)$record->assigned_to===$actor->id);
}
public function interestQuery($query, User $actor): void
{
if (!$actor->can('manage-portal')) $query->where('assigned_to',$actor->id);
}
public function deskDecision(User $actor, object $request): bool
{
return $actor->can('manage-portal')
|| ($this->administrative($actor) && (int)$request->analyst_id===$actor->id);
}
public function deskCaseDecision(User $actor, string $id): bool
{
return $actor->can('manage-portal') || ($this->administrative($actor)
&& DB::table('desk_requests')->where('case_id',$id)->where('analyst_id',$actor->id)->exists());
}
}
+2 -1
View File
@@ -59,9 +59,10 @@ class VerifiedDocumentBinary
if ($table === 'opportunity_images') {
$query->orWhereRaw("payload->'derivatives' @> ?::jsonb", [json_encode([['document_id' => $document->id]], JSON_THROW_ON_ERROR)]);
}
$links = $links->concat($query->get(['opportunity_id', 'deleted_at']));
$links = $links->concat($query->get(['opportunity_id', 'deleted_at', 'payload']));
}
$privateOrigin = in_array($document->source_system ?? null, ['opportunity-resources', 'opportunity-image-derivatives'], true);
if (! $privateOrigin) $links = $links->reject(fn ($link) => isset(json_decode($link->payload, true)['library_source']));
if ($links->isEmpty() && ! $privateOrigin) {
return; // Other consumers retain their own publication/authorization rules.
}
+3 -3
View File
@@ -388,7 +388,7 @@ class VirtualDesk
abort_if($this->owns($actor, $case, false), 403, 'O proponente não pode analisar ou decidir o seu próprio pedido.');
abort_unless($actor->can('manage-portal') || (int) $request->analyst_id === $actor->id, 403);
if (in_array($data['status'], ['accepted', 'rejected', 'closed'], true)) {
abort_unless($actor->can('manage-portal'), 403);
abort_unless(app(ScopedPortalAdministration::class)->deskDecision($actor,$request), 403);
}
abort_unless(in_array($data['status'], config('virtual-desk.transitions.'.$request->status, []), true), 409, 'Transição inválida.');
}
@@ -412,13 +412,13 @@ class VirtualDesk
return DB::transaction(function () use ($actor, $id, $data) {
$case = $this->case($actor, $id, false, true);
abort_unless($this->owns($actor, $case) || $actor->can('manage-portal'), 403);
abort_unless($this->owns($actor, $case) || app(ScopedPortalAdministration::class)->deskCaseDecision($actor,$case->id), 403);
abort_unless((int) $case->version === (int) $data['version'], 409);
$workflow = json_decode($case->workflow, true, 512, JSON_THROW_ON_ERROR);
$current = $workflow[$data['stage']];
$decision = in_array($data['status'], ['completed', 'not_applicable'], true);
if ($decision) {
abort_unless($actor->can('manage-portal') && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.');
abort_unless(app(ScopedPortalAdministration::class)->deskCaseDecision($actor,$case->id) && ! $this->owns($actor, $case, false), 403, 'Conclusão documental exige revisão interna por outra pessoa autorizada.');
abort_unless(trim($data['evidence'] ?? '') !== '', 422, 'Identifique a evidência documental.');
foreach ($current['dependencies'] as $dependency) {
abort_unless(in_array($workflow[$dependency]['status'], ['completed', 'not_applicable'], true) || (($data['exception'] ?? false) && ($data['document_id'] ?? null)), 422, 'Dependência '.$dependency.' pendente; uma exceção exige evidência própria.');
+2 -5
View File
@@ -19,7 +19,7 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
$app = Application::configure(basePath: dirname(__DIR__))
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
@@ -30,6 +30,7 @@ $app = Application::configure(basePath: dirname(__DIR__))
$middleware->encryptCookies(except: ['appearance', 'sidebar_state', EnsureSimulationRecoveryCookie::NAME]);
$middleware->web(append: [
\App\Http\Middleware\EnsureHomologationAccess::class,
PortalProfileSession::class,
ProtectPrivilegedIdentity::class,
AuditProfileDecision::class,
@@ -106,7 +107,3 @@ $app = Application::configure(basePath: dirname(__DIR__))
return $response;
});
})->create();
(require __DIR__.'/storage.php')($app->basePath(), $app->storagePath());
return $app;
+157
View File
@@ -0,0 +1,157 @@
<?php
return json_decode(<<<'JSON'
{
"accounts": [
"nala@tempo.co.mz",
"onala@me.com"
],
"resources": [
{
"id": 8,
"sha256": "ea1b0dc6f5cf6e595f6d9a19e7042f398999e0087caef1785f6e8fac571b5f53",
"external_id": "homologation-fdcb31f1b5b9-ea1b0dc6f5cf6e595f6d9a19e7042f398999e0087caef1785f6e8fac571b5f53"
},
{
"id": 9,
"sha256": "ccf2a8deeca820f2dd076b4b2141b1efe3f6a806568c3a587ca59a8e92f4c251",
"external_id": "homologation-fdcb31f1b5b9-ccf2a8deeca820f2dd076b4b2141b1efe3f6a806568c3a587ca59a8e92f4c251"
},
{
"id": 10,
"sha256": "11f0cab15eb6e51010952203ffa24fdc533a24fd642e19525fe4f46b17a1f5e5",
"external_id": "homologation-fdcb31f1b5b9-11f0cab15eb6e51010952203ffa24fdc533a24fd642e19525fe4f46b17a1f5e5"
},
{
"id": 11,
"sha256": "5c69b6f189a1d3f5307e0cf7dd8224554705158a682637d3105afad615db26e2",
"external_id": "homologation-fdcb31f1b5b9-5c69b6f189a1d3f5307e0cf7dd8224554705158a682637d3105afad615db26e2"
},
{
"id": 12,
"sha256": "5e3840705cf98226d1f189cf665aaa98cb05da96c645159a1332a1d429d642eb",
"external_id": "homologation-fdcb31f1b5b9-5e3840705cf98226d1f189cf665aaa98cb05da96c645159a1332a1d429d642eb"
},
{
"id": 13,
"sha256": "0929184a6d4645b9046512675394ad4f60575273f6dab0955f71cc7474a6a4e2",
"external_id": "homologation-fdcb31f1b5b9-0929184a6d4645b9046512675394ad4f60575273f6dab0955f71cc7474a6a4e2"
},
{
"id": 18,
"sha256": "26a70f16cd511c579f08633655ff312db05122f81e44852dd4b163fa6eef85f1",
"external_id": "homologation-fdcb31f1b5b9-26a70f16cd511c579f08633655ff312db05122f81e44852dd4b163fa6eef85f1"
},
{
"id": 3,
"sha256": "538495cd5b31047ac1c5993fdaf298ec1303330368248b251c87d201d31ff211",
"external_id": "homologation-fdcb31f1b5b9-538495cd5b31047ac1c5993fdaf298ec1303330368248b251c87d201d31ff211"
},
{
"id": 17,
"sha256": "05eb492dd6cfca6a7f305b633658a16723c315168f4b2c75103f22d1fac84032",
"external_id": "homologation-fdcb31f1b5b9-05eb492dd6cfca6a7f305b633658a16723c315168f4b2c75103f22d1fac84032"
},
{
"id": 6,
"sha256": "e90e39e49ba65bbbcbbc303c6eca456e445238daf9acc935ab03690819281678",
"external_id": "homologation-fdcb31f1b5b9-e90e39e49ba65bbbcbbc303c6eca456e445238daf9acc935ab03690819281678"
},
{
"id": 21,
"sha256": "8c5e91c937032680883793a12844f1128f7ac99e97c63b68c23d1396e009ba9d",
"external_id": "homologation-fdcb31f1b5b9-8c5e91c937032680883793a12844f1128f7ac99e97c63b68c23d1396e009ba9d"
},
{
"id": 23,
"sha256": "345dfd72d8ebf459eae8eb025d699afddbae751df55ffa8d053568cd08096c97",
"external_id": "homologation-fdcb31f1b5b9-345dfd72d8ebf459eae8eb025d699afddbae751df55ffa8d053568cd08096c97"
},
{
"id": 24,
"sha256": "4f4570f7bbd5599cb925ed996c1734a23582362fe485736931a53b9266565d4e",
"external_id": "homologation-fdcb31f1b5b9-4f4570f7bbd5599cb925ed996c1734a23582362fe485736931a53b9266565d4e"
},
{
"id": 26,
"sha256": "0d468ae0de9a76d2b140bc8cb5c555f29626fb6422287c917762bec3e615a70c",
"external_id": "homologation-fdcb31f1b5b9-0d468ae0de9a76d2b140bc8cb5c555f29626fb6422287c917762bec3e615a70c"
},
{
"id": 27,
"sha256": "28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa",
"external_id": "homologation-fdcb31f1b5b9-28f41f12e10ab34545d8e432f6e8c74f86960e17e0c4a9956f514bcde4881eaa"
},
{
"id": 14,
"sha256": "0a2c50d91dad3a6007544634540e0f61b11d3506682a5a19066bae03cdcba490",
"external_id": "homologation-fdcb31f1b5b9-0a2c50d91dad3a6007544634540e0f61b11d3506682a5a19066bae03cdcba490"
},
{
"id": 16,
"sha256": "8e58544e49e719f9c865c91e07ef1030155a9e3d59260e6dc93e9c9e25674e39",
"external_id": "homologation-fdcb31f1b5b9-8e58544e49e719f9c865c91e07ef1030155a9e3d59260e6dc93e9c9e25674e39"
},
{
"id": 15,
"sha256": "fe9a2e382e385b35f6e61ae320e199a979678677fd327fb7d4b07d8ce0111c11",
"external_id": "homologation-fdcb31f1b5b9-fe9a2e382e385b35f6e61ae320e199a979678677fd327fb7d4b07d8ce0111c11"
},
{
"id": 1,
"sha256": "2003755b7dac53b43f4608e707321db6d2f29878926a0e74f4cf27627b70a9cb",
"external_id": "homologation-fdcb31f1b5b9-2003755b7dac53b43f4608e707321db6d2f29878926a0e74f4cf27627b70a9cb"
},
{
"id": 29,
"sha256": "0ba605ae43dec7f685610f828df884bc82cb4da67131fc9783b5cdbe4cc6d9f0",
"external_id": "homologation-fdcb31f1b5b9-0ba605ae43dec7f685610f828df884bc82cb4da67131fc9783b5cdbe4cc6d9f0"
},
{
"id": 28,
"sha256": "926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941",
"external_id": "homologation-fdcb31f1b5b9-926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941"
},
{
"id": 2,
"sha256": "3ae780f082a8fa7c767635cce98563f5bc027c9656c9f41f8788843a17fa1f54",
"external_id": "homologation-fdcb31f1b5b9-3ae780f082a8fa7c767635cce98563f5bc027c9656c9f41f8788843a17fa1f54"
},
{
"id": 19,
"sha256": "8cab862be245382dd4c1fd5fcb423c47aed7be3a22e0a7f8d5521510cc36bc72",
"external_id": "homologation-fdcb31f1b5b9-8cab862be245382dd4c1fd5fcb423c47aed7be3a22e0a7f8d5521510cc36bc72"
},
{
"id": 4,
"sha256": "dcc29cbd36cf57e0a5c88dd312c84ee03c32a3d18ae9216d3715dc7b5a6960bf",
"external_id": "homologation-fdcb31f1b5b9-dcc29cbd36cf57e0a5c88dd312c84ee03c32a3d18ae9216d3715dc7b5a6960bf"
},
{
"id": 5,
"sha256": "88177fccd78d8516dba670c6f71272af062cc981d52d97adefe5bf77c613b16a",
"external_id": "homologation-fdcb31f1b5b9-88177fccd78d8516dba670c6f71272af062cc981d52d97adefe5bf77c613b16a"
},
{
"id": 20,
"sha256": "48314a576dea2f2dadf47d8f1eadd7ab58030606b02eebe17aa9bd6badffb7e9",
"external_id": "homologation-fdcb31f1b5b9-48314a576dea2f2dadf47d8f1eadd7ab58030606b02eebe17aa9bd6badffb7e9"
},
{
"id": 22,
"sha256": "a62a43e31da32795a7286f4c152a93d5dc829233e72023b00ea61fd8e2fcb58c",
"external_id": "homologation-fdcb31f1b5b9-a62a43e31da32795a7286f4c152a93d5dc829233e72023b00ea61fd8e2fcb58c"
},
{
"id": 25,
"sha256": "514a9d88c097d234038fa6341f5a5b01e8938cb6a9535a807145c84c37010a4d",
"external_id": "homologation-fdcb31f1b5b9-514a9d88c097d234038fa6341f5a5b01e8938cb6a9535a807145c84c37010a4d"
},
{
"id": 7,
"sha256": "43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5",
"external_id": "homologation-fdcb31f1b5b9-43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5"
}
],
"authority": "Tempo approved owner administration/acervo decisions 11 and 18 October, reconciled 19 October; no universal private access"
}
JSON, true, 512, JSON_THROW_ON_ERROR);
+127
View File
@@ -0,0 +1,127 @@
<?php
// Explicit correspondence from the authorised transport manifest; no record IDs are rewritten.
return json_decode(<<<'JSON'
{
"legacy-tempo-post-171985-20260420": {
"resource_id": 13,
"external_id": "homologation-fdcb31f1b5b9-0929184a6d4645b9046512675394ad4f60575273f6dab0955f71cc7474a6a4e2",
"sha256": "0929184a6d4645b9046512675394ad4f60575273f6dab0955f71cc7474a6a4e2",
"source_url": "https://investemoz.tempo.co.mz/agronegocio"
},
"legacy-tempo-post-171981-20260430": {
"resource_id": 18,
"external_id": "homologation-fdcb31f1b5b9-26a70f16cd511c579f08633655ff312db05122f81e44852dd4b163fa6eef85f1",
"sha256": "26a70f16cd511c579f08633655ff312db05122f81e44852dd4b163fa6eef85f1",
"source_url": "https://investemoz.tempo.co.mz/energia-mineracao"
},
"legacy-tempo-post-172679-20260430": {
"resource_id": 1,
"external_id": "homologation-fdcb31f1b5b9-2003755b7dac53b43f4608e707321db6d2f29878926a0e74f4cf27627b70a9cb",
"sha256": "2003755b7dac53b43f4608e707321db6d2f29878926a0e74f4cf27627b70a9cb",
"source_url": "https://investemoz.tempo.co.mz/energia/"
},
"legacy-tempo-post-172674-20260430": {
"resource_id": 2,
"external_id": "homologation-fdcb31f1b5b9-3ae780f082a8fa7c767635cce98563f5bc027c9656c9f41f8788843a17fa1f54",
"sha256": "3ae780f082a8fa7c767635cce98563f5bc027c9656c9f41f8788843a17fa1f54",
"source_url": "https://investemoz.tempo.co.mz/mineracao"
},
"legacy-tempo-post-171990-20260616": {
"resource_id": 19,
"external_id": "homologation-fdcb31f1b5b9-8cab862be245382dd4c1fd5fcb423c47aed7be3a22e0a7f8d5521510cc36bc72",
"sha256": "8cab862be245382dd4c1fd5fcb423c47aed7be3a22e0a7f8d5521510cc36bc72",
"source_url": "https://investemoz.tempo.co.mz/turismo"
},
"legacy-tempo-post-172263-20260414": {
"resource_id": 3,
"external_id": "homologation-fdcb31f1b5b9-538495cd5b31047ac1c5993fdaf298ec1303330368248b251c87d201d31ff211",
"sha256": "538495cd5b31047ac1c5993fdaf298ec1303330368248b251c87d201d31ff211",
"source_url": "https://investemoz.tempo.co.mz/provincia-da-zambezia-onde-os-corredores-de-mocambique-convergem-com-a-natureza-a-cultura-e-o-turismo-sustentavel"
},
"legacy-tempo-post-172256-20260414": {
"resource_id": 4,
"external_id": "homologation-fdcb31f1b5b9-dcc29cbd36cf57e0a5c88dd312c84ee03c32a3d18ae9216d3715dc7b5a6960bf",
"sha256": "dcc29cbd36cf57e0a5c88dd312c84ee03c32a3d18ae9216d3715dc7b5a6960bf",
"source_url": "https://investemoz.tempo.co.mz/provincia-de-sofala-onde-a-natureza-a-historia-e-o-futuro-do-turismo-convergem/"
},
"legacy-tempo-post-172261-20260414": {
"resource_id": 5,
"external_id": "homologation-fdcb31f1b5b9-88177fccd78d8516dba670c6f71272af062cc981d52d97adefe5bf77c613b16a",
"sha256": "88177fccd78d8516dba670c6f71272af062cc981d52d97adefe5bf77c613b16a",
"source_url": "https://investemoz.tempo.co.mz/provincia-de-tete-onde-o-rio-zambeze-a-cultura-viva-e-a-conservacao-comunitaria-criam-o-futuro-do-turismo/"
},
"legacy-tempo-post-172258-20260417": {
"resource_id": 20,
"external_id": "homologation-fdcb31f1b5b9-48314a576dea2f2dadf47d8f1eadd7ab58030606b02eebe17aa9bd6badffb7e9",
"sha256": "48314a576dea2f2dadf47d8f1eadd7ab58030606b02eebe17aa9bd6badffb7e9",
"source_url": "https://investemoz.tempo.co.mz/provincia-de-manica-onde-o-turismo-de-montanha-natureza-e-cultura-encontra-o-proximo-ciclo-de-investimento/"
},
"legacy-tempo-post-172253-20260414": {
"resource_id": 6,
"external_id": "homologation-fdcb31f1b5b9-e90e39e49ba65bbbcbbc303c6eca456e445238daf9acc935ab03690819281678",
"sha256": "e90e39e49ba65bbbcbbc303c6eca456e445238daf9acc935ab03690819281678",
"source_url": "https://investemoz.tempo.co.mz/provincia-de-inhambane-um-destino-estrategico-para-investimento-turistico/"
},
"legacy-tempo-post-172240-20260414": {
"resource_id": 21,
"external_id": "homologation-fdcb31f1b5b9-8c5e91c937032680883793a12844f1128f7ac99e97c63b68c23d1396e009ba9d",
"sha256": "8c5e91c937032680883793a12844f1128f7ac99e97c63b68c23d1396e009ba9d",
"source_url": "https://investemoz.tempo.co.mz/maputo-cidade-plataforma-estrategica-de-turismo-negocios-e-investimento-sustentavel/"
},
"legacy-tempo-post-172237-20260417": {
"resource_id": 22,
"external_id": "homologation-fdcb31f1b5b9-a62a43e31da32795a7286f4c152a93d5dc829233e72023b00ea61fd8e2fcb58c",
"sha256": "a62a43e31da32795a7286f4c152a93d5dc829233e72023b00ea61fd8e2fcb58c",
"source_url": "https://investemoz.tempo.co.mz/provincia-de-maputo-turismo/"
},
"legacy-tempo-post-172233-20260417": {
"resource_id": 23,
"external_id": "homologation-fdcb31f1b5b9-345dfd72d8ebf459eae8eb025d699afddbae751df55ffa8d053568cd08096c97",
"sha256": "345dfd72d8ebf459eae8eb025d699afddbae751df55ffa8d053568cd08096c97",
"source_url": "https://investemoz.tempo.co.mz/provincia-de-gaza-oportunidades-mais-solidas-e-estrategicas-de-investimento-no-turismo-no-sul-de-mocambique/"
},
"legacy-tempo-post-172230-20260414": {
"resource_id": 24,
"external_id": "homologation-fdcb31f1b5b9-4f4570f7bbd5599cb925ed996c1734a23582362fe485736931a53b9266565d4e",
"sha256": "4f4570f7bbd5599cb925ed996c1734a23582362fe485736931a53b9266565d4e",
"source_url": "https://investemoz.tempo.co.mz/provincia-de-nampula-perfil-de-investimento-no-setor-do-turismo/"
},
"legacy-tempo-post-172223-20260414": {
"resource_id": 25,
"external_id": "homologation-fdcb31f1b5b9-514a9d88c097d234038fa6341f5a5b01e8938cb6a9535a807145c84c37010a4d",
"sha256": "514a9d88c097d234038fa6341f5a5b01e8938cb6a9535a807145c84c37010a4d",
"source_url": "https://investemoz.tempo.co.mz/cabo-delgado-pemba-a-capital-do-turismo-costeiro-do-norte-de-mocambique/"
},
"legacy-tempo-post-172219-20260414": {
"resource_id": 26,
"external_id": "homologation-fdcb31f1b5b9-0d468ae0de9a76d2b140bc8cb5c555f29626fb6422287c917762bec3e615a70c",
"sha256": "0d468ae0de9a76d2b140bc8cb5c555f29626fb6422287c917762bec3e615a70c",
"source_url": "https://investemoz.tempo.co.mz/niassa-o-ultimo-grande-destino-virgem-do-turismo-africano/"
},
"legacy-tempo-post-171992-20260430": {
"resource_id": 17,
"external_id": "homologation-fdcb31f1b5b9-05eb492dd6cfca6a7f305b633658a16723c315168f4b2c75103f22d1fac84032",
"sha256": "05eb492dd6cfca6a7f305b633658a16723c315168f4b2c75103f22d1fac84032",
"source_url": "https://investemoz.tempo.co.mz/infraestruturas"
},
"legacy-tempo-post-173209-20260623": {
"resource_id": 28,
"external_id": "homologation-fdcb31f1b5b9-926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941",
"sha256": "926f5e4096737c936c9d5002ebdc64be9e63b0c0822023d17ce618111a65c941",
"source_url": "https://investemoz.tempo.co.mz/tech-fintech"
},
"legacy-tempo-post-173218-20260616": {
"resource_id": 7,
"external_id": "homologation-fdcb31f1b5b9-43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5",
"sha256": "43612c358c7573475cea24ef4825c5b7c7925306fa5861051220eed3f4e333b5",
"source_url": "https://investemoz.tempo.co.mz/imobiliario"
},
"legacy-tempo-post-173213-20260616": {
"resource_id": 29,
"external_id": "homologation-fdcb31f1b5b9-0ba605ae43dec7f685610f828df884bc82cb4da67131fc9783b5cdbe4cc6d9f0",
"sha256": "0ba605ae43dec7f685610f828df884bc82cb4da67131fc9783b5cdbe4cc6d9f0",
"source_url": "https://investemoz.tempo.co.mz/bens-de-consumo-franquias"
}
}
JSON, true, 512, JSON_THROW_ON_ERROR);
+14
View File
@@ -1,6 +1,14 @@
<?php
return [
// Technical homologation reservation, independent of roles and publication.
// Technical access mechanism; no new portal role or MFA requirement.
'homologation_access_mode' => env('INVESTMOZ_HOMOLOGATION_ACCESS_MODE', 'reviewers'),
'homologation_only' => (bool) env('INVESTMOZ_HOMOLOGATION_ONLY', false),
'homologation_reviewers' => array_values(array_filter(array_map(
fn ($email) => strtolower(trim($email)),
explode(',', (string) env('INVESTMOZ_HOMOLOGATION_REVIEWERS', '')),
))),
'organization_country_enabled' => env('PORTAL_ORGANIZATION_COUNTRY_ENABLED', false),
'legacy_content_enabled' => env('PORTAL_LEGACY_CONTENT_ENABLED', false),
'legacy_content_qa_enabled' => env('PORTAL_LEGACY_CONTENT_QA_ENABLED', false),
@@ -14,6 +22,8 @@ return [
'opportunity_modular_profiles_qa_enabled' => env('PORTAL_OPPORTUNITY_MODULAR_PROFILES_QA_ENABLED', false),
'opportunity_other_qa_subtypes' => [],
'operator_name' => env('PORTAL_OPERATOR_NAME'),
// Tempo, 19/10/2026: institutional contact; commercial department remains separate.
'institutional_contact_email' => env('PORTAL_INSTITUTIONAL_CONTACT_EMAIL', 'info@tempo.co.mz'),
'support' => [
'email' => env('PORTAL_SUPPORT_EMAIL'),
'phone' => env('PORTAL_SUPPORT_PHONE'),
@@ -23,6 +33,10 @@ return [
'notifications_enabled' => env('PORTAL_NOTIFICATIONS_ENABLED', false),
'notification_recipients' => array_values(array_filter(array_map('trim', explode(',', (string) env('PORTAL_NOTIFICATION_RECIPIENTS', ''))))),
// Explicit access list; public registration never grants management access.
'document_managers' => array_values(array_filter(array_map(
fn ($email) => strtolower(trim($email)),
explode(',', (string) env('PORTAL_DOCUMENT_MANAGER_EMAILS', '')),
))),
'managers' => array_values(array_filter(array_map(
fn ($email) => strtolower(trim($email)),
explode(',', (string) env('PORTAL_MANAGER_EMAILS', '')),
+3
View File
@@ -1,6 +1,9 @@
<?php
return [
// Explicit deployment designation; grants only the separate bootstrap ceremony.
'bootstrap_designated_emails' => array_values(array_filter(array_map(fn ($email) => strtolower(trim($email)), explode(',', (string) env('INVESTMOZ_PROFILE_BOOTSTRAP_HOLDERS', ''))))),
'bootstrap_designated_database' => (string) env('INVESTMOZ_PROFILE_BOOTSTRAP_DATABASE', ''),
'proof_directory' => storage_path('app/private/investmoz-v2/portal-profile-installation'),
// Scoped execution flags are set and reset only by the authenticated services.
'authenticated_installation' => false,
+1
View File
@@ -4,6 +4,7 @@ return [
'malware' => [
'scanner' => env('MALWARE_SCANNER', 'clamd'),
'socket' => env('CLAMD_SOCKET'),
'host' => env('CLAMD_HOST', '127.0.0.1'),
'port' => (int) env('CLAMD_PORT', 3310),
'timeout_seconds' => (int) env('CLAMD_TIMEOUT_SECONDS', 120),
@@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if (!config('investment-zone-profile.authenticated_installation') && !\App\Support\IsolatedQaDatabase::allowsSchemaChanges()) { throw new RuntimeException('Use a ação autenticada de instalação nominal da ficha.'); }
if (!config('investment-zone-profile.authenticated_installation')) { throw new RuntimeException('Use a ação autenticada de instalação nominal da ficha.'); }
Schema::table('investment_zones',fn(Blueprint $table)=>$table->text('editorial_draft')->nullable());
}
public function down(): void
@@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
if(!config('investment-zone-details.authenticated_installation') && !\App\Support\IsolatedQaDatabase::allowsSchemaChanges()){throw new RuntimeException('Use a instalação autenticada nominal L1.');}
if(!config('investment-zone-details.authenticated_installation')){throw new RuntimeException('Use a instalação autenticada nominal L1.');}
if(!Schema::hasColumn('investment_zones','editorial_draft') || !DB::table('migrations')->where('migration','2026_10_16_130000_add_investment_zone_editorial_draft')->exists()){throw new RuntimeException('Instale/verifique primeiro a base 130000.');}
Schema::table('investment_zones',fn(Blueprint $table)=>$table->jsonb('operational_details')->nullable());
}
@@ -8,7 +8,7 @@ return new class extends Migration
{
public function up(): void
{
if (! config('investor-context.authenticated_installation') && ! \App\Support\IsolatedQaDatabase::allowsSchemaChanges()) {
if (! config('investor-context.authenticated_installation')) {
throw new RuntimeException('Use a ação autenticada nominal de instalação do contexto.');
}
Schema::create('investor_navigation_preferences', function (Blueprint $t) {
@@ -9,7 +9,7 @@ return new class extends Migration
{
public function up(): void
{
if (!config('editorial.authenticated_installation') && !\App\Support\IsolatedQaDatabase::allowsSchemaChanges()) { throw new RuntimeException('Use a instalação editorial autenticada.'); }
if (!config('editorial.authenticated_installation')) { throw new RuntimeException('Use a instalação editorial autenticada.'); }
Schema::table('portal_resources', fn (Blueprint $t) => $t->jsonb('editorial_content')->nullable());
}
+442
View File
@@ -0,0 +1,442 @@
{
"base_commit": "507387502c6ef887433e8e9b5cf20ea39d4f17d4",
"installed_revision": "423bd512d8c5",
"approved_runtime_files_adopted": [
{
"path": "app/Http/Controllers/Api/V1/InterestRequestController.php",
"remote_sha256": "50b65d0dd576d1c0e4357fe5a192744900eb77b2e5737191ecc553428e6ccf14"
},
{
"path": "app/Http/Controllers/DocumentMalwareController.php",
"remote_sha256": "b19b028ae53fcb8ed648745c9de0121e128e489a9cdd1c3eb8a4210c297416d4"
},
{
"path": "app/Http/Controllers/EditorialArticleController.php",
"remote_sha256": "41e34a865890c81191041ec1c34b0abc1ab052c99ae3b96edc930f841c5a33c5"
},
{
"path": "app/Http/Controllers/ExistingBusinessController.php",
"remote_sha256": "e73ac0fbe1c4ae3939c27e5ca1b6e76642611a5c117c7473f21c284235fe75da"
},
{
"path": "app/Http/Controllers/InvestmentSubmissionController.php",
"remote_sha256": "257837ff8100d5fbc3a432ec336aacc4f37af01d7044e0c44d37de2d4ec44b4c"
},
{
"path": "app/Http/Controllers/InvestmentZoneController.php",
"remote_sha256": "32ed3b3f662b7fcfb8fce09c7626680f3409b0b3bd6acd662088e0adc22cf53b"
},
{
"path": "app/Http/Controllers/OpportunityResourceController.php",
"remote_sha256": "da265c473a783423321fa75f1b02eb62f311b80e4cb317298d5ac4883630e2c6"
},
{
"path": "app/Http/Controllers/PortalManagementController.php",
"remote_sha256": "c49ba1d490f88e65daae21ad0038608a71aef547d7b77acd096cdc8a4d695c0d"
},
{
"path": "app/Http/Controllers/PortalServicesController.php",
"remote_sha256": "8e7595a42945876f9d2787a8fafaa7cec43c6dfafdc79a9aee73fa8aa533c359"
},
{
"path": "app/Http/Controllers/VirtualDeskController.php",
"remote_sha256": "e4620b41820945512283f7f0cdd46f70916b9935288ec91a00e3d1c388af950d"
},
{
"path": "app/Http/Middleware/EnsureDocumentManagement.php",
"remote_sha256": "7e373bffc2517366066a32889b36f277f5cea242c0f3277ef291e2c98fb8520e"
},
{
"path": "app/Http/Middleware/EnsureHomologationAccess.php",
"remote_sha256": "259f467b08d4ad5a1605a40c5f3ccb9e41b2a879643df1f8e4814f5d0c82a8b4"
},
{
"path": "app/Http/Middleware/EnsureScopedEditorial.php",
"remote_sha256": "62c4c16506f5e75ef76e3fa987d6b5ba42d5df953604f1aeecb89c87c7c5c7ca"
},
{
"path": "app/Http/Middleware/EnsureScopedPortalAdministration.php",
"remote_sha256": "3842b05ddd4433b5a26c0dea6b97542b2b3846f46f0393c5515872ded43f0f61"
},
{
"path": "app/Http/Middleware/HandleInertiaRequests.php",
"remote_sha256": "01083ba8e8aec0f0a346e6f86739155122b52fd44f736c066683f0c04c9bdc73"
},
{
"path": "app/Http/Middleware/VerifiedOrSimpleParticipation.php",
"remote_sha256": "301e0ce37a92e0985bfb5b914aa13a32a0081f8ead88842ff4911c043553780a"
},
{
"path": "app/Http/Requests/StoreInterestRequestRequest.php",
"remote_sha256": "a064fc0c71139b6ced9d209a490c1f339733ae9ad513c0128520b2942384eeac"
},
{
"path": "app/Policies/ExistingBusinessPolicy.php",
"remote_sha256": "69c75db509d876d6125da7d4254ac3be6e23e0ee942dea460bd86fc73aa02def"
},
{
"path": "app/Services/BusinessFormSchema.php",
"remote_sha256": "bf7509574c8c66da49b2f2d488a4dbc9896a15daebd78404fa4832c0322fe6f7"
},
{
"path": "app/Services/DeclaredOriginStatistics.php",
"remote_sha256": "b5a20759938e2d5652018d6ec0b1b1d8dda6cdddbd4f7767fd5dbfa0da0e2f4c"
},
{
"path": "app/Services/DocumentManagementAccess.php",
"remote_sha256": "6b2a0e8b75cb99f8baa9f40b4eda7cd5171348dd2126833ca3399e252baed4d0"
},
{
"path": "app/Services/EconomicReferenceInstallation.php",
"remote_sha256": "a22050130c8cefdb18f2dafe03a4158f3e1596c5158cb9fd262abbf63c5ab144"
},
{
"path": "app/Services/ExistingBusinessIntake.php",
"remote_sha256": "30d2325030e4024da1a15bc08085db8037fa42d09b1ece9c071386f346597937"
},
{
"path": "app/Services/ExistingBusinessProfileCategory.php",
"remote_sha256": "46c045c19bff743a507a2efe0fc333454e82a9a48c2b355cf56834b638951737"
},
{
"path": "app/Services/InterestWorkflow.php",
"remote_sha256": "d29080a7757c07505b418d4148cdb4abaaeb213a7b004bc21c52a135af99dca8"
},
{
"path": "app/Services/InvestmentZoneOperationalDetailsInstallation.php",
"remote_sha256": "2b9f47cf7e1ddf14069440691485733f29cbe5fc0fb5b0091d15e96f5b8391d9"
},
{
"path": "app/Services/InvestmentZoneProfileInstallation.php",
"remote_sha256": "026d6e1752fc8c4c80c14d5ce87bf5ea53d585a917a0cd3cd2c9d522247bb7db"
},
{
"path": "app/Services/InvestorContext.php",
"remote_sha256": "1e7d9c7096b72a23df17611247c315ae6c8e76472c87638409be987c3cbba273"
},
{
"path": "app/Services/LegacyContent.php",
"remote_sha256": "7138d214165859ad9ae4f4be81de9ab39978257264025a357b9b84aa90e661bc"
},
{
"path": "app/Services/Malware/ClamDScanner.php",
"remote_sha256": "b708454c18793cbdfa761010905a0e423adfbaddeaf49ac342d88b1dc2e5d3e0"
},
{
"path": "app/Services/OpportunityCanonicalContract.php",
"remote_sha256": "75ffb95495b630193710830f5b9913681f544baacebeac7e7be5d7f7815e59cd"
},
{
"path": "app/Services/OpportunityCategoryFields.php",
"remote_sha256": "d7d294c72b7c416bd91726e7d7e2a045968e85d62ddaf57137da921e0340f25c"
},
{
"path": "app/Services/OpportunityCompleteness.php",
"remote_sha256": "1497712c221332e5f879205d94470c5cbe657d543565a99f08eee4543da0ddfb"
},
{
"path": "app/Services/OpportunityInitiativeApplicability.php",
"remote_sha256": "7efeae3c445bc3e7feb3275bd66a54a0b1370931a1331ac5ef6d7bfa58b4cdb5"
},
{
"path": "app/Services/OpportunityLibrarySupport.php",
"remote_sha256": "2dea4f3efc76b16c4cccb97ffcfbeccbe4b3151a9a871fe8e2c175f7874fec11"
},
{
"path": "app/Services/OpportunityResources.php",
"remote_sha256": "cf3baa0e5592f0b039189877faca514c62db65ae2e2bd634b5380c74f56ab412"
},
{
"path": "app/Services/OpportunityTerritorialPresentation.php",
"remote_sha256": "81d91ffe07531c43e057c81887bfa2891e50c38ff7cda9414a26af67592a2b78"
},
{
"path": "app/Services/PortalProfileInstallation.php",
"remote_sha256": "4ab58a07c93e743327c592434dc1931057baf1ba81f488f4b51a28022beec4ac"
},
{
"path": "app/Services/ScopedPortalAdministration.php",
"remote_sha256": "013efc04ecfddaea7473f50c0d0c31f56b5d958bb1594ed913d3d3855b9b0372"
},
{
"path": "app/Services/VerifiedDocumentBinary.php",
"remote_sha256": "3a602b504bcb26edd3c9182dcab83fef2fd25166e61a840d1503b7a4d98f0728"
},
{
"path": "app/Services/VirtualDesk.php",
"remote_sha256": "dc958b808d91b353d2c8e7d1244f594c54fc5eb4924e64654818f1cbd1aebda9"
},
{
"path": "bootstrap/app.php",
"remote_sha256": "b6e04e06d1d7d17f43ca81370ced7d470d5b9274957680110203f20e44ffacc1"
},
{
"path": "config/homologation_editorial.php",
"remote_sha256": "451a24a9a93fcdc2fd2aec98fb5b8689e13de1239c9b1671906ec58eb1b4e09e"
},
{
"path": "config/legacy_content_aliases.php",
"remote_sha256": "c8bfbead3a0908c84f7e1d595154d6d75ecebd24737651dad396e8bd0c900c50"
},
{
"path": "config/portal.php",
"remote_sha256": "6f26c86f4599a93eb1b80bb6fa0657b72fdbc03935d2c262080858fd79287b00"
},
{
"path": "config/portal_profiles.php",
"remote_sha256": "2e9e3d40277980c9fc139a51eb33212fe3df71a74397c353fe355a5642f99a07"
},
{
"path": "config/services.php",
"remote_sha256": "98f2875d58ab9283018b5c8e3ba24000f2d2b8be4d67ff107ff33ceac578be7c"
},
{
"path": "database/migrations/2026_10_16_130000_add_investment_zone_editorial_draft.php",
"remote_sha256": "ff9b56f7c3d156bedfe0050953c2ea6c10811fd859524a8fb994e176527846f0"
},
{
"path": "database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php",
"remote_sha256": "3d3e7f00df749475a67c4d098a42cbbc81f9015afc8b74e6be58c86dd1f2abfb"
},
{
"path": "database/migrations/2026_10_16_150000_create_investor_navigation_preferences.php",
"remote_sha256": "e12017e6a4d1a960057cf2d1df9dbd69faa867bf6a2d08a50dd3089a6a967100"
},
{
"path": "database/migrations/2026_10_18_160000_add_portal_editorial_content.php",
"remote_sha256": "b3aa3d2510cd17975dbb4ff5f49d741af9f67601ce7e181f6173cfa2368d6334"
},
{
"path": "resources/js/app.tsx",
"remote_sha256": "858eabfe3f5bf45a2e5a11c08e123fdd8c3243c6648c0d54def6966a0a683f88"
},
{
"path": "resources/js/components/app-logo.tsx",
"remote_sha256": "e8e95de1d180014edc8fce2a36851727e5721319a9e9dffa90c82734f7b5939c"
},
{
"path": "resources/js/components/context-documents.tsx",
"remote_sha256": "516871523445ea1efd2415f73c63e1ae74be2da76ceb620708bdf39fe0605999"
},
{
"path": "resources/js/components/country-select.tsx",
"remote_sha256": "291676dc034a5f3d4d6498f1b80aa9de01b2b256f092fc855177a232b29ec714"
},
{
"path": "resources/js/components/investment-map-panel.tsx",
"remote_sha256": "7cee11792343dd3ac94d4aeb782e6b23a29cf826b56209dcc5b4291d57b738c5"
},
{
"path": "resources/js/components/investment-territory.tsx",
"remote_sha256": "38b2945a59601c5a1cea33827f57f105fc521ed3679223d95d5aa76cf6e13855"
},
{
"path": "resources/js/components/opportunity-canonical-fields.tsx",
"remote_sha256": "f3c763063eee9aeb2da354c54e84c86e3c54f46dfecce4665ee0e8c6c6970d05"
},
{
"path": "resources/js/components/opportunity-classification.tsx",
"remote_sha256": "06bdcf091f51a7eb44f75790f7d40462172571d1f1219f12620ee84a64fd668e"
},
{
"path": "resources/js/components/opportunity-library-reuse.tsx",
"remote_sha256": "e95e3358a0016edd00596b09eeb80f5c2f0d149bdb456d8bc2c812cfb32e62f7"
},
{
"path": "resources/js/components/partnership-search-results.tsx",
"remote_sha256": "efc037858a9a01d643894b308af8145959a7111b2667e393392dae53a3e5ba88"
},
{
"path": "resources/js/components/password-input.tsx",
"remote_sha256": "f13f872ca86ebfd48fe475efdea32d5ec667414a4bba4529efe19a3bf81b2c16"
},
{
"path": "resources/js/components/portal-grouped-menu.tsx",
"remote_sha256": "0c1de9b30772632afadcef6871edd6938cdab8f838983fd00cbd473d135432e7"
},
{
"path": "resources/js/components/portal-product-identity.tsx",
"remote_sha256": "a0d5e22d06bd9dd12dce152425645f0247c7cb88767c23d5f0367e4f91c1e0cc"
},
{
"path": "resources/js/components/search-feedback.tsx",
"remote_sha256": "bbe5b489b55b5853de553f68bdc6d38708247ca7a0649dda8a26e60148a8905d"
},
{
"path": "resources/js/components/search-state.tsx",
"remote_sha256": "70d705f327d561c822a679e0739ce02470fbe7a18e4c8b44c9305f7619b496d6"
},
{
"path": "resources/js/components/structure-geography.tsx",
"remote_sha256": "62f7570cf0b9149f006721ea59d694d66038530702d368444abc3589128619d4"
},
{
"path": "resources/js/components/tempo-footer.tsx",
"remote_sha256": "14bbdc61d82cf392b43f7bf35e326cec87ddb40fbcce22d9aab9da872f746bc6"
},
{
"path": "resources/js/hooks/use-interest-draft.ts",
"remote_sha256": "787b3295f3bee1282b4cacfefc1e7650feea54738d35f542d879a4dc13c9d475"
},
{
"path": "resources/js/layouts/auth/auth-simple-layout.tsx",
"remote_sha256": "6c2b046bd02e7e02017a536873add53b02df79ef17798476b8021c401546462b"
},
{
"path": "resources/js/layouts/portal-navigation.tsx",
"remote_sha256": "08876167e7de425470d6b97133441221437081b2065b2f1e6ccc9efce40a0b45"
},
{
"path": "resources/js/lib/investor-context.ts",
"remote_sha256": "19e2e5e6ef85059c29354a0f2a14d26012fdcc9868b84144b18df5fc41a667f9"
},
{
"path": "resources/js/lib/participation-identity.ts",
"remote_sha256": "e4446f51529d787e83beeb044eede9a3e5f3894cf7f9c51d97542c403f563b6c"
},
{
"path": "resources/js/lib/portal-api.ts",
"remote_sha256": "2ad0ad502e7805ae834e2368982f17b90cd9e32e4c0de112d14c1f3ef9268ca8"
},
{
"path": "resources/js/lib/portal-navigation.ts",
"remote_sha256": "17e541f5f0add27bf4af1dc6dae0ce7cdeab96de0162f1b3663cbf4dea2d8c85"
},
{
"path": "resources/js/lib/project-dossier-document.ts",
"remote_sha256": "28e2ac9929038f9fdbc2fe5bf8dde46e4ff477db29964dc7921381bfbf00cc65"
},
{
"path": "resources/js/lib/project-dossier-report.ts",
"remote_sha256": "f4a428cd337912df60ec3029e95c6fe9beaf2d3f81cca903690c626828719f22"
},
{
"path": "resources/js/pages/PortalInvestidor.tsx",
"remote_sha256": "18885a653fe13ea9040a998fb721a9e21a77fc3ead359378fac7b7a740eb9c5d"
},
{
"path": "resources/js/pages/auth/login.tsx",
"remote_sha256": "3f9231ba2cb69216c61215c701e8929ff9b90fb164f9a179f42d00b7c30c3aa7"
},
{
"path": "resources/js/pages/auth/register.tsx",
"remote_sha256": "6ea92f1ec50058e39af8901b78256f4c99fcfbefd0f9ebb299acdff05c99d50a"
},
{
"path": "resources/js/pages/desk/request.tsx",
"remote_sha256": "7b3b8bec1a66a0403e6cdf9a11cf83d59b6a529f5f43b6a5871ba76c1bc7935d"
},
{
"path": "resources/js/pages/investment/business-interest.tsx",
"remote_sha256": "0cfbde1ba8e4cd164bd2a904c9f5560e4603609c71854b3fdb6db91561b883c4"
},
{
"path": "resources/js/pages/investment/business-wizard.tsx",
"remote_sha256": "03ce4217bcb34583ceeeab454e40b1ee8aa73ba361d24e833e3c16e5fa165ec4"
},
{
"path": "resources/js/pages/investment/business.tsx",
"remote_sha256": "3682717821510a19f7e02c14179d72144f50ee822d96ebd011e48d37d6ab2055"
},
{
"path": "resources/js/pages/investment/catalogue.tsx",
"remote_sha256": "7ac06e80303992fae94b6ba2313ede5cff13da974815460cf59100b07b1ce338"
},
{
"path": "resources/js/pages/investment/context.tsx",
"remote_sha256": "50b86276a3ac238b678f06dfb536b61ca3711a495453debae2e795082cdc0053"
},
{
"path": "resources/js/pages/investment/detail.tsx",
"remote_sha256": "743de540cd1caeda5f3afb2b2dc78c088ea56ebe527a5466a3d039b430548688"
},
{
"path": "resources/js/pages/investment/submission.tsx",
"remote_sha256": "71f03043fd16a806632fa7c0bf503998f0dff99fdd1bbfcaa703a032a2fa6558"
},
{
"path": "resources/js/pages/investment/zones.tsx",
"remote_sha256": "a1f0b395f027a05ecef134e1bd0c03a20a23b089c7d8c5aa45403eb1c7920a92"
},
{
"path": "resources/js/pages/investor-path/context-start.tsx",
"remote_sha256": "36d95c80a64a09ee40bd0fec8f6c0f623a47e576667fe08fadf2e3dc8969691e"
},
{
"path": "resources/js/pages/investor-path/context.tsx",
"remote_sha256": "823da4d6092f553fef7aa801fb1c7d809b53b2c96571aaaeffece67a92af454a"
},
{
"path": "resources/js/pages/investor-path/show.tsx",
"remote_sha256": "5d56de96fe41ad5b8782a4f1c472c110cbb3e93493bc92f88626853a7dff563d"
},
{
"path": "resources/js/pages/land-assistance/print.tsx",
"remote_sha256": "6355e9adb55de69deb335abff1c5d872e8993845c329f3227b81cfee4f7484c8"
},
{
"path": "resources/js/pages/management/desk-assignments.tsx",
"remote_sha256": "4ce516c67caf67215a8f9fa2cb5ca7e7c959353fc0c0b661ef31c47751d97274"
},
{
"path": "resources/js/pages/management/opportunity-draft.tsx",
"remote_sha256": "4281780693b7d25f2d8bb8f764e89555a1bc628b551725b671691fd0a7760f01"
},
{
"path": "resources/js/pages/management/opportunity-resources.tsx",
"remote_sha256": "494aab642c84e3fd603cf7d0c03d8cbc317e1f52d9575d6bace00360e6334346"
},
{
"path": "resources/js/pages/management/territory-associations.tsx",
"remote_sha256": "23997e11cc0a6735e128fdabb1453be9022a51487addaa69fe4d301a49cc2725"
},
{
"path": "resources/js/pages/portal/manage.tsx",
"remote_sha256": "b6b96686aaccf6699551b0090a92fc40a9828affb953a8a87568d35fdc90688b"
},
{
"path": "resources/js/pages/portal/services.tsx",
"remote_sha256": "8685509a3678323d9471e5921edcd195ca7dd3a50cc301d44222117d5df690e2"
},
{
"path": "resources/views/desk/receipt.blade.php",
"remote_sha256": "74c52186b38f75cae100283081fad22b90b13ec69ead512c4cd4f91bc2ca3a44"
},
{
"path": "resources/views/errors/403.blade.php",
"remote_sha256": "608ca8b0af82357afc1e6246767b777d2ec8b260f06f7f667089b1a465b24100"
},
{
"path": "routes/api.php",
"remote_sha256": "03afdc58de664a5d42459f4dcbd7367788f49c7736ec821ebd4bfaab609c3444"
},
{
"path": "routes/investment-zones.php",
"remote_sha256": "34b2d9c5042e59e4442906399e72e057771422840655185733fe32e8d7013737"
},
{
"path": "routes/investment.php",
"remote_sha256": "ce048ba84c38cb4c3676c05f04853ab41686970eadf583ba849ac63d19fce011"
},
{
"path": "routes/virtual-desk.php",
"remote_sha256": "fcef540ff90f6212b84b097492f34398c585fb03c6aeaa332943234e134cc9cb"
},
{
"path": "routes/web.php",
"remote_sha256": "67da2d4dd4c2572f42c734bf0e538b86d0deb44180a11e8a999b325159352198"
}
],
"unchanged_runtime_files": 676,
"runtime_files_requiring_remote_read": [],
"excluded": "All .env, storage, generated route helpers, vendor/node_modules, synthetic actors and records; no database imports. Wayfinder helpers regenerated from committed routes during build.",
"existing_diagnostic_source_preserved": [
"app/Console/Commands/CheckDevelopmentEnvironment.php"
],
"generated_helper_not_committed": [
{
"path": "resources/js/wayfinder/index.ts",
"remote_sha256": "3efc1c9f2eaa4828a4169414a1239420fc1bea8d3752fbd7a95b4a6da4a60d11"
}
]
}
+1 -1
View File
@@ -8,7 +8,7 @@ import AuthLayout from '@/layouts/auth-layout';
import SettingsLayout from '@/layouts/settings/layout';
import PortalNavigation from '@/layouts/portal-navigation';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const appName = import.meta.env.VITE_APP_NAME || 'InvesteMoz';
const pages = import.meta.glob<{ default: ComponentType }>('./pages/**/*.tsx');
void createInertiaApp({
+2 -11
View File
@@ -1,11 +1,2 @@
export default function AppLogo() {
return (
<img
src="/images/logo/logo-vermelho.png"
alt="Tempo"
width="1787"
height="564"
className="h-8 w-auto max-w-full object-contain group-data-[collapsible=icon]:h-auto group-data-[collapsible=icon]:w-8"
/>
);
}
import PortalProductIdentity from '@/components/portal-product-identity';
export default function AppLogo() { return <PortalProductIdentity />; }
@@ -0,0 +1,54 @@
import { Link } from '@inertiajs/react';
import type { ContextReference } from '@/lib/investor-context';
export default function ContextDocuments({
source,
}: {
source?: ContextReference;
}) {
if (!source) return null;
return (
<section
aria-label="Fonte e documentos do contexto"
className="space-y-2 rounded border p-4"
>
<h2 className="font-semibold">Fonte e documentos do contexto</h2>
<p>
<Link className="underline" href={source.href}>
{source.title}
</Link>{' '}
· versão da oportunidade {source.version ?? 'não indicada'} ·{' '}
{source.visibility === 'private'
? 'Preparação privada — não publicada'
: 'Referência pública'}
</p>
{(source.documents ?? []).map((doc) => (
<article
key={doc.support_id}
className="space-y-1 rounded border p-3"
>
<p>
{doc.title} · {doc.edition ?? 'Edição não indicada'} ·
versão documental {doc.document_version} · metadados v
{doc.metadata_version}
</p>
<p className="break-all">SHA256: {doc.sha256}</p>
{doc.section && <p>Referência utilizada: {doc.section}</p>}
<p>{doc.source_reference}</p>
<a className="underline" href={doc.download_url}>
Consultar original autorizado —{' '}
{doc.edition ?? doc.title}
</a>
</article>
))}
{!source.documents?.length && (
<p>Sem suporte da biblioteca associado nesta versão.</p>
)}
<p>
Referência guardada nesta versão do dossier. A consulta do
original continua sujeita às permissões actuais; esta ligação
não publica nem concede acesso adicional.
</p>
</section>
);
}
@@ -50,6 +50,7 @@ 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>}
<select
disabled={disabled}
id={id ?? generatedId}
@@ -1,3 +1,4 @@
import SearchState from '@/components/search-state';
import { useEffect, useState } from 'react';
import TerritoryGeometryPreview from './territory-geometry-preview';
import InteractiveInvestmentMap, {
@@ -18,11 +19,12 @@ export default function InvestmentMapPanel({
{ id: number; title: string; url: string }[]
>([]);
const [error, setError] = useState('');
const [retry, setRetry] = useState(0);
const [areas, setAreas] = useState<InvestmentMapArea[]>([]);
useEffect(() => {
const controller = new AbortController();
const params = new URLSearchParams(JSON.parse(key));
setPoints(null);
setPoints(null); setError(''); setMissing([]);
setAreas([]);
fetch(`/api/v1/investment/map?${params}`, {
signal: controller.signal,
@@ -43,14 +45,14 @@ export default function InvestmentMapPanel({
: '',
);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
.catch(() => {
if (!controller.signal.aborted) setError('Não foi possível concluir a pesquisa.');
});
return () => controller.abort();
}, [key]);
}, [key, retry]);
return (
<section className="space-y-3">
{error && <p role="alert">{error}</p>}
<SearchState loading={points === null && !error} error={error} total={(points?.length ?? 0) + missing.length} retry={() => setRetry(value => value + 1)} />
<InteractiveInvestmentMap points={points} areas={areas} />
<TerritoryGeometryPreview
territoryId={
@@ -1,3 +1,4 @@
import SearchState from '@/components/search-state';
import { useEffect, useId, useState } from 'react';
import { api } from '@/pages/investment/shared';
import { replaceTerritoryPath } from '@/lib/territory-path.js';
@@ -110,12 +111,10 @@ export function ProvinceSelect({
.then((data) => {
if (!controller.signal.aborted) setOptions(data.data);
})
.catch((cause) => {
.catch(() => {
if (!controller.signal.aborted)
setError(
cause instanceof Error
? cause.message
: 'Erro de ligação.',
'Não foi possível concluir a pesquisa.',
);
})
.finally(() => {
@@ -131,6 +130,7 @@ export function ProvinceSelect({
return (
<>
<SearchState loading={loading} error={error} total={options.length} retry={() => setRetry(value => value + 1)} />
<input
className={className}
aria-label={`Pesquisar ${label}`}
@@ -162,17 +162,6 @@ export function ProvinceSelect({
</option>
))}
</select>
{error && (
<span role="alert" className="block text-sm">
{error}{' '}
<button
type="button"
onClick={() => setRetry((current) => current + 1)}
>
Tentar novamente
</button>
</span>
)}
{geometryFeedback && <p role="status">{geometryFeedback}</p>}
<TerritoryGeometryPreview
territoryId={
@@ -309,10 +298,10 @@ export function TerritoryLevel({
if (controller.signal.aborted) return;
setOptions(data.data);
setMore(Boolean(data.next_page_url));
} catch (e) {
} catch {
if (!controller.signal.aborted)
setError(
e instanceof Error ? e.message : 'Erro de ligação.',
'Não foi possível concluir a pesquisa.',
);
} finally {
if (!controller.signal.aborted) setLoading(false);
@@ -325,6 +314,7 @@ 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)} />}
<label htmlFor={id} className="font-medium">
{labels[index]}
</label>
@@ -370,22 +360,11 @@ export function TerritoryLevel({
))}
</select>
{loading && <p role="status">A carregar…</p>}
{error && (
<div role="alert">
{error}{' '}
<button type="button" onClick={() => setRetry(retry + 1)}>
Tentar novamente
</button>
</div>
)}
{fieldError && (
<p id={`${id}-error`} role="alert">
{fieldError}
</p>
)}
{enabled && !loading && !error && options.length === 0 && (
<p className="text-sm">Sem opções para esta pesquisa.</p>
)}
<div className="flex gap-3 text-sm">
{page > 1 && (
<button type="button" onClick={() => setPage(page - 1)}>
@@ -72,7 +72,7 @@ const labels: Record<string, string> = {
location_mode: 'Uma ou várias localizações',
geographic_scope: 'Âmbito geográfico',
multi_district_rationale: 'Justificação territorial',
locations: 'Localizações guardadas',
locations: 'Onde se localiza — locais documentados',
executive_summary: 'Resumo executivo',
demand_statement: 'Necessidade e proposta',
visibility: 'Visibilidade após publicação',
@@ -632,6 +632,16 @@ export default function OpportunityCanonicalFields({
disabled?: boolean;
errors?: Record<string, string>;
}) {
const initiative = (value.initiative ?? {}) as Data;
const isInitiative = category === 'investment_project' && ['program', 'portfolio'].includes(valueText(initiative.kind ?? ''));
const applicability = (initiative.applicability ?? {}) as Record<string, string>;
const groups: Record<string, { label: string; fields: string[] }> = {
physical_site: { label: 'Implantação física, obra e licenças próprias', fields: ['location_mode', 'licenses', 'environmental_social'] },
individual_business: { label: 'Empresa ou operação individual', fields: ['promoter_id', 'promoter_type', 'opportunity_type', 'investor_profiles', 'products', 'assets', 'inputs', 'markets', 'competitive_advantage', 'employment', 'impact_indicators', 'enterprise_size'] },
unit_investment: { label: 'Investimento e financiamento por projecto', fields: ['total_investment', 'currency', 'investment_breakdown', 'secured_financing', 'funding_sought', 'financial_indicators'] },
};
const ignoredFields = isInitiative ? ['promoter_type', ...Object.entries(groups).flatMap(([key, group]) => applicability[key] === 'not_applicable' ? group.fields : [])] : [];
const setInitiative = (key: string, next: unknown) => onChange({ ...value, opportunity_category: category, initiative: { ...initiative, [key]: next } });
const set = (key: string, v: unknown) =>
onChange({
...value,
@@ -642,7 +652,7 @@ export default function OpportunityCanonicalFields({
? { economic_activity_id: null }
: {}),
opportunity_category: category,
promoter_id: organization ?? actorId,
...(isInitiative ? {} : { promoter_id: organization ?? actorId }),
});
const common = [
'official_title',
@@ -820,7 +830,49 @@ export default function OpportunityCanonicalFields({
propostas não representam compromisso nem decisão externa.
</p>
)}
{Array.from(new Set([...common, ...specialized])).map((key) => {
{category === 'investment_project' && (
<section aria-label="Enquadramento e aplicabilidade" className="space-y-4 rounded border p-4">
<label className="block">Natureza da ficha
<select className={control} aria-label="Natureza da ficha" value={valueText(initiative.kind ?? '')} onChange={(e) => {
if (!e.target.value) { const next = { ...value }; delete next.initiative; onChange(next); }
else setInitiative('kind', e.target.value);
}}>
<option value="">Projecto ou componente concreta</option>
<option value="program">Programa ou iniciativa agregadora</option>
<option value="portfolio">Carteira de oportunidades/PME</option>
</select>
</label>
{isInitiative && <>
<p>O responsável do programa/carteira não substitui os promotores das componentes. Abrangência não comprova implantação. A classificação não confirma financiamento, licenças ou disponibilidade.</p>
<label className="block">Responsável do programa ou carteira
<input className={control} aria-label="Responsável do programa ou carteira" value={valueText(initiative.responsible_name ?? '')} onChange={(e) => setInitiative('responsible_name', e.target.value || null)} />
</label>
<FieldErrors path="canonical.initiative.responsible_name" errors={errors} />
<label className="block">Abrangência territorial documentada
<textarea className={control} aria-label="Abrangência territorial documentada" value={valueText(initiative.coverage_statement ?? '')} onChange={(e) => setInitiative('coverage_statement', e.target.value || null)} />
</label>
<FieldErrors path="canonical.initiative.coverage_statement" errors={errors} />
<label className="block">Orçamento agregado — fonte e situação, sem confirmação automática
<textarea className={control} aria-label="Orçamento agregado" value={valueText(initiative.aggregate_budget_statement ?? '')} onChange={(e) => setInitiative('aggregate_budget_statement', e.target.value || null)} />
</label>
{Object.entries(groups).map(([key, group]) => <div key={key}>
<label className="block">{group.label}
<select className={control} aria-label={group.label} value={applicability[key] ?? 'missing'} onChange={(e) => setInitiative('applicability', { ...applicability, [key]: e.target.value })}>
<option value="missing">Informação em falta — aplicabilidade por determinar</option>
<option value="not_applicable">Não aplicável a esta ficha agregadora</option>
<option value="applicable">Aplicável — preencher os dados próprios</option>
</select>
</label>
<FieldErrors path={'canonical.initiative.applicability.' + key} errors={errors} />
{applicability[key] === 'not_applicable' && <details><summary>Não aplicável — consultar dados anteriores preservados</summary><pre className="overflow-auto whitespace-pre-wrap">{JSON.stringify(Object.fromEntries(group.fields.filter((field) => field in value).map((field) => [field, value[field]])), null, 2)}</pre><p>Sem eliminação dos valores. As componentes concretas mantêm os seus requisitos.</p></details>}
</div>)}
<p>Os recursos territoriais abaixo documentam a abrangência quando a implantação não é aplicável. A informação em falta continua pendente; não é convertida em “não aplicável”.</p>
</>}
</section>
)}
{[...new Set([...common, ...specialized])].map((key) => {
if (ignoredFields.includes(key)) return null;
if (key === 'relations' && options.classification) {
const rows = (value.relations ??
[]) as ClassificationRelation[];
@@ -936,14 +988,40 @@ export default function OpportunityCanonicalFields({
if (a.historical_only) return false;
if (a.level) {
return key === 'subsector_id'
? a.level === 2 && a.code === '01' && a.sector_id === Number(value.sector_id)
: a.level === 5 && a.code === '01120' && !!a.path?.some((p) => p.id === value.subsector_id);
? a.level === 2 &&
a.code === '01' &&
a.sector_id ===
Number(
value.sector_id,
)
: a.level === 5 &&
a.code === '01120' &&
!!a.path?.some(
(p) =>
p.id ===
value.subsector_id,
);
}
return key === 'subsector_id'
? options.activities.some((root) => root.sector_id === Number(value.sector_id) && a.parent_id === root.id)
: a.parent_id === value.subsector_id;
? options.activities.some(
(root) =>
root.sector_id ===
Number(
value.sector_id,
) &&
a.parent_id === root.id,
)
: a.parent_id ===
value.subsector_id;
})
.map((a) => ({ id: a.id, label: (a.display_label ?? a.label_pt_mz) + (a.historical_only ? ' — histórico preservado' : '') }));
.map((a) => ({
id: a.id,
label:
(a.display_label ?? a.label_pt_mz) +
(a.historical_only
? ' — histórico preservado'
: ''),
}));
return (
<label key={key} className="block">
{labels[key]}
@@ -972,12 +1050,25 @@ export default function OpportunityCanonicalFields({
</option>
))}
</select>
{key === 'economic_activity_id' && options.activities.find((a) => a.id === value[key])?.level && (
<p className="mt-1 text-sm" data-economic-path="five-levels">
{options.activities.find((a) => a.id === value[key])?.path?.map((p) => `${p.code} — ${p.label}`).join(' → ')}
{' · Exemplar eBAU; edição normativa não determinada; divergência 600/579 aberta.'}
</p>
)}
{key === 'economic_activity_id' &&
options.activities.find(
(a) => a.id === value[key],
)?.level && (
<p
className="mt-1 text-sm"
data-economic-path="five-levels"
>
{options.activities
.find((a) => a.id === value[key])
?.path?.map(
(p) => `${p.code} — ${p.label}`,
)
.join(' → ')}
{
' · Exemplar eBAU; edição normativa não determinada; divergência 600/579 aberta.'
}
</p>
)}
</label>
);
}
@@ -987,7 +1078,19 @@ export default function OpportunityCanonicalFields({
: [];
return (
<fieldset key={key}>
<legend>{labels[key]}</legend>
<legend>{isInitiative && key === 'locations' ? 'Abrangência territorial — recursos guardados' : labels[key]}</legend>
{key === 'locations' && (
<p>
Seleccione os locais já guardados em
Recursos; não volte a introduzir a
hierarquia. O local principal, os adicionais
e a finalidade de cada um são definidos
nesse registo. Abrangência, impacto e
contexto não significam implantação. Pode
guardar incompleto; a submissão conserva as
verificações existentes.
</p>
)}
<FieldErrors
path={'canonical.' + key}
errors={errors}
@@ -30,7 +30,18 @@ export type ClassificationOptions = {
dimensions: Record<string, string>;
types: Record<string, string>;
purposes: Record<string, string>;
targets: Record<string, { id: string | number; label: string; code?: string; level?: number | null; path?: { id: string; code: string; label: string }[]; source_version?: string | null; edition?: string | null }[]>;
targets: Record<
string,
{
id: string | number;
label: string;
code?: string;
level?: number | null;
path?: { id: string; code: string; label: string }[];
source_version?: string | null;
edition?: string | null;
}[]
>;
externalStates: string[];
};
const typeLabels: Record<string, string> = {
@@ -61,7 +72,7 @@ const externalLabels: Record<string, string> = {
expired: 'Caducado',
};
const help =
'Associação, publicação e ato externo têm estados separados. Localização ou proximidade não concedem licença, benefício, terreno ou capacidade. Dados ausentes permanecem por determinar. Os elos identificados como Modelo editorial não confirmam operadores, capacidade ou relações comerciais. O recorte CAE conserva cinco níveis; 600/579 permanece documentalmente aberto.';
'Indique relações com corredores, clusters, projectos âncora, zonas, parques, cadeias e actividades, quando aplicáveis. Cada associação tem finalidade, fonte e estado próprios. A implantação, a abrangência e a proximidade são diferentes: estar num distrito não comprova pertença a um cluster, parque ou regime especial. Associação, publicação e acto externo são decisões separadas; dados ausentes permanecem por determinar.';
export function ClassificationEditor({
rows,
@@ -117,7 +128,16 @@ export function ClassificationEditor({
className="space-y-4"
aria-label="Classificação multidimensional"
>
<h3 className="font-semibold">Quatro dimensões da oportunidade</h3>
<h3 className="font-semibold">
Associações territoriais e produtivas
</h3>
<p>
Inclui relações territoriais, produtivas, regulatórias e de
actividade. Não repita aqui o endereço nem a hierarquia dos
locais. Pode preparar relações preliminares; confirmação e
publicação seguem as verificações já existentes de fonte,
citação e revisão.
</p>
<p>{help}</p>
{Object.entries(options.dimensions).map(([dimension, label]) => (
<fieldset
@@ -193,9 +213,25 @@ export function ClassificationEditor({
<option value="">
Selecionar referência
</option>
{row.related_id && !(options.targets[row.classification.target_type] ?? []).some((t) => String(t.id) === String(row.related_id)) && (
<option value={row.related_id}>Referência guardada, atualmente indisponível — {row.related_id}</option>
)}
{row.related_id &&
!(
options.targets[
row.classification
.target_type
] ?? []
).some(
(t) =>
String(t.id) ===
String(row.related_id),
) && (
<option
value={row.related_id}
>
Referência guardada,
atualmente indisponível
— {row.related_id}
</option>
)}
{(
options.targets[
row.classification
@@ -210,9 +246,27 @@ export function ClassificationEditor({
</option>
))}
</select>
{row.related_id && !(options.targets[row.classification.target_type] ?? []).some((t) => String(t.id) === String(row.related_id)) && (
<span className="block text-sm">A referência deixou de estar disponível para novas escolhas. A relação histórica permanece guardada; não selecione outro valor para a recuperar.</span>
)}
{row.related_id &&
!(
options.targets[
row.classification
.target_type
] ?? []
).some(
(t) =>
String(t.id) ===
String(row.related_id),
) && (
<span className="block text-sm">
A referência deixou de estar
disponível para novas
escolhas. A relação
histórica permanece
guardada; não selecione
outro valor para a
recuperar.
</span>
)}
{errors[
`canonical.relations.${index}.related_id`
] && (
@@ -775,7 +829,7 @@ export function ClassificationSummary({
aria-label="Quatro dimensões da oportunidade"
>
<h2 className="text-xl font-bold">
Classificação multidimensional
Associações territoriais e produtivas
</h2>
<p>{help}</p>
{Object.entries({
@@ -792,12 +846,22 @@ export function ClassificationSummary({
.map((r, i) => (
<p key={i} className="break-words">
{r.reference_path?.length
? r.reference_path.map((n) => `${n.code} — ${n.label}`).join(' → ')
: r.label} · {classificationStates[r.state]}{' '}
? r.reference_path
.map(
(n) =>
`${n.code} — ${n.label}`,
)
.join(' → ')
: r.label}{' '}
·{' '}
{classificationStates[r.state] ??
'Por determinar'}{' '}
· {r.purpose_label ?? r.purpose} ·{' '}
{r.source_reference || 'Fonte por indicar'}{' '}
{r.citation} {r.production_stage} · Ato
externo: {externalLabels[r.external_status]}
externo:{' '}
{externalLabels[r.external_status] ??
'Não determinado'}
. Benefício ou capacidade disponível: não
inferido.
</p>
@@ -0,0 +1,121 @@
import { router } from '@inertiajs/react';
import { useState } from 'react';
import { createOperationKey } from '@/lib/operation-key';
import { contextApi } from '@/lib/investor-context';
export type LibrarySource = {
id: number;
title: string;
version_label: string | null;
metadata_version: number;
sha256: string;
};
export default function OpportunityLibraryReuse({
sources,
action,
etag,
}: {
sources: LibrarySource[];
action: string;
etag: string;
}) {
const [selected, setSelected] = useState('');
const [section, setSection] = useState('');
const [reason, setReason] = useState('');
const [key, setKey] = useState(createOperationKey);
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
return (
<section
aria-label="Reutilizar original da biblioteca"
className="space-y-3 rounded border p-4"
>
<h2 className="text-xl font-semibold">
Reutilizar original da biblioteca
</h2>
<p>
O suporte aponta para os mesmos bytes e conserva edição, hash e
histórico. Mantém-se privado; a ficha e o original têm
permissões e publicação próprias.
</p>
<label className="grid gap-1">
Original da biblioteca
<select
aria-label="Original da biblioteca"
className="w-full rounded border p-2"
value={selected}
onChange={(e) => setSelected(e.target.value)}
>
<option value="">Seleccionar original acessível</option>
{sources.map((s) => (
<option key={s.id} value={s.id}>
{s.title} · {s.version_label ?? 'sem edição'} ·
metadados v{s.metadata_version}
</option>
))}
</select>
</label>
<label className="grid gap-1">
Secções utilizadas
<input
aria-label="Secções utilizadas"
className="w-full rounded border p-2"
value={section}
maxLength={255}
onChange={(e) => setSection(e.target.value)}
/>
</label>
<label className="grid gap-1">
Motivo da reutilização
<textarea
aria-label="Motivo da reutilização"
className="w-full rounded border p-2"
value={reason}
minLength={20}
maxLength={1000}
onChange={(e) => setReason(e.target.value)}
/>
</label>
<button
className="rounded border bg-emerald-800 px-4 py-2 text-white disabled:opacity-50"
type="button"
disabled={!selected || reason.length < 20 || busy}
onClick={async () => {
setBusy(true);
setMessage('');
try {
const result = await contextApi<{ message: string }>(
action,
{
resource_id: Number(selected),
section: section || null,
reason,
etag,
creation_key: key,
},
);
setMessage(result.message);
setKey(createOperationKey);
router.reload();
} catch (e) {
setMessage(
e instanceof Error
? e.message
: 'Não foi possível associar o original. Selecção preservada.',
);
} finally {
setBusy(false);
}
}}
>
Associar original como suporte privado
</button>
<p role="status">
{message ||
(!sources.length
? 'Não há originais acessíveis à conta neste âmbito.'
: 'Seleccionar não grava nem divulga o original.')}
</p>
</section>
);
}
@@ -1,39 +1,13 @@
import { Link } from '@inertiajs/react';
import { useEffect, useState } from 'react';
export default function PartnershipSearchResults({ query }: { query: string }) {
const [items, setItems] = useState<{ id: number; title: string }[]>([]);
useEffect(() => {
if (!query.trim()) {
setItems([]);
return;
}
const controller = new AbortController();
fetch('/api/v1/partnerships?q=' + encodeURIComponent(query), {
signal: controller.signal,
headers: { Accept: 'application/json' },
})
.then((r) => (r.ok ? r.json() : null))
.then((data) => setItems(data?.data ?? []))
.catch(() => {});
return () => controller.abort();
}, [query]);
if (!items.length) return null;
return (
<section className="my-5 space-y-3 rounded border p-4">
<h2 className="text-xl font-bold">Parcerias nesta pesquisa</h2>
{items.slice(0, 5).map((item) => (
<p key={item.id}>
<Link className="underline" href={'/parcerias/' + item.id}>
{item.title}
</Link>
</p>
))}
<Link
className="underline"
href={'/parcerias?q=' + encodeURIComponent(query)}
>
Ver todas as parcerias correspondentes
</Link>
</section>
);
import { usePortalQuery } from '@/hooks/use-portal-query';
import SearchState from '@/components/search-state';
export default function PartnershipSearchResults({query}: {query:string}) {
const result = usePortalQuery<{data:{id:number;title:string}[]; total?:number}>('/api/v1/partnerships?q=' + encodeURIComponent(query), 250);
const items = result.data?.data ?? [];
if (!query.trim()) return null;
return <section className="my-5 space-y-3 rounded border p-4"><h2 className="text-xl font-bold">Parcerias nesta pesquisa</h2>
<SearchState loading={result.loading} error={result.error} total={result.data?.total ?? items.length} retry={result.retry}/>
{!result.error && !result.loading && items.slice(0,5).map(item => <p key={item.id}><Link className="underline" href={'/parcerias/'+item.id}>{item.title}</Link></p>)}
<Link className="underline" href={'/parcerias?q='+encodeURIComponent(query)}>Ver todas as parcerias correspondentes</Link>
</section>;
}
+1 -1
View File
@@ -23,7 +23,7 @@ export default function PasswordInput({
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:ring-[3px] focus-visible:outline-none"
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-label={showPassword ? 'Ocultar palavra-passe' : 'Mostrar palavra-passe'}
tabIndex={-1}
>
{showPassword ? (
@@ -1,3 +1,4 @@
import PortalProductIdentity from '@/components/portal-product-identity';
import { Link, router, usePage } from '@inertiajs/react';
import {
ChevronDown,
@@ -48,6 +49,7 @@ export function PortalGroupedMenu() {
legacyContentEnabled?: boolean;
profileAccess?: ProfileAccess | null;
opportunityManagement?: OpportunityManagementAccess | null;
scopedAdministration?: {label:string;href:string}[];
}>();
const { t, language, requestCount } = useTempoUi();
const navigation = useNavigationContext();
@@ -65,6 +67,7 @@ export function PortalGroupedMenu() {
opportunityManagement: props.opportunityManagement,
profileAccess: props.profileAccess,
});
if (props.scopedAdministration?.length) groups.push({id:'scoped-administration',label:'Administração',children:props.scopedAdministration.map((item,index)=>({id:`scoped-${index}`,label:item.label,href:item.href}))});
const current = currentNavigationGroup(groups, url);
const currentItem = currentNavigationItem(groups, url);
const [loggingOut, setLoggingOut] = useState(false);
@@ -127,16 +130,10 @@ export function PortalGroupedMenu() {
<div className="tempo-header-inner">
<Link
href="/"
aria-label="Tempo — início"
aria-label="InvesteMoz — início"
className="tempo-logo-link"
>
<img
src="/images/logo/logo-vermelho.png"
alt="Tempo"
width="1787"
height="564"
className="tempo-header-logo"
/>
<PortalProductIdentity />
</Link>
<nav
id={institutionalId}
@@ -0,0 +1,18 @@
/** Product identity and ownership are distinct; reuse the existing owner logo. */
export default function PortalProductIdentity() {
return (
<span className="flex flex-col items-start gap-1">
<span className="text-xl font-semibold tracking-tight">InvesteMoz</span>
<span className="flex items-center gap-1.5 text-[10px] font-normal">
Propriedade da
<img
src="/images/logo/logo-vermelho.png"
alt="Tempo, Lda."
width="1787"
height="564"
className="h-3.5 w-auto object-contain"
/>
</span>
</span>
);
}
@@ -0,0 +1,46 @@
import { router, usePage } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
// 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'];
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 (typeof props.total === 'number') return props.total;
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;
if (!value) continue;
if (Array.isArray(value)) return value.length;
if (typeof value.total === 'number') return value.total;
if (typeof value.meta?.total === 'number') return value.meta.total;
if (Array.isArray(value.data)) return value.data.length;
}
return 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 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;
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 (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>;
}
+5
View File
@@ -0,0 +1,5 @@
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>;
}
@@ -57,7 +57,7 @@ export type StructureGeography = {
};
const roles: Record<string, string> = {
opportunity: 'Implantação cadastrada da oportunidade',
opportunity: 'Localização declarada da oportunidade',
primary: 'Localização principal',
secondary: 'Localização secundária',
impact: 'Área de impacto',
+8 -15
View File
@@ -1,4 +1,5 @@
import { Link } from '@inertiajs/react';
import PortalProductIdentity from '@/components/portal-product-identity';
import { Link, usePage } from '@inertiajs/react';
import { ChevronRight, Mail, MapPin, Phone } from 'lucide-react';
import { TempoSocialLinks, useTempoUi } from '@/components/tempo-brand';
@@ -9,6 +10,7 @@ const navigation = [
export function TempoFooter() {
const { t, language } = useTempoUi();
const { portalInstitutionalContactEmail } = usePage<{ portalInstitutionalContactEmail?: string | null }>().props;
return (
<footer
className="tempo-footer border-t border-white/5"
@@ -18,17 +20,10 @@ export function TempoFooter() {
<div className="space-y-8">
<Link
href="/"
aria-label="Tempo — início"
aria-label="InvesteMoz — início"
className="block w-fit"
>
<img
src="/images/logo/logo-vermelho.png"
alt="Tempo"
width="1787"
height="564"
className="h-10 w-auto object-contain md:h-12"
loading="lazy"
/>
<PortalProductIdentity />
</Link>
<div className="flex items-start gap-4">
<MapPin
@@ -63,16 +58,14 @@ export function TempoFooter() {
</a>
))}
<a
href="mailto:info@tempo.co.mz"
href={portalInstitutionalContactEmail ? `mailto:${portalInstitutionalContactEmail}` : '/apoio'}
className="tempo-contact-link"
>
<span className="tempo-contact-icon">
<Mail size={16} aria-hidden="true" />
</span>
<strong className="min-w-0 text-sm font-medium [overflow-wrap:anywhere]">
comercial@
<wbr />
tempo.co.mz
<strong className="min-w-0 text-sm font-medium [overflow-wrap:anywhere]">
{portalInstitutionalContactEmail ?? t('Apoio ao investidor')}
</strong>
</a>
</div>
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useState } from 'react';
import { newSubmissionKey } from '@/lib/portal-api';
export function useInterestDraft<T>(name: string, initial: T) {
const storage = 'investemoz-interest:' + name;
const [draft, setDraft] = useState<{form:T; key:string}>(() => {
try { const value = JSON.parse(sessionStorage.getItem(storage) ?? 'null');
if (value?.form && typeof value.key === 'string') return value;
} catch { /* unavailable storage leaves the current form usable */ }
return { form: initial, key: newSubmissionKey() };
});
useEffect(() => { try { sessionStorage.setItem(storage, JSON.stringify(draft)); } catch {} }, [storage, draft]);
const setForm = (value: T | ((old:T)=>T)) => setDraft(old => ({...old, form: typeof value === 'function' ? (value as (old:T)=>T)(old.form) : value}));
return { form: draft.form, setForm, key: draft.key,
clear: () => { sessionStorage.removeItem(storage); sessionStorage.removeItem('investemoz-participation-identity'); },
authenticate: () => { const identity = draft.form as {name?: string; fullName?: string; email?: string}; sessionStorage.setItem('investemoz-participation-identity', JSON.stringify({name: identity.fullName ?? identity.name ?? '', email: identity.email ?? ''})); sessionStorage.setItem(storage, JSON.stringify(draft)); window.location.assign('/participar/entrar?return=' + encodeURIComponent(location.pathname + location.search)); } };
}
@@ -1,5 +1,5 @@
import PortalProductIdentity from '@/components/portal-product-identity';
import { Link } from '@inertiajs/react';
import AppLogoIcon from '@/components/app-logo-icon';
import { home } from '@/routes';
import type { AuthLayoutProps } from '@/types';
@@ -9,7 +9,7 @@ export default function AuthSimpleLayout({
description,
}: AuthLayoutProps) {
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="bg-background flex min-h-[28rem] flex-col items-center justify-start gap-6 p-6 md:p-8">
<div className="w-full max-w-sm">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-4">
@@ -17,8 +17,8 @@ export default function AuthSimpleLayout({
href={home()}
className="flex flex-col items-center gap-2 font-medium"
>
<div className="mb-1 flex h-9 w-9 items-center justify-center rounded-md">
<AppLogoIcon className="size-9 fill-current text-[var(--foreground)] dark:text-white" />
<div className="mb-1 flex min-h-12 items-center justify-center">
<PortalProductIdentity />
</div>
<span className="sr-only">{title}</span>
</Link>
@@ -1,3 +1,4 @@
import SearchFeedback from '@/components/search-feedback';
import { Link, usePage } from '@inertiajs/react';
import { createContext, useContext } from 'react';
import { PortalGroupedMenu } from '@/components/portal-grouped-menu';
@@ -120,6 +121,11 @@ export default function PortalNavigation({
Saltar para o conteúdo
</a>
</nav>
{props.homologationReserved === true && (
<aside aria-label="Ambiente de homologação" className="border-b border-amber-200 bg-amber-50 px-5 py-2 text-sm text-amber-950 print:hidden">
Homologação reservada à revisão da Tempo. Consulte o conteúdo público; entre na sua conta quando iniciar uma operação.
</aside>
)}
{portalShell && (
<div
className={
@@ -164,6 +170,7 @@ export default function PortalNavigation({
<NavigationContextSummary />
</div>
)}
<SearchFeedback />
{children}
</div>
<aside
+18
View File
@@ -6,6 +6,21 @@ function csrfToken(): string {
?.slice('XSRF-TOKEN='.length) ?? '',
);
}
export type ContextDocument = {
resource_id: number;
document_id: string;
metadata_version: number;
document_version: number;
edition: string | null;
sha256: string;
source_reference: string;
title: string;
support_id: string;
support_version: number;
section: string | null;
opportunity_id: number;
download_url: string;
};
export type ContextReference = {
key: string;
kind: string;
@@ -15,6 +30,9 @@ export type ContextReference = {
version: number | string | null;
sha256: string;
fields: Record<string, string>;
documents?: ContextDocument[];
visibility?: 'private' | 'public';
state?: string | null;
};
export type DossierContext = {
revision: number;
@@ -0,0 +1,5 @@
export function participationIdentity(): {name:string;email:string} {
try { const value = JSON.parse(sessionStorage.getItem('investemoz-participation-identity') ?? 'null');
return {name: typeof value?.name === 'string' ? value.name : '', email: typeof value?.email === 'string' ? value.email : ''};
} catch { return {name:'',email:''}; }
}
+4
View File
@@ -113,6 +113,10 @@ export async function portalRequest<T>(
): Promise<T> {
const headers = new Headers(options.headers);
headers.set('Accept', 'application/json');
if (options.method && !['GET', 'HEAD'].includes(options.method.toUpperCase())) {
const token = document.cookie.split('; ').find(value => value.startsWith('XSRF-TOKEN='))?.slice(11);
if (token) headers.set('X-XSRF-TOKEN', decodeURIComponent(token));
}
const response = await fetch(url, {
...options,
credentials: 'same-origin',
+1 -1
View File
@@ -440,11 +440,11 @@ export function currentNavigationItem(
export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
const previous = legacyPortalNavigation(access);
return uniqueNavigationDestinations([
link('home', 'Início', '/'),
{
id: 'discovery',
label: 'Descoberta',
children: [
link('home', 'Início', '/'),
link(
'why',
'Porquê investir em Moçambique',
+14 -13
View File
@@ -367,7 +367,7 @@ export async function generateProjectDossierDocx(
annexesIncluded: boolean,
): Promise<Blob> {
const children: (Paragraph | Table)[] = [
new Paragraph({ text: 'InvestMoz', heading: HeadingLevel.HEADING_2 }),
new Paragraph({ text: 'InvesteMoz', heading: HeadingLevel.HEADING_2 }),
new Paragraph({
text: 'Relatório consolidado do projecto',
heading: HeadingLevel.TITLE,
@@ -447,7 +447,8 @@ export async function generateProjectDossierDocx(
alignment: AlignmentType.RIGHT,
children: [
new TextRun({
text: 'InvestMoz · relatório privado · página ',
text: 'InvesteMoz · '+model.title.slice(0, 60)+' · privado · página ',
size: 18,
}),
new SimpleField('PAGE'),
],
@@ -455,14 +456,14 @@ export async function generateProjectDossierDocx(
],
});
const doc = new Document({
creator: 'InvestMoz',
creator: 'InvesteMoz',
title: `Relatório do projecto — ${model.title}`,
description:
'Dossier consolidado exportado localmente a partir do formulário InvestMoz.',
'Dossier consolidado exportado localmente a partir do formulário InvesteMoz.',
styles: {
default: {
document: {
run: { font: 'Aptos', size: 20, color: '17211F' },
run: { font: 'Arial', size: 20, color: '17211F' },
paragraph: { spacing: { after: 120, line: 276 } },
},
},
@@ -472,7 +473,7 @@ export async function generateProjectDossierDocx(
name: 'Title',
basedOn: 'Normal',
run: {
font: 'Aptos Display',
font: 'Arial',
size: 34,
bold: true,
color: '123B35',
@@ -485,7 +486,7 @@ export async function generateProjectDossierDocx(
basedOn: 'Normal',
next: 'Normal',
run: {
font: 'Aptos Display',
font: 'Arial',
size: 26,
bold: true,
color: '123B35',
@@ -501,7 +502,7 @@ export async function generateProjectDossierDocx(
basedOn: 'Normal',
next: 'Normal',
run: {
font: 'Aptos',
font: 'Arial',
size: 24,
bold: true,
color: '27675C',
@@ -517,7 +518,7 @@ export async function generateProjectDossierDocx(
basedOn: 'Normal',
next: 'Normal',
run: {
font: 'Aptos',
font: 'Arial',
size: 22,
bold: true,
color: '27675C',
@@ -533,7 +534,7 @@ export async function generateProjectDossierDocx(
{
properties: {
page: {
size: { width: 12240, height: 15840 },
size: { width: 11906, height: 16838 },
margin: {
top: 900,
right: 960,
@@ -569,7 +570,7 @@ async function fileDataUrl(file: File): Promise<string> {
}
function htmlTable(headers: string[], rows: string[][]): string {
return `<table><thead><tr>${headers.map((value) => `<th>${escapeHtml(value)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${headers.map((_, index) => `<td>${escapeHtml(row[index] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
return `<table class="${headers[0] === 'Campo' && headers[2] === 'Classificação / origem' ? 'report-table' : 'annex-table'}"><thead><tr>${headers.map((value) => `<th>${escapeHtml(value)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${headers.map((_, index) => `<td>${escapeHtml(row[index] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
}
export async function createProjectDossierPrintHtml(
@@ -640,8 +641,8 @@ export async function createProjectDossierPrintHtml(
content.push('</section>');
}
return `<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><title>Relatório — ${escapeHtml(model.title)}</title><style>
@page{size:A4;margin:18mm 17mm 20mm}*{box-sizing:border-box}body{font:10.5pt/1.45 Arial,sans-serif;color:#17211f;margin:0}h1{font-size:24pt;color:#123b35;margin:0 0 7mm}h2{font-size:16pt;color:#123b35;border-bottom:1px solid #b8c9c3;padding-bottom:2mm;margin:8mm 0 3mm;break-after:avoid}h3{font-size:12pt;color:#27675c;break-after:avoid}p{white-space:pre-wrap}table{width:100%;border-collapse:collapse;table-layout:fixed;margin:3mm 0 5mm;break-inside:auto}th,td{border:1px solid #cbd5d1;padding:5px 6px;text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#123b35;color:white}tr{break-inside:avoid}th:first-child,td:first-child{width:22%}th:last-child,td:last-child{width:32%;font-size:9pt;color:#40524d}.private{padding:3mm;background:#fff4df;border-left:3px solid #b66a0c;font-weight:bold}section{break-inside:auto}figure{margin:5mm auto;text-align:center;break-inside:avoid}img{max-width:100%;max-height:210mm;object-fit:contain}figcaption{font-style:italic;color:#52615d}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:9.5pt/1.4 Arial,sans-serif}@media print{a{color:inherit;text-decoration:none}.no-print{display:none}}
</style></head><body><h1>InvestMoz<br>Relatório consolidado do projecto</h1><p><strong>${escapeHtml(model.title)}</strong><br>Gerado em ${escapeHtml(new Date(model.generatedAt).toLocaleString('pt-MZ'))}<br>Versão/revisão: ${escapeHtml(model.version)}</p><p class="private">Documento privado. O portal não publica nem partilha este conteúdo.</p>${content.join('')}<footer><small>InvestMoz · Relatório privado · ${escapeHtml(model.version)}</small></footer><script>window.addEventListener('load',()=>setTimeout(()=>window.print(),350));</script></body></html>`;
@page{size:A4;margin:18mm 17mm 20mm}*{box-sizing:border-box}body{font:10.5pt/1.45 Arial,sans-serif;color:#17211f;margin:0}h1{font-size:24pt;color:#123b35;margin:0 0 7mm}h2{font-size:16pt;color:#123b35;border-bottom:1px solid #b8c9c3;padding-bottom:2mm;margin:8mm 0 3mm;break-after:avoid}h3{font-size:12pt;color:#27675c;break-after:avoid}p{white-space:pre-wrap}table{width:100%;border-collapse:collapse;table-layout:fixed;margin:3mm 0 5mm;break-inside:auto}th,td{border:1px solid #cbd5d1;padding:5px 6px;text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#123b35;color:white}tr{break-inside:avoid}.report-table th:first-child,.report-table td:first-child{width:22%}.report-table th:last-child,.report-table td:last-child{width:32%}.report-table td:last-child{font-size:9pt;color:#40524d}.annex-table{table-layout:fixed;font-size:9pt}thead{display:table-header-group}.private{padding:3mm;background:#fff4df;border-left:3px solid #b66a0c;font-weight:bold}section{break-inside:auto}figure{margin:5mm auto;text-align:center;break-inside:avoid}img{max-width:100%;max-height:210mm;object-fit:contain}figcaption{font-style:italic;color:#52615d}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:9.5pt/1.4 Arial,sans-serif}@media screen{body{max-width:1040px;margin:24px auto;padding:24px}table{font-size:11pt}}@media screen{body{max-width:1040px;margin:24px auto;padding:24px}table{font-size:11pt}}@media print{a{color:inherit;text-decoration:none}.no-print{display:none}th{color:#17211f;background:#edf4f1}footer{position:fixed;bottom:-14mm;left:0;right:0;font-size:9pt;color:#40524d}}
</style></head><body><h1>InvesteMoz<br>Relatório consolidado do projecto</h1><p><strong>${escapeHtml(model.title)}</strong><br>Gerado em ${escapeHtml(new Date(model.generatedAt).toLocaleString('pt-MZ'))}<br>Versão/revisão: ${escapeHtml(model.version)}</p><p class="private">Documento privado. O portal não publica nem partilha este conteúdo.</p>${content.join('')}<footer><small>InvesteMoz · Relatório privado · ${escapeHtml(model.version)}</small></footer><script>window.addEventListener('load',()=>setTimeout(()=>window.print(),350));</script></body></html>`;
}
export async function generateProjectDossierPackage(
+34 -7
View File
@@ -111,6 +111,30 @@ function classification(origin: string): string {
return 'Referência/estado indicado';
}
export function formatDossierReportValue(field: string, value: string, currency = ''): string {
if (field === 'Estado do plano detalhado') {
return value.replace('Snapshot versionado disponível; usar também a exportação do plano financeiro', 'Plano guardado disponível; consulte também a exportação financeira');
}
if (field === 'Reconciliação do financiamento') {
const states: Record<string, string> = { shortfall: 'Financiamento insuficiente', surplus: 'Fontes superiores aos usos previstos', balanced: 'Fontes e usos equilibrados', unknown: 'Informação insuficiente', incomplete: 'Informação insuficiente' };
return states[value] ?? value;
}
if (/^Necessidade \d+$/.test(field)) {
value = value.replace('; gap;', '; Competência em falta;').replace('; unknown;', '; Por confirmar;').replace('; available;', '; Disponível (declarado);');
}
if (/^Marco \d+$/.test(field)) {
value = value.replace('; planned;', '; Planeado;').replace('; in_progress;', '; Em curso;').replace('; completed;', '; Concluído (declarado);');
}
if (/^(Necessidade|Marco) \d+$/.test(field)) {
value = value.replace(/(custo|planeado|realizado): ([^;]*)(?=;|$)/g, (_, label: string, amount: string) => {
const raw = amount.trim();
return `${label}: ${raw ? (/^\d+(?:\.\d{1,2})?$/.test(raw) ? `${raw} ${currency || '(moeda não informada)'}` : raw) : 'Não informado'}`;
}).replace(/prazo: (?=;|$)/g, 'prazo: Não informado');
}
// Presentation only: group the decimal string without converting the amount
// to a JavaScript float or changing the saved financial snapshot.
return value.replace(/^(-?)(\d+)\.(\d{2}) ([A-Z]{3})$/, (_, sign: string, whole: string, cents: string, currency: string) => `${sign}${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')},${cents} ${currency}`);
}
const rowSection: Record<string, DossierReportSectionId> = {
Projecto: 'project',
Mercado: 'market',
@@ -180,28 +204,31 @@ function makeSections(
{
field: 'Data de geração',
value: new Date(generatedAt).toLocaleString('pt-MZ'),
origin: 'Relógio do dispositivo no momento da exportação',
classification: 'Metadado do relatório',
origin: 'Emissão no momento da exportação',
classification: 'Data de emissão',
},
{
field: 'Versão/revisão do dossier',
value: version,
origin: 'Revisão de rascunho guardada ou impressão digital local',
classification: 'Metadado técnico',
origin: 'Revisão do dossier exportado',
classification: 'Referência da revisão',
},
);
}
for (const [sectionName, field, value, origin] of rows.slice(1)) {
// Questionnaire schema identifiers remain stored; they do not help the
// reader assess the project and are not a business report field.
if (field === 'Versão do questionário') continue;
const id = rowSection[sectionName];
if (!id) continue;
const section = sections.get(id);
if (!section) continue;
section.rows.push({
field,
value: absent(value),
value: absent(formatDossierReportValue(field, value, text(draft, 'currency'))),
origin,
classification: classification(origin),
classification: value.trim() === '' ? 'Informação em falta' : classification(origin),
});
}
@@ -321,7 +348,7 @@ function makeSections(
field: 'Versão do motor/cenário',
value: `${typeof context?.engine_version === 'string' ? context.engine_version : 'não identificada'} / ${typeof context?.scenario_version === 'number' ? context.scenario_version : currentCalculation.version}`,
origin: 'Contexto guardado na execução',
classification: 'Metadado técnico',
classification: 'Referência da revisão',
},
{
field: 'Fórmula e valores usados',
+73 -14
View File
@@ -1,7 +1,8 @@
import SearchState from '@/components/search-state';
import { useInterestDraft } from '@/hooks/use-interest-draft';
import { createContext, useContext, useRef, useState } from 'react';
import { router } from '@inertiajs/react';
import { PortalMenu } from '@/layouts/portal-navigation';
import { useTempoUi } from '@/components/tempo-brand';
import { ProvinceSelect } from '@/components/investment-territory';
import { MATURITY_LEVELS } from '@/lib/investment-maturity';
import CountrySelect, { type Country } from '@/components/country-select';
@@ -12,7 +13,6 @@ import {
PortalApiError,
fieldErrors,
interestPayload,
newSubmissionKey,
money,
portalRequest,
searchUrl,
@@ -95,6 +95,48 @@ function QueryStatus({
/* Componentes */
/* ------------------------------------------------------------------ */
function TopBar({
onNavigate,
requestCount,
}: {
onNavigate: (view: View) => void;
requestCount: number;
}) {
return (
<nav
aria-label="Navegação das manifestações"
className="sticky top-0 z-30 border-b border-slate-200 bg-white/95 backdrop-blur"
>
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3 px-5 py-4">
<button
onClick={() => onNavigate('home')}
className="flex items-baseline gap-1 font-black tracking-tight text-slate-900"
>
<span className="text-lg">INVESTE</span>
<span className="text-lg text-emerald-700">Moçambique</span>
</button>
<div className="flex items-center gap-3 text-xs text-slate-500">
<a href="/investidor/manifestacoes" className="underline">
O meu painel
</a>
<span className="hidden sm:inline">
{requestCount} pedido{requestCount === 1 ? '' : 's'}{' '}
recebido
{requestCount === 1 ? '' : 's'}
</span>
<button
onClick={() => onNavigate('interest')}
className="rounded-lg bg-slate-900 px-4 py-2 text-sm font-bold text-white hover:bg-slate-700"
>
Manifestar interesse
</button>
</div>
</div>
</nav>
);
}
function Hero({
query,
setQuery,
@@ -140,12 +182,6 @@ function Hero({
>
Explorar oportunidades <ArrowRight size={16} />
</button>
<button
onClick={() => onNavigate('interest')}
className="rounded-lg border border-white/30 px-5 py-3 font-semibold text-white hover:bg-white/10"
>
Manifestar interesse
</button>
<a
href="/simulador-fiscal"
className="rounded-lg border border-emerald-300 px-5 py-3 font-semibold text-emerald-100 hover:bg-emerald-900"
@@ -356,10 +392,11 @@ function OpportunitiesView({
return (
<section className="mx-auto max-w-6xl px-5 py-12">
<SearchState loading={query.loading} error={query.error} total={total} retry={query.retry} />
<h1 className="text-3xl font-black text-slate-900">
Oportunidades de investimento
</h1>
<p className="mt-2 text-slate-600">
<p role="status" aria-live="polite" className="mt-2 text-slate-600">
{query.loading ? (
'A pesquisar oportunidades...'
) : query.error ? (
@@ -668,7 +705,7 @@ function InterestForm({
const pending = useRef(false);
const submission = useRef<{ body: string; key: string } | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [form, setForm] = useState<InterestFormData>({
const draft = useInterestDraft<InterestFormData>('opportunity:' + (preselected?.reference ?? 'general'), {
name: '',
email: '',
phone: '',
@@ -681,6 +718,7 @@ function InterestForm({
message: '',
privacyConsent: false,
});
const { form, setForm } = draft;
const [errors, setErrors] = useState<FormErrors>({});
const [result, setResult] = useState<{ reference: string } | null>(null);
@@ -719,7 +757,7 @@ function InterestForm({
try {
const body = JSON.stringify(interestPayload(form));
if (submission.current?.body !== body) {
submission.current = { body, key: newSubmissionKey() };
submission.current = { body, key: draft.key };
}
const response = await portalRequest<{
success: boolean;
@@ -737,9 +775,11 @@ function InterestForm({
typeof response.data?.reference !== 'string'
)
throw new Error('Invalid confirmation');
draft.clear();
setResult({ reference: response.data.reference });
onSubmitted();
} catch (error: unknown) {
if (error instanceof PortalApiError && error.status === 401) { draft.authenticate(); return; }
if (error instanceof PortalApiError) {
setErrors(fieldErrors(error));
setSubmitError(error.message);
@@ -765,7 +805,7 @@ function InterestForm({
A sua referência é <strong>{result.reference}</strong>. A
equipa do portal irá analisar o pedido e poderá solicitar
informação adicional.
</p>
</p><a href="/investidor/manifestacoes" className="mt-4 inline-block underline">Acompanhar manifestações</a>
</section>
);
}
@@ -1011,6 +1051,23 @@ function Field({
);
}
function Footer() {
return (
<footer className="border-t border-slate-200 bg-slate-950 py-10 text-slate-400">
<div className="mx-auto max-w-6xl px-5 text-sm">
<p className="font-bold text-white">INVESTE Moçambique</p>
<p className="mt-2 max-w-md">
Portal do Investidor — conheça as oportunidades de
investimento em Moçambique e manifeste o seu interesse.{' '}
<a href="/acesso-gestao" className="underline">
Acesso à gestão
</a>
</p>
</div>
</footer>
);
}
/* ------------------------------------------------------------------ */
/* App */
/* ------------------------------------------------------------------ */
@@ -1038,7 +1095,7 @@ export default function PortalInvestidorApp({
const selected = initialOpportunity
? toOpportunity(initialOpportunity)
: null;
const { registerSubmittedInterest } = useTempoUi();
const [requestCount, setRequestCount] = useState(0);
const [heroQuery, setHeroQuery] = useState('');
const [filters, setFilters] = useState<Filters>({
query: initialQuery,
@@ -1083,6 +1140,7 @@ export default function PortalInvestidorApp({
return (
<SectorContext.Provider value={sectors}>
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
{view !== 'home' && <TopBar onNavigate={navigate} requestCount={requestCount} />}
<PortalMenu />
<main>
{view === 'home' && (
@@ -1321,10 +1379,11 @@ export default function PortalInvestidorApp({
key={selected?.reference ?? 'general'}
preselected={selected}
countries={countries}
onSubmitted={registerSubmittedInterest}
onSubmitted={() => setRequestCount((c) => c + 1)}
/>
)}
</main>
<Footer />
<style>{`
.input {
+14 -14
View File
@@ -1,3 +1,4 @@
import { participationIdentity } from '@/lib/participation-identity';
import { Form, Head, usePage } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
@@ -27,7 +28,7 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
const pageErrors = usePage<{ errors: { email?: string } }>().props.errors;
return (
<>
<Head title="Log in" />
<Head title="Entrar" />
{qaAccount && (
<aside
@@ -46,7 +47,7 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
)}
{/* @chisel-passkeys */}
<PasskeyVerify />
<PasskeyVerify label="Entrar com chave de acesso" loadingLabel="A autenticar…" separator="Ou continuar com email" />
{/* @end-chisel-passkeys */}
<Form
@@ -58,17 +59,16 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
<>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
name="email"
required
autoFocus
tabIndex={1}
autoComplete="email"
placeholder="email@example.com"
defaultValue={qaAccount?.email}
defaultValue={qaAccount?.email ?? participationIdentity().email}
/>
<InputError
message={errors.email ?? pageErrors.email}
@@ -77,14 +77,14 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">Palavra-passe</Label>
{canResetPassword && (
<TextLink
href={request()}
className="ml-auto text-sm"
tabIndex={5}
>
Forgot your password?
Esqueceu a palavra-passe?
</TextLink>
)}
</div>
@@ -94,7 +94,7 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
required
tabIndex={2}
autoComplete="current-password"
placeholder="Password"
placeholder="Palavra-passe"
/>
<InputError message={errors.password} />
</div>
@@ -105,7 +105,7 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
name="remember"
tabIndex={3}
/>
<Label htmlFor="remember">Remember me</Label>
<Label htmlFor="remember">Manter sessão iniciada</Label>
</div>
<Button
@@ -116,15 +116,15 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
data-test="login-button"
>
{processing && <Spinner />}
Log in
Entrar
</Button>
</div>
{/* @chisel-registration */}
<div className="text-muted-foreground text-center text-sm">
Don't have an account?{' '}
Ainda não tem conta?{' '}
<TextLink href={register()} tabIndex={5}>
Sign up
Criar conta
</TextLink>
</div>
{/* @end-chisel-registration */}
@@ -142,6 +142,6 @@ export default function Login({ status, canResetPassword, qaAccount }: Props) {
}
Login.layout = {
title: 'Log in to your account',
description: 'Enter your email and password below to log in',
title: 'Entrar na sua conta',
description: 'Entre para continuar a operação que escolheu. Os dados preenchidos permanecem guardados neste navegador.',
};
+16 -14
View File
@@ -1,3 +1,4 @@
import { participationIdentity } from '@/lib/participation-identity';
import { Form, Head } from '@inertiajs/react';
import InputError from '@/components/input-error';
import PasswordInput from '@/components/password-input';
@@ -16,7 +17,7 @@ type Props = {
export default function Register({ passwordRules }: Props) {
return (
<>
<Head title="Register" />
<Head title="Criar conta" />
<Form
{...store.form()}
resetOnSuccess={['password', 'password_confirmation']}
@@ -27,16 +28,16 @@ export default function Register({ passwordRules }: Props) {
<>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Label htmlFor="name">Nome</Label>
<Input
id="name"
defaultValue={participationIdentity().name}
type="text"
required
autoFocus
tabIndex={1}
autoComplete="name"
name="name"
placeholder="Full name"
placeholder="Nome completo"
/>
<InputError
message={errors.name}
@@ -45,9 +46,10 @@ export default function Register({ passwordRules }: Props) {
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Label htmlFor="email">Email</Label>
<Input
id="email"
defaultValue={participationIdentity().email}
type="email"
required
tabIndex={2}
@@ -59,14 +61,14 @@ export default function Register({ passwordRules }: Props) {
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">Palavra-passe</Label>
<PasswordInput
id="password"
required
tabIndex={3}
autoComplete="new-password"
name="password"
placeholder="Password"
placeholder="Palavra-passe"
passwordrules={passwordRules}
/>
<InputError message={errors.password} />
@@ -74,7 +76,7 @@ export default function Register({ passwordRules }: Props) {
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm password
Confirmar palavra-passe
</Label>
<PasswordInput
id="password_confirmation"
@@ -82,7 +84,7 @@ export default function Register({ passwordRules }: Props) {
tabIndex={4}
autoComplete="new-password"
name="password_confirmation"
placeholder="Confirm password"
placeholder="Confirmar palavra-passe"
passwordrules={passwordRules}
/>
<InputError
@@ -97,14 +99,14 @@ export default function Register({ passwordRules }: Props) {
data-test="register-user-button"
>
{processing && <Spinner />}
Create account
Criar conta
</Button>
</div>
<div className="text-muted-foreground text-center text-sm">
Already have an account?{' '}
Já tem conta?{' '}
<TextLink href={login()} tabIndex={6}>
Log in
Entrar
</TextLink>
</div>
</>
@@ -115,6 +117,6 @@ export default function Register({ passwordRules }: Props) {
}
Register.layout = {
title: 'Create an account',
description: 'Enter your details below to create your account',
title: 'Criar conta',
description: 'Crie uma conta simples para continuar e acompanhar a sua operação. Não é necessário configurar MFA para participar.',
};
+2 -2
View File
@@ -190,9 +190,9 @@ export default function RequestPage({
{request.protocol && (
<a
className="underline"
href={`/balcao-virtual/pedidos/${request.id}/recibo`}
href={`/balcao-virtual/pedidos/${request.id}/recibo?format=html`}
>
Descarregar recibo interno e snapshot
Consultar e imprimir recibo interno
</a>
)}
</section>
@@ -1,3 +1,4 @@
import { useInterestDraft } from '@/hooks/use-interest-draft';
import { useRef, useState } from 'react';
import { Link } from '@inertiajs/react';
import type { FormEvent } from 'react';
@@ -38,7 +39,7 @@ export default function BusinessInterest({
returnUrl = '/negocios',
countries,
}: Props) {
const [form, setForm] = useState<Form>({
const draft = useInterestDraft<Form>('business:' + record.id, {
declared_origin_country_code: '',
fullName: '',
email: '',
@@ -52,11 +53,12 @@ export default function BusinessInterest({
privacyConsent: false,
shareWithProponent: false,
});
const {form, setForm} = draft;
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof Form, string>>>({});
const [receipt, setReceipt] = useState('');
const key = useRef(newSubmissionKey());
const key = useRef(draft.key);
const update = <K extends keyof Form>(field: K, value: Form[K]) => {
setForm((current) => ({ ...current, [field]: value }));
setFieldErrors((current) => ({ ...current, [field]: undefined }));
@@ -102,6 +104,7 @@ export default function BusinessInterest({
existingBusinessId: record.id,
}),
});
if (response.status === 401) { draft.authenticate(); return; }
const data = await response.json();
if (!response.ok) {
const errors: Partial<Record<keyof Form, string>> = {};
@@ -117,6 +120,7 @@ export default function BusinessInterest({
return;
}
setFieldErrors({});
draft.clear();
setReceipt(data.data.reference);
key.current = newSubmissionKey();
} catch (cause) {
@@ -140,7 +144,7 @@ export default function BusinessInterest({
{receipt ? (
<section className={card} role="status">
<h2 className="text-xl font-bold">Pedido recebido</h2>
<p>Referência: {receipt}</p>
<p>Referência: {receipt}</p><Link href="/investidor/manifestacoes" className="underline">Acompanhar manifestações</Link>
<p>
A equipa fará a triagem antes de qualquer partilha com o
proponente.
@@ -32,6 +32,7 @@ type Props = {
record: Business;
modalities: string[];
formSchema: FormSchema;
modalityFormType?: string | null;
objects: BusinessObject[];
objectKinds: string[];
sectors: { id: number; name: string }[];
@@ -176,42 +177,11 @@ function TerritoryField({
</div>
);
}
function opportunityTypeFor(modality: string): string {
if (
[
'investment_project',
'company_sale',
'asset_sale',
'business_partnership',
'financing_request',
'concession_opportunity',
'supply_opportunity',
].includes(modality)
)
return modality;
if (['participation_sale'].includes(modality)) return 'company_sale';
if (['asset_lease'].includes(modality)) return 'asset_sale';
if (['capital_increase', 'financial_investor'].includes(modality))
return 'financing_request';
if (['strategic_partner', 'joint_venture'].includes(modality))
return 'business_partnership';
if (
[
'expansion',
'agricultural_investment',
'internationalization',
].includes(modality)
)
return 'investment_project';
if (['buyer_search', 'position_assignment'].includes(modality))
return 'company_sale';
return 'investment_project';
}
export default function BusinessWizard({
countries,
record,
formSchema,
modalityFormType,
objects,
objectKinds,
sectors,
@@ -223,7 +193,7 @@ export default function BusinessWizard({
...record.payload,
opportunity_type:
record.payload.opportunity_type ??
opportunityTypeFor(record.modality),
modalityFormType ?? '',
wizard_version: '2',
};
if (
@@ -236,9 +206,7 @@ export default function BusinessWizard({
}
return initial;
});
const [modality, setModality] = useState(
record.payload.opportunity_type ?? opportunityTypeFor(record.modality),
);
const modality = record.modality;
const [sectorId, setSectorId] = useState(record.sector_id ?? null);
const [sectorSearch, setSectorSearch] = useState('');
const [publicTitle, setPublicTitle] = useState(record.public_title ?? '');
@@ -397,7 +365,6 @@ export default function BusinessWizard({
);
const update = (key: string, value: string) => {
setError('');
if (key === 'opportunity_type') setModality(value);
setPayload((current) => {
const next = { ...current, [key]: value };
if (key === 'geography_scope' && value === 'multiple') {
@@ -533,6 +500,8 @@ export default function BusinessWizard({
)}
>
<div lang={english ? 'en' : 'pt'} className="space-y-5">
<p>{t('Modalidade registada', 'Registered modality')}: {businessModalities[record.modality] ?? record.modality}</p>
{!modalityFormType && <p role="status">{t('Pode guardar e recuperar esta preparação. O formulário específico de submissão desta modalidade ainda não está disponível; os dados e a modalidade original serão preservados.', 'You can save and recover this draft. Its specific submission form is not available yet; the original modality and data are preserved.')}</p>}
<a className="underline" href={`/investidor/negocios/${record.id}/classificacao`}>{t('Classificação multidimensional deste anúncio', 'Multidimensional classification of this listing')}</a>
<button
type="button"
@@ -14,6 +14,7 @@ import type { Country } from '@/components/country-select';
type Props = {
countries: Country[];
formSchema: FormSchema;
modalityFormType?: string | null;
sectors: { id: number; name: string }[];
record: Business;
canEdit: boolean;
+10 -29
View File
@@ -1,3 +1,5 @@
import { usePortalQuery } from '@/hooks/use-portal-query';
import SearchState from '@/components/search-state';
import CatalogueNavigation from '@/components/catalogue-navigation';
import { ClassificationFilters } from '@/components/opportunity-classification';
import { Link, router, useForm, usePage } from '@inertiajs/react';
@@ -114,9 +116,7 @@ export default function Catalogue({
{ id: string; title: string; kind: string; url: string }[]
>([]);
const [error, setError] = useState('');
const [suggestions, setSuggestions] = useState<
{ title: string; slug: string }[]
>([]);
const [mapAttempt, setMapAttempt] = useState(0);
const filterKey = JSON.stringify(filters);
const draftFilterKey = JSON.stringify(form.data);
const lastRequest = useRef(draftFilterKey);
@@ -189,29 +189,9 @@ export default function Catalogue({
return () => {
active = false;
};
}, [view, filterKey]);
useEffect(() => {
if (form.data.q.length < 2) {
setSuggestions([]);
return;
}
let active = true;
const timer = setTimeout(() => {
api(
`/api/v1/investment/suggest?q=${encodeURIComponent(form.data.q)}`,
)
.then((data) => {
if (active) setSuggestions(data.opportunities);
})
.catch(() => {
if (active) setSuggestions([]);
});
}, 300);
return () => {
active = false;
clearTimeout(timer);
};
}, [form.data.q]);
}, [view, filterKey, mapAttempt]);
const suggestionQuery = usePortalQuery<{opportunities:{title:string;slug:string}[]}>(`/api/v1/investment/suggest?q=${encodeURIComponent(form.data.q)}`, 300);
const currentSuggestions = form.data.q.length >= 2 ? suggestionQuery.data?.opportunities ?? [] : [];
const query = () => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
@@ -369,13 +349,14 @@ export default function Catalogue({
aria-describedby="search-suggestions"
/>
</Field>
{suggestions.length > 0 && (
{form.data.q.length >= 2 && <SearchState loading={suggestionQuery.loading} error={suggestionQuery.error} total={currentSuggestions.length} retry={suggestionQuery.retry} />}
{currentSuggestions.length > 0 && (
<ul
id="search-suggestions"
aria-label="Sugestões de pesquisa"
className="space-y-2"
>
{suggestions.map((s) => (
{currentSuggestions.map((s) => (
<li key={s.slug}>
<Link
href={`/oportunidades/${s.slug}`}
@@ -1130,7 +1111,7 @@ export default function Catalogue({
Favoritos e pedidos
</Link>
</div>
{error && <p role="alert">{error}</p>}
<SearchState loading={points === null && !error} error={error} total={(points?.length ?? 0) + unlocated.length} retry={() => setMapAttempt(value => value + 1)} />
{!error && <Map points={points} areas={areas} />}
{['map', 'combined'].includes(view) && (
<TerritoryGeometryPreview
+17 -3
View File
@@ -28,8 +28,10 @@ export default function EditContext({
return (
<Management title={'Contexto: ' + record.title}>
<p>
As relações e características são parte da ficha publicada.
Guardar devolve-a a rascunho para revisão.
Associações territoriais e produtivas ligam esta oportunidade a
corredores, clusters e projectos; não substituem o registo de
onde se localiza. As relações e características são parte da
ficha publicada. Guardar devolve-a a rascunho para revisão.
</p>
<Link
href={`/management/opportunities/${record.id}/edit`}
@@ -37,6 +39,18 @@ export default function EditContext({
>
Editar e publicar a ficha principal
</Link>
<p>
As localizações documentadas são preparadas nos recursos da
ficha, reutilizando a hierarquia existente. As referências
anteriores abaixo são preservadas; não constituem confirmação de
implantação ou de regime especial.
</p>
<Link
className="underline"
href={`/management/opportunities/${record.id}/resources`}
>
Onde se localiza — consultar ou documentar locais
</Link>
<form
className="space-y-6"
onSubmit={(e) => {
@@ -78,7 +92,7 @@ export default function EditContext({
'Clusters',
'Corredores',
'Projectos âncora',
'Localizações',
'Referências territoriais anteriores',
][i]
}
</legend>
+5 -6
View File
@@ -22,7 +22,8 @@ export default function Detail({
returnUrl?: string;
dossierOrigin?: { id: string; title: string } | null;
}) {
const { auth } = usePage<{ auth: { user: unknown } }>().props;
const page = usePage<{ auth: { user: unknown } }>();
const { auth } = page.props;
const [sharing, setSharing] = useState('');
return (
<Frame title={r.title}>
@@ -61,9 +62,7 @@ export default function Detail({
<ClassificationSummary rows={c.classification ?? []} />
<TerritorialSources sources={c.source_evidence} />
<section className={card + ' space-y-3'}>
<h2 className="text-xl font-bold">
Implantação declarada da oportunidade
</h2>
<h2 className="text-xl font-bold">Onde se localiza</h2>
<p>
As relações cadastradas não confirmam disponibilidade ou
capacidade. Locais dos projetos associados, áreas de impacto
@@ -111,7 +110,7 @@ export default function Detail({
Preparar manifestação de interesse
</button>
) : (
<Link className={button} href="/login">
<Link className={button} href={`/participar/entrar?return=${encodeURIComponent(page.url)}`}>
Entrar para preparar manifestação
</Link>
)}
@@ -237,7 +236,7 @@ export default function Detail({
'Clusters',
'Corredores',
'Projectos âncora',
'Territórios',
'Referências territoriais anteriores',
][i]
}
</h2>
+16 -2
View File
@@ -9,6 +9,9 @@ import { Conversation } from './conversation';
import type { Message } from './conversation';
import CountrySelect, { type Country } from '@/components/country-select';
type Payload = {
simple_participation?: boolean;
opportunity_title?: string;
opportunity_reference?: string;
full_name: string;
email: string;
phone: string;
@@ -82,7 +85,7 @@ type Props = {
needs: Need[];
attachments: { id: string; file_name: string }[];
events: { action: string; version: number; created_at: string }[];
receipt: { reference: string; status: string } | null;
receipt: { reference: string; status: string; next_action?: string | null; next_action_due_at?: string | null } | null;
target: string | null;
management: boolean;
messages: Message[];
@@ -338,6 +341,16 @@ function Editor({
)[receipt.status] ?? receipt.status}
</p>
)}
{receipt?.next_action && <p role="status">Próxima acção: {receipt.next_action} {receipt.next_action_due_at && new Date(receipt.next_action_due_at).toLocaleDateString("pt-MZ")}</p>}
{s.status === 'submitted' && s.payload.simple_participation ? <section className={card}>
<h2 className="text-xl font-bold">Dados da manifestação</h2>
<dl className="mt-3 space-y-3">{[
['Nome', s.payload.full_name], ['Email', s.payload.email],
['Telefone', s.payload.phone], ['País declarado', s.payload.country],
['Sector', s.payload.sector], ['Oportunidade', target],
['Mensagem', s.payload.objective],
].filter(([,value]) => value).map(([label,value]) => <div key={label}><dt className="font-semibold">{label}</dt><dd className="whitespace-pre-wrap">{value}</dd></div>)}</dl>
</section> : <>
<nav
aria-label="Etapas da manifestação"
className="flex flex-wrap gap-2"
@@ -1117,6 +1130,7 @@ function Editor({
)}
</section>
)}
</>}
<details className={card}>
<summary className="cursor-pointer font-bold">
Histórico do pedido
@@ -1130,7 +1144,7 @@ function Editor({
draft_created: 'Rascunho criado',
draft_saved: 'Rascunho guardado',
attachment_added: 'Anexo acrescentado',
submitted: 'Submetido',
submitted: 'Submetido', received: 'Recebido', in_review: 'Em análise', contacted: 'Contactado', closed: 'Encerrado',
} as Record<string, string>
)[e.action] ?? e.action}{' '}
· versão {e.version}
+9 -1
View File
@@ -123,7 +123,15 @@ export default function Zones({
Parques industriais
</Link>
</nav>
<form method="get" className={card + ' grid gap-4 md:grid-cols-3'}>
<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}
@@ -1,3 +1,4 @@
import ContextDocuments from '@/components/context-documents';
import { Link } from '@inertiajs/react';
import { useState } from 'react';
import { Frame, card, input, button } from '@/pages/investment/shared';
@@ -26,6 +27,7 @@ export default function ContextStart({
dossier. A referência é para preparação e não confirma direitos
ou benefícios.
</p>
<ContextDocuments source={source ?? undefined} />
{source && (
<section className={card}>
<h2 className="font-bold">Origem consultada</h2>
@@ -1,3 +1,4 @@
import ContextDocuments from '@/components/context-documents';
import { Link } from '@inertiajs/react';
import { useState } from 'react';
import { Frame, button, card, input } from '@/pages/investment/shared';
@@ -130,6 +131,7 @@ export default function ContextEditor({
}
return (
<Frame title="Contexto do investimento">
<ContextDocuments source={view.context.selection.opportunity} />
<div className="space-y-5 [overflow-wrap:anywhere]">
<Link
className="underline"
@@ -1,3 +1,4 @@
import ContextDocuments from '@/components/context-documents';
import type { DossierContext } from '@/lib/investor-context';
import FintechInvestment, {
type FintechView,
@@ -411,6 +412,9 @@ export default function InvestmentPath(path: Path) {
Os meus investimentos
</Link>
<h1 className="text-2xl font-semibold">{path.case.title}</h1>
<ContextDocuments
source={path.investment_context?.selection.opportunity}
/>
{path.investment_context && (
<section className="space-y-2 rounded border border-emerald-300 bg-emerald-50 p-4">
<h2 className="font-semibold">
+4 -4
View File
@@ -22,13 +22,13 @@ export default function PrintCase({
const p = record.payload ?? {};
const text = (value: unknown): string =>
value === null || value === undefined || value === ''
? '—'
? 'Não informado'
: Array.isArray(value)
? value.map(text).join(', ')
: typeof value === 'object'
? Object.values(value as Record<string, unknown>)
.map(text)
.filter((x) => x !== '—')
.filter((x) => x !== 'Não informado')
.join(' · ')
: displayValue(value);
return (
@@ -45,7 +45,7 @@ export default function PrintCase({
</button>
</div>
<header className="space-y-2 border-b pb-4">
<p>InvestMoz · modelo de preparação · {modelVersion}</p>
<p>InvesteMoz · modelo de preparação · {modelVersion}</p>
<h1 className="text-2xl font-bold">
Terra e DUAT — dados preenchidos pelo requerente
</h1>
@@ -56,7 +56,7 @@ export default function PrintCase({
</p>
<p className="font-semibold">{disclaimer}</p>
<p>
Esta cópia reflecte os dados guardados no InvestMoz e não é
Esta cópia reflecte os dados guardados no InvesteMoz e não é
requerimento oficialmente recebido, título, parecer, prova
de consulta ou decisão.
</p>
@@ -0,0 +1,15 @@
import { Head, router, usePage } from '@inertiajs/react';
import { useRef, useState } from 'react';
type Row={id:string;protocol:string;status:string;version:number;analyst_id:number|null;due_at:string|null;etag?:string};
export default function DeskAssignments(){
const {requests,analysts,kind,flash}=usePage<{requests:Row[];analysts:{id:number;name:string}[];kind?:'interest';flash?:{success?:string}}>().props;
const alertRef=useRef<HTMLDivElement>(null);
const stateNames:Record<string,string>={received:'Recebido',submitted:'Submetido',in_review:'Em revisão',reviewed:'Revisto',qualification:'Em qualificação',qualified:'Qualificado',contacted:'Contactado',contact_accepted:'Contacto aceite',referred:'Encaminhado',due_diligence:'Em diligência',negotiation:'Em negociação',pending:'Pendente',accepted:'Aceite',rejected:'Recusado',returned:'Devolvido',processing:'Em tratamento',draft:'Rascunho'};
const title=kind==='interest'?'Atribuições de interesses':'Atribuições do Balcão';
const [selected,setSelected]=useState<Record<string,string>>({});
const [reason,setReason]=useState<Record<string,string>>({});
const [errors,setErrors]=useState<Record<string,string>>({});
const [busy,setBusy]=useState<string|null>(null);
function assign(row:Row){setBusy(row.id);setErrors({});router.post(kind==='interest'?`/management/interests/${row.id}/responsavel`:`/balcao-virtual/pedidos/${row.id}/responsavel`,{...(kind==='interest'?{etag:row.etag}:{version:row.version}),analyst_id:selected[row.id]??String(row.analyst_id??''),reason:reason[row.id]??''},{preserveScroll:true,onError:e=>{setErrors(e);queueMicrotask(()=>alertRef.current?.focus());},onFinish:()=>setBusy(null)});}
return <main className="mx-auto max-w-5xl space-y-6 p-6"><Head title={title}/><h1 className="text-2xl font-semibold">{title}</h1><p>Atribua o acompanhamento de um pedido. Esta lista não dá acesso ao conteúdo privado do dossier. O proponente não pode ser designado como analista do seu pedido.</p><a href={kind==='interest'?'/management/interests':'/balcao-virtual/painel'} className="underline">{kind==='interest'?'Interesses atribuídos':'Voltar ao Balcão'}</a><div ref={alertRef} role="alert" tabIndex={-1}>{Object.values(errors).join(' ')}</div>{flash?.success&&<p role="status">{flash.success}</p>}{requests.length===0?<p>Não há pedidos para atribuir.</p>:requests.map(row=><section key={row.id} className="space-y-3 rounded border p-4"><h2 className="font-semibold">{row.protocol||row.id}</h2><p>Estado: {stateNames[row.status]??'Por esclarecer'}</p><label className="block">Responsável<select className="ml-2 border p-2" value={selected[row.id]??String(row.analyst_id??'')} onChange={e=>setSelected({...selected,[row.id]:e.target.value})}><option value="">Seleccione</option>{analysts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label className="block">Fundamento<textarea className="block w-full border p-2" value={reason[row.id]??''} onChange={e=>setReason({...reason,[row.id]:e.target.value})}/></label><button className="rounded bg-emerald-900 px-4 py-2 text-white" disabled={busy!==null} onClick={()=>assign(row)}>{busy===row.id?'A guardar…':'Guardar atribuição'}</button></section>)}</main>;
}
@@ -245,6 +245,14 @@ export default function OpportunityDraft({
<Link href={basePath}>
{profileMode ? 'Minhas oportunidades' : 'Voltar à gestão'}
</Link>
{record && (
<Link
href={`/investimentos/iniciar?source=opportunity:${record.id}`}
className={button}
>
Preparar ou continuar investimento com esta ficha
</Link>
)}
{profileMode && (
<section
aria-label="Âmbito desta entrega"
@@ -459,7 +467,18 @@ export default function OpportunityDraft({
disabled={busy || readOnly || unavailable}
className="space-y-4"
>
<section aria-label="Localização registada">
<section aria-label="Onde se localiza — referência principal">
<h2 className="font-semibold">
Onde se localiza — referência principal
</h2>
<p>
Seleccione o território conhecido. Endereço,
geometria e locais adicionais documentados podem
ser preparados em Localizações, reutilizando
esta hierarquia. Uma referência provincial não
determina distrito, coordenadas ou pertença a
uma associação.
</p>
<TerritoryGeometryPreview
territoryId={path.at(-1)?.id}
management
@@ -863,7 +882,7 @@ export default function OpportunityDraft({
)}
{record && !profileMode && (
<Link href={`${basePath}/${record.id}/context`}>
Consultar projetos, clusters e relações existentes
Associações territoriais e produtivas existentes
</Link>
)}
{profileMode && canonicalOptions && (
@@ -1,3 +1,6 @@
import OpportunityLibraryReuse, {
type LibrarySource,
} from '@/components/opportunity-library-reuse';
import { createOperationKey } from '@/lib/operation-key';
import { valueText } from '@/lib/value-text';
import { Link, router, usePage } from '@inertiajs/react';
@@ -69,7 +72,7 @@ const fields: Record<Kind, string[]> = {
const labels: Record<string, string> = {
role: 'Papel da localização',
imageRole: 'Tipo de imagem',
territorial_purpose: 'Natureza da relação territorial',
territorial_purpose: 'Finalidade deste local',
rights_status: 'Direitos de utilização',
visibility: 'Âmbito de consulta',
classification: 'Classificação documental',
@@ -98,6 +101,20 @@ const labels: Record<string, string> = {
section: 'Página / secção',
supported_statement: 'Afirmação fundamentada',
};
const localLabels: Record<string, string> = {
implementation: 'Implantação',
context: 'Contexto',
impact: 'Impacto',
coverage: 'Abrangência',
headquarters: 'Sede',
production: 'Produção',
raw_material: 'Matéria-prima',
storage: 'Armazenagem',
logistics: 'Logística',
market: 'Mercado',
support: 'Apoio',
other: 'Outro',
};
const choices: Record<string, string[]> = {
role: [
'headquarters',
@@ -133,6 +150,8 @@ export default function OpportunityResources({
existingLocations,
assessment,
imageProcessingAvailable,
librarySources = [],
locationReference = { path: [], differences: [] },
}: {
opportunity: {
id: number;
@@ -145,6 +164,11 @@ export default function OpportunityResources({
existingLocations: { id: string; name: string; location_type: string }[];
assessment: string[];
imageProcessingAvailable: boolean;
librarySources?: LibrarySource[];
locationReference?: {
path: TerritoryOption[];
differences: { resource_id: string; title: string; message: string }[];
};
}) {
const {
basePath = '/management/opportunities',
@@ -322,11 +346,79 @@ export default function OpportunityResources({
</p>
)}
{kind !== 'locations' && (
<section aria-label="Privacidade dos anexos" className="rounded border p-3">
<section
aria-label="Privacidade dos anexos"
className="rounded border p-3"
>
<h2>Privacidade dos anexos</h2>
<p>Os anexos pertencem à oportunidade. Podem ser consultados pelos Promotores autorizados da organização, pelos Validadores e Publicadores com acesso ao tipo e âmbito e pelos Auditores autorizados. O Superutilizador precisa também de perfil operacional no âmbito e de elevação temporária para consulta, aprovada por outro titular. O gestor da biblioteca, por si só, não obtém esse acesso.</p>
<p>Classificação e âmbito de consulta são metadados; escolher «public» ou «authenticated» não divulga o ficheiro. Publicar o anúncio não publica os anexos. A retirada lógica impede a entrega do ficheiro; a revogação do perfil impede acessos futuros.</p>
<p>Partilhar um anexo com um destinatário individual ou restringi-lo a alguns membros da organização permanece indisponível. Não guarde neste fluxo um ficheiro que exija essa restrição individual. Não é possível retirar cópias já descarregadas.</p>
<p>
Os anexos pertencem à oportunidade. Podem ser
consultados pelos Promotores autorizados da organização,
pelos Validadores e Publicadores com acesso ao tipo e
âmbito e pelos Auditores autorizados. O Superutilizador
precisa também de perfil operacional no âmbito e de
elevação temporária para consulta, aprovada por outro
titular. O gestor da biblioteca, por si só, não obtém
esse acesso.
</p>
<p>
Classificação e âmbito de consulta são metadados;
escolher «public» ou «authenticated» não divulga o
ficheiro. Publicar o anúncio não publica os anexos. A
retirada lógica impede a entrega do ficheiro; a
revogação do perfil impede acessos futuros.
</p>
<p>
Partilhar um anexo com um destinatário individual ou
restringi-lo a alguns membros da organização permanece
indisponível. Não guarde neste fluxo um ficheiro que
exija essa restrição individual. Não é possível retirar
cópias já descarregadas.
</p>
</section>
)}
{kind === 'locations' && (
<section
aria-label="Onde se localiza"
className="space-y-3 rounded border p-4"
>
<h2 className="text-xl font-semibold">Onde se localiza</h2>
<p>
Registe o local principal e os locais adicionais
documentados: país, hierarquia territorial, endereço e
geometria ou parcela quando conhecidos. Indique a
finalidade de cada local: implantação, abrangência,
impacto ou contexto. Uma área de contexto não comprova
implantação.
</p>
<p>
Referência principal já guardada:{' '}
{locationReference.path.length
? locationReference.path
.map((item) => item.name)
.join(' → ')
: 'Não indicada'}
. Os níveis em falta continuam desconhecidos.
</p>
<p>
Pode guardar a preparação incompleta. No percurso de
submissão existente são conferidos distrito, papel,
indicação de principal e uma única localização principal
seleccionada; área e unidade devem ser coerentes quando
preenchidas. Não se acrescentam obrigatoriedades ao
guardar este recurso.
</p>
{locationReference.differences.map((item) => (
<p role="status" key={item.resource_id}>
{item.title}: {item.message}
</p>
))}
<p>
Corredores, clusters, ZEE, ZFI, parques e projectos
associados são descritos nas associações da ficha, com
finalidade, fonte e estado próprios. Estar num distrito
não confirma pertença a nenhum deles.
</p>
</section>
)}
<section>
@@ -339,7 +431,11 @@ export default function OpportunityResources({
</section>
{existingLocations.length > 0 && (
<section>
<h2>Ligações territoriais existentes preservadas</h2>
<h2>Referências territoriais anteriores — preservadas</h2>
<p>
Estas referências não são novas associações confirmadas
nem obrigam a repetir o preenchimento.
</p>
{existingLocations.map((r) => (
<p key={r.id}>
{r.name} · {r.location_type}
@@ -347,6 +443,13 @@ export default function OpportunityResources({
))}
</section>
)}
{!readOnly && kind === 'documents' && (
<OpportunityLibraryReuse
sources={librarySources}
action={`${basePath}/${opportunity.id}/library-support`}
etag={etag}
/>
)}
<nav className="flex flex-wrap gap-3">
{(['locations', 'images', 'documents'] as Kind[]).map((k) => (
<button
@@ -377,6 +480,12 @@ export default function OpportunityResources({
? 'eliminado logicamente'
: 'rascunho'}
</p>
{!!r.payload.library_source && (
<p>
Original da biblioteca reutilizado; edição e
hash preservados, sem cópia dos bytes.
</p>
)}
{r.file && (
<p className="break-all">
Scan: {r.file.malware_scan_status} · motor:{' '}
@@ -437,6 +546,39 @@ export default function OpportunityResources({
</h2>
{kind === 'locations' && (
<div>
{locationReference.path.length > 0 && (
<div className="my-3 rounded border p-3">
<button
type="button"
className={button}
disabled={
readOnly ||
Boolean(editing) ||
path.length > 0 ||
foreignCountry
}
onClick={() => {
setPath(locationReference.path);
setPayload((previous) => ({
...previous,
country_code: 'MZ',
}));
setMessage(
'Hierarquia principal reutilizada no preenchimento. Só foi reutilizada a hierarquia: coordenadas e parcela não foram copiadas. Papel, finalidade, fonte e indicação de principal continuam sob a sua escolha; nada foi guardado automaticamente.',
);
}}
>
Reutilizar hierarquia principal guardada
</button>
<p>
Use-a quando documentar o mesmo território.
Para um local diferente, escolha outra
hierarquia. A reutilização não substitui um
local em edição, um país estrangeiro ou uma
selecção já preenchida.
</p>
</div>
)}
<Field
label="País da localização"
error={errors.country_code}
@@ -549,6 +691,7 @@ export default function OpportunityResources({
key={key}
aria-label="Original / substituir por nova versão"
type="file"
disabled={!!editing?.payload.library_source}
accept={
kind === 'images'
? '.jpg,.jpeg,.png,.webp'
@@ -619,7 +762,11 @@ export default function OpportunityResources({
>
<option value="">Pendente</option>
{choices[name].map((option) => (
<option key={option}>{option}</option>
<option key={option} value={option}>
{kind === 'locations'
? (localLabels[option] ?? option)
: option}
</option>
))}
</select>
</Field>
@@ -679,30 +826,53 @@ export default function OpportunityResources({
</Field>
),
)}
{kind !== 'documents' && (
{kind === 'locations' && (
<Field
label="Local principal ou adicional"
error={errors.is_primary}
>
<select
aria-label="Local principal ou adicional"
className={control}
disabled={readOnly}
value={
typeof payload.is_primary === 'boolean'
? payload.is_primary
? 'primary'
: 'additional'
: ''
}
onChange={(event) =>
set(
'is_primary',
event.target.value === ''
? null
: event.target.value === 'primary',
)
}
>
<option value="">Por indicar</option>
<option value="primary">Principal</option>
<option value="additional">Adicional</option>
</select>
<p>
A escolha identifica o papel do local nesta ficha;
não confirma a sua implantação nem uma associação
territorial.
</p>
</Field>
)}
{kind === 'images' && (
<label>
<input
key={key}
type="checkbox"
checked={
!!payload[
kind === 'images'
? 'is_cover'
: 'is_primary'
]
}
checked={!!payload.is_cover}
onChange={(event) =>
set(
kind === 'images'
? 'is_cover'
: 'is_primary',
event.target.checked,
)
set('is_cover', event.target.checked)
}
/>
{kind === 'images'
? 'Selecionar capa de preparação'
: 'Localização principal'}
Selecionar capa de preparação
</label>
)}
<Field
@@ -1,3 +1,4 @@
import SearchState from '@/components/search-state';
import { useEffect, useState } from 'react';
import InvestmentTerritory from '@/components/investment-territory';
import { Management, Field, control } from './shared';
@@ -10,8 +11,11 @@ export default function TerritoryAssociations() {
const [rows, setRows] = useState<{ id: string; name: string }[]>([]);
const [more, setMore] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [retry, setRetry] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true); setError(''); setRows([]);
const timer = setTimeout(async () => {
try {
const params = new URLSearchParams({
@@ -31,21 +35,22 @@ export default function TerritoryAssociations() {
'Não foi possível carregar os investimentos.',
);
const data = await response.json();
if (controller.signal.aborted) return;
setRows(data.data);
setMore(Boolean(data.next_page_url));
setError('');
} catch (e) {
} catch {
if (!controller.signal.aborted)
setError(
e instanceof Error ? e.message : 'Erro de ligação.',
'Não foi possível concluir a pesquisa.',
);
}
} finally { if (!controller.signal.aborted) setLoading(false); }
}, 250);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [kind, q, page]);
}, [kind, q, page, retry]);
return (
<Management title="Localizações dos investimentos">
<Field label="Tipo de investimento">
@@ -71,6 +76,7 @@ export default function TerritoryAssociations() {
<option value="opportunities">Oportunidades</option>
</select>
</Field>
<SearchState loading={loading} error={error} total={rows.length} retry={() => setRetry(value => value + 1)} />
<Field label="Pesquisar investimento">
<input
className={control}
+12 -1
View File
@@ -162,8 +162,12 @@ export default function Manage({
requirementAssociationsEnabled,
disclosureAuthorization,
legacyContentEnabled = false,
documentAccessOnly = false,
editorialScopeOnly = false,
}: {
legacyContentEnabled?: boolean;
documentAccessOnly?: boolean;
editorialScopeOnly?: boolean;
documents: DocumentFile[];
resources: Resource[];
requirementCatalogue: { key: string; label: string; tax: string }[];
@@ -221,10 +225,14 @@ export default function Manage({
);
return (
<Management title="Biblioteca documental e Bolsa de Parcerias">
<Management title={documentAccessOnly ? "Os meus ficheiros privados" : editorialScopeOnly ? "Gestão editorial do acervo autorizado" : "Biblioteca documental e Bolsa de Parcerias"}>
{documentAccessOnly && <p>Carregue e consulte os seus ficheiros. Este acesso não permite publicar nem consultar ficheiros privados de outras contas.</p>}
{!documentAccessOnly && <>
{!editorialScopeOnly && <>
<a className="underline" href="/management/editorial-installation">
Estado da gestão editorial
</a>
</>}
<section
className="rounded border border-amber-700 bg-amber-50 p-4"
aria-live="polite"
@@ -236,6 +244,7 @@ export default function Manage({
para revisão. A contagem não é certificação documental.
</p>
</section>
</>}
<section
className="space-y-3 rounded border p-5"
aria-label="Estado antimalware"
@@ -410,6 +419,7 @@ export default function Manage({
</button>
</form>
{!documentAccessOnly && <>
<form
className="space-y-4 rounded border p-5"
onSubmit={(e) => {
@@ -1398,6 +1408,7 @@ export default function Manage({
</button>
</article>
))}
</>}
</Management>
);
}

Some files were not shown because too many files have changed in this diff Show More