novas funcionalidades dos formulario
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-02 11:13:37 +02:00
parent a6afb4e97a
commit 4f35313d06
104 changed files with 7653 additions and 178 deletions
@@ -6,12 +6,16 @@ use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\InterestWorkflow;
use App\Services\InvestmentGeography;
use App\Services\InvestmentWorkspace;
use App\Services\PortalAudit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
@@ -22,7 +26,7 @@ use Inertia\Response;
class PortalManagementController extends Controller
{
private const STATES = ['received', 'in_review', 'contacted', 'closed'];
private const STATES = ['received', 'submitted', 'in_review', 'reviewed', 'qualification', 'qualified', 'contacted', 'contact_accepted', 'referred', 'due_diligence', 'negotiation', 'pending', 'refused', 'converted', 'cancelled', 'closed'];
public function history(): Response
{
@@ -50,9 +54,10 @@ class PortalManagementController extends Controller
$filters = $request->validate([
'q' => ['nullable', 'string', 'max:150'],
'status' => ['nullable', Rule::in(self::STATES)],
'follow_up' => ['nullable', Rule::in(['overdue', 'upcoming', 'unassigned'])],
]);
$query = InterestRequest::query()->select([
'id', 'reference', 'full_name', 'email', 'sector_interest', 'status', 'created_at',
$query = InterestRequest::query()->with('assignee:id,name')->select([
'id', 'reference', 'full_name', 'email', 'sector_interest', 'existing_business_id', 'modality', 'status', 'assigned_to', 'next_action', 'next_action_due_at', 'created_at',
]);
if ($term = $filters['q'] ?? null) {
$query->where(fn ($q) => $q->whereLike('reference', "%{$term}%")
@@ -61,6 +66,15 @@ class PortalManagementController extends Controller
if ($status = $filters['status'] ?? null) {
$query->where('status', $status);
}
if (($filters['follow_up'] ?? null) === 'overdue') {
$query->whereNotNull('next_action_due_at')->where('next_action_due_at', '<', now())
->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
} elseif (($filters['follow_up'] ?? null) === 'upcoming') {
$query->whereNotNull('next_action_due_at')->where('next_action_due_at', '>=', now())
->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
} elseif (($filters['follow_up'] ?? null) === 'unassigned') {
$query->whereNull('assigned_to')->whereNotIn('status', ['refused', 'converted', 'cancelled', 'closed']);
}
return Inertia::render('management/interests', [
'requests' => $query->orderByDesc('id')->paginate(20)->withQueryString(),
@@ -70,31 +84,94 @@ class PortalManagementController extends Controller
public function interest(InterestRequest $interest): Response
{
$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) {
$row->note = $row->reason ? Crypt::decryptString($row->reason) : null;
unset($row->reason);
return $row;
});
$consents = DB::table('consents')->where('interest_request_id', $interest->id)
->orderBy('created_at')->get(['purpose', 'policy_version', 'status', 'granted_at', 'withdrawn_at']);
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',
'existing_business_id', 'modality', 'assigned_to', 'next_action', 'next_action_due_at',
]),
'history' => $history,
'consents' => $consents,
'assignees' => $this->interestAssignees()->get(['id', 'name']),
]);
}
public function review(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
public function review(Request $request, InterestRequest $interest, InterestWorkflow $workflow): 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']]);
$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'))],
]);
$before = $interest->status;
$updated = $workflow->transition($request->user(), $interest, $validated['status'], $validated['note'] ?? null,
$validated['next_action'] ?? null, $validated['next_action_due_at'] ?? null, isset($validated['assigned_to']) ? (int) $validated['assigned_to'] : null,
$request->exists('assigned_to'), $request->exists('next_action') || $request->exists('next_action_due_at'));
PortalAudit::save($updated, ['status' => $updated->status, 'reviewed_at' => $updated->reviewed_at, 'reviewed_by' => $updated->reviewed_by], $request->user()->id);
$submission = InvestmentSubmission::where('interest_request_id', $interest->id)->first();
if ($before !== $updated->status && $submission) {
app(InvestmentWorkspace::class)->notify($submission->user_id, $submission, 'status', (string) Str::uuid(), ['status' => $updated->status]);
}
return $request->expectsJson() ? response()->json(['reference' => $updated->reference, 'status' => $updated->status]) : back()->with('success', 'Estado do pedido actualizado.');
}
public function withdrawProponentSharingConsent(Request $request, InterestRequest $interest): RedirectResponse|JsonResponse
{
$data = $request->validate(['reason' => ['required', 'string', 'min:10', 'max:2000']]);
DB::transaction(function () use ($request, $interest, $data) {
$consent = DB::table('consents')->where('interest_request_id', $interest->id)
->where('purpose', 'share_contact_with_proponent')->where('status', 'granted')->lockForUpdate()->first();
if (! $consent) {
return;
}
DB::table('consents')->where('id', $consent->id)->update(['status' => 'withdrawn', 'withdrawn_at' => now(), 'updated_at' => now()]);
$correlationId = (string) Str::uuid();
DB::table('business_workflow_transitions')->insert([
'id' => (string) Str::uuid(), 'interest_request_id' => $interest->id,
'from_status' => $interest->status, 'to_status' => $interest->status,
'actor_id' => $request->user()->id, 'reason' => Crypt::encryptString($data['reason']),
'correlation_id' => $correlationId,
'metadata' => json_encode(['source' => 'management-interest', 'action' => 'consent_withdrawn',
'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
'created_at' => now(),
]);
DB::table('outbox_events')->insertOrIgnore([
'source_system' => 'portal-intake', 'external_id' => 'InterestConsentWithdrawn:'.$consent->id,
'aggregate_type' => 'interest_consent', 'aggregate_id' => $consent->id,
'event_type' => 'InterestConsentWithdrawn', 'data_classification' => 'confidential',
'correlation_id' => $correlationId, 'status' => 'pending',
'payload' => json_encode(['schema_version' => 1, 'consent_id' => $consent->id,
'interest_id' => $interest->integration_id, 'purpose' => 'share_contact_with_proponent'], JSON_THROW_ON_ERROR),
]);
});
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'status' => $validated['status']]) : back()->with('success', 'Estado do pedido actualizado.');
return $request->expectsJson() ? response()->json(['reference' => $interest->reference, 'sharing_consent' => 'withdrawn'])
: back()->with('success', 'Consentimento de partilha revogado.');
}
private function interestAssignees(): Builder
{
$emails = array_unique(array_merge(config('portal.managers', []), ...array_map(
fn (string $designation) => config('existing-business.'.$designation, []),
['triagers', 'reviewers', 'approvers_l1', 'approvers_l2', 'publishers'],
)));
return User::query()->whereNotNull('email_verified_at')->whereIn(DB::raw('lower(email)'), $emails)->orderBy('name');
}
public function catalog(Request $request, string $resource): Response