From 2e0217b4864ae72e4a1eff1a7972d62857a5a1b8 Mon Sep 17 00:00:00 2001 From: nala Date: Tue, 29 Sep 2026 08:41:57 +0200 Subject: [PATCH] varias alteracoes --- app/Console/Commands/AuditTerritorialCsv.php | 45 ++++ .../InvestmentCoordinatesController.php | 61 +++++ .../InvestmentDiscoveryController.php | 11 + .../InvestmentStructureController.php | 15 +- .../InvestmentTerritoryController.php | 103 ++++++++ .../Controllers/InvestmentZoneController.php | 19 +- .../PortalManagementController.php | 14 + .../Controllers/PublicGeographyController.php | 26 ++ app/Models/Opportunity.php | 2 + app/Services/InvestmentCatalogue.php | 62 ++++- app/Services/InvestmentGeography.php | 83 ++++++ app/Services/PortalAudit.php | 2 +- app/Services/TerritorialCsvAudit.php | 133 ++++++++++ ...28_160000_investment_primary_territory.php | 37 +++ docs/investmoz/delivery-gaps.md | 42 +++ docs/investmoz/gis/README.md | 10 + docs/investmoz/gis/ine-import-status.md | 68 +++++ docs/investmoz/gis/interactive-map.md | 33 +++ docs/investmoz/gis/investment-dropdowns.md | 42 +++ docs/investmoz/gis/map-coverage.json | 77 ++++++ package-lock.json | 25 ++ package.json | 2 + .../components/interactive-investment-map.tsx | 179 +++++++++++++ .../js/components/investment-coordinates.tsx | 106 ++++++++ .../js/components/investment-map-panel.tsx | 56 ++++ .../js/components/investment-territory.tsx | 230 +++++++++++++++++ resources/js/lib/investment-maturity.ts | 9 + resources/js/pages/PortalInvestidor.tsx | 9 +- resources/js/pages/investment/catalogue.tsx | 240 ++++++++++-------- resources/js/pages/investment/detail.tsx | 5 + resources/js/pages/investment/shared.tsx | 1 + resources/js/pages/investment/zone.tsx | 18 +- resources/js/pages/investment/zones.tsx | 5 + resources/js/pages/management/editor.tsx | 43 +++- resources/js/pages/management/shared.tsx | 3 + .../management/territory-associations.tsx | 108 ++++++++ resources/js/pages/portfolio/show.tsx | 12 + resources/js/pages/structures/editor.tsx | 219 ++++++---------- routes/web.php | 12 + tests/Feature/InvestmentTerritoryTest.php | 115 +++++++++ tests/Unit/TerritorialCsvAuditTest.php | 75 ++++++ tools/import-territory-workbook.php | 46 ++++ tools/investment-map-browser.mjs | 29 +++ tools/investment-map-coverage.php | 22 ++ tools/prepare-territory-workbook.py | 69 +++++ tools/publish-territory-references.php | 67 +++++ tools/record-territorial-owner-acceptance.php | 73 ++++++ tools/repair-territory-designations.php | 58 +++++ tools/territorial-pdf-review.py | 79 ++++++ tools/territory-browser.mjs | 39 +++ tools/territory-public-browser.mjs | 36 +++ 51 files changed, 2605 insertions(+), 270 deletions(-) create mode 100644 app/Console/Commands/AuditTerritorialCsv.php create mode 100644 app/Http/Controllers/InvestmentCoordinatesController.php create mode 100644 app/Http/Controllers/InvestmentTerritoryController.php create mode 100644 app/Http/Controllers/PublicGeographyController.php create mode 100644 app/Services/InvestmentGeography.php create mode 100644 app/Services/TerritorialCsvAudit.php create mode 100644 database/migrations/2026_09_28_160000_investment_primary_territory.php create mode 100644 docs/investmoz/delivery-gaps.md create mode 100644 docs/investmoz/gis/ine-import-status.md create mode 100644 docs/investmoz/gis/interactive-map.md create mode 100644 docs/investmoz/gis/investment-dropdowns.md create mode 100644 docs/investmoz/gis/map-coverage.json create mode 100644 resources/js/components/interactive-investment-map.tsx create mode 100644 resources/js/components/investment-coordinates.tsx create mode 100644 resources/js/components/investment-map-panel.tsx create mode 100644 resources/js/components/investment-territory.tsx create mode 100644 resources/js/lib/investment-maturity.ts create mode 100644 resources/js/pages/management/territory-associations.tsx create mode 100644 tests/Feature/InvestmentTerritoryTest.php create mode 100644 tests/Unit/TerritorialCsvAuditTest.php create mode 100644 tools/import-territory-workbook.php create mode 100644 tools/investment-map-browser.mjs create mode 100644 tools/investment-map-coverage.php create mode 100644 tools/prepare-territory-workbook.py create mode 100644 tools/publish-territory-references.php create mode 100644 tools/record-territorial-owner-acceptance.php create mode 100644 tools/repair-territory-designations.php create mode 100644 tools/territorial-pdf-review.py create mode 100644 tools/territory-browser.mjs create mode 100644 tools/territory-public-browser.mjs diff --git a/app/Console/Commands/AuditTerritorialCsv.php b/app/Console/Commands/AuditTerritorialCsv.php new file mode 100644 index 00000000..ccc871ff --- /dev/null +++ b/app/Console/Commands/AuditTerritorialCsv.php @@ -0,0 +1,45 @@ +argument('path'); + if (! is_file($path) || ! is_readable($path) || filesize($path) > 20 * 1024 * 1024) { + $this->error('CSV inacessível ou superior a 20 MiB.'); + + return self::FAILURE; + } + $directory = storage_path('app/private/geo-csv-audits/'.Str::uuid()); + File::ensureDirectoryExists($directory, 0700); + try { + if (! copy($path, $directory.'/original.csv')) { + throw new \RuntimeException('Não foi possível arquivar o original.'); + } + $codes = DB::table('geo_locations as p')->join('geo_locations as c', 'p.parent_id', '=', 'c.id') + ->where('p.location_type', 'province')->where('c.location_type', 'country')->where('c.code', '508') + ->whereNull('p.deleted_at')->whereNull('c.deleted_at')->pluck('p.code')->map(fn ($code) => (string) $code)->all(); + $report = $audit->inspect($directory.'/original.csv', array_values($codes)); + } catch (Throwable $exception) { + $report = ['status' => 'rejected', 'error' => $exception->getMessage()]; + } + File::put($directory.'/report.json', json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); + $this->info('Relatório privado: '.$directory.'/report.json'); + $this->info('Nenhum território foi criado ou aprovado.'); + + return $report['status'] === 'rejected' ? self::FAILURE : self::SUCCESS; + } +} diff --git a/app/Http/Controllers/InvestmentCoordinatesController.php b/app/Http/Controllers/InvestmentCoordinatesController.php new file mode 100644 index 00000000..7e7c342b --- /dev/null +++ b/app/Http/Controllers/InvestmentCoordinatesController.php @@ -0,0 +1,61 @@ +join('geo_geometries as s', 's.geo_location_id', '=', 'g.id') + ->where('g.source_system', 'investment-site-editor')->where('g.external_id', (string) $id)->whereNull('g.deleted_at')->whereNull('s.deleted_at') + ->first(['s.version_no', 's.properties', DB::raw('ST_X(s.geom) as longitude'), DB::raw('ST_Y(s.geom) as latitude')]); + + return response()->json(['version' => $point->version_no ?? 0, 'latitude' => $point?->latitude, 'longitude' => $point?->longitude, + 'source_reference' => $point ? (json_decode($point->properties, true)['source_reference'] ?? '') : '']); + } + + public function save(Request $request, int $id): JsonResponse + { + $data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'latitude' => ['required', 'numeric', 'between:-90,90'], + 'longitude' => ['required', 'numeric', 'between:-180,180'], 'source_reference' => ['required', 'string', 'min:10', 'max:2000'], + 'confirmed_site' => ['accepted']]); + $version = DB::transaction(function () use ($data, $id, $request) { + DB::select('SELECT pg_advisory_xact_lock(280926, 150000)'); + $opportunity = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail(); + $location = DB::table('geo_locations')->where('source_system', 'investment-site-editor')->where('external_id', (string) $id)->first(); + abort_if($location && $location->deleted_at, 409, 'Local arquivado; requer reconciliação.'); + abort_if($location && $location->data_classification !== 'public', 409, 'Local retirado de divulgação; requer revisão da classificação.'); + $geometry = $location ? DB::table('geo_geometries')->where('geo_location_id', $location->id)->where('geometry_type', 'point')->first() : null; + abort_if($geometry && $geometry->deleted_at, 409, 'Geometria arquivada; requer reconciliação.'); + abort_if($geometry && $geometry->data_classification !== 'public', 409, 'Geometria retirada de divulgação; requer revisão da classificação.'); + abort_unless((int) ($geometry->version_no ?? 0) === $data['version'], 409, 'Coordenadas alteradas. Recarregue.'); + $locationId = $location->id ?? (string) Str::uuid(); + if (! $location) { + DB::table('geo_locations')->insert(['id' => $locationId, 'source_system' => 'investment-site-editor', 'external_id' => (string) $id, + 'name' => $opportunity->title, 'location_type' => 'site', 'code' => 'SITE-OPP-'.$id, 'data_classification' => 'public', 'parent_id' => $opportunity->primary_territory_id]); + } + $version = $data['version'] + 1; + $geometryId = $geometry->id ?? (string) Str::uuid(); + DB::statement("INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom,data_classification,version_no,properties) + VALUES(?,?,'point',ST_SetSRID(ST_MakePoint(?,?),4326),'public',?,?::jsonb) + ON CONFLICT(id) DO UPDATE SET geom=EXCLUDED.geom,version_no=EXCLUDED.version_no,properties=EXCLUDED.properties,updated_at=now()", + [$geometryId, $locationId, $data['longitude'], $data['latitude'], $version, json_encode(['source_reference' => $data['source_reference'], 'accuracy' => 'site_confirmed_by_editor'], JSON_THROW_ON_ERROR)]); + DB::table('opportunity_locations')->updateOrInsert(['opportunity_id' => $id, 'geo_location_id' => $locationId], []); + DB::table('audit_events')->insert(['source_system' => 'investment-coordinates', 'entity_type' => 'geo_geometries', 'entity_id' => $geometryId, + 'actor_user_id' => $request->user()->id, 'action' => 'site_coordinates_saved', + 'before_data' => $geometry ? json_encode(['version' => $geometry->version_no, 'geometry_hex' => $geometry->geom], JSON_THROW_ON_ERROR) : null, + 'after_data' => json_encode(['opportunity_id' => $id, 'version' => $version, 'latitude' => $data['latitude'], 'longitude' => $data['longitude'], 'source_sha256' => hash('sha256', $data['source_reference'])], JSON_THROW_ON_ERROR)]); + + return $version; + }); + + return response()->json(['version' => $version]); + } +} diff --git a/app/Http/Controllers/InvestmentDiscoveryController.php b/app/Http/Controllers/InvestmentDiscoveryController.php index f44d8a92..b8c87903 100644 --- a/app/Http/Controllers/InvestmentDiscoveryController.php +++ b/app/Http/Controllers/InvestmentDiscoveryController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\InvestmentDossier; use App\Models\Opportunity; use App\Services\InvestmentCatalogue; +use App\Services\InvestmentGeography; use App\Services\InvestmentZones; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\JsonResponse; @@ -24,6 +25,7 @@ class InvestmentDiscoveryController extends Controller } return Inertia::render('investment/catalogue', ['filters' => $filters, 'references' => $catalogue->references(), + 'territorySelection' => app(InvestmentGeography::class)->selection($filters), 'results' => $catalogue->query($filters)->paginate(12)->withQueryString(), 'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [], 'territorial' => $this->territorial($filters)]); @@ -36,6 +38,15 @@ class InvestmentDiscoveryController extends Controller private function territorial(array $filters): Collection { $query = InvestmentPortfolioController::visible(); + $path = app(InvestmentGeography::class)->selection($filters); + if ($path !== []) { + $ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id); + $query->where(function ($query) use ($ids) { + foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $foreign => $table) { + $query->orWhereIn($foreign, DB::table($table)->whereNull('deleted_at')->whereIn('primary_territory_id', $ids)->select('id')); + } + }); + } if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) { return $query->whereRaw('false')->get(); } diff --git a/app/Http/Controllers/InvestmentStructureController.php b/app/Http/Controllers/InvestmentStructureController.php index 65666b2b..087551c4 100644 --- a/app/Http/Controllers/InvestmentStructureController.php +++ b/app/Http/Controllers/InvestmentStructureController.php @@ -7,6 +7,7 @@ use App\Models\EconomicCluster; use App\Models\InvestmentGap; use App\Models\Project; use App\Models\ValueChain; +use App\Services\InvestmentGeography; use App\Services\PortalAudit; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -74,7 +75,19 @@ class InvestmentStructureController extends Controller } else { $rules['sector'] = ['required', 'string', 'max:200']; } - PortalAudit::save($record, $request->validate($rules), $request->user()->id); + if (! $id) { + foreach (InvestmentGeography::FIELDS as $field => $type) { + $rules[$field] = ['nullable', 'uuid']; + } + } + $data = $request->validate($rules); + if (! $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; + } + PortalAudit::save($record, $data, $request->user()->id); return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.'); } diff --git a/app/Http/Controllers/InvestmentTerritoryController.php b/app/Http/Controllers/InvestmentTerritoryController.php new file mode 100644 index 00000000..f12a30d5 --- /dev/null +++ b/app/Http/Controllers/InvestmentTerritoryController.php @@ -0,0 +1,103 @@ + 'opportunities', 'projects' => 'projects', 'clusters' => 'economic_clusters', 'corridors' => 'development_corridors', 'zones' => 'investment_zones']; + + public function subjects(Request $request): JsonResponse + { + $data = $request->validate(['kind' => ['required', 'in:'.implode(',', array_keys(self::TABLES))], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]); + $column = $data['kind'] === 'opportunities' ? 'title' : 'name'; + $query = DB::table(self::TABLES[$data['kind']])->whereNull('deleted_at'); + if (! empty($data['q'])) { + $query->whereRaw($column.' ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']); + } + + return response()->json($query->orderBy($column)->orderBy('id')->paginate(50, ['id', $column.' as name'])); + } + + public function options(Request $request): JsonResponse + { + $data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]); + $query = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $data['type']) + ->whereIn('data_classification', ['public', 'internal']); + if ($data['type'] !== 'province') { + $query->where('parent_id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000'); + } + if (! empty($data['q'])) { + $query->whereRaw('name ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']); + } + + return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type'])); + } + + public function show(string $kind, string $id): JsonResponse + { + $table = self::TABLES[$kind] ?? abort(404); + $record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first(); + abort_unless($record !== null, 404); + $path = []; + $current = $record->primary_territory_id; + for ($i = 0; $current && $i < 5; $i++) { + $location = DB::table('geo_locations')->where('id', $current)->first(['id', 'name', 'parent_id', 'location_type', 'deleted_at']); + if (! $location) { + break; + } + $path[] = $location; + $current = $location->parent_id; + } + + return response()->json(['version' => $record->territory_version, 'path' => $path]); + } + + public function save(Request $request, string $kind, string $id): JsonResponse + { + $table = self::TABLES[$kind] ?? abort(404); + if ($kind === 'zones') { + abort_unless($request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403); + } + $data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'path' => ['present', 'array', 'max:4'], 'path.*' => ['required', 'uuid', 'distinct']]); + $version = DB::transaction(function () use ($request, $table, $id, $data) { + DB::select('SELECT pg_advisory_xact_lock(280926, 150000)'); + $record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first(); + abort_unless($record !== null, 404); + abort_unless((int) $record->territory_version === $data['version'], 409, 'Localização alterada. Recarregue a ficha.'); + $parent = null; + $names = []; + foreach (array_values($data['path']) as $index => $locationId) { + $location = DB::table('geo_locations')->where('id', $locationId)->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])->first(); + if (! $location || $location->location_type !== ['province', 'district', 'admin_post', 'locality'][$index] || ($index > 0 && $location->parent_id !== $parent)) { + throw ValidationException::withMessages(['path' => 'Seleccione uma hierarquia territorial coerente e activa.']); + } + $parent = $locationId; + $names[] = $location->name; + } + $version = $data['version'] + 1; + $changes = ['primary_territory_id' => $parent, 'territory_version' => $version, 'updated_at' => now()]; + if (in_array($table, ['opportunities', 'investment_zones'], true)) { + $changes['province'] = $names[0] ?? null; + $changes['district'] = $names[1] ?? null; + } + if ($table === 'investment_zones') { + app(InvestmentZones::class)->save($id, [...$changes, 'version_no' => $record->version_no], $request->user()->id); + } else { + DB::table($table)->where('id', $id)->update($changes); + } + DB::table('audit_events')->insert(['source_system' => 'investment-territory', 'entity_type' => $table, + 'actor_user_id' => $request->user()->id, 'action' => 'primary_territory_saved', + 'after_data' => json_encode(['record_id' => $id, 'territory_id' => $parent, 'version' => $version], JSON_THROW_ON_ERROR)]); + + return $version; + }); + + return response()->json(['version' => $version]); + } +} diff --git a/app/Http/Controllers/InvestmentZoneController.php b/app/Http/Controllers/InvestmentZoneController.php index ed36f114..eb0d52df 100644 --- a/app/Http/Controllers/InvestmentZoneController.php +++ b/app/Http/Controllers/InvestmentZoneController.php @@ -6,6 +6,7 @@ use App\Models\InvestmentDossier; use App\Models\InvestmentZone; use App\Models\Opportunity; use App\Services\InvestmentCatalogue; +use App\Services\InvestmentGeography; use App\Services\InvestmentZoneAccess; use App\Services\InvestmentZones; use Illuminate\Http\JsonResponse; @@ -49,13 +50,18 @@ class InvestmentZoneController extends Controller public function index(Request $request, InvestmentZones $zones): Response|JsonResponse { - $filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1']]); + $filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'], + 'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]); $management = $request->is('management/*', 'api/v2/investment/admin/*'); $query = $management ? InvestmentZone::query() : $zones->visible(); + $path = app(InvestmentGeography::class)->selection($filters); + if ($path !== []) { + $query->whereIn('primary_territory_id', app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id)); + } $rows = $query->when($filters['category'] ?? null, fn ($q, $v) => $q->where('category', $v)) ->when($filters['q'] ?? null, fn ($q, $v) => $q->where(fn ($q) => $q->where('name', 'ilike', '%'.$v.'%')->orWhere('code', 'ilike', '%'.$v.'%'))) ->orderBy('name')->orderBy('id')->paginate(12, $management ? ['*'] : ['id', 'code', 'name', 'name_en', 'category', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'])->withQueryString(); - $props = ['records' => $rows, 'filters' => $filters, 'management' => $management]; + $props = ['records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path]; return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props); } @@ -70,6 +76,15 @@ class InvestmentZoneController extends Controller 'operator_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')], 'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]); + if (! $id && $request->has('province_id')) { + $geo = $request->validate(['province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]); + $path = app(InvestmentGeography::class)->selection($geo); + $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; + } + return response()->json($zones->save($id, $data, $request->user()->id), $id ? 200 : 201); } diff --git a/app/Http/Controllers/PortalManagementController.php b/app/Http/Controllers/PortalManagementController.php index 986e8c6d..59994e07 100644 --- a/app/Http/Controllers/PortalManagementController.php +++ b/app/Http/Controllers/PortalManagementController.php @@ -6,6 +6,7 @@ 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; @@ -177,7 +178,20 @@ class PortalManagementController extends Controller '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()) { diff --git a/app/Http/Controllers/PublicGeographyController.php b/app/Http/Controllers/PublicGeographyController.php new file mode 100644 index 00000000..f8c92834 --- /dev/null +++ b/app/Http/Controllers/PublicGeographyController.php @@ -0,0 +1,26 @@ +validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]); + $query = $geography->publicReferences()->where('location_type', $data['type']); + if ($data['type'] !== 'province') { + $parentType = ['district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']]; + $parent = $geography->publicReferences()->where('id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000')->where('location_type', $parentType)->value('id'); + $query->where('parent_id', $parent ?? '00000000-0000-0000-0000-000000000000'); + } + if (! empty($data['q'])) { + $query->whereRaw('name ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']); + } + + return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type'])); + } +} diff --git a/app/Models/Opportunity.php b/app/Models/Opportunity.php index 86320b86..7ac3ce1c 100644 --- a/app/Models/Opportunity.php +++ b/app/Models/Opportunity.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +/** @property string|null $primary_territory_id */ class Opportunity extends Model { use SoftDeletes; @@ -18,6 +19,7 @@ class Opportunity extends Model 'estimated_investment_currency', 'financing_modality', 'expected_impact', 'status', 'is_featured', 'published_at', 'last_validated_at', + 'primary_territory_id', 'territory_version', ]; protected $casts = [ diff --git a/app/Services/InvestmentCatalogue.php b/app/Services/InvestmentCatalogue.php index 100e6ab7..6d861437 100644 --- a/app/Services/InvestmentCatalogue.php +++ b/app/Services/InvestmentCatalogue.php @@ -16,7 +16,9 @@ class InvestmentCatalogue /** @return array */ public function filters(Request $request): array { - return $request->validate(['q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'], + $filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'], + 'opportunity_id' => ['nullable', 'integer', 'min:1'], + 'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'], 'province' => ['nullable', 'string', 'max:100'], 'district' => ['nullable', 'string', 'max:100'], 'cluster_id' => ['nullable', 'uuid'], 'corridor_id' => ['nullable', 'uuid'], 'project_id' => ['nullable', 'uuid'], 'geo_location_id' => ['nullable', 'uuid'], 'education' => ['nullable', 'boolean'], @@ -28,6 +30,9 @@ class InvestmentCatalogue 'currency' => ['nullable', 'required_with:min_amount,max_amount', 'in:MZN,USD,EUR,ZAR'], 'before' => ['nullable', 'date_format:Y-m-d'], 'sort' => ['nullable', 'in:recent,title,amount_asc,amount_desc'], 'per_page' => ['nullable', 'integer', 'between:1,50'], 'page' => ['nullable', 'integer', 'min:1']]); + app(InvestmentGeography::class)->selection($filters); + + return $filters; } /** @@ -37,6 +42,14 @@ class InvestmentCatalogue public function query(array $filters): Builder { $query = Opportunity::published()->with('sector:id,name,slug'); + if (! empty($filters['opportunity_id'])) { + $query->whereKey($filters['opportunity_id']); + } + $path = app(InvestmentGeography::class)->selection($filters); + if ($path !== []) { + $ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id); + $query->whereIn('primary_territory_id', $ids); + } if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) { $query->whereIn('opportunities.id', app(InvestmentZones::class)->links()->join('investment_item_opportunities as mapping', 'mapping.item_id', '=', 'zl.item_id') ->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')->where('z.category', $filters['dimension'])->select('mapping.opportunity_id')); @@ -147,6 +160,7 @@ class InvestmentCatalogue $result['zones'] = app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id') ->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')->where('m.opportunity_id', $id) ->get(['z.id', 'z.name', 'z.category', 'zl.association_type', 'zl.source_reference', 'zl.valid_from', 'zl.valid_until']); + $result['primary_territory_path'] = app(InvestmentGeography::class)->path(DB::table('opportunities')->where('id', $id)->value('primary_territory_id')); return $result; } @@ -157,14 +171,46 @@ class InvestmentCatalogue */ public function map(array $filters): array { - $ids = $this->query($filters)->reorder()->select('opportunities.id'); - $points = DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id') - ->join('geo_geometries as shape', 'shape.geo_location_id', '=', 'g.id')->join('opportunities as o', 'o.id', '=', 'l.opportunity_id') + $records = $this->query($filters)->limit(1000)->get(); + $total = $this->query($filters)->reorder()->count(); + $ids = $records->pluck('id'); + $points = DB::table('opportunities as o')->join('geo_locations as g', function ($join) { + $join->on('g.id', '=', 'o.primary_territory_id')->orWhereExists(fn ($q) => $q->selectRaw('1')->from('opportunity_locations as l')->whereColumn('l.opportunity_id', 'o.id')->whereColumn('l.geo_location_id', 'g.id')); + })->join('geo_geometries as shape', 'shape.geo_location_id', '=', 'g.id') ->whereIn('o.id', $ids)->where('g.data_classification', 'public')->where('shape.data_classification', 'public') - ->whereNull('g.deleted_at')->whereNull('shape.deleted_at')->where('shape.geometry_type', 'point')->limit(1000) - ->get(['o.id', 'o.title', 'o.slug', 'g.name', DB::raw('ST_AsGeoJSON(shape.geom) as geometry')]); + ->where('g.location_type', 'site')->whereNull('g.deleted_at')->whereNull('shape.deleted_at')->where('shape.geometry_type', 'point') + ->whereRaw("NOT EXISTS (WITH RECURSIVE parents AS ( + SELECT id,parent_id,deleted_at,data_classification,ARRAY[id] AS visited,false AS cycle FROM geo_locations WHERE id=g.id + UNION ALL SELECT p.id,p.parent_id,p.deleted_at,p.data_classification,a.visited||p.id,p.id=ANY(a.visited) + FROM geo_locations p JOIN parents a ON p.id=a.parent_id WHERE NOT a.cycle + ) SELECT 1 FROM parents WHERE deleted_at IS NOT NULL OR data_classification<>'public' OR cycle)") + ->whereRaw('ST_IsValid(shape.geom) AND NOT ST_IsEmpty(shape.geom) AND ST_SRID(shape.geom)=4326') + ->orderBy('o.id')->orderByRaw("CASE WHEN g.source_system='investment-site-editor' THEN 0 ELSE 1 END")->orderBy('g.id')->orderBy('shape.id') + ->get(['o.id', 'g.name', DB::raw('ST_AsGeoJSON(shape.geom) as geometry')])->unique('id')->keyBy('id'); + $relations = []; + foreach (['clusters', 'corridors', 'projects'] as $relation) { + [$foreign, $table] = self::RELATIONS[$relation]; + $relations[$relation] = DB::table('opportunity_'.$relation.' as l')->join($table.' as t', 't.id', '=', 'l.'.$foreign) + ->whereIn('l.opportunity_id', $ids)->whereNull('t.deleted_at') + ->whereIn('t.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->select($foreign)) + ->get(['l.opportunity_id', 't.name'])->groupBy('opportunity_id'); + } + $features = $missing = []; + foreach ($records as $record) { + $properties = ['id' => $record->id, 'title' => $record->title, 'category' => $record->sector?->name, + 'location' => trim($record->province.' '.$record->district), 'url' => '/oportunidades/'.$record->slug]; + foreach ($relations as $relation => $groups) { + $properties[$relation] = ($groups->get($record->id) ?? collect())->pluck('name')->unique()->values()->all(); + } + if ($point = $points->get($record->id)) { + $features[] = ['type' => 'Feature', 'geometry' => json_decode($point->geometry, true), 'properties' => [...$properties, 'location' => trim($properties['location'].' — '.$point->name)]]; + } else { + $missing[] = [...$properties, 'reason' => 'missing_public_site_coordinates']; + } + } - return ['type' => 'FeatureCollection', 'limit' => 1000, 'features' => $points->map(fn ($p) => ['type' => 'Feature', - 'geometry' => json_decode($p->geometry, true), 'properties' => ['id' => $p->id, 'title' => $p->title, 'location' => $p->name, 'url' => '/oportunidades/'.$p->slug]])->all()]; + return ['type' => 'FeatureCollection', 'limit' => 1000, 'matched_count' => $total, 'truncated' => $total > $records->count(), + 'georeferenced_count' => count($features), 'unlocated' => $missing, 'features' => $features, + 'complete' => $total === count($features)]; } } diff --git a/app/Services/InvestmentGeography.php b/app/Services/InvestmentGeography.php new file mode 100644 index 00000000..efb10ced --- /dev/null +++ b/app/Services/InvestmentGeography.php @@ -0,0 +1,83 @@ + 'province', 'district_id' => 'district', 'admin_post_id' => 'admin_post', 'locality_id' => 'locality']; + + public function publicReferences(): Builder + { + return DB::table('geo_locations')->whereNull('deleted_at')->where('data_classification', 'public') + ->whereRaw("NOT EXISTS (WITH RECURSIVE ancestors AS ( + SELECT id,parent_id,deleted_at,data_classification,ARRAY[id] AS visited,false AS cycle FROM geo_locations a WHERE a.id=geo_locations.id + UNION ALL SELECT a.id,a.parent_id,a.deleted_at,a.data_classification,p.visited||a.id,a.id=ANY(p.visited) + FROM geo_locations a JOIN ancestors p ON a.id=p.parent_id WHERE NOT p.cycle + ) SELECT 1 FROM ancestors WHERE deleted_at IS NOT NULL OR data_classification<>'public' OR cycle)") + ->where(function ($query) { + $query->where(function ($workbook) { + $workbook->where('source_system', 'cartografia-workbook') + ->where('external_id', 'like', '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:%'); + })->orWhereIn('id', app(TerritorialRegistry::class)->published()->select('g.id')); + }); + } + + /** @param array $input + * @return list<\stdClass> + */ + public function selection(array $input): array + { + $path = []; + $parent = null; + $gap = false; + foreach (self::FIELDS as $field => $type) { + if (empty($input[$field])) { + $gap = true; + + continue; + } + $node = $this->publicReferences()->where('id', $input[$field])->where('location_type', $type)->first(['id', 'name', 'parent_id', 'location_type']); + if ($gap || ! $node || ($parent && $node->parent_id !== $parent)) { + throw ValidationException::withMessages([$field => 'Seleccione a localização dentro da hierarquia indicada.']); + } + $path[] = $node; + $parent = $node->id; + } + + return $path; + } + + /** @return list */ + public function descendants(string $id): array + { + $rows = DB::select('WITH RECURSIVE tree AS ( + SELECT id, ARRAY[id] AS visited FROM geo_locations WHERE id=? AND deleted_at IS NULL + UNION ALL SELECT g.id, tree.visited||g.id FROM geo_locations g JOIN tree ON g.parent_id=tree.id + WHERE g.deleted_at IS NULL AND NOT g.id=ANY(tree.visited) AND cardinality(tree.visited)<5 + ) SELECT id FROM tree', [$id]); + + return array_values($this->publicReferences()->whereIn('id', array_map(fn ($row) => $row->id, $rows))->pluck('id')->map(fn ($id) => (string) $id)->all()); + } + + /** @return list<\stdClass> */ + public function path(?string $id): array + { + $path = []; + for ($i = 0; $id && $i < 5; $i++) { + $node = $this->publicReferences()->where('id', $id)->first(['id', 'name', 'parent_id', 'location_type']); + if (! $node) { + return []; + } + if ($node->location_type !== 'country') { + array_unshift($path, $node); + } + $id = $node->parent_id; + } + + return $path; + } +} diff --git a/app/Services/PortalAudit.php b/app/Services/PortalAudit.php index c78a6d01..864107ec 100644 --- a/app/Services/PortalAudit.php +++ b/app/Services/PortalAudit.php @@ -18,7 +18,7 @@ class PortalAudit $record->setRawAttributes($locked->getAttributes(), true); } // Exclude free text and personal data from the immutable event payload. - $fields = ['version_no', 'status', 'is_active', 'is_featured', 'sort_order', 'sector_id', 'maturity_level', + $fields = ['primary_territory_id', 'territory_version', 'version_no', 'status', 'is_active', 'is_featured', 'sort_order', 'sector_id', 'maturity_level', 'estimated_investment_amount', 'estimated_investment_currency', 'owner_user_id', 'assignee_user_id', 'due_at', 'cluster_id', 'project_id', 'corridor_id', 'dossier_id', 'interest_request_id', 'publication_status', 'editor_id', 'reviewer_id', 'role', 'stage', 'value_chain_id', 'from_value_chain_id', 'to_value_chain_id', 'link_type', 'deleted_at']; $before = array_intersect_key($record->getAttributes(), array_flip($fields)); diff --git a/app/Services/TerritorialCsvAudit.php b/app/Services/TerritorialCsvAudit.php new file mode 100644 index 00000000..651aed17 --- /dev/null +++ b/app/Services/TerritorialCsvAudit.php @@ -0,0 +1,133 @@ + $provinceCodes + * @return array + */ + public function inspect(string $path, array $provinceCodes): array + { + if (! is_file($path) || ! is_readable($path) || filesize($path) > 20 * 1024 * 1024) { + throw new RuntimeException('CSV inacessível ou superior a 20 MiB.'); + } + $bytes = file_get_contents($path); + if ($bytes === false || ! mb_check_encoding($bytes, 'UTF-8') || str_contains($bytes, "\0")) { + throw new RuntimeException('O CSV deve ser UTF-8 sem bytes nulos.'); + } + // Parse the exact bytes hashed, avoiding a second read of a changing input. + $stream = fopen('php://temp', 'w+'); + if ($stream === false) { + throw new RuntimeException('Não foi possível preparar a leitura.'); + } + fwrite($stream, preg_replace('/^\xEF\xBB\xBF/', '', $bytes) ?? $bytes); + rewind($stream); + try { + if (fgetcsv($stream, null, ';', '"', '') !== self::HEADERS) { + throw new RuntimeException('Cabeçalho inválido: usar o contrato INE com separador ponto e vírgula.'); + } + $errors = $seen = $names = $totals = []; + $duplicates = $count = 0; + while (($values = fgetcsv($stream, null, ';', '"', '')) !== false) { + $count++; + if ($count > 50000) { + throw new RuntimeException('O CSV excede 50.000 registos.'); + } + $issues = []; + if (count($values) !== count(self::HEADERS)) { + $errors[] = ['record' => $count + 1, 'issues' => ['column_count']]; + + continue; + } + $row = array_combine(self::HEADERS, array_map(fn ($v) => trim((string) $v), $values)); + foreach ($row as $field => $value) { + if (mb_strlen($value) > 255 || preg_match('/[\x00-\x1F\x7F]/', $value)) { + $issues[] = 'invalid_field:'.$field; + } + } + if (! preg_match('/^[0-9]{2}$/D', $row['province_code']) || ! in_array($row['province_code'], $provinceCodes, true)) { + $issues[] = 'unknown_province'; + } + foreach (['province', 'district', 'admin_post', 'locality'] as $kind) { + $code = $row[$kind.'_code']; + $name = $row[$kind.'_name']; + if (($kind === 'province' || $kind === 'district' || $code !== '') && $name === '') { + $issues[] = 'missing_name:'.$kind; + } + if (($kind === 'province' || $kind === 'district' || $name !== '') && $code === '') { + $issues[] = 'missing_code:'.$kind; + } + if ($code === '') { + continue; + } + if (! preg_match('/^[0-9]+$/D', $code)) { + $issues[] = 'invalid_code:'.$kind; + } + $parent = match ($kind) { + 'province' => 'MZ', 'district' => $row['province_code'], + 'admin_post' => $row['district_code'], default => $row['admin_post_code'], + }; + $identity = $kind.':'.$code; + $definition = [$parent, $name]; + if ($kind === 'locality') { + $definition = [...$definition, $row['locality_type'], $row['latitude'], $row['longitude']]; + } + if (isset($seen[$identity]) && $seen[$identity] !== $definition) { + $issues[] = 'conflicting_code:'.$kind; + } + $seen[$identity] ??= $definition; + $nameKey = $kind.':'.$parent.':'.mb_strtolower($name); + if (isset($names[$nameKey]) && $names[$nameKey] !== $code) { + $issues[] = 'ambiguous_name:'.$kind; + } + $names[$nameKey] ??= $code; + $totals[$row['province_code']][$kind][$code] = true; + } + if ($row['locality_code'] !== '' && $row['admin_post_code'] === '') { + $issues[] = 'locality_requires_reviewed_parent_exception'; + } + if (($row['latitude'] === '') !== ($row['longitude'] === '')) { + $issues[] = 'incomplete_coordinates'; + } + foreach (['latitude' => 90, 'longitude' => 180] as $field => $bound) { + if ($row[$field] !== '' && (! preg_match('/^-?[0-9]+(?:\.[0-9]+)?$/D', $row[$field]) || abs((float) $row[$field]) > $bound)) { + $issues[] = 'invalid_coordinate:'.$field; + } + } + if ($row['locality_code'] === '' && ($row['latitude'] !== '' || $row['longitude'] !== '' || $row['locality_type'] !== '')) { + $issues[] = 'locality_metadata_without_locality'; + } + $fingerprint = hash('sha256', json_encode($row, JSON_THROW_ON_ERROR)); + if (isset($seen['row:'.$fingerprint])) { + $duplicates++; + } + $seen['row:'.$fingerprint] = []; + if ($issues !== []) { + $errors[] = ['record' => $count + 1, 'issues' => array_values(array_unique($issues))]; + } + } + if ($count === 0) { + $errors[] = ['record' => 1, 'issues' => ['empty_dataset']]; + } + ksort($totals); + $counts = []; + foreach ($totals as $province => $kinds) { + $counts[$province] = array_map('count', $kinds); + } + + return ['schema_version' => 1, 'sha256' => hash('sha256', $bytes), 'encoding' => 'UTF-8', + 'delimiter' => ';', 'coordinate_crs' => 'EPSG:4326', 'records' => $count, + 'duplicate_records' => $duplicates, 'rejected_records' => count($errors), + 'status' => $errors === [] ? 'structurally_valid_requires_review' : 'rejected', + 'candidate_counts_by_province' => $counts, 'errors' => $errors]; + } finally { + fclose($stream); + } + } +} diff --git a/database/migrations/2026_09_28_160000_investment_primary_territory.php b/database/migrations/2026_09_28_160000_investment_primary_territory.php new file mode 100644 index 00000000..17485274 --- /dev/null +++ b/database/migrations/2026_09_28_160000_investment_primary_territory.php @@ -0,0 +1,37 @@ +foreignUuid('primary_territory_id')->nullable()->constrained('geo_locations')->restrictOnDelete(); + $t->unsignedInteger('territory_version')->default(0); + }); + } + } + + public function down(): void + { + $tables = ['opportunities', 'projects', 'economic_clusters', 'development_corridors', 'investment_zones']; + foreach ($tables as $table) { + if (Schema::hasColumn($table, 'primary_territory_id') && DB::table($table)->whereNotNull('primary_territory_id')->exists()) { + throw new RuntimeException('Preserve territorial assignments before rollback.'); + } + } + foreach ($tables as $table) { + if (Schema::hasColumn($table, 'primary_territory_id')) { + Schema::table($table, function (Blueprint $t) { + $t->dropConstrainedForeignId('primary_territory_id'); + $t->dropColumn('territory_version'); + }); + } + } + } +}; diff --git a/docs/investmoz/delivery-gaps.md b/docs/investmoz/delivery-gaps.md new file mode 100644 index 00000000..4a65c567 --- /dev/null +++ b/docs/investmoz/delivery-gaps.md @@ -0,0 +1,42 @@ +# Revisão das entregas e omissões — 29/09/2026 + +As instruções de implementação e avanço entre fases estão autorizadas. Não é necessária nova autorização genérica. Este inventário distingue funcionalidades entregues de trabalho por terminar; não substitui a verificação integral de cada requisito dos anexos. + +## Falhas confirmadas e corrigidas nesta revisão + +1. O Excel foi inicialmente interpretado de forma errada: B:G são códigos, H é a designação do nível mais profundo da linha. Os dropdowns receberam códigos como nomes. Corrigida a transformação, preservando UUID; 1.920 nomes reparados, 69 referências sem definição inequívoca retiradas das opções e preservadas para auditoria. A contagem anterior de 1.990 não provava qualidade ou completude. +2. Os dropdowns só estavam na gestão. Acrescentados ao catálogo público, ao filtro de zonas e à criação de oportunidades, clusters/projectos e zonas. Removidos os inputs livres de província/distrito das fichas de edição de oportunidades e zonas; alterações de localização usam os identificadores. +3. As associações guardadas não eram usadas pela pesquisa pública. A pesquisa passa a usar o território seleccionado e os seus descendentes, com validação da hierarquia. A pesquisa da carteira considera a localização dos projectos/clusters/corredores ligados aos dossiers publicados. +4. As seis oportunidades existentes estavam sem relação territorial. Foram associadas às províncias por correspondência única de nome, normalizando apenas caixa, espaços exteriores e acentuação. Não se inferiram distritos a partir de descrições. +5. O envio do filtro pelo navegador falhava: `education=false` em texto não passava a validação booleana e `filters=[]` fazia a leitura de `.sort` devolver o método nativo JavaScript. Corrigidos serialização 0/1 e valor inicial da ordenação; teste real de selecção, submissão e recarregamento passou. +6. Alterar a localização de uma zona podia deixar a versão documental intacta. A alteração passa pelo serviço da zona, cria snapshot e devolve o registo a rascunho. + +## Dados e disponibilidade actual + +Referências com nomes efectivamente extraídos: **11 províncias, 148 distritos, 437 postos administrativos e 1.324 localidades**, além do país. Estes totais referem-se ao Excel recebido; não são declaração de cobertura administrativa actual do país. Dez linhas foram sinalizadas por conflitos/lacunas; 18.477 linhas de bairros/aldeias não são convertidas em localidades. As 69 referências anteriores sem definição inequívoca ficaram arquivadas, sem quebra de FK. + +Disponível localmente em `/oportunidades`, `/zonas`, nas páginas de criação/edição aplicáveis e em `/management/territory-associations`. Filtro público expõe apenas ID/nome/tipo/pai das referências divulgadas, não os ficheiros originais. Publicação do vocabulário alfanumérico por instrução do responsável não equivale a publicação de limites geográficos homologados. Os dados confidenciais/restritos e ascendentes retirados não entram nas opções públicas. + +Backup/restauro verificado antes desta correcção: `storage/app/private/zones-backup-20260929-005946/`, 113 tabelas. Relatórios e candidatos anteriores permanecem privados. A reparação não introduziu nova migration nem apagou referências fisicamente. + +## Trabalho que continua por terminar + +| Área | Lacuna confirmada nos relatórios/código | Trabalho necessário | +| --- | --- | --- | +| GIS completo | Limites geográficos versionados, transformação/validação de geometrias, endpoints resolve/near/bbox e cobertura espacial não entregues integralmente | Implementar serviços e contratos; carregar geometrias apenas quando fornecidas, sem inventar coordenadas | +| Integração territorial transversal | A nova associação é principal; os consumidores educativos, organizações e restantes formulários não usam ainda todos o mesmo selector; relações múltiplas legadas continuam separadas | Consolidar consumidores e testar cada jornada, incluindo históricos e remoção de referências | +| Importação territorial | CSV auditado, mas não existe ainda o workflow integral de batches/rejeições/aprovação/carga; PDF histórico tem colunas ambíguas | Terminar pipeline, excepções documentadas e reconciliação dos códigos; não tratar aprovação como reparação automática de dados contraditórios | +| IA | Base lexical/determinística; não há modelo generativo, embeddings, reranker ou benchmark generativo | Implementar RAG e avaliação com corpus autorizado; documentar recursos e fornecedor efectivamente usados | +| Mercado de Negócios | Preparação/submissão privada e objectos tipados; publicação, revisão completa, sala documental segura e integrações não concluídas | Implementar os estados/jornadas restantes, acesso documental, scanner, retenção e testes | +| ZEE/ZFI | Núcleo e filtros existentes; cadastro local de zonas continua vazio; modelo integral de actividades, benefícios, obrigações, lotes e indicadores não entregue | Completar domínio e importar conteúdos disponíveis com origem; não substituir dados ausentes por zonas fictícias | +| Educação | Há implementação parcial; metodologia, consumidores, integração e piloto não estão integralmente entregues | Confrontar decisões posteriores com o relatório histórico, executar lacunas técnicas e testar o fluxo completo | +| CRM/integrações | A integração externa real com SuiteCRM e a consolidação da outra máquina não foram executadas | Validar ligação quando disponível; a consolidação da outra máquina foi expressamente adiada pelo utilizador | +| Operação/QA | Testes automáticos e alguns percursos de navegador não cobrem todos os prompts; carga, acessibilidade assistiva e produção não demonstradas | Matriz de aceitação por jornada, testes com dados reais, ensaios operacionais e disponibilização no destino configurado | + +Estas lacunas não são todas bloqueios de autorização. A maior parte corresponde a implementação que ficou por fazer. Dependências de dados, de ligações externas e de recursos de execução devem ser identificadas separadamente; não servem para suspender trabalho independente já autorizado. + +## Evidência desta correcção + +`tools/territory-public-browser.mjs` verifica o portal local sem escrever dados: 11 províncias com nomes, 17 opções de distrito para Zambezia, duas oportunidades correspondentes, filtro no URL preservado após recarregar, larguras 390/1.440 px sem overflow e zero erros JavaScript. A suite PHP cobre autorização, referências privadas, hierarquia, criação, associação, conflitos de versão e pesquisa. Os totais finais são registados no relatório GIS. + +Não declarar os prompts integralmente concluídos enquanto a matriz acima contiver lacunas. A disponibilidade de uma tabela, de um endpoint ou de um teste isolado não equivale à entrega da jornada pedida pelo utilizador. diff --git a/docs/investmoz/gis/README.md b/docs/investmoz/gis/README.md index 2b962158..e374aacc 100644 --- a/docs/investmoz/gis/README.md +++ b/docs/investmoz/gis/README.md @@ -1,5 +1,15 @@ # Cartografia GIS — execução incremental +**Mapa interactivo:** [implementação, testes e cobertura real](interactive-map.md). Catálogo e fichas usam marcadores, zoom e filtros reactivos. A base contém seis oportunidades públicas e zero coordenadas de local; o critério de georreferenciação integral ainda não está cumprido. + +**Correcção de 29/09/2026:** a extracção anterior confundiu códigos com nomes. Reparados os nomes a partir de «Designação» e arquivadas 69 referências sem definição inequívoca. Dropdowns públicos e filtros ligados às associações agora disponíveis em `/oportunidades` e `/zonas`. Contagens válidas: 11 províncias, 148 distritos, 437 postos, 1.324 localidades. Ver [omissões, correcções e trabalho restante](../delivery-gaps.md). + +**29/09/2026 — dropdowns aplicados:** [selecção em cascata e associação aos investimentos](investment-dropdowns.md). Disponível em `/management/territory-associations` e nas fichas de gestão. Excel carregado: 11 províncias, 150 distritos, 451 postos e 1.377 localidades; relações guardadas por UUID. + +Aceitação do responsável aplicada ao pacote INE: declaração de dados validados e autenticados registada na auditoria local e vinculada aos checksums. Ver [decisão e limites técnicos](ine-import-status.md#aceitação-expressa-do-responsável--aplicada). A carga de territórios continua dependente da resolução das colunas desalinhadas. + +Novo anexo: [preparação da importação INE, evidências e fases pendentes](ine-import-status.md). Auditoria CSV e extracção para revisão entregues; a carga territorial integral continua por implementar. + Actualização: recebido o novo documento do modelo territorial, com autorização integral. Foi implementado o [núcleo do registo territorial e APIs editoriais](territorial-mvp.md), sem duplicar a identidade de geo_locations. O histórico abaixo descreve a auditoria anterior; não representa a cobertura integral do novo MVP. ## Inspecção do Excel indicado pelo utilizador diff --git a/docs/investmoz/gis/ine-import-status.md b/docs/investmoz/gis/ine-import-status.md new file mode 100644 index 00000000..155fea1e --- /dev/null +++ b/docs/investmoz/gis/ine-import-status.md @@ -0,0 +1,68 @@ +# Preparação da importação INE — 28/09/2026 + +Execução parcial do novo anexo; a autorização para todas as fases está registada. Este incremento entrega validação offline, não o importador transaccional completo. + +## Aceitação expressa do responsável — aplicada + +Em 28/09/2026, o utilizador instruiu: «aplicar os resultados e aceite os dados como validados e autenticados». A decisão foi aplicada ao pacote INE como **aceite, validado e autenticado por declaração do responsável**, vinculada aos checksums do PDF e da extracção. Não é uma alegação de autenticação independente pelo sistema. + +A decisão foi gravada na base local em `audit_events` (`source_system=territorial-owner-acceptance`, `action=owner_accepted`) e em recibo privado `geo-ine-review-20260928-v2/owner-acceptance-242d74394061d175e70e56161a48f66f02cce8d5989fc61098599d082da38f60.json`. O script `tools/record-territorial-owner-acceptance.php` verifica os checksums antes da escrita; a segunda execução confirmou idempotência, sem duplicar o evento. Não foi atribuída a declaração a uma conta da aplicação. + +A aprovação do responsável deixou de estar pendente para este pacote. O relatório original da extracção é preservado como histórico; a decisão posterior está no recibo. Permanecem pendentes as correspondências concretas entre colunas: aprovação não fornece os pares posto/distrito ausentes da extracção. Por isso, esta operação registou a aceitação mas não inseriu territórios nem alterou permissões ou publicou dados. O importador deve conservar separadamente a aceitação documental e a validação estrutural. + +## Fonte e evidência + +Foi preservado o [PDF do INE](https://www.ine.gov.mz/documents/20119/44337/Codificador%20da%20Divisao%20Politico%20administrativa.pdf/14aed3d0-9296-c038-dbac-041a5cf40f1d), cujo rodapé indica **07-06-2012**. A disponibilidade no site não comprova vigência actual nem licença de reutilização. + +- Original privado: `storage/app/private/geo-ine-source-20260928/original.pdf`, 67.067 bytes. +- SHA-256: `5c69b6f189a1d3f5307e0cf7dd8224554705158a682637d3105afad615db26e2`. +- Pacote concluído: `storage/app/private/geo-ine-review-20260928-v2/`. +- 666 linhas extraídas para revisão; **zero territórios aceites automaticamente**. + +As colunas extraídas têm alturas diferentes e podem associar postos a distritos errados. Não se inferiram relações. Contagens de códigos por prefixo no relatório são ocorrências, não totais territoriais confirmados; a comparação visual com a fonte está pendente. O directório sem sufixo `-v2` conserva apenas o original de uma tentativa interrompida pela convenção de exit code do Xpdf. + +## Componentes e execução + +`tools/territorial-pdf-review.py` preserva o original, executa `pdftotext`, regista versão e checksums e gera `review.csv` e `report.json`. Recusa sobrescrever pacotes anteriores. Não gera associações a partir das colunas desalinhadas. + +```powershell +python tools/territorial-pdf-review.py original.pdf storage/app/private/ine-review ` + --pdftotext C:/laragon/bin/git/mingw64/bin/pdftotext.exe ` + --source-url 'URL da fonte' --source-version 'Versão documental' +``` + +Após revisão, o CSV deve ser UTF-8, separado por `;`, com cabeçalho exacto: + +```csv +province_code;province_name;district_code;district_name;admin_post_code;admin_post_name;locality_code;locality_name;locality_type;latitude;longitude +``` + +```sh +php artisan geo:audit-csv caminho/territorios.csv +``` + +O comando arquiva original e relatório num directório UUID privado. Consulta as províncias existentes sob país de código `508`; o cadastro local vazio implica rejeição dos códigos até existir o seed. Não altera a base. + +`TerritorialCsvAudit` valida UTF-8, limites de 20 MiB/50.000 registos, cabeçalho, campos obrigatórios, códigos numéricos preservados como texto, conflitos de código/nome/pai, nomes ambíguos e pares de coordenadas EPSG:4326. Localidade sem posto é rejeitada enquanto não existir o workflow de excepção. Não cria localidades a partir de postos. Duplicados idênticos são contados; qualquer erro rejeita o conjunto. Validade estrutural não equivale a aprovação documental. + +Relatórios indicam ordinal do registo CSV e códigos de erro, sem repetir o conteúdo das linhas. O original privado conserva a evidência. Registos multilinha não têm ordinal igual à linha física. + +## Aceitação e próximas fases + +Validação executada: suite PHP completa com **168 testes e 1.707 verificações aprovadas** (32,524 s), incluindo seis novos testes unitários de CSV; PHPStan sem erros e Pint aprovado nos ficheiros PHP alterados. A extracção do PDF real terminou com 666 linhas de revisão. Não houve alteração de frontend nem alegação de testes visuais nesta fase. + +| Critério | Situação e motivo | Impacto / próxima acção | +| --- | --- | --- | +| Original, checksum e revisão reproduzível | Executado | Conservar pacote privado | +| Auditoria estrutural CSV | Executada | Não substitui carga ou aprovação | +| Seed MZ, 11 províncias e capitais em staging | Por implementar | Criar seed idempotente com proveniência, sem localidades fictícias | +| Batch persistente e carga atómica | Por implementar | Preservar rejeições e impedir alteração silenciosa de dados aprovados | +| CSV final extraído do PDF | Colunas ambíguas | Reconciliar visualmente e validar antes de converter | +| Vigência, licença e totais oficiais | Não comprovados | Obter evidência documental antes da publicação | +| Selectores em cascata e consumidores | Por implementar | Criar componente acessível e validar UUID/hierarquia no servidor | +| Atribuições FK, versões de limites e serviços espaciais | Por implementar | Completar schema e serviços sem duplicar cadastro | +| OIDC, OpenAPI integral, p95 e carga | Por implementar | Completar integração e ensaios; sessão local continua com limites documentados | +| UI editorial, acessibilidade e UAT | Por implementar | Entregar e testar jornadas no navegador | +| Go-live integral | Não concluído | Resolver itens anteriores e verificar critérios do anexo | + +Nenhuma migration ou novo endpoint neste incremento; nenhum território importado, publicado ou alterado. Reversão: retirar os componentes acrescentados, preservando os pacotes de auditoria; não há rollback de dados. O núcleo territorial anterior está documentado em [territorial-mvp.md](territorial-mvp.md). Os itens pendentes são trabalho restante, não novos pedidos de autorização. diff --git a/docs/investmoz/gis/interactive-map.md b/docs/investmoz/gis/interactive-map.md new file mode 100644 index 00000000..a45142b3 --- /dev/null +++ b/docs/investmoz/gis/interactive-map.md @@ -0,0 +1,33 @@ +# Cartografia interactiva — execução e cobertura + +29/09/2026. **Critério integral do prompt ainda não atingido.** Há mapa interactivo implementado, mas nenhuma das seis oportunidades públicas tem coordenadas de local na base. O inventário não contém geometrias de investimento ou de zona. O Excel fornece códigos/designações administrativos, não posições de projectos. O documento da carteira inclui programas nacionais e traçados dependentes de estudos; não permite deduzir uma posição exacta de cada investimento. + +## Implementado + +- `InteractiveInvestmentMap`: Leaflet 1.9.4 com arrastamento, zoom, teclado, toque, escala, marcadores individuais e popups construídos por DOM/textContent; não interpola nomes em HTML. Popup inclui nome, sector, localização, corredores, clusters, projectos âncora e ligação à ficha. A lista acessível selecciona e centra o marcador correspondente. +- Substituído o SVG estático do catálogo. O mapa fica visível com as vistas de resultados; alterações de pesquisa/filtros desencadeiam navegação Inertia com debounce de 350 ms, sem recarregamento integral. Pedidos anteriores são cancelados; carregamento não mantém a lista de ausências de uma consulta anterior. +- Componente comum integrado no catálogo, ficha pública de oportunidade, dossiers de clusters/corredores/projectos e fichas de ZEE/ZFI/parques. Consulta a mesma API e as mesmas regras de publicação. A vista geométrica de limites das zonas continua separada; não foi transformada em georreferenciação de oportunidades. +- `/api/v1/investment/map` reutiliza filtros do catálogo, devolve metadados relacionais públicos e reconcilia `matched_count`, `georeferenced_count`, `unlocated`, `truncated` e `complete`. Não confunde centróide provincial com local de investimento. Só geometrias pontuais de locais (`site`) públicos, válidos, WGS84, activos e com ascendentes públicos são elegíveis. +- Um marcador por oportunidade; quando existem vários locais elegíveis, a edição explícita da posição tem precedência sobre os locais legados, seguindo-se ordem estável de UUID. As geometrias legadas não são apagadas. Não se deslocam coordenadas para separar marcadores coincidentes; cada um continua acessível na lista. +- Formulário **Posição do investimento no mapa** na edição da oportunidade: latitude, longitude, referência da fonte e confirmação expressa de correspondência ao local/divulgação. GET/POST `/management/opportunities/{id}/coordinates`, gestor autenticado/verificado, CSRF, limites de coordenadas, controlo de versão, transacção, FK e auditoria. Pontos retirados de divulgação não são reactivados por esta operação. A confirmação editorial não é validação geodésica independente. + +## Mapa base + +[Documentação Leaflet](https://leafletjs.com/reference.html) e [política do serviço de tiles OSM](https://operations.osmfoundation.org/policies/tiles/). URL HTTPS, atribuição visível e cache normal do navegador; sem prefetch/download offline. `VITE_MAP_TILE_URL` permite substituir o endereço no build; se substituir o fornecedor, ajustar também a atribuição/licença no componente. Falha do mapa base apresenta aviso e preserva marcadores/lista. Testes automatizados bloqueiam pedidos de tiles, evitando descarregamentos automáticos do serviço comunitário. + +## Verificação executada + +- Suite completa: **172 testes / 1.766 verificações**, aprovada em 45,176 s; PHPStan e Pint aprovados. +- TypeScript e lint dos componentes verificados; build de 2.370 módulos aprovado. +- `tools/investment-map-browser.mjs`, exclusivamente QA com um investimento sintético: marcador, popup, ligação, controlo de zoom, pesquisa automática, remoção do marcador após filtro sem correspondências e ausência de recarregamento integral. 390/1.440 px sem overflow, zero excepções JavaScript. Tráfego de tiles bloqueado no teste; não é evidência de disponibilidade externa. +- Nenhuma coordenada sintética foi inserida na base real. Não houve migration neste incremento. + +## Reconciliação e impedimentos concretos + +[map-coverage.json](map-coverage.json), gerado por `php tools/investment-map-coverage.php`, enumera as seis oportunidades públicas: **0 georreferenciadas, 6 sem coordenadas**. Não estão silenciosamente excluídas: a interface identifica-as, mas a representação cartográfica obrigatória de cada uma permanece por cumprir. + +Para terminar, faltam as coordenadas, moradas exactas ou plantas que permitam identificar os locais, ou a definição documental do que representa um programa nacional sem local único. Foi pedida a localização dessas fontes; não é um pedido de nova autorização de implementação. Não usar sedes, capitais ou centros provinciais como se fossem o local do investimento. + +Também permanecem por concluir: georreferenciação de todos os registos aplicáveis, integração dos restantes consumidores da suite, mapa privado de todos os rascunhos e paginação cartográfica além de 1.000 oportunidades (a API assinala truncagem, sem alegar cobertura). Limites jurídicos/geometrias de zonas e projectos lineares não se tornam pontos por este incremento. A aprovação integral só pode ser declarada depois de reconciliar esses itens e obter `complete=true` com evidência de precisão. + +Reversão: retirar os componentes e rotas deste incremento, conservando geometrias e auditoria; não executar rollback destrutivo das tabelas geográficas partilhadas. Ficheiros legados, referências administrativas e associações existentes foram preservados. diff --git a/docs/investmoz/gis/investment-dropdowns.md b/docs/investmoz/gis/investment-dropdowns.md new file mode 100644 index 00000000..2cff2925 --- /dev/null +++ b/docs/investmoz/gis/investment-dropdowns.md @@ -0,0 +1,42 @@ +# Dropdowns e associação territorial — aplicados em 29/09/2026 + +**Actualização que substitui as contagens e limites da primeira entrega abaixo:** corrigido o uso de códigos como nomes na extracção do Excel. Actualmente há 11 províncias, 148 distritos, 437 postos e 1.324 localidades com designações explícitas. Os filtros públicos e os formulários de criação foram integrados; as seis oportunidades existentes têm associação provincial. Histórico e lacunas restantes em [delivery-gaps.md](../delivery-gaps.md). As secções abaixo conservam a descrição da primeira entrega, anterior a esta correcção. + +## Onde utilizar + +Aceder como gestor a **Gestão → Localizações dos investimentos**, endereço `/management/territory-associations`. Escolher tipo e investimento; seleccionar Província → Distrito → Posto administrativo → Localidade e clicar **Guardar localização**. Pode guardar só até ao nível conhecido, ou limpar a província e guardar para remover a associação. + +O mesmo painel está nas fichas de edição de oportunidades, clusters e projectos, e na gestão de zonas (ZEE, ZFI e parques industriais). A página central também permite associar corredores. Numa ficha nova, guardar primeiro o registo para obter o identificador. A associação territorial tem botão e versão próprios, separados do formulário principal. + +Campos textuais legados de província/distrito permanecem por compatibilidade; não são a relação geográfica guardada pelo novo painel. Os filtros do catálogo público ainda usam o contrato anterior. Este incremento entrega a associação na gestão, não a migração de todos os filtros públicos. + +## Dados efectivamente carregados + +Fonte: `C:/Mac/Home/Documents/eBAU/cartografia_moz.xlsx`, SHA-256 `964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff`, indicada pelo utilizador e utilizada ao abrigo das suas instruções de aplicação dos dados. Extraídas apenas as colunas explícitas Provincia/Distrito/Posto_Administrativo/Localidade. O PDF INE com colunas desalinhadas não foi usado para construir estas relações. + +Carregadas **1.990 entradas** em `geo_locations`: 1 país, 11 províncias, 150 distritos, 451 postos administrativos e 1.377 localidades. Onze linhas apresentam lacunas: níveis descendentes de uma lacuna não foram inferidos; os níveis anteriores completos puderam ser aproveitados. Identificadores determinísticos deduplicam nome/tipo/pai, preservando localidades homónimas em pais diferentes. Os códigos `XLSX-…` são internos, não códigos oficiais INE. Não foram geradas coordenadas. + +Origem `cartografia-workbook`, classificação interna. Estas referências são consultáveis por gestores; não se apresentam como territórios publicados pelo workflow institucional. Nenhum investimento real recebeu uma localização inferida automaticamente. Os campos de associação estão disponíveis para seleccionar e guardar o local conhecido. + +Artefactos privados: `storage/app/private/territory-workbook-candidates.json` contém candidatos e linhas sinalizadas. Preparação reproduzível: `tools/prepare-territory-workbook.py`; carga transaccional: `tools/import-territory-workbook.php`. Segunda execução criou zero entradas, confirmando idempotência. Os scripts verificam o checksum da fonte e não substituem registos divergentes. + +## Persistência e endpoints + +Migration `2026_09_28_160000_investment_primary_territory` aplicada: `primary_territory_id` com FK restritiva para `geo_locations` e `territory_version` em opportunities, projects, economic_clusters, development_corridors e investment_zones. A localização principal mais específica é guardada; os ascendentes são obtidos pelas relações parent_id. As relações múltiplas legadas são preservadas. + +- GET `/management/territory-associations`: página de gestão. +- GET `/management/territory-subjects?kind=clusters&q=…`: investimentos paginados, 50/página. +- GET `/management/territory-options?type=district&parent_id=…&q=…`: opções paginadas, 100/página. +- GET `/management/territories/{kind}/{id}`: versão e caminho actual. +- POST `/management/territories/{kind}/{id}`: `{ "version": 0, "path": ["UUID-província", "UUID-distrito"] }`. + +Tipos: opportunities, projects, clusters, corridors, zones. Sessão autenticada, email verificado, permissão manage-portal e CSRF nas escritas. Escrita em zonas mantém requisito MFA. Servidor rejeita níveis fora de ordem, pais incompatíveis, referências apagadas e versões desactualizadas; mantém FK e auditoria da gravação. Opções não expõem classificações confidenciais/restritas. Pesquisa tem debounce e cancelamento de pedidos anteriores. Selecção arquivada preexistente é mostrada sem substituição silenciosa. + +## Verificação e recuperação + +- Backup/restauro verificado antes da aplicação: `storage/app/private/zones-backup-20260929-003609/`, 113 tabelas reconciliadas. +- Suite completa: **170 testes / 1.722 verificações** aprovadas antes das últimas verificações da página central; testes focados depois: **2 testes / 18 verificações** aprovadas. +- PHPStan, Pint, TypeScript, lint dos componentes novos e build aprovados. +- Navegador QA com dados sintéticos: cascata completa, gravação e reset dos descendentes aprovados; 390/1.440 px sem overflow, zero erros JavaScript. Script `tools/territory-browser.mjs`, conta sintética e papel de gestor configurados exclusivamente no servidor QA durante a execução. + +Rollback da migration recusa associações existentes. Com dados, preservar o backup e corrigir progressivamente. Não apagar `geo_locations` para desfazer a interface: são referências partilhadas. Não houve publicação automática, georreferenciação ou mudança de papéis de utilizadores da aplicação local. diff --git a/docs/investmoz/gis/map-coverage.json b/docs/investmoz/gis/map-coverage.json new file mode 100644 index 00000000..f1d56c6b --- /dev/null +++ b/docs/investmoz/gis/map-coverage.json @@ -0,0 +1,77 @@ +{ + "generated_at": "2026-09-29T06:19:06+00:00", + "published_opportunities": 6, + "georeferenced": 0, + "complete": false, + "truncated": false, + "unlocated": [ + { + "id": 6, + "title": "Plataforma de crédito para PME agrícolas", + "category": "Mercado Financeiro", + "location": "Maputo Cidade", + "url": "\/oportunidades\/inv-mz-fin-006-plataforma-de-credito-para-pme-agricolas", + "clusters": [], + "corridors": [], + "projects": [], + "reason": "missing_public_site_coordinates" + }, + { + "id": 5, + "title": "Processamento de pescado e cadeia fria", + "category": "Economia Azul", + "location": "Zambézia", + "url": "\/oportunidades\/inv-mz-blue-005-processamento-de-pescado-e-cadeia-fria", + "clusters": [], + "corridors": [], + "projects": [], + "reason": "missing_public_site_coordinates" + }, + { + "id": 4, + "title": "Centro logístico provincial", + "category": "Infraestruturas", + "location": "Sofala", + "url": "\/oportunidades\/inv-mz-inf-004-centro-logistico-provincial", + "clusters": [], + "corridors": [], + "projects": [], + "reason": "missing_public_site_coordinates" + }, + { + "id": 3, + "title": "Eco-lodge costeiro com integração comunitária", + "category": "Turismo", + "location": "Inhambane", + "url": "\/oportunidades\/inv-mz-tur-003-eco-lodge-costeiro-com-integracao-comunitaria", + "clusters": [], + "corridors": [], + "projects": [], + "reason": "missing_public_site_coordinates" + }, + { + "id": 2, + "title": "Mini-rede solar para polo produtivo", + "category": "Energia e Mineração", + "location": "Zambézia", + "url": "\/oportunidades\/inv-mz-ene-002-mini-rede-solar-para-polo-produtivo", + "clusters": [], + "corridors": [], + "projects": [], + "reason": "missing_public_site_coordinates" + }, + { + "id": 1, + "title": "Unidade de processamento agrícola em Nampula", + "category": "Agronegócio", + "location": "Nampula", + "url": "\/oportunidades\/inv-mz-agr-001-unidade-de-processamento-agricola-em-nampula", + "clusters": [], + "corridors": [], + "projects": [], + "reason": "missing_public_site_coordinates" + } + ], + "geo_geometries": 0, + "zone_geometries": 0 +} diff --git a/package-lock.json b/package-lock.json index 61fc9064..252ab659 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "concurrently": "^10.0.3", "input-otp": "^1.4.2", "laravel-vite-plugin": "^3.0.0", + "leaflet": "^1.9.4", "lucide-react": "^0.475.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -43,6 +44,7 @@ "devDependencies": { "@laravel/vite-plugin-wayfinder": "^0.1.3", "@rolldown/plugin-babel": "^0.2.3", + "@types/leaflet": "^1.9.21", "@types/node": "^22.13.5", "babel-plugin-react-compiler": "^1.0.0", "vite-plus": "0.3.0" @@ -2945,6 +2947,13 @@ "license": "MIT", "peer": true }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsesc": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", @@ -2953,6 +2962,16 @@ "license": "MIT", "peer": true }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -4811,6 +4830,12 @@ } } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/package.json b/package.json index 3a536df8..a41717ee 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "concurrently": "^10.0.3", "input-otp": "^1.4.2", "laravel-vite-plugin": "^3.0.0", + "leaflet": "^1.9.4", "lucide-react": "^0.475.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -49,6 +50,7 @@ "devDependencies": { "@laravel/vite-plugin-wayfinder": "^0.1.3", "@rolldown/plugin-babel": "^0.2.3", + "@types/leaflet": "^1.9.21", "@types/node": "^22.13.5", "babel-plugin-react-compiler": "^1.0.0", "vite-plus": "0.3.0" diff --git a/resources/js/components/interactive-investment-map.tsx b/resources/js/components/interactive-investment-map.tsx new file mode 100644 index 00000000..bb1488de --- /dev/null +++ b/resources/js/components/interactive-investment-map.tsx @@ -0,0 +1,179 @@ +import { useEffect, useRef, useState } from "react"; +import type { Map as LeafletMap, Marker } from "leaflet"; +import "leaflet/dist/leaflet.css"; + +export type InvestmentMapFeature = { + geometry: { coordinates: [number, number] }; + properties: { + id: number; + title: string; + location: string; + url: string; + category?: string; + clusters?: string[]; + corridors?: string[]; + projects?: string[]; + }; +}; + +export default function InteractiveInvestmentMap({ + points, +}: { + points: InvestmentMapFeature[] | null; +}) { + const element = useRef(null); + const map = useRef(null); + const markers = useRef(new Map()); + const [selected, setSelected] = useState(null); + const [error, setError] = useState(""); + useEffect(() => { + let cancelled = false; + let observer: ResizeObserver | undefined; + void import("leaflet") + .then((L) => { + if (cancelled || !element.current) return; + const instance = L.map(element.current, { + scrollWheelZoom: false, + keyboard: true, + }).setView([-18.5, 35], 5); + map.current = instance; + L.tileLayer( + import.meta.env.VITE_MAP_TILE_URL || + "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + { + maxZoom: 19, + attribution: + '© OpenStreetMap contributors', + }, + ) + .on("tileerror", () => { + if (!cancelled) + setError( + "Mapa base indisponível. Os marcadores e a lista continuam acessíveis.", + ); + }) + .addTo(instance); + L.control.scale({ imperial: false }).addTo(instance); + const bounds: [number, number][] = []; + for (const [index, point] of (points ?? []).entries()) { + const [longitude, latitude] = point.geometry.coordinates; + if ( + !Number.isFinite(latitude) || + !Number.isFinite(longitude) || + Math.abs(latitude) > 90 || + Math.abs(longitude) > 180 + ) + continue; + const { properties: p } = point; + const popup = document.createElement("div"); + const heading = document.createElement("strong"); + heading.textContent = p.title; + popup.append(heading); + for (const text of [ + p.category, + p.location, + `Corredor: ${p.corridors?.join(", ") || "Não associado"}`, + `Cluster: ${p.clusters?.join(", ") || "Não associado"}`, + `Projecto âncora: ${p.projects?.join(", ") || "Não associado"}`, + ]) { + const line = document.createElement("p"); + line.textContent = text ?? ""; + popup.append(line); + } + if (p.url.startsWith("/") && !p.url.startsWith("//")) { + const link = document.createElement("a"); + link.href = p.url; + link.textContent = "Abrir ficha detalhada"; + popup.append(link); + } + const icon = L.divIcon({ + className: "", + html: `${index + 1}`, + iconSize: [32, 32], + iconAnchor: [16, 16], + }); + const marker = L.marker([latitude, longitude], { + icon, + title: p.title, + alt: p.title, + keyboard: true, + }) + .bindPopup(popup) + .on("click", () => setSelected(p.id)) + .addTo(instance); + markers.current.set(p.id, marker); + bounds.push([latitude, longitude]); + } + if (bounds.length) instance.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 }); + observer = new ResizeObserver(() => instance.invalidateSize()); + observer.observe(element.current); + }) + .catch(() => { + if (!cancelled) setError("Não foi possível iniciar o mapa."); + }); + return () => { + cancelled = true; + observer?.disconnect(); + map.current?.remove(); + map.current = null; + markers.current.clear(); + }; + }, [points]); + return ( +
+

Mapa dos investimentos

+

+ Arraste para navegar; use +/− para ampliar. Pode seleccionar um investimento na + lista para o localizar. +

+ {error &&

{error}

} +
+ {points === null ? ( +

A carregar localizações…

+ ) : ( +

{points.length} investimentos com coordenadas disponíveis.

+ )} +
    + {(points ?? []).map((p) => ( +
  • + +

    + {p.properties.category} · {p.properties.location} +

    + + Abrir ficha + +
  • + ))} +
+
+ ); +} diff --git a/resources/js/components/investment-coordinates.tsx b/resources/js/components/investment-coordinates.tsx new file mode 100644 index 00000000..94231868 --- /dev/null +++ b/resources/js/components/investment-coordinates.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; +import { api } from "@/pages/investment/shared"; + +export default function InvestmentCoordinates({ id }: { id: number | string }) { + const [data, setData] = useState({ + version: 0, + latitude: "", + longitude: "", + source_reference: "", + }); + const [ready, setReady] = useState(false); + const [confirmed, setConfirmed] = useState(false); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + useEffect(() => { + let active = true; + api(`/management/opportunities/${id}/coordinates`) + .then((result) => { + if (active) { + setData({ + ...result, + latitude: String(result.latitude ?? ""), + longitude: String(result.longitude ?? ""), + }); + setReady(true); + } + }) + .catch((e) => { + if (active) setMessage(e.message); + }); + return () => { + active = false; + }; + }, [id]); + return ( +
{ + event.preventDefault(); + setBusy(true); + try { + const result = await api(`/management/opportunities/${id}/coordinates`, { + ...data, + confirmed_site: confirmed, + }); + setData((previous) => ({ ...previous, version: result.version })); + setMessage( + "Coordenadas guardadas. O marcador acompanha a publicação da oportunidade.", + ); + } catch (e) { + setMessage(e instanceof Error ? e.message : "Falha ao guardar."); + } finally { + setBusy(false); + } + }} + > +

Posição do investimento no mapa

+

Registe a posição do local em WGS84 e a referência que a confirma.

+
+ {(["latitude", "longitude"] as const).map((field) => ( + + ))} +
+ + + + {message &&

{message}

} +
+ ); +} diff --git a/resources/js/components/investment-map-panel.tsx b/resources/js/components/investment-map-panel.tsx new file mode 100644 index 00000000..0b19dd84 --- /dev/null +++ b/resources/js/components/investment-map-panel.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import InteractiveInvestmentMap, { type InvestmentMapFeature } from "./interactive-investment-map"; + +export default function InvestmentMapPanel({ + filters, +}: { + filters: Record; +}) { + const key = JSON.stringify(filters); + const [points, setPoints] = useState(null); + const [missing, setMissing] = useState<{ id: number; title: string; url: string }[]>([]); + const [error, setError] = useState(""); + useEffect(() => { + const controller = new AbortController(); + const params = new URLSearchParams(JSON.parse(key)); + setPoints(null); + fetch(`/api/v1/investment/map?${params}`, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }) + .then(async (response) => { + if (!response.ok) throw new Error("Mapa indisponível."); + return response.json(); + }) + .then((data) => { + if (controller.signal.aborted) return; + setPoints(data.features); + setMissing(data.unlocated ?? []); + setError(data.truncated ? "Cobertura limitada: refine os filtros." : ""); + }) + .catch((e) => { + if (!controller.signal.aborted) setError(e.message); + }); + return () => controller.abort(); + }, [key]); + return ( +
+ {error &&

{error}

} + + {missing.length > 0 && ( +
+

{missing.length} investimentos aguardam coordenadas do local:

+ +
+ )} +
+ ); +} diff --git a/resources/js/components/investment-territory.tsx b/resources/js/components/investment-territory.tsx new file mode 100644 index 00000000..d6327888 --- /dev/null +++ b/resources/js/components/investment-territory.tsx @@ -0,0 +1,230 @@ +import { useEffect, useId, useState } from "react"; +import { api } from "@/pages/investment/shared"; + +export type TerritoryOption = { + id: string; + name: string; + parent_id: string | null; + location_type: string; + deleted_at?: string | null; +}; +type Option = TerritoryOption; +const levels = ["province", "district", "admin_post", "locality"]; +const labels = ["Província", "Distrito", "Posto administrativo", "Localidade"]; +const control = "w-full rounded border bg-background p-2 text-foreground"; + +export function TerritoryFields({ + initial = [], + onChange, +}: { + initial?: TerritoryOption[]; + onChange?: (path: TerritoryOption[]) => void; +}) { + const [path, setPath] = useState(initial); + return ( +
+ {(["province_id", "district_id", "admin_post_id", "locality_id"] as const).map( + (field, index) => ( +
+ + { + const next = [...path.slice(0, index), ...(value ? [value] : [])]; + setPath(next); + onChange?.(next); + }} + /> +
+ ), + )} +
+ ); +} + +export function TerritoryLevel({ + index, + parent, + selected, + onChange, + endpoint = "/management/territory-options", +}: { + index: number; + parent: string; + selected?: Option; + onChange: (value?: Option) => void; + endpoint?: string; +}) { + const id = useId(); + const [q, setQ] = useState(""); + const [page, setPage] = useState(1); + const [options, setOptions] = useState([]); + const [more, setMore] = useState(false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const enabled = index === 0 || Boolean(parent); + useEffect(() => { + if (!enabled) return; + const controller = new AbortController(); + const timer = setTimeout(async () => { + setLoading(true); + setError(""); + try { + const params = new URLSearchParams({ + type: levels[index], + parent_id: parent, + q, + page: String(page), + }); + const response = await fetch(`${endpoint}?${params}`, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }); + if (!response.ok) throw new Error("Não foi possível carregar as localizações."); + const data = await response.json(); + setOptions(data.data); + setMore(Boolean(data.next_page_url)); + } catch (e) { + if (!controller.signal.aborted) + setError(e instanceof Error ? e.message : "Erro de ligação."); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }, 250); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [index, parent, q, page, enabled, endpoint]); + return ( +
+ + { + setQ(e.target.value); + setPage(1); + }} + placeholder="Pesquisar por nome" + /> + + {loading &&

A carregar…

} + {error &&

{error}

} + {enabled && !loading && !error && options.length === 0 && ( +

Sem opções para esta pesquisa.

+ )} +
+ {page > 1 && ( + + )} + {more && ( + + )} +
+
+ ); +} + +export default function InvestmentTerritory({ kind, id }: { kind: string; id: string | number }) { + const [path, setPath] = useState([]); + const [version, setVersion] = useState(null); + const [message, setMessage] = useState(""); + const [busy, setBusy] = useState(false); + useEffect(() => { + let active = true; + api(`/management/territories/${kind}/${id}`) + .then((data) => { + if (!active) return; + setPath( + levels.flatMap((level) => + data.path.filter((o: Option) => o.location_type === level), + ), + ); + setVersion(data.version); + }) + .catch((e) => { + if (active) setMessage(e.message); + }); + return () => { + active = false; + }; + }, [kind, id]); + return ( +
+

Localização do investimento

+

Seleccione até ao nível conhecido. Alterar um nível limpa as selecções seguintes.

+
+ {levels.map((level, index) => ( + { + setPath([...path.slice(0, index), ...(value ? [value] : [])]); + setMessage(""); + }} + /> + ))} +
+ + {message &&

{message}

} +
+ ); +} diff --git a/resources/js/lib/investment-maturity.ts b/resources/js/lib/investment-maturity.ts new file mode 100644 index 00000000..ad92a98b --- /dev/null +++ b/resources/js/lib/investment-maturity.ts @@ -0,0 +1,9 @@ +/** Estados de preparação usados pelo cadastro de oportunidades (níveis 1–6). */ +export const MATURITY_LEVELS = [ + "Ideia identificada", + "Pré-estudo disponível", + "Estudo de viabilidade em curso", + "Projecto estruturado", + "Pronto para investimento", + "Em negociação ou implementação", +] as const; diff --git a/resources/js/pages/PortalInvestidor.tsx b/resources/js/pages/PortalInvestidor.tsx index f5602a28..e7efcc4b 100644 --- a/resources/js/pages/PortalInvestidor.tsx +++ b/resources/js/pages/PortalInvestidor.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, useRef, useState } from "react"; import { router } from "@inertiajs/react"; import { PortalMenu } from "@/layouts/portal-navigation"; +import { MATURITY_LEVELS } from "@/lib/investment-maturity"; import type { Dispatch, FormEvent, ReactNode, SetStateAction } from "react"; import type { LucideIcon } from "lucide-react"; import { usePortalQuery } from "@/hooks/use-portal-query"; @@ -94,14 +95,6 @@ const PROVINCES = [ "Niassa", ]; -const MATURITY_LEVELS = [ - "Ideia identificada", - "Pré-estudo disponível", - "Estudo de viabilidade em curso", - "Projecto estruturado", - "Pronto para investimento", - "Em negociação ou implementação", -]; /* ------------------------------------------------------------------ */ /* Componentes */ diff --git a/resources/js/pages/investment/catalogue.tsx b/resources/js/pages/investment/catalogue.tsx index 8a866200..f0f5be34 100644 --- a/resources/js/pages/investment/catalogue.tsx +++ b/resources/js/pages/investment/catalogue.tsx @@ -1,5 +1,8 @@ import { Link, router, useForm, usePage } from "@inertiajs/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import Map from "@/components/interactive-investment-map"; +import { TerritoryLevel, type TerritoryOption } from "@/components/investment-territory"; +import { MATURITY_LEVELS } from "@/lib/investment-maturity"; import { Frame, Field, input, button, card, money, api, groupLabels } from "./shared"; import type { Opportunity, References } from "./shared"; @@ -13,6 +16,7 @@ export default function Catalogue({ results, favourites, territorial, + territorySelection, }: { filters: Record; references: References; @@ -23,6 +27,7 @@ export default function Catalogue({ next_page_url: string | null; }; favourites: number[]; + territorySelection: TerritoryOption[]; territorial: { id: string; title: string; @@ -40,6 +45,10 @@ export default function Catalogue({ sector: String(filters.sector ?? ""), province: String(filters.province ?? ""), district: String(filters.district ?? ""), + province_id: String(filters.province_id ?? ""), + district_id: String(filters.district_id ?? ""), + admin_post_id: String(filters.admin_post_id ?? ""), + locality_id: String(filters.locality_id ?? ""), cluster_id: String(filters.cluster_id ?? ""), zee_id: String(filters.zee_id ?? ""), zfi_id: String(filters.zfi_id ?? ""), @@ -53,19 +62,49 @@ export default function Catalogue({ currency: String(filters.currency ?? ""), maturity_level: String(filters.maturity_level ?? ""), before: String(filters.before ?? ""), - sort: String(filters.sort ?? "recent"), + sort: typeof filters.sort === "string" ? filters.sort : "recent", facets: (Array.isArray(filters.facets) ? filters.facets : []).map(Number), }); const view = String(filters.view ?? "grid"); + const [territoryPath, setTerritoryPath] = useState(territorySelection); const [saved, setSaved] = useState(favourites); const [selected, setSelected] = useState([]); const [comparison, setComparison] = useState<{ record: Opportunity }[]>([]); const [points, setPoints] = useState(null); + const [unlocated, setUnlocated] = useState< + { id: number; title: string; location: string; url: string }[] + >([]); + const [truncated, setTruncated] = useState(false); const [error, setError] = useState(""); const [suggestions, setSuggestions] = useState<{ title: string; slug: string }[]>([]); const filterKey = JSON.stringify(filters); + const draftFilterKey = JSON.stringify(form.data); + const lastRequest = useRef(draftFilterKey); + useEffect(() => { + if (draftFilterKey === lastRequest.current) return; + let cancel: (() => void) | undefined; + const timer = setTimeout(() => { + const data = JSON.parse(draftFilterKey); + lastRequest.current = draftFilterKey; + router.get( + "/oportunidades", + { ...data, education: data.education ? 1 : 0 }, + { + preserveState: true, + preserveScroll: true, + replace: true, + onCancelToken: (token) => { + cancel = () => token.cancel(); + }, + }, + ); + }, 350); + return () => { + clearTimeout(timer); + cancel?.(); + }; + }, [draftFilterKey]); useEffect(() => { - if (view !== "map" && view !== "combined") return; let active = true; const values = JSON.parse(filterKey) as Record; const params = new URLSearchParams(); @@ -75,10 +114,16 @@ export default function Catalogue({ params.set(key, String(value === true ? 1 : value === false ? 0 : value)); } setPoints(null); + setUnlocated([]); + setTruncated(false); setError(""); api(`/api/v1/investment/map?${params}`) .then((data) => { - if (active) setPoints(data.features); + if (active) { + setPoints(data.features); + setUnlocated(data.unlocated ?? []); + setTruncated(Boolean(data.truncated)); + } }) .catch(() => { if (active) @@ -166,6 +211,7 @@ export default function Catalogue({ setPoints(null); setComparison([]); setSelected([]); + form.transform((data) => ({ ...data, education: data.education ? 1 : 0 })); form.get("/oportunidades", { preserveState: true }); }} > @@ -218,39 +264,30 @@ export default function Catalogue({ ))} - {(["clusters", "corridors", "projects", "locations"] as const).map( - (group, i) => { - const key = ( - [ - "cluster_id", - "corridor_id", - "project_id", - "geo_location_id", - ] as const - )[i]; - return ( - { + const key = ( + ["cluster_id", "corridor_id", "project_id", "geo_location_id"] as const + )[i]; + return ( + + form.setData(key, e.target.value)} - > - - {references[group].map((r) => ( - - ))} - - - ); - }, - )} + + {references[group].map((r) => ( + + ))} + + + ); + })} form.setData(key, e.target.value)} - maxLength={100} + {(["province_id", "district_id", "admin_post_id", "locality_id"] as const).map( + (key, index) => ( + { + const path = [ + ...territoryPath.slice(0, index), + ...(value ? [value] : []), + ]; + setTerritoryPath(path); + form.setData((data) => ({ + ...data, + province: "", + district: "", + geo_location_id: "", + province_id: path[0]?.id ?? "", + district_id: path[1]?.id ?? "", + admin_post_id: path[2]?.id ?? "", + locality_id: path[3]?.id ?? "", + })); + }} /> - - ))} + ), + )} @@ -434,7 +489,34 @@ export default function Catalogue({ {error &&

{error}

} - {(view === "map" || view === "combined") && !error && } + {!error && } + {truncated && ( +

+ O mapa atingiu o limite de 1.000 registos; refine os filtros. A cobertura não + está completa. +

+ )} + {unlocated.length > 0 && ( +
+

+ {unlocated.length} investimentos sem coordenadas de local confirmadas +

+

+ Continuam nos resultados. Seleccionar uma província não determina a posição + exacta do investimento. +

+
    + {unlocated.map((record) => ( +
  • + + {record.title} + {" "} + — {record.location || "Localização por completar"} +
  • + ))} +
+
+ )} {(view !== "map" || error || points?.length === 0) && (
); } -function Map({ points }: { points: Feature[] | null }) { - if (points === null) return

A carregar localizações…

; - if (!points.length) - return ( -

- Sem coordenadas públicas associadas aos resultados. Não são atribuídas localizações - aproximadas. -

- ); - const xs = points.map((p) => p.geometry.coordinates[0]), - ys = points.map((p) => p.geometry.coordinates[1]); - const minX = Math.min(...xs) - 0.1, - maxX = Math.max(...xs) + 0.1, - minY = Math.min(...ys) - 0.1, - maxY = Math.max(...ys) + 0.1; - return ( -
-

Localizações georreferenciadas

-

- Coordenadas WGS84 publicadas, sem mapa base externo. Até 1.000 pontos; a lista - acessível apresenta os mesmos dados. -

- - - {points.map((p, i) => ( - - - {p.properties.title} - - - ))} - -
    - {points.map((p, i) => ( -
  • - - {p.properties.title} - {" "} - — {p.properties.location}; longitude {p.geometry.coordinates[0]}, latitude{" "} - {p.geometry.coordinates[1]} -
  • - ))} -
-
- ); -} diff --git a/resources/js/pages/investment/detail.tsx b/resources/js/pages/investment/detail.tsx index e4af559f..0ece84dd 100644 --- a/resources/js/pages/investment/detail.tsx +++ b/resources/js/pages/investment/detail.tsx @@ -1,5 +1,6 @@ import { Link, router, usePage } from "@inertiajs/react"; import { useState } from "react"; +import InvestmentMapPanel from "@/components/investment-map-panel"; import { Frame, card, button, money, groupLabels } from "./shared"; import type { Opportunity, Context } from "./shared"; export default function Detail({ @@ -19,6 +20,10 @@ export default function Detail({ {r.reference} · {r.sector?.name} · {r.province} {r.district}

{r.summary}

+ + {c.primary_territory_path && c.primary_territory_path.length > 0 && ( +

Localização: {c.primary_territory_path.map((node) => node.name).join(" → ")}

+ )}
{auth.user ? (

{records.total} registos disponíveis

@@ -115,6 +119,7 @@ export default function Zones({ }} >

Criar rascunho

+ diff --git a/resources/js/pages/management/editor.tsx b/resources/js/pages/management/editor.tsx index 75ec281f..b1a294b3 100644 --- a/resources/js/pages/management/editor.tsx +++ b/resources/js/pages/management/editor.tsx @@ -1,4 +1,8 @@ import { Link, useForm } from "@inertiajs/react"; +import InvestmentTerritory from "@/components/investment-territory"; +import InvestmentCoordinates from "@/components/investment-coordinates"; +import { TerritoryLevel, type TerritoryOption } from "@/components/investment-territory"; +import { useState } from "react"; import { Management, Field, button, control, states } from "./shared"; type Values = Record; @@ -9,8 +13,6 @@ const opportunityFields = { slug: "Slug (endereço)", summary: "Resumo", description: "Descrição", - province: "Província", - district: "Distrito", promoter_name: "Promotor", promoter_type: "Tipo de promotor", estimated_investment_amount: "Investimento estimado", @@ -66,6 +68,7 @@ export default function Editor({ initial.is_featured = Boolean(record?.is_featured ?? false); } const form = useForm(initial); + const [creationPath, setCreationPath] = useState([]); return (
+ {!isSector && !record && ( +
+ {( + [ + "province_id", + "district_id", + "admin_post_id", + "locality_id", + ] as const + ).map((key, index) => ( + { + const path = [ + ...creationPath.slice(0, index), + ...(value ? [value] : []), + ]; + setCreationPath(path); + form.setData((data) => ({ + ...data, + province_id: path[0]?.id ?? "", + district_id: path[1]?.id ?? "", + admin_post_id: path[2]?.id ?? "", + locality_id: path[3]?.id ?? "", + })); + }} + /> + ))} +
+ )} {Object.entries(fields).map(([key, label]) => ( {[ @@ -177,6 +214,8 @@ export default function Editor({
+ {!isSector && record && } + {!isSector && record && } ); } diff --git a/resources/js/pages/management/shared.tsx b/resources/js/pages/management/shared.tsx index 964d2ec9..d72b2c05 100644 --- a/resources/js/pages/management/shared.tsx +++ b/resources/js/pages/management/shared.tsx @@ -41,6 +41,9 @@ export function Management({ title, children }: { title: string; children: React Investidores Projectos Clusters + + Localizações dos investimentos + ZEE, ZFI e parques {educationEnabled && ( Competências e formação diff --git a/resources/js/pages/management/territory-associations.tsx b/resources/js/pages/management/territory-associations.tsx new file mode 100644 index 00000000..2c4174eb --- /dev/null +++ b/resources/js/pages/management/territory-associations.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from "react"; +import InvestmentTerritory from "@/components/investment-territory"; +import { Management, Field, control } from "./shared"; + +export default function TerritoryAssociations() { + const [kind, setKind] = useState("projects"); + const [id, setId] = useState(""); + const [q, setQ] = useState(""); + const [page, setPage] = useState(1); + const [rows, setRows] = useState<{ id: string; name: string }[]>([]); + const [more, setMore] = useState(false); + const [error, setError] = useState(""); + useEffect(() => { + const controller = new AbortController(); + const timer = setTimeout(async () => { + try { + const params = new URLSearchParams({ kind, q, page: String(page) }); + const response = await fetch(`/management/territory-subjects?${params}`, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }); + if (!response.ok) throw new Error("Não foi possível carregar os investimentos."); + const data = await response.json(); + setRows(data.data); + setMore(Boolean(data.next_page_url)); + setError(""); + } catch (e) { + if (!controller.signal.aborted) + setError(e instanceof Error ? e.message : "Erro de ligação."); + } + }, 250); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [kind, q, page]); + return ( + + + + + + { + setQ(e.target.value); + setPage(1); + setId(""); + }} + /> + + + + + {error &&

{error}

} + {!error && rows.length === 0 &&

Sem registos para esta pesquisa.

} +
+ {page > 1 && ( + + )} + {more && ( + + )} +
+ {id && } +
+ ); +} diff --git a/resources/js/pages/portfolio/show.tsx b/resources/js/pages/portfolio/show.tsx index b02123bb..f76fd1ce 100644 --- a/resources/js/pages/portfolio/show.tsx +++ b/resources/js/pages/portfolio/show.tsx @@ -1,4 +1,5 @@ import { Link, useForm, router } from "@inertiajs/react"; +import InvestmentMapPanel from "@/components/investment-map-panel"; import { Management } from "../management/shared"; import { Layout, Field, control, button, labels, kind } from "./shared"; import type { Dossier } from "./shared"; @@ -47,6 +48,17 @@ function Editor({ {kind(record)} · {record.content.code}

Fonte: {record.source}

+ {!management && (record.cluster_id || record.corridor_id || record.project_id) && ( + + )} {zones.length > 0 && (

ZEE, ZFI e parques relacionados

diff --git a/resources/js/pages/structures/editor.tsx b/resources/js/pages/structures/editor.tsx index bdb42b47..c2430a2b 100644 --- a/resources/js/pages/structures/editor.tsx +++ b/resources/js/pages/structures/editor.tsx @@ -1,15 +1,16 @@ -import { Link, useForm } from '@inertiajs/react'; -import { Management, Field, button, control } from '../management/shared'; -import { titles, statuses, stages, roles } from './types'; -import type { Structure, StructureRecord, Chain } from './types'; -import ValueNetwork from './value-network'; -import type { ChainLink, Gap } from './value-network'; +import { Link, useForm } from "@inertiajs/react"; +import InvestmentTerritory, { TerritoryFields } from "@/components/investment-territory"; +import { Management, Field, button, control } from "../management/shared"; +import { titles, statuses, stages, roles } from "./types"; +import type { Structure, StructureRecord, Chain } from "./types"; +import ValueNetwork from "./value-network"; +import type { ChainLink, Gap } from "./value-network"; function ChainForm({ clusterId, chain }: { clusterId: string; chain?: Chain }) { const form = useForm({ - code: chain?.code ?? '', - name: chain?.name ?? '', - stage: chain?.stage ?? 'input', + code: chain?.code ?? "", + name: chain?.name ?? "", + stage: chain?.stage ?? "input", }); return (
{ e.preventDefault(); const url = `/management/clusters/${clusterId}/chains`; - if (chain) - form.put(`${url}/${chain.id}`, { preserveScroll: true }); + if (chain) form.put(`${url}/${chain.id}`, { preserveScroll: true }); else form.post(url, { preserveScroll: true, @@ -26,28 +26,26 @@ function ChainForm({ clusterId, chain }: { clusterId: string; chain?: Chain }) { }); }} > -

- {chain ? chain.name : 'Nova etapa'} -

+

{chain ? chain.name : "Nova etapa"}

form.setData('code', e.target.value)} + onChange={(e) => form.setData("code", e.target.value)} /> form.setData('name', e.target.value)} + onChange={(e) => form.setData("name", e.target.value)} /> form.setData('code', e.target.value)} + onChange={(e) => form.setData("code", e.target.value)} /> form.setData('name', e.target.value)} + onChange={(e) => form.setData("name", e.target.value)} /> {!isProject && ( @@ -152,25 +159,18 @@ export default function Editor({ - form.setData('sector', e.target.value) - } + onChange={(e) => form.setData("sector", e.target.value)} /> )} {isProject && ( <> - +