213 lines
11 KiB
PHP
213 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\InterestRequest;
|
|
use App\Models\InvestmentSubmission;
|
|
use App\Models\Opportunity;
|
|
use App\Models\Sector;
|
|
use App\Services\InvestmentGeography;
|
|
use App\Services\InvestmentWorkspace;
|
|
use App\Services\PortalAudit;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class PortalManagementController extends Controller
|
|
{
|
|
private const STATES = ['received', 'in_review', 'contacted', 'closed'];
|
|
|
|
public function history(): Response
|
|
{
|
|
$events = DB::table('audit_events')
|
|
->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;
|
|
|
|
return Inertia::render('management/editor', [
|
|
'resource' => $resource,
|
|
'record' => $id ? $model::findOrFail($id) : null,
|
|
'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' && ! $id) {
|
|
foreach (InvestmentGeography::FIELDS as $field => $type) {
|
|
$rules[$field] = ['nullable', 'uuid'];
|
|
}
|
|
}
|
|
$data = $request->validate($rules);
|
|
if ($resource === 'opportunities' && ! $id && $request->has('province_id')) {
|
|
$path = app(InvestmentGeography::class)->selection($data);
|
|
$data = array_diff_key($data, InvestmentGeography::FIELDS);
|
|
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
|
|
$data['territory_version'] = $path === [] ? 0 : 1;
|
|
$data['province'] = $path[0]->name ?? null;
|
|
$data['district'] = $path[1]->name ?? null;
|
|
}
|
|
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;
|
|
}
|
|
PortalAudit::save($record, $data, $request->user()->id);
|
|
|
|
return redirect('/management/'.$resource)->with('success', 'Registo guardado.');
|
|
}
|
|
}
|