From e662230df796b641e3a12a32d5c83bf547e6e082 Mon Sep 17 00:00:00 2001 From: nala Date: Fri, 9 Oct 2026 15:00:37 +0200 Subject: [PATCH] oportunidades 3 --- app/Http/Controllers/CrmController.php | 9 +- .../InvestmentDiscoveryController.php | 19 +- .../InvestmentSubmissionController.php | 16 +- .../InvestmentWorkspaceController.php | 10 +- .../OpportunityResourceController.php | 4 +- .../Controllers/VirtualDeskController.php | 6 +- app/Services/CountryCatalogue.php | 35 +++ app/Services/DeclaredOriginStatistics.php | 50 ++++ app/Services/OpportunityImageProcessor.php | 7 +- .../OpportunityResourceInstallation.php | 2 + app/Services/VirtualDesk.php | 3 +- docs/investmoz/v2/backlog-mestre.md | 13 + .../v2/gestao-oportunidades-dashboard.md | 4 + .../v2/revisao-funcional-transversal.md | 55 ++++ resources/data/iso3166.tab | 279 ++++++++++++++++++ resources/js/components/country-select.tsx | 20 ++ .../js/components/investment-territory.tsx | 10 +- resources/js/pages/crm/leads.tsx | 83 +++++- resources/js/pages/desk/panel.tsx | 11 +- resources/js/pages/desk/request.tsx | 7 + resources/js/pages/investment/catalogue.tsx | 14 +- resources/js/pages/investment/profile.tsx | 24 +- resources/js/pages/investment/submission.tsx | 16 +- .../pages/management/opportunity-install.tsx | 6 + .../management/opportunity-resources.tsx | 6 +- tests/Feature/InvestmentWorkspaceTest.php | 78 +++++ tests/Feature/InvestorGuidanceTest.php | 24 +- tools/desk-activation-backup.php | 3 +- tools/opportunity-resources-browser.cjs | 2 +- .../opportunity-resources-install-preview.php | 30 ++ tools/opportunity-resources-qa.php | 5 + tools/portal-functional-country-browser.cjs | 51 ++++ 32 files changed, 865 insertions(+), 37 deletions(-) create mode 100644 app/Services/CountryCatalogue.php create mode 100644 app/Services/DeclaredOriginStatistics.php create mode 100644 docs/investmoz/v2/revisao-funcional-transversal.md create mode 100644 resources/data/iso3166.tab create mode 100644 resources/js/components/country-select.tsx create mode 100644 tools/opportunity-resources-install-preview.php create mode 100644 tools/portal-functional-country-browser.cjs diff --git a/app/Http/Controllers/CrmController.php b/app/Http/Controllers/CrmController.php index 5bc37967..b94fd90a 100644 --- a/app/Http/Controllers/CrmController.php +++ b/app/Http/Controllers/CrmController.php @@ -9,6 +9,8 @@ use App\Models\Investor; use App\Models\Lead; use App\Models\User; use App\Services\ConvertInterestToLead; +use App\Services\CountryCatalogue; +use App\Services\DeclaredOriginStatistics; use App\Services\PortalAudit; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\RedirectResponse; @@ -77,7 +79,8 @@ class CrmController extends Controller { $filters = $request->validate(['q' => ['nullable', 'string', 'max:150'], 'status' => ['nullable', Rule::in(['new', 'qualified', 'disqualified', 'converted'])], - 'owner_user_id' => ['nullable', 'integer', 'exists:users,id']]); + 'owner_user_id' => ['nullable', 'integer', 'exists:users,id'], + 'origin' => ['nullable', Rule::in(['unknown', ...array_column(app(CountryCatalogue::class)->all(), 'code')])]]); $query = Lead::with(['investor:id,legal_name', 'owner:id,name']) ->withCount(['tasks as overdue_tasks_count' => fn ($q) => $q->whereIn('status', ['open', 'in_progress'])->where('due_at', '<', now())]); if ($term = $filters['q'] ?? null) { @@ -92,7 +95,9 @@ class CrmController extends Controller } return Inertia::render('crm/leads', ['leads' => $query->latest()->orderBy('id')->paginate(20)->withQueryString(), - 'filters' => $filters, 'owners' => $this->owners()]); + 'filters' => $filters, 'owners' => $this->owners(), + 'countries' => app(CountryCatalogue::class)->all(), + 'originStatistics' => app(DeclaredOriginStatistics::class)->forManager($request->user(), $filters['origin'] ?? null)]); } public function prepare(InterestRequest $interest): Response|RedirectResponse diff --git a/app/Http/Controllers/InvestmentDiscoveryController.php b/app/Http/Controllers/InvestmentDiscoveryController.php index b4ffb8c9..2339856b 100644 --- a/app/Http/Controllers/InvestmentDiscoveryController.php +++ b/app/Http/Controllers/InvestmentDiscoveryController.php @@ -128,7 +128,24 @@ class InvestmentDiscoveryController extends Controller public function map(Request $request, InvestmentCatalogue $catalogue): JsonResponse { - return response()->json($catalogue->map($catalogue->filters($request))); + $filters = $catalogue->filters($request); + $context = $this->territorial($filters)->map(fn ($d) => ['id' => 'dossier:'.$d->id, 'title' => $d->title, 'kind' => $d->cluster_id ? 'Cluster' : ($d->corridor_id ? 'Corredor' : 'Projeto âncora'), 'url' => '/carteira/'.$d->id]); + $opportunityOnlyFilters = array_filter(\Illuminate\Support\Arr::only($filters, ['education', 'sector', 'facets', 'project_id', 'cluster_id', 'corridor_id', 'min_amount', 'max_amount', 'currency', 'before', 'maturity_level']), fn ($value) => $value !== null && $value !== '' && $value !== false && $value !== []); + if ($opportunityOnlyFilters === [] && ! in_array($filters['dimension'] ?? null, ['cluster', 'corridor'], true)) { + $zoneFilters = $filters; + if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) { + $zoneFilters['category'] = $filters['dimension']; + } + $zones = app(InvestmentZones::class)->search($zoneFilters); + foreach (['zee_id', 'zfi_id', 'park_id'] as $key) { + if (! empty($filters[$key])) { + $zones->where('id', $filters[$key]); + } + } + $context = $context->concat($zones->orderBy('name')->limit(30)->get(['id', 'name', 'category'])->map(fn ($z) => ['id' => 'zone:'.$z->id, 'title' => $z->name, 'kind' => ['zee' => 'ZEE', 'zfi' => 'ZFI', 'industrial_park' => 'Parque industrial'][$z->category] ?? $z->category, 'url' => '/zonas/'.$z->id])); + } + + return response()->json([...$catalogue->map($filters), 'territorial_context' => $context->values(), 'context_limit_per_collection' => 30]); } public function compare(Request $request, InvestmentCatalogue $catalogue): JsonResponse diff --git a/app/Http/Controllers/InvestmentSubmissionController.php b/app/Http/Controllers/InvestmentSubmissionController.php index 8340223d..b5fadccf 100644 --- a/app/Http/Controllers/InvestmentSubmissionController.php +++ b/app/Http/Controllers/InvestmentSubmissionController.php @@ -49,7 +49,7 @@ class InvestmentSubmissionController extends Controller $company = DB::table('investment_company_declarations')->where('id', $data['company_id'])->where('user_id', $request->user()->id)->first(); abort_unless($company !== null, 404); $companyData = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR); - $payload = [...$payload, 'organisation' => $companyData['name'], 'representative_role' => $companyData['role'], 'country' => $companyData['country']]; + $payload = [...$payload, 'organisation' => $companyData['name'], 'representative_role' => $companyData['role'], 'incorporation_country_code' => $companyData['incorporation_country_code'] ?? null]; } unset($data['company_id']); if (! empty($data['opportunity_id'])) { @@ -111,7 +111,7 @@ 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']); - $data = ['submission' => $row->only(['id', 'payload', 'version', 'status', 'opportunity_id', 'dossier_id']), 'zone_origin' => $zoneOrigin, + $data = ['countries' => app(\App\Services\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' => [ ...$catalogue->references(), 'options' => DB::table('investment_reference_options')->orderBy('label')->get()], 'needs' => InvestmentSpecificNeed::where('submission_id', $id)->get(), @@ -144,10 +144,13 @@ class InvestmentSubmissionController extends Controller { $required = $submitting ? 'required' : 'nullable'; $data = $request->validate(['version' => ['required', 'integer', 'min:1'], - 'payload' => ['required', 'array:full_name,email,phone,country,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'], + 'payload' => ['required', 'array:full_name,email,phone,country,residence_country_code,incorporation_country_code,declared_origin_country_code,organisation,representative_role,experience,investment_capacity,profile_id,sector,objective,participation_id,partnership_id,estimated_amount,currency,horizon,location,cluster_ids,corridor_ids,project_ids,contact_consent,other_details'], + 'payload.residence_country_code' => ['nullable', 'string', app(\App\Services\CountryCatalogue::class)->rule()], + 'payload.incorporation_country_code' => ['nullable', 'string', app(\App\Services\CountryCatalogue::class)->rule()], + 'payload.declared_origin_country_code' => ['nullable', 'string', app(\App\Services\CountryCatalogue::class)->rule()], 'payload.other_details' => ['nullable', 'string', 'max:5000'], 'payload.full_name' => [$required, 'string', ...($submitting ? ['min:3'] : []), 'max:150'], 'payload.email' => [$required, $submitting ? 'email:rfc' : 'string', 'max:150'], - 'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$required, 'string', 'max:100'], + 'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => [$submitting ? 'required_without:payload.declared_origin_country_code' : 'nullable', 'nullable', 'string', 'max:100'], 'payload.organisation' => ['nullable', 'string', 'max:200'], 'payload.representative_role' => ['nullable', 'string', 'max:150'], 'payload.experience' => ['nullable', 'string', 'max:2000'], 'payload.investment_capacity' => ['nullable', 'string', 'max:1000'], 'payload.profile_id' => [$required, 'integer'], 'payload.sector' => [$required, 'string', 'max:150'], @@ -257,10 +260,13 @@ class InvestmentSubmissionController extends Controller $interest = $intake->receive(['email' => $p['email'], 'fullName' => $p['full_name'], 'message' => $p['objective'], 'submission_id' => $id], $id, fn () => InterestRequest::create(['reference' => 'IR-'.now()->year.'-'.Str::ulid(), 'opportunity_id' => $opportunity?->id, 'opportunity_reference' => $opportunity?->reference, 'full_name' => $p['full_name'], 'email' => $p['email'], 'phone' => $p['phone'] ?? null, - 'country' => $p['country'], 'investor_type' => empty($p['organisation']) ? 'individual' : 'empresa', + 'country' => $p['country'] ?? 'Não informado', 'investor_type' => empty($p['organisation']) ? 'individual' : 'empresa', 'sector_interest' => $p['sector'], 'message' => $p['objective'], 'privacy_consent' => true, 'consented_at' => now(), 'status' => 'received', 'source_page' => '/investidor/manifestacoes/'.$id, 'metadata' => ['submission_id' => $id, 'portal_user_id' => $request->user()->id, 'dossier_id' => $row->dossier_id, + 'declared_origin_country_code' => $p['declared_origin_country_code'] ?? null, + 'residence_country_code' => $p['residence_country_code'] ?? null, + 'incorporation_country_code' => $p['incorporation_country_code'] ?? null, ...($zoneOrigin ? ['zone_origin' => (array) $zoneOrigin] : [])]])); $row->update(['payload' => $p, 'version' => $row->version + 1, 'interest_request_id' => $interest->id, 'status' => 'submitted']); InvestmentSpecificNeed::where('submission_id', $id)->delete(); diff --git a/app/Http/Controllers/InvestmentWorkspaceController.php b/app/Http/Controllers/InvestmentWorkspaceController.php index 22558952..8af192f1 100644 --- a/app/Http/Controllers/InvestmentWorkspaceController.php +++ b/app/Http/Controllers/InvestmentWorkspaceController.php @@ -18,7 +18,7 @@ class InvestmentWorkspaceController extends Controller { public function profile(Request $request, InvestmentWorkspace $workspace): Response|JsonResponse { - $data = ['account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations') + $data = ['countries' => app(\App\Services\CountryCatalogue::class)->all(), 'account' => $workspace->account($request->user()->id), 'companies' => DB::table('investment_company_declarations') ->where('user_id', $request->user()->id)->orderByDesc('created_at')->get(['id', 'version', 'payload'])->map(function ($company) { $company->payload = json_decode(Crypt::decryptString($company->payload), true, 512, JSON_THROW_ON_ERROR); @@ -31,7 +31,8 @@ class InvestmentWorkspaceController extends Controller public function saveProfile(Request $request): RedirectResponse|JsonResponse { $data = $request->validate(['version' => ['required', 'integer', 'min:0'], - 'payload' => ['required', 'array:phone,country,sector,experience,investment_capacity'], + 'payload' => ['required', 'array:phone,country,residence_country_code,sector,experience,investment_capacity'], + 'payload.residence_country_code' => ['nullable', 'string', app(\App\Services\CountryCatalogue::class)->rule()], 'payload.phone' => ['nullable', 'string', 'max:30'], 'payload.country' => ['nullable', 'string', 'max:100'], 'payload.sector' => ['nullable', 'string', 'max:150'], 'payload.experience' => ['nullable', 'string', 'max:2000'], 'payload.investment_capacity' => ['nullable', 'string', 'max:1000']]); @@ -53,8 +54,9 @@ class InvestmentWorkspaceController extends Controller public function company(Request $request, ?string $id = null): RedirectResponse|JsonResponse { $data = $request->validate(['version' => ['required', 'integer', 'min:0'], - 'payload' => ['required', 'array:name,country,registration,sector,role,description'], - 'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required', 'string', 'max:100'], + 'payload' => ['required', 'array:name,country,incorporation_country_code,registration,sector,role,description'], + 'payload.incorporation_country_code' => ['nullable', 'string', app(\App\Services\CountryCatalogue::class)->rule()], + 'payload.name' => ['required', 'string', 'min:2', 'max:200'], 'payload.country' => ['required_without:payload.incorporation_country_code', 'nullable', 'string', 'max:100'], 'payload.registration' => ['nullable', 'string', 'max:100'], 'payload.sector' => ['nullable', 'string', 'max:150'], 'payload.role' => ['required', 'string', 'max:150'], 'payload.description' => ['nullable', 'string', 'max:2000']]); $result = DB::transaction(function () use ($request, $id, $data) { diff --git a/app/Http/Controllers/OpportunityResourceController.php b/app/Http/Controllers/OpportunityResourceController.php index d6d4cf74..e47a3514 100644 --- a/app/Http/Controllers/OpportunityResourceController.php +++ b/app/Http/Controllers/OpportunityResourceController.php @@ -23,7 +23,8 @@ class OpportunityResourceController extends Controller { public function installation(Request $request, OpportunityResourceInstallation $service): JsonResponse|Response { - $preview = [...$service->preview(), 'actionUrl' => '/management/opportunity-resources-install']; + $preview = [...$service->preview(), 'imageProcessingAvailable' => app(OpportunityImageProcessor::class)->available(), 'actionUrl' => '/management/opportunity-resources-install']; + $preview['changes'] = [...$preview['changes'], 'Código reconciliado: catálogo ISO comum; residência e constituição distintas; origem declarada guardada no payload existente e estatísticas internas autorizadas. Não converte países históricos nem usa IP.', 'Seletores territoriais reutilizados com pesquisa; filtros contextuais do catálogo e fichas territoriais consultáveis no percurso do mapa. Zero geometrias não permite desenhar estruturas sem dados.', 'Não reinstala a gestão básica nem acrescenta associações territoriais. Revisão transversal, cadastro completo em todos os campos e percursos educativos continuam parciais; consultar a matriz por módulo.']; return $request->expectsJson() ? response()->json($preview) : Inertia::render('management/opportunity-install', $preview); } @@ -41,6 +42,7 @@ class OpportunityResourceController extends Controller $opportunity = Opportunity::withTrashed()->findOrFail($id); abort_unless($service->installed(), 503, 'Recursos ainda não instalados; consulte o plano autenticado.'); $resources = []; + Inertia::share('imageProcessingAvailable', app(OpportunityImageProcessor::class)->available()); foreach (OpportunityResources::KINDS as $kind) { $resources[$kind] = $service->listing($id, $kind); } diff --git a/app/Http/Controllers/VirtualDeskController.php b/app/Http/Controllers/VirtualDeskController.php index a2f3ac74..963065e9 100644 --- a/app/Http/Controllers/VirtualDeskController.php +++ b/app/Http/Controllers/VirtualDeskController.php @@ -48,8 +48,8 @@ class VirtualDeskController extends Controller $data = ['cases' => $cases, 'queue' => $queue, 'notifications' => DB::table('desk_notifications')->where('user_id', $actor->id)->orderByDesc('id')->limit(100)->get(), 'legacyRequests' => DB::table('interest_requests')->where('metadata->portal_user_id', $actor->id)->whereNull('deleted_at')->orderByDesc('created_at')->get(['id', 'reference', 'status', 'created_at']), 'companies' => DB::table('investment_company_declarations')->where('user_id', $actor->id)->get(['id', 'payload'])->map(fn ($c) => ['id' => $c->id, 'name' => $desk->decode($c->payload)['name'] ?? 'Declaração empresarial']), - 'submissions' => DB::table('investment_submissions')->where('user_id', $actor->id)->get(['id', 'status']), - 'journeys' => DB::table('investor_journeys')->where('user_id', $actor->id)->whereNull('deleted_at')->get(['id', 'dossier_id', 'status']), + 'submissions' => \App\Models\InvestmentSubmission::where('user_id', $actor->id)->get()->map(fn ($s) => ['id' => $s->id, 'status' => $s->status, 'name' => ($s->opportunity_id ? DB::table('opportunities')->where('id', $s->opportunity_id)->value('title') : null) ?? ($s->dossier_id ? DB::table('investment_dossiers')->where('id', $s->dossier_id)->value('title') : null) ?? ($s->payload['organisation'] ?? null)]), + 'journeys' => DB::table('investor_journeys as j')->leftJoin('investment_dossiers as d', 'd.id', '=', 'j.dossier_id')->where('j.user_id', $actor->id)->whereNull('j.deleted_at')->get(['j.id', 'j.dossier_id', 'j.status', 'd.title as name']), 'simulationDrafts' => DB::table('simulation_form_drafts')->where('owner_user_id', $actor->id)->whereNull('deleted_at')->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->get(['id', 'version_no']), 'guidanceTasks' => app(InvestorGuidance::class)->installed() ? DB::table('tasks as t')->join('desk_cases as c', 'c.id', '=', 't.desk_case_id') ->whereNull('t.deleted_at')->when(! $actor->can('manage-portal'), fn ($q) => $q->where(fn ($p) => $p->where('t.assignee_user_id', $actor->id)->orWhere(fn ($s) => $s->where('c.user_id', $actor->id)->whereNull('c.organization_id'))->orWhereIn('c.organization_id', $organizations))) @@ -130,7 +130,7 @@ class VirtualDeskController extends Controller } } $this->auditRead($request, 'desk_requests', $id); - $data = ['request' => [...(array) $row, 'payload' => $payload, 'submitted_snapshot' => $desk->decode($row->submitted_snapshot), 'editable_fields' => json_decode($row->editable_fields ?? '[]', true)], + $data = ['countries' => app(\App\Services\CountryCatalogue::class)->all(), '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' => $desk->definition($row), '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, diff --git a/app/Services/CountryCatalogue.php b/app/Services/CountryCatalogue.php new file mode 100644 index 00000000..835c4798 --- /dev/null +++ b/app/Services/CountryCatalogue.php @@ -0,0 +1,35 @@ + */ + public function all(): array + { + $countries = []; + $lines = file(resource_path('data/iso3166.tab'), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + throw new \RuntimeException('Catálogo ISO local indisponível.'); + } + foreach ($lines as $line) { + if (str_starts_with($line, '#')) { + continue; + } + [$code, $english] = explode("\t", $line, 2); + $name = class_exists(\Locale::class) ? \Locale::getDisplayRegion('und_'.$code, 'pt') : $english; + $countries[] = ['code' => $code, 'name' => is_string($name) && $name !== '' ? $name : $english, 'name_en' => $english]; + } + usort($countries, fn ($a, $b) => strnatcasecmp($a['name'], $b['name'])); + + return $countries; + } + + public function rule(): \Illuminate\Validation\Rules\In + { + return Rule::in(array_column($this->all(), 'code')); + } +} diff --git a/app/Services/DeclaredOriginStatistics.php b/app/Services/DeclaredOriginStatistics.php new file mode 100644 index 00000000..58d51f62 --- /dev/null +++ b/app/Services/DeclaredOriginStatistics.php @@ -0,0 +1,50 @@ +> */ + public function forManager(User $actor, ?string $origin = null): array + { + abort_unless($actor->can('manage-portal'), 403); + $names = array_column(app(CountryCatalogue::class)->all(), 'name', 'code'); + $groups = []; + foreach (DB::table('interest_requests')->whereNull('deleted_at')->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')) { + 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 : ''; + $desk[$key] = ($desk[$key] ?? 0) + 1; + } + } + // Distinct request collections; summing them could count the same investor twice. + if ($origin !== null && $origin !== '') { + $key = $origin === 'unknown' ? '' : $origin; + $groups = array_intersect_key($groups, [$key => true]); + $desk = array_intersect_key($desk, [$key => true]); + } + + return ['interests' => $this->format($groups, $names), 'desk' => $this->format($desk, $names)]; + } + + /** @param array $counts + * @param array $names + * @return list */ + private function format(array $counts, array $names): array + { + ksort($counts); + + return array_map(fn (string $code, int $count) => ['code' => $code ?: null, 'name' => $names[$code] ?? 'Não informado', 'count' => $count], array_keys($counts), array_values($counts)); + } +} diff --git a/app/Services/OpportunityImageProcessor.php b/app/Services/OpportunityImageProcessor.php index c132b99c..b447092d 100644 --- a/app/Services/OpportunityImageProcessor.php +++ b/app/Services/OpportunityImageProcessor.php @@ -12,11 +12,16 @@ use Illuminate\Support\Str; class OpportunityImageProcessor { + public function available(): bool + { + return function_exists('imagecreatefromstring') && function_exists('imagewebp'); + } + /** @param array $input */ public function prepare(User $actor, int $opportunityId, string $id, array $input): void { abort_unless($actor->can('manage-portal'), 403); - abort_unless(function_exists('imagecreatefromstring') && function_exists('imagewebp'), 423, 'GD/WebP não disponível neste processo. Original e metadados preservados; processamento pendente.'); + abort_unless($this->available(), 423, 'GD/WebP não disponível neste processo. Original e metadados preservados; processamento pendente.'); $data = validator($input, ['etag' => 'required|string|size:64', 'version' => 'required|integer|min:1', 'reason' => 'required|string|min:20|max:1000'])->validate(); $stored = []; $documents = []; diff --git a/app/Services/OpportunityResourceInstallation.php b/app/Services/OpportunityResourceInstallation.php index 0e426c06..deed165f 100644 --- a/app/Services/OpportunityResourceInstallation.php +++ b/app/Services/OpportunityResourceInstallation.php @@ -14,6 +14,8 @@ class OpportunityResourceInstallation { $files = [__FILE__, app_path('Services/OpportunityImageProcessor.php'), app_path('Services/OpportunityResources.php'), app_path('Http/Controllers/OpportunityResourceController.php'), app_path('Http/Controllers/OpportunityManagementController.php'), app_path('Jobs/ScanDocumentForMalware.php'), database_path('migrations/'.self::MIGRATION.'.php'), base_path('routes/web.php'), resource_path('js/pages/management/opportunity-resources.tsx'), resource_path('js/pages/management/opportunity-draft.tsx'), resource_path('js/pages/management/opportunity-install.tsx')]; + $files = [...$files, app_path('Services/CountryCatalogue.php'), app_path('Services/DeclaredOriginStatistics.php'), app_path('Services/VirtualDesk.php'), app_path('Http/Controllers/CrmController.php'), app_path('Http/Controllers/InvestmentWorkspaceController.php'), app_path('Http/Controllers/InvestmentSubmissionController.php'), app_path('Http/Controllers/InvestmentDiscoveryController.php'), app_path('Http/Controllers/VirtualDeskController.php'), resource_path('data/iso3166.tab'), resource_path('js/components/country-select.tsx'), resource_path('js/components/investment-territory.tsx'), resource_path('js/pages/investment/profile.tsx'), resource_path('js/pages/investment/submission.tsx'), resource_path('js/pages/investment/catalogue.tsx'), resource_path('js/pages/desk/request.tsx'), resource_path('js/pages/desk/panel.tsx'), resource_path('js/pages/crm/leads.tsx')]; + return hash('sha256', json_encode(array_map(fn ($file) => hash_file('sha256', $file), $files), JSON_THROW_ON_ERROR)); } diff --git a/app/Services/VirtualDesk.php b/app/Services/VirtualDesk.php index ec32d122..3f59cf3e 100644 --- a/app/Services/VirtualDesk.php +++ b/app/Services/VirtualDesk.php @@ -205,7 +205,8 @@ class VirtualDesk * @return array */ public function validateFields(array $payload, array $definition, bool $submitting): array { - $rules = ['payload' => ['required', 'array:'.implode(',', array_keys($definition['fields']))]]; + $rules = ['payload' => ['required', 'array:'.implode(',', [...array_keys($definition['fields']), 'declared_origin_country_code'])], + 'payload.declared_origin_country_code' => ['nullable', 'string', app(CountryCatalogue::class)->rule()]]; foreach ($definition['fields'] as $key => $field) { $active = ! isset($field['when']) || ($payload[$field['when']['field']] ?? null) === $field['when']['equals']; $required = $submitting && ($field['required'] ?? false) && $active; diff --git a/docs/investmoz/v2/backlog-mestre.md b/docs/investmoz/v2/backlog-mestre.md index 6ba4bd94..eeaff8c0 100644 --- a/docs/investmoz/v2/backlog-mestre.md +++ b/docs/investmoz/v2/backlog-mestre.md @@ -229,3 +229,16 @@ Leitura em 09/10/2026 06:34:48 UTC: migration aplicada uma vez; evento `opportun **QA:** 80 testes passaram, 1 252 asserções, três casos reais de ClamD saltados; percurso desktop/móvel e nova sessão sem armazenamento local passaram. Restauro/reversão de 288 tabelas/cinco ficheiros; 14 conjuntos preservados após navegador. **Ativo:** gestão básica confirmada; seis IDs reais e 124 eventos anteriores preservados, dois eventos posteriores; recursos ainda não instalados, zero eventos próprios. Plano `/management/opportunity-resources-install`, válido até 10/10/2026 11:09:13 UTC, exclusivamente autenticado. **Lacunas mantidas:** publicação/revisão segregada, direitos/dados pessoais, editor cartográfico integral/limites oficiais em falta, catálogos pesquisáveis, evidências por campo, módulos especializados e critérios integrais da especificação. Cobertura do inventário, questões jurídicas, ClamD real, fontes institucionais específicas e primeiro pedido real continuam separados. Fontes oficiais já aceites não exigem segunda validação. + +### Revisão transversal — países, cartografia e integração de `9d6176f` + +[Matriz por módulo, percurso demonstrado e lacunas](revisao-funcional-transversal.md). O incremento anterior foi preservado e integrado; os seus 80 testes não certificam esta revisão. + +- **Países, parcial:** catálogo local comum de 249 códigos ISO, pesquisa e traduções; residência, constituição e origem da solicitação distintas. Persistência e recuperação no perfil, entidade, manifestação e pedido do Balcão pelo armazenamento existente; origem no mesmo pedido submetido em QA. Estatísticas internas autorizadas conservam históricos como «não informado» e separam conjuntos de pedidos. Cadastro inicial, endpoints legados e restantes módulos/filtro estatístico ainda por completar. +- **Cartografia, parcial:** pesquisa no seletor reutilizado; consumidores públicos passam a consultar diretamente o cadastro aceite, eliminando dependência de publicação de uma versão intermediária. Fichas territoriais independentes consultáveis no percurso do mapa. Inventário dos campos registado; seletores próprios, campo plano do Balcão e cobertura gráfica ainda abertos. Leitura ativa: 1.921 territórios e zero geometrias; não inventar pontos/polígonos nem invalidar dados aceites. +- **Percursos:** navegador desktop/móvel demonstra perfil guardado com resultado visível, recuperação em nova sessão sem localStorage, filtros/pesquisa territorial e fichas no mapa; recursos demonstram preparação, anexos, versões, exclusão/restauro. Manifestações/Balcão/CRM têm evidência HTTP e persistência em QA; percursos integrais e educação continuam com lacunas expressas na matriz. +- **Limitações operacionais visíveis:** GD/WebP verificado no PHP web; ausência desativa apenas geração de miniaturas. Scan pendente restringe binário/preview, sem bloquear rascunhos/metadados/localizações. ClamD real indisponível; fixtures não verificam ficheiros ativos. Plano único de recursos atualizado; nenhuma repetição da gestão instalada. + +**Estado ativo:** só leitura/backup pelo Codex. Seis IDs reais, relações e 124 eventos anteriores preservados; recursos ainda não instalados. Instalação autenticada continua necessária; cobertura transversal não é dada como concluída. Questões jurídicas específicas, diferença de cobertura documental, fontes institucionais, ClamD real e primeiro pedido real mantêm estados separados. + +Continuação de 9 de outubro: filtro de país/«Não informado» nas estatísticas do CRM preparado, com conjuntos separados e sem inferir origem de campos históricos. GET autorizado e validado; a lista de leads mantém os seus filtros próprios. 25 testes/430 asserções em QA isolada, TypeScript, PHPStan e build passaram. Percurso deste filtro no navegador ainda pendente; sem migration nem escrita ativa. [Escopo e evidência](revisao-funcional-transversal.md#continuação--filtro-das-estatísticas-de-origem). diff --git a/docs/investmoz/v2/gestao-oportunidades-dashboard.md b/docs/investmoz/v2/gestao-oportunidades-dashboard.md index 63839e80..4cd35c18 100644 --- a/docs/investmoz/v2/gestao-oportunidades-dashboard.md +++ b/docs/investmoz/v2/gestao-oportunidades-dashboard.md @@ -63,6 +63,10 @@ Os seis IDs continuam classificados real. Comparação do conteúdo original das ## Próximo incremento — recursos em QA +Continuação da revisão transversal: [filtro das estatísticas de origem no CRM e validação QA](revisao-funcional-transversal.md#continuação--filtro-das-estatísticas-de-origem). O filtro não altera oportunidades, classificações ou relações; a instalação de recursos e as lacunas da especificação conservam os estados abaixo. + +Tentativa de conclusão às 14:19:42 locais de 9 de outubro: controlo de navegador sem browsers disponíveis; abertura do navegador interno devolveu indisponibilidade. Verificação visual do filtro não realizada. Leitura ativa em transação `READ ONLY` confirmou recursos não instalados e plano não pronto: hash atual `80eee708a85e18895eb2f51d17421ffcc86c69d8bb5560880ff9d9466ca0454d`, distinto do hash na prova de ativação `dcdfa8511b6b3554dd44ab236264822da8aaf2dd09eb8c66dcea78b0f2215e53`. É necessário renovar as provas correspondentes ao código antes de executar o fluxo autenticado. Leitor `tools/opportunity-resources-install-preview.php`; evidência privada `opportunity-resources-readiness.json`. Zero escritas ativas; instalação não tentada com prova desatualizada. + [Imagens, documentos e multilocalização: matriz, QA, lacunas e plano](recursos-oportunidades.md). Guardar/recuperar recursos, originais cifrados, versões e exclusão lógica/restauro implementados; coleção territorial até 50 itens com hierarquia/papéis, áreas/distribuição e GeoJSON validado. Relações existentes conservadas; novas associações são preparação privada até workflow explícito. Processador GD gera WebP/miniaturas sem EXIF e scan independente; GD ativo e ClamD real mantêm dependências concretas. Não publicar automaticamente. Leitura ativa em 11:10:21 UTC confirmou novamente os seis IDs reais, dados/relações e 124 eventos anteriores intactos; dois eventos posteriores explicam diferença global do histórico. Novo esquema de recursos ausente, zero eventos deste incremento. QA: 80 testes passaram/1 252 asserções, três ClamD reais saltados; navegador desktop/móvel passou. Plano único `/management/opportunity-resources-install`, backup/restauro e reversão em cópia, validade até 10/10/2026 11:09:13 UTC. Não aplicado automaticamente. diff --git a/docs/investmoz/v2/revisao-funcional-transversal.md b/docs/investmoz/v2/revisao-funcional-transversal.md new file mode 100644 index 00000000..4bdb2468 --- /dev/null +++ b/docs/investmoz/v2/revisao-funcional-transversal.md @@ -0,0 +1,55 @@ +# Revisão funcional transversal — integração de 9d6176f + +9 de outubro de 2026. Incremento de correções, com cobertura por percurso; não é certificação de todos os módulos. Preserva a gestão básica instalada, classificações, relações e fontes oficiais aceites. A revisão não reabre a proveniência oficial. + +## Resultados demonstrados e lacunas + +| Módulo / rota | Ação e resultado demonstrado nesta revisão | Correção / persistência | Lacuna restante | +| --- | --- | --- | --- | +| Perfil `/investidor/profile` | Guardar residência, mensagem visível, HTTP 303, recarregar; nova sessão móvel sem localStorage recupera PT; pesquisar Portugal | ISO comum, país de residência explícito, payload privado cifrado e versão existentes | País no cadastro inicial e restantes perfis ainda por reconciliar | +| Entidades `/investidor/companies` | QA de servidor: guardar constituição MZ; recuperar no contexto da conta; negar acesso de outra conta | País de constituição distinto; valor histórico conservado; sem concessão de representação | Novo percurso completo de criação/edição de entidade no navegador ainda por demonstrar | +| Manifestações `/investidor/manifestacoes/{id}` | QA HTTP: guardar/recuperar três países diferentes; rejeitar ZZ com 422 sem alterar versão/payload; submissão só em QA conserva origem no pedido | A constituição da entidade deixa de substituir o país do perfil; origem explícita no payload, revisão e metadata do mesmo pedido | Teste de todos os passos da manifestação com os novos dropdowns no navegador; os endpoints públicos/legados ainda não recolhem ISO específico | +| Balcão `/balcao-virtual/painel` e `/pedidos/{id}` | QA HTTP: origem de rascunho recuperada pelo proprietário; outra conta negada; 422 preserva rascunho; nenhum pedido ativo submetido | Seletores mostram nomes dos investimentos/percursos da conta; título reutilizado quando disponível, sem substituir título corrigido; origem não inferida do perfil | Comprovar no navegador o percurso de reutilização de projeto, pedido e responsável; primeiro pedido real continua pendente | +| CRM `/management/crm/leads` | QA: estatísticas e filtro de origem autorizados, acesso não autorizado rejeitado; pedido antigo com país Moçambique e sem origem continua «não informado» | Contagens separadas de manifestações e pedidos em acompanhamento; filtro por país/não informado; sem somar como investidores únicos ou exportar contactos | Verificação das contagens e do filtro no navegador; cobertura dos endpoints legados | +| Catálogo `/oportunidades` | Navegador móvel: dimensão Cluster oculta filtros incompatíveis; pesquisa Maputo devolve opção do cadastro | Troca de dimensão limpa filtros incompatíveis; pesquisa no seletor reutilizado | Guião de preferências integrado e percurso completo oportunidade → localização → acompanhamento ainda por demonstrar | +| Mapa `/oportunidades?view=map` | Navegador: fichas de estruturas territoriais acessíveis dentro do percurso do mapa | API inclui contexto territorial independente das seis oportunidades, com links de ficheiro; não cria coordenadas nem associações | Desenhar corredores/âncoras/ZEE/ZFI/parques exige geometrias existentes; a base ativa tem zero. Lista consultável não equivale a cartografia completa | +| Recursos `/management/opportunities/{id}/resources` | Navegador desktop/móvel: múltiplos locais, PDF/imagem, recuperação, exclusão lógica/restauro; falta de GD visível, preparação desativada | Integra 9d6176f; metadados e rascunhos continuam guardáveis; originais conservados; scan por ficheiro independente | Instalação ativa pendente, ClamD real, GD no PHP web e revisão/publicação final | +| Educação `/educacao`, `/management/education` | Testes HTTP existentes executados com a revisão; regras de lacuna preservadas | `/educacao` é catálogo de investimentos educativos; a gestão de procura/capacidade é outra rota | Fluxo educativo integral solicitado, recuperação dos seus rascunhos, painéis por ator e aceitação funcional permanecem abertos; testes existentes não certificam a especificação inteira | +| Simulador | Correções do incremento anterior preservadas | Nenhum cálculo fiscal alterado ou ativado pela introdução de países | Nova revisão transversal do simulador ainda não demonstrada nesta frente; não conclui ClamD, fontes institucionais ou pedido real | +| Bolsa, serviços, carteira, negócios, fundiário e mapas de zonas | Inventário de consumidores territoriais abaixo | Componentes e entidades existentes preservados | Ações de criar/editar/recuperar por rota e cobertura completa dos países permanecem abertas | + +## Continuação — filtro das estatísticas de origem + +Em 9 de outubro de 2026, acrescentado o filtro de país e «Não informado» em `/management/crm/leads`. O filtro aplica-se apenas às estatísticas de solicitações; a lista de leads conserva os seus filtros próprios. Manifestações e pedidos do Balcão continuam separados. Seleção sem ocorrências mostra conjunto vazio; país histórico, residência e constituição não substituem origem declarada. GET validado pelo catálogo comum, protegido pela permissão existente, sem migration ou atualização de dados. + +QA isolada definida em `phpunit.xml` (`investmoz_test`, porta 55441), com guarda antes da limpeza/migração: **25 testes, 430 asserções passaram** nas suites `InvestmentWorkspaceTest` e `InvestorGuidanceTest`. Cobrem país explícito, não informado, ausência de ocorrências, reposição de todas as origens, país inválido, acesso negado e igualdade dos registos antes/depois. TypeScript e PHPStan passaram. Percurso específico deste novo filtro no navegador ainda não demonstrado; não altera a aceitação funcional dos restantes percursos. Nenhuma escrita na base ativa. + +## Países e dados históricos + +Catálogo comum local de **249 códigos ISO alpha-2**, sem seed de substituição de cadastros. Fonte técnica: [tabela IANA](https://data.iana.org/time-zones/tzdb/iso3166.tab), consultada em 09/10/2026; declara códigos referentes a ISO/TC 46 N1127 de 29/02/2024. SHA-256 local `837c80785080c8433fd9d4ea87e78f161ac7a40389301c5153d4f90198baeb2a`. Nomes portugueses pelo ICU/Intl, nomes ingleses preservados e usados como alternativa. Consulta e pesquisa não dependem de serviço externo. + +Três significados distintos: `residence_country_code`, `incorporation_country_code`, `declared_origin_country_code`. Os dois primeiros não preenchem o terceiro. O campo antigo `country` conserva o valor original sem conversão automática; dados ausentes continuam não informados. O IP técnico mantém-se separado. O uso do JSON/payload e metadata existentes evita nova tabela de perfil, novo pedido e migration de países nesta parcela. Estatísticas de origem usam apenas a declaração explícita, sem reclassificar históricos pelo campo antigo. + +## Inventário territorial inicial — cobertura ainda parcial + +O inventário de código identificou consumidores em `portal/services`, `portal/manage`, `education/index`, `education/analysis`, `portfolio/index`, `structures/editor`, `management/relationship-repair`, `management/editor`, `management/opportunity-draft`, `management/opportunity-resources`, `partnerships/catalogue`, `partnerships/editor`, `investment/business-marketplace`, `investment/business-wizard`, `investment/catalogue`, `investment/detail`, `investment/zone`, `investment/zones` e `land-assistance/form`. Acrescenta-se o campo territorial do pedido do Balcão e os mapas do catálogo e das zonas. + +Os consumidores de `ProvinceSelect` e `TerritoryFields` públicos passam a usar `/api/v1/investment/territory-options`, que consulta o cadastro aceite, em vez de depender da publicação/validade de uma versão intermediária em `/api/v1/catalogs/jurisdictions`. Os seletores de gestão conservam `/management/territory-options` e a permissão existente. `TerritoryLevel` passa a pesquisar nome/código, preservando o pai, paginação e valor guardado. + +**Não está concluído «todos os campos e mapas».** O Balcão ainda apresenta o seletor territorial plano; a Bolsa e serviços têm consumidores próprios; os seletores de gestão genéricos, campos livres de localização, filtros e cada mapa precisam de demonstração individual. Não substituir localidades ausentes nem inferir pontos pelo centro de província. A recuperação de referências mantém as validações existentes. Na leitura ativa de 11:42:46 UTC: 1.921 territórios, zero geometrias, 28 locais de projeto e 13 ligações cluster–projeto preservados. + +## GD e ClamD — impacto mostrado antes da operação + +GD/WebP é verificado no processo PHP que atende a página, não pelo resultado de outro CLI. Quando indisponível, o formulário conserva original/metadados, mostra a limitação e desativa apenas a preparação de WebP/miniaturas. O endpoint mantém HTTP 423 como guarda. Não foi alterado o PHP ativo. + +ClamD real continua indisponível (`malware:health`: `scanner_unavailable`, versão/assinaturas desconhecidas). Scan pendente bloqueia acesso ao binário/preview/processamento que o exige; não impede preparar metadados, localizações ou rascunhos. A página de instalação também explica estas limitações. Scanner simulado verifica apenas fixtures identificadas de QA, nunca ficheiros reais. Direitos e publicação continuam independentes do scan. + +## QA, preservação e plano consolidado + +QA exclusivamente em `*_test:55441`, incluindo cópia restaurada e navegador restrito a `127.0.0.1:18096`. Provas privadas: `functional-country-browser.json`, `opportunity-resources-browser.json`, `opportunity-resources-preservation.json`, `opportunity-resources-compatibility.json` e `opportunity-resources-backup-proof.json`. Tests/TypeScript/PHPStan/build são verificações técnicas; a matriz acima delimita a aceitação funcional. + +Plano autenticado único para o esquema ainda pendente: [recursos e revisão funcional](http://127.0.0.1:8000/management/opportunity-resources-install). Inclui apenas `2026_10_09_030000_extend_opportunity_resources`, versão atual do código, backup/restauro, reversão e limitações. Não repete `2026_10_09_020000_extend_opportunity_management`. Países desta parcela reutilizam armazenamento existente; não convertem históricos. Nenhuma localização, relação ou oportunidade ativa é criada pelo plano. + +Reversão da migration apenas antes de novos recursos/documentos cifrados, preservando a guarda de dados. Depois de uso, exige reconciliação dos dados posteriores e ação autorizada; não restaurar automaticamente o backup por cima de novas escritas. Reversão de código deve preservar os novos campos no payload antes de voltar a formulários antigos que não os conheçam. + +**Base ativa:** apenas leitura e backup nesta revisão; seis IDs reais e 124 eventos anteriores confirmados, zero eventos de recursos. Nenhuma instalação repetida, seed ativo, submissão ou alteração de configuração. Cobertura documental, questões jurídicas específicas, fontes institucionais, ClamD real e primeiro pedido real permanecem separados. diff --git a/resources/data/iso3166.tab b/resources/data/iso3166.tab new file mode 100644 index 00000000..4ae35234 --- /dev/null +++ b/resources/data/iso3166.tab @@ -0,0 +1,279 @@ +# ISO 3166 alpha-2 country codes +# +# This file is in the public domain, so clarified as of +# 2009-05-17 by Arthur David Olson. +# +# From Paul Eggert (2025-07-01): +# This file contains a table of two-letter country codes. Columns are +# separated by a single tab. Lines beginning with ‘#’ are comments. +# All text uses UTF-8 encoding. The columns of the table are as follows: +# +# 1. ISO 3166-1 alpha-2 country code, current as of +# ISO/TC 46 N1127 (2024-02-29). See: ISO/TC 46 Documents +# https://www.iso.org/committee/48750.html?view=documents +# 2. The usual English name for the coded region. This sometimes +# departs from ISO-listed names, sometimes so that sorted subsets +# of names are useful (e.g., “Samoa (American)” and “Samoa +# (western)” rather than “American Samoa” and “Samoa”), +# sometimes to avoid confusion among non-experts (e.g., +# “Czech Republic” and “Turkey” rather than “Czechia” and “Türkiye”), +# and sometimes to omit needless detail or churn (e.g., “Netherlands” +# rather than “Netherlands (the)” or “Netherlands (Kingdom of the)”). +# +# The table is sorted by country code. +# +# This table is intended as an aid for users, to help them select time +# zone data appropriate for their practical needs. It is not intended +# to take or endorse any position on legal or territorial claims. +# +#country- +#code name of country, territory, area, or subdivision +AD Andorra +AE United Arab Emirates +AF Afghanistan +AG Antigua & Barbuda +AI Anguilla +AL Albania +AM Armenia +AO Angola +AQ Antarctica +AR Argentina +AS Samoa (American) +AT Austria +AU Australia +AW Aruba +AX Åland Islands +AZ Azerbaijan +BA Bosnia & Herzegovina +BB Barbados +BD Bangladesh +BE Belgium +BF Burkina Faso +BG Bulgaria +BH Bahrain +BI Burundi +BJ Benin +BL St Barthelemy +BM Bermuda +BN Brunei +BO Bolivia +BQ Caribbean NL +BR Brazil +BS Bahamas +BT Bhutan +BV Bouvet Island +BW Botswana +BY Belarus +BZ Belize +CA Canada +CC Cocos (Keeling) Islands +CD Congo (Dem. Rep.) +CF Central African Rep. +CG Congo (Rep.) +CH Switzerland +CI Côte d’Ivoire +CK Cook Islands +CL Chile +CM Cameroon +CN China +CO Colombia +CR Costa Rica +CU Cuba +CV Cape Verde +CW Curaçao +CX Christmas Island +CY Cyprus +CZ Czech Republic +DE Germany +DJ Djibouti +DK Denmark +DM Dominica +DO Dominican Republic +DZ Algeria +EC Ecuador +EE Estonia +EG Egypt +EH Western Sahara +ER Eritrea +ES Spain +ET Ethiopia +FI Finland +FJ Fiji +FK Falkland Islands +FM Micronesia +FO Faroe Islands +FR France +GA Gabon +GB Britain (UK) +GD Grenada +GE Georgia +GF French Guiana +GG Guernsey +GH Ghana +GI Gibraltar +GL Greenland +GM Gambia +GN Guinea +GP Guadeloupe +GQ Equatorial Guinea +GR Greece +GS South Georgia & the South Sandwich Islands +GT Guatemala +GU Guam +GW Guinea-Bissau +GY Guyana +HK Hong Kong +HM Heard Island & McDonald Islands +HN Honduras +HR Croatia +HT Haiti +HU Hungary +ID Indonesia +IE Ireland +IL Israel +IM Isle of Man +IN India +IO British Indian Ocean Territory +IQ Iraq +IR Iran +IS Iceland +IT Italy +JE Jersey +JM Jamaica +JO Jordan +JP Japan +KE Kenya +KG Kyrgyzstan +KH Cambodia +KI Kiribati +KM Comoros +KN St Kitts & Nevis +KP Korea (North) +KR Korea (South) +KW Kuwait +KY Cayman Islands +KZ Kazakhstan +LA Laos +LB Lebanon +LC St Lucia +LI Liechtenstein +LK Sri Lanka +LR Liberia +LS Lesotho +LT Lithuania +LU Luxembourg +LV Latvia +LY Libya +MA Morocco +MC Monaco +MD Moldova +ME Montenegro +MF St Martin (French) +MG Madagascar +MH Marshall Islands +MK North Macedonia +ML Mali +MM Myanmar (Burma) +MN Mongolia +MO Macau +MP Northern Mariana Islands +MQ Martinique +MR Mauritania +MS Montserrat +MT Malta +MU Mauritius +MV Maldives +MW Malawi +MX Mexico +MY Malaysia +MZ Mozambique +NA Namibia +NC New Caledonia +NE Niger +NF Norfolk Island +NG Nigeria +NI Nicaragua +NL Netherlands +NO Norway +NP Nepal +NR Nauru +NU Niue +NZ New Zealand +OM Oman +PA Panama +PE Peru +PF French Polynesia +PG Papua New Guinea +PH Philippines +PK Pakistan +PL Poland +PM St Pierre & Miquelon +PN Pitcairn +PR Puerto Rico +PS Palestine +PT Portugal +PW Palau +PY Paraguay +QA Qatar +RE Réunion +RO Romania +RS Serbia +RU Russia +RW Rwanda +SA Saudi Arabia +SB Solomon Islands +SC Seychelles +SD Sudan +SE Sweden +SG Singapore +SH St Helena +SI Slovenia +SJ Svalbard & Jan Mayen +SK Slovakia +SL Sierra Leone +SM San Marino +SN Senegal +SO Somalia +SR Suriname +SS South Sudan +ST Sao Tome & Principe +SV El Salvador +SX St Maarten (Dutch) +SY Syria +SZ Eswatini (Swaziland) +TC Turks & Caicos Is +TD Chad +TF French S. Terr. +TG Togo +TH Thailand +TJ Tajikistan +TK Tokelau +TL East Timor +TM Turkmenistan +TN Tunisia +TO Tonga +TR Turkey +TT Trinidad & Tobago +TV Tuvalu +TW Taiwan +TZ Tanzania +UA Ukraine +UG Uganda +UM US minor outlying islands +US United States +UY Uruguay +UZ Uzbekistan +VA Vatican City +VC St Vincent +VE Venezuela +VG Virgin Islands (UK) +VI Virgin Islands (US) +VN Vietnam +VU Vanuatu +WF Wallis & Futuna +WS Samoa (western) +YE Yemen +YT Mayotte +ZA South Africa +ZM Zambia +ZW Zimbabwe diff --git a/resources/js/components/country-select.tsx b/resources/js/components/country-select.tsx new file mode 100644 index 00000000..5865a543 --- /dev/null +++ b/resources/js/components/country-select.tsx @@ -0,0 +1,20 @@ +import { useState } from 'react'; + +export type Country = { code: string; name: string; name_en: string }; + +export default function CountrySelect({ countries, value, onChange, id, disabled = false }: { + countries: Country[]; value: string; onChange: (value: string) => void; id: string; disabled?: boolean; +}) { + const [search, setSearch] = useState(''); + const matches = countries.filter((country) => country.code === value || + `${country.name} ${country.name_en} ${country.code}`.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase() + .includes(search.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase())); + return
+ setSearch(event.target.value)} placeholder="Pesquisar nome ou código do país" /> + + {matches.length === 0 &&

Nenhum país corresponde à pesquisa.

} +
; +} diff --git a/resources/js/components/investment-territory.tsx b/resources/js/components/investment-territory.tsx index da4639bf..dfd10ee6 100644 --- a/resources/js/components/investment-territory.tsx +++ b/resources/js/components/investment-territory.tsx @@ -19,7 +19,7 @@ const emptyTerritoryPath: TerritoryOption[] = []; export function ProvinceSelect({ value, onChange, - endpoint = '/api/v1/catalogs/jurisdictions', + endpoint = '/api/v1/investment/territory-options', placeholder = 'Seleccione uma província', className = control, label = 'Província', @@ -107,7 +107,7 @@ export function ProvinceSelect({ export function TerritoryFields({ initial = emptyTerritoryPath, onChange, - endpoint = '/api/v1/catalogs/jurisdictions', + endpoint = '/api/v1/investment/territory-options', }: { initial?: TerritoryOption[]; onChange?: (path: TerritoryOption[]) => void; @@ -174,7 +174,9 @@ export function TerritoryLevel({ const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [retry, setRetry] = useState(0); + const [search, setSearch] = useState(''); const enabled = index === 0 || Boolean(parent); + useEffect(() => { setPage(1); setSearch(''); }, [parent, index]); useEffect(() => { setOptions([]); setMore(false); @@ -189,6 +191,7 @@ export function TerritoryLevel({ type: levels[index], parent_id: parent, page: String(page), + q: search, }); const response = await fetch(`${endpoint}?${params}`, { signal: controller.signal, @@ -215,12 +218,13 @@ export function TerritoryLevel({ clearTimeout(timer); controller.abort(); }; - }, [index, parent, page, enabled, endpoint, retry]); + }, [index, parent, page, enabled, endpoint, retry, search]); return (
+ { setSearch(event.target.value); setPage(1); }} placeholder={`Pesquisar ${labels[index].toLowerCase()}`} /> + form.setData('origin', e.target.value) + } + > + + + {countries.map((country) => ( + + ))} + + + + +

+ Este filtro aplica-se apenas às contagens de solicitações. A + lista de leads mantém os filtros abaixo. +

+ {(['interests', 'desk'] as const).map((group) => ( +
+

+ {group === 'interests' + ? 'Manifestações recebidas' + : 'Pedidos do Balcão em acompanhamento'} +

+ {originStatistics[group].length ? ( +
    + {originStatistics[group].map((row) => ( +
  • + {row.name}: {row.count} +
  • + ))} +
+ ) : ( +

Sem pedidos neste conjunto.

+ )} +
+ ))} +
{ diff --git a/resources/js/pages/desk/panel.tsx b/resources/js/pages/desk/panel.tsx index a547e4e3..fe52a481 100644 --- a/resources/js/pages/desk/panel.tsx +++ b/resources/js/pages/desk/panel.tsx @@ -1,5 +1,5 @@ import { Link, useForm, usePage } from '@inertiajs/react'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Frame, input, button, card } from '@/pages/investment/shared'; type Option = { id: string; @@ -66,6 +66,14 @@ export default function Panel({ organization_id: ['Organização representada', organizations], journey_id: ['Percurso territorial existente', journeys], } as const; + const derivedTitle = useRef(''); + const contextTitle = journeys.find((item) => item.id === form.data.journey_id)?.name ?? submissions.find((item) => item.id === form.data.submission_id)?.name ?? companies.find((item) => item.id === form.data.company_id)?.name ?? ''; + useEffect(() => { + if ((!form.data.title || form.data.title === derivedTitle.current) && contextTitle) { + form.setData('title', contextTitle.slice(0, 200)); + derivedTitle.current = contextTitle.slice(0, 200); + } + }, [contextTitle, form.data.title]); return (
+ {kind === 'images' && !imageProcessingAvailable &&

Processamento de imagens indisponível: GD/WebP não está carregado no servidor. Pode guardar o original e os metadados; miniaturas e imagens para divulgação ficam pendentes.

} + {kind !== 'locations' &&

Pode guardar anexos para preparação. Cada ficheiro permanece sem download ou pré-visualização enquanto o seu scan não estiver concluído. ClamD pendente não impede guardar metadados, locais ou rascunhos; testes com scanner simulado não verificam os seus ficheiros.

}

Conferência e pendências

    @@ -610,7 +614,7 @@ export default function OpportunityResources({