Reconciliar fontes instaladas 423bd512d8c5 com main
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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', '')),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:''}; }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user