leftJoin('users', 'users.id', '=', 'audit_events.actor_user_id') ->whereIn('audit_events.source_system', ['portal-management', 'investment-context', 'investment-submission', 'investment-workspace', 'investment-zones']) ->orderByDesc('audit_events.occurred_at')->orderByDesc('audit_events.id') ->select(['audit_events.id', 'audit_events.entity_id', 'entity_type', 'action', 'occurred_at', 'before_data', 'after_data', 'users.name as actor']) ->paginate(20)->through(function ($event) { $before = json_decode($event->before_data ?? '{}', true) ?? []; $after = json_decode($event->after_data ?? '{}', true) ?? []; $values = $after['values'] ?? array_intersect_key($after, array_flip(['version', 'status', 'active'])); $event->before_data = ['values' => $before['values'] ?? []]; $event->after_data = ['record_id' => $after['record_id'] ?? $event->entity_id, 'changed_fields' => $after['changed_fields'] ?? array_keys($values), 'values' => $values]; return $event; }); return Inertia::render('management/history', ['events' => $events]); } public function interests(Request $request): Response { $filters = $request->validate([ 'q' => ['nullable', 'string', 'max:150'], 'status' => ['nullable', Rule::in(self::STATES)], ]); $query = InterestRequest::query()->select([ 'id', 'reference', 'full_name', 'email', 'sector_interest', 'status', 'created_at', ]); if ($term = $filters['q'] ?? null) { $query->where(fn ($q) => $q->whereLike('reference', "%{$term}%") ->orWhereLike('full_name', "%{$term}%")->orWhereLike('email', "%{$term}%")); } if ($status = $filters['status'] ?? null) { $query->where('status', $status); } return Inertia::render('management/interests', [ 'requests' => $query->orderByDesc('id')->paginate(20)->withQueryString(), 'filters' => $filters, ]); } public function interest(InterestRequest $interest): Response { return Inertia::render('management/interest', [ 'interest' => $interest->only([ 'id', 'reference', 'full_name', 'email', 'phone', 'investor_type', 'country', 'sector_interest', 'opportunity_reference', 'investment_range', 'message', 'privacy_consent', 'consented_at', 'status', 'created_at', 'reviewed_at', 'reviewed_by', 'metadata', ]), ]); } public function review(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse { $validated = $request->validate(['status' => ['required', Rule::in(self::STATES)]]); DB::transaction(function () use ($request, $interest, $validated) { $locked = InterestRequest::whereKey($interest->id)->lockForUpdate()->firstOrFail(); $changed = $locked->status !== $validated['status']; PortalAudit::save($locked, [ ...$validated, 'reviewed_at' => now(), 'reviewed_by' => $request->user()->id, ], $request->user()->id); $submission = InvestmentSubmission::where('interest_request_id', $interest->id)->first(); if ($changed && $submission) { app(InvestmentWorkspace::class)->notify($submission->user_id, $submission, 'status', (string) Str::uuid(), ['status' => $validated['status']]); } }); return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'status' => $validated['status']]) : back()->with('success', 'Estado do pedido actualizado.'); } public function catalog(Request $request, string $resource): Response { $model = $resource === 'sectors' ? Sector::class : Opportunity::class; $filters = $request->validate([ 'q' => ['nullable', 'string', 'max:150'], 'status' => ['nullable', Rule::in($resource === 'sectors' ? ['active', 'inactive'] : ['draft', 'published', 'archived'])], 'sector_id' => ['nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')], 'page' => ['nullable', 'integer', 'min:1'], ]); $query = $model::query(); if ($term = trim($filters['q'] ?? '')) { $query->where(function ($query) use ($term, $resource) { $query->whereLike($resource === 'sectors' ? 'name' : 'title', "%{$term}%") ->orWhereLike('slug', "%{$term}%"); if ($resource === 'opportunities') { $query->orWhereLike('reference', "%{$term}%"); } }); } if ($status = $filters['status'] ?? null) { if ($resource === 'sectors') { $query->where('is_active', $status === 'active'); } elseif ($status === 'published') { $query->where('status', 'published')->whereNotNull('published_at')->where('published_at', '<=', now()); } else { $query->where('status', $status); } } if ($resource === 'opportunities' && ($filters['sector_id'] ?? null)) { $query->where('sector_id', $filters['sector_id']); } return Inertia::render('management/catalog', [ 'resource' => $resource, 'records' => $query->orderByDesc('id')->paginate(20)->withQueryString(), 'filters' => $filters, 'sectors' => $resource === 'opportunities' ? Sector::orderBy('name')->get(['id', 'name']) : [], ]); } public function edit(string $resource, ?int $id = null): Response { $model = $resource === 'sectors' ? Sector::class : Opportunity::class; $record = $id ? $model::findOrFail($id) : null; return Inertia::render('management/editor', [ 'resource' => $resource, 'record' => $record, 'territoryPath' => $record instanceof Opportunity ? app(InvestmentGeography::class)->path($record->primary_territory_id, true) : [], 'sectors' => Sector::orderBy('name')->get(['id', 'name']), ]); } public function save(Request $request, string $resource, ?int $id = null): RedirectResponse { $model = $resource === 'sectors' ? Sector::class : Opportunity::class; $record = $id ? $model::findOrFail($id) : new $model; $rules = $resource === 'sectors' ? [ 'name' => ['required', 'string', 'max:150'], 'slug' => ['required', 'alpha_dash:ascii', 'max:180', Rule::unique('sectors')->ignore($record->id)], 'short_description' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:20000'], 'sort_order' => ['required', 'integer', 'min:0', 'max:2147483647'], 'is_active' => ['required', 'boolean'], ] : [ 'sector_id' => ['required', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')], 'reference' => ['required', 'string', 'max:80', Rule::unique('opportunities')->ignore($record->id)], 'title' => ['required', 'string', 'max:200'], 'slug' => ['required', 'alpha_dash:ascii', 'max:220', Rule::unique('opportunities')->ignore($record->id)], 'summary' => ['nullable', 'string', 'max:500'], 'description' => ['nullable', 'string', 'max:50000'], 'province' => ['nullable', 'string', 'max:100'], 'district' => ['nullable', 'string', 'max:100'], 'promoter_name' => ['nullable', 'string', 'max:200'], 'promoter_type' => ['nullable', 'string', 'max:100'], 'maturity_level' => ['nullable', 'integer', 'between:1,6'], 'estimated_investment_amount' => ['nullable', 'regex:/^\d{1,16}(\.\d{1,2})?$/'], 'estimated_investment_currency' => ['required', 'regex:/^[A-Z]{3}$/'], 'financing_modality' => ['nullable', 'string', 'max:120'], 'expected_impact' => ['nullable', 'string', 'max:10000'], 'status' => ['required', Rule::in(['draft', 'published', 'archived'])], 'is_featured' => ['required', 'boolean'], ]; if ($resource === 'opportunities') { foreach (InvestmentGeography::FIELDS as $field => $type) { $rules[$field] = ['nullable', 'uuid']; } $rules['territory_version'] = [$id && $request->hasAny(array_keys(InvestmentGeography::FIELDS)) ? 'required' : 'nullable', 'integer', 'min:0']; } $data = $request->validate($rules); if ($resource === 'opportunities' && $data['status'] === 'published' && $record->exists && Schema::hasTable('education_investment_proposals') && DB::table('education_investment_proposals')->where('opportunity_id', $record->id)->exists()) { throw ValidationException::withMessages([ 'status' => 'Esta proposta educativa aguarda validação institucional da lacuna e da resposta formativa. A revisão interna não autoriza publicação.', ]); } if ($resource === 'opportunities') { $labels = [1 => 'Ideia identificada', 'Pré-estudo disponível', 'Estudo de viabilidade em curso', 'Projecto estruturado', 'Pronto para investimento', 'Em negociação ou implementação']; $data['maturity_label'] = $labels[$data['maturity_level'] ?? 0] ?? null; $data['published_at'] = $data['status'] === 'published' ? ($record->published_at ?? now()) : null; } DB::transaction(function () use ($record, $data, $request, $resource) { if ($resource === 'opportunities' && $request->hasAny(array_keys(InvestmentGeography::FIELDS))) { DB::select('SELECT pg_advisory_xact_lock(280926, 150000)'); if ($record->exists) { $current = Opportunity::whereKey($record->id)->lockForUpdate()->firstOrFail(); abort_unless((int) $current->territory_version === (int) $data['territory_version'], 409, 'Localização alterada. Recarregue a ficha.'); } $path = app(InvestmentGeography::class)->selection($data, true); $data = array_diff_key($data, InvestmentGeography::FIELDS); $data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id; $data['territory_version'] = (int) ($data['territory_version'] ?? 0) + 1; $data['province'] = $path[0]->name ?? null; $data['district'] = $path[1]->name ?? null; } else { unset($data['territory_version']); } PortalAudit::save($record, $data, $request->user()->id); }); return redirect('/management/'.$resource)->with('success', 'Registo guardado.'); } }