commit 23

This commit is contained in:
2026-11-04 08:49:36 +02:00
8 changed files with 2060 additions and 229 deletions
@@ -0,0 +1,174 @@
{
"date": "2026-10-29",
"scope": "Reorganização visual local do catálogo autorizada pela Tempo; sem instalação no portal.",
"base": "838ad941a24ae76ec4bf91ccc3f92012668fe8cf",
"design": {
"desktop": "Painel lateral de filtros e resultados em duas colunas; lista com acções na coluna lateral do cartão.",
"mobile": "Filtros expansíveis; cartões numa coluna; áreas de toque e foco visível.",
"content": "Todos os campos, descrições, avisos e acções conservados; 29 fichas agrupadas por tipo. Aviso provincial e título dos resultados recuperados do pedido fornecido."
},
"checks": {
"types": {
"command": "npm run types:check",
"exit": 0
},
"build": {
"command": "INVESTMOZ_QA_BUILD=1 npm run build",
"exit": 0,
"target": "public/build-qa-opportunities",
"limitations": "Avisos existentes de fontaine opcional e chunks grandes. Compilação anterior à formatação final, apenas cosmética."
},
"browser": {
"result": "passed",
"scope": "Local compiled React application with public document-based preview fixtures and mocked HTTP, no database or external requests. Does not prove backend or live availability.",
"layouts": [
{
"width": 1440,
"scrollWidth": 1440,
"count": 12,
"headingY": 296.65625,
"sidebarVisible": true,
"groups": [
"Corredores6",
"Clusters8",
"Projectos âncora15"
]
},
{
"width": 1024,
"scrollWidth": 1024,
"count": 12,
"headingY": 292.234375,
"sidebarVisible": true,
"groups": [
"Corredores6",
"Clusters8",
"Projectos âncora15"
]
},
{
"width": 768,
"scrollWidth": 768,
"count": 12,
"headingY": 368.078125,
"sidebarVisible": false,
"groups": [
"Corredores6",
"Clusters8",
"Projectos âncora15"
]
},
{
"width": 390,
"scrollWidth": 390,
"count": 12,
"headingY": 395.265625,
"sidebarVisible": false,
"groups": [
"Corredores6",
"Clusters8",
"Projectos âncora15"
]
},
{
"width": 320,
"scrollWidth": 320,
"count": 12,
"headingY": 425.96875,
"sidebarVisible": false,
"groups": [
"Corredores6",
"Clusters8",
"Projectos âncora15"
]
}
],
"checks": [
"All 12 sample titles, full descriptions and territorial caveats preserved at every width; expanded filter controls fit 320/390/1024px.",
"Missing currency error remains visible on mobile.",
"Keyboard opens mobile filters; search preserved through collapse, reopen and reload.",
"Hierarchical territory selector still loads and updates filter context (synthetic QA territory).",
"Investment selection and dossier link survive reload.",
"Comparison still limits four selections and renders the existing comparison table.",
"All 29 territorial reference cards remain accessible in three expandable groups.",
"All five views activate and fit mobile width; map uses explicit no-coordinates preview data."
],
"pageErrors": 0
},
"logic": [
{
"file": "resources/js/pages/investment/catalogue.tsx",
"existingNonPresentationLogicBlocks": 33,
"missing": 0,
"excludes": "JSX and its inline handlers; those reviewed in source diff and directed browser checks."
},
{
"file": "resources/js/components/opportunity-search-filters.tsx",
"existingNonPresentationLogicBlocks": 5,
"missing": 0,
"excludes": "JSX and its inline handlers; those reviewed in source diff and directed browser checks."
}
]
},
"preservation": {
"schema": "Sem alterações.",
"data": "Sem acesso à base de dados; prévia baseada nos documentos locais e referências públicas já guardadas.",
"configuration": "Sem alterações.",
"permissions": "Sem alterações.",
"queries": "Filtros, endpoints e regras preservados; testes de backend anteriores reutilizados, sem nova prova de backend ou autenticação.",
"sourceFiles": [
{
"file": "resources/js/pages/investment/catalogue.tsx",
"sha256": "d4697cb83c2f92a786108286c7743401b44d61c5b367e9ff322b05d0424b0d70"
},
{
"file": "resources/js/components/opportunity-search-filters.tsx",
"sha256": "d5b75fc78f7dc272b745117d37e040f1bb921cec2783c3f50ddaaafff7e2df66"
},
{
"file": "resources/js/components/catalogue-filter-group.tsx",
"sha256": "f2728dfc2393de7d4f4338d3ea1057ed84317603980a2dc44f8a208d225cda45"
},
{
"file": "resources/css/investment-catalogue.css",
"sha256": "034a79a62ba13220f814027afd62e43c49e2947afd1d9e25a87ba37d3648fd93"
}
]
},
"evidence": {
"directory": "outputs/catalogue-layout-20261029/",
"screenshots": [
"catalogue-1440.png",
"catalogue-1024.png",
"catalogue-768.png",
"catalogue-390.png",
"catalogue-320.png",
"filters-expanded-390.png",
"territorial-expanded.png",
"list-1440.png"
]
},
"matrix": [
"RF-MN-010",
"RF-PII-003",
"RNF-EDU-008",
"MASTER-471012ab-0124"
],
"deployment": "Não realizada; alteração activa fora desta entrega local.",
"dynamicMoreFilters": {
"origin": "Pedido expresso da Tempo, 29/10/2026",
"collapsed": "Mais filtros",
"expanded": "Menos filtros",
"verification": {
"result": "passed",
"checks": [
"Mais filtros quando recolhido",
"Menos filtros quando aberto",
"Alternância por clique e teclado",
"Escolhas e contagem conservadas"
],
"scope": "Componente React real, compilado e exercitado em Chromium; sem backend ou base de dados."
},
"deployment": "Alteração local; sem instalação."
}
}
@@ -7620,6 +7620,7 @@ Cobertura: identificação/período, 27 campos manuais IVA e 24 campos manuais M
Pedido expresso da Tempo: preencher formulário de IVA ou IRPC, em vez de abrir legislação. Base preservada: entrega instalada 789fffd0, Modelo A e aquisições/apuramento já aceites, L6 M20/M22 instalado em 5aaf1a56 e incrementos de L8. Nenhuma perda confirmada neste recorte; não se apagam as aceitações nem se repetem provas fiscais anteriores. Fontes reutilizadas do inventário de 20 exemplares acessíveis em outputs/irpc-l6-values-forms/accessible-source-inventory.json da QA anterior, incluindo temporários identificados; inventário do corpus e originais preservados. M22: três páginas conferidas visualmente nesta revisão, SHA256 4b61bd83d1fbcfd3b7b5c5523cea5923f7d0f65dc4c004eff58e5db38f763c88. Modelo A: correspondência e divergências aceites em modelo-a-reconciliacao-20261021.md; sem nova validação de proveniência.
Incremento em preparação QA: entrada directa em Documentos e Formulários, preenchimento manual dos campos correspondidos de IVA e subconjunto M22, identificação/período, revisão, impressão e ficheiro privado para recuperação mediante confirmação. Escolha técnica: sem persistência no servidor nem ligação automática ao dossier neste incremento; não altera schema, dados, permissões ou cálculos. Ficheiro não publicado. IRPC ajustamentos completos, outros regimes, anexos, assinatura e submissão à AT fora da cobertura; L8 permanece parcial. Ainda sem instalação deste incremento. Próximas provas apenas do delta: fidelidade da recuperação, rejeição de ficheiros incompatíveis sem perda, formatos/valores desconhecidos, acesso público e compilação.
<<<<<<< HEAD
@@ -9071,3 +9072,5 @@ Validação e diagnósticos:29 Feature/364 finais, com seis novos de reutilizaç
Rastreabilidade dos executores: o recibo anterior organization_country_editor_increment_20261103 conservava na lista tooling_sha256 nomes de helpers da etapa workspace, por uma cópia de metadados. Mantêm-se esse recibo e todas as suas provas exactamente como estavam. Este incremento acrescenta prior_tooling_supplement_sha256 para os ficheiros efectivamente usados e retidos de check-organization-country-editor-qa, verify-organization-country-editor-concurrency e record-organization-country-editor, e liga os próprios executores actuais pelos caminhos correctos, incluindo este recorder. A suplementação de rastreabilidade não altera os resultados, fontes, observações de concorrência ou estado de instalação anteriores.
Preservação e próxima acção:79 tabelas de teste vazias, lock/identidade/owner/RLS/FORCE conferidos e fixture de falha revertida.89 recibos/411 provas anteriores preservados e16 novas provas arquivadas. Nenhuma migração/escrita activa ou comunicação externa. AUD-COUNTRY-02 e AUD-PROFILE-25 continuam parciais: clientes legados sem chave mantêm semântica anterior; restam outros consumidores/perfis, retoma durável/expiração, visual/móvel/browser, preenchimento real autorizado onde falta país, backup/restauro do candidato e disponibilização autenticada. A fonte aprovada não volta a ser submetida a uma validação genérica nem se inventam representantes. Próxima acção: confrontar os consumidores restantes e a recuperação de preparações com a matriz, reutilizando os mecanismos agora comuns.
=======
>>>>>>> origin/main
File diff suppressed because one or more lines are too long
@@ -0,0 +1,284 @@
AGENTS.md | 4 +
app/Exceptions/OpportunityWorkflowUnavailable.php | 21 +
.../Controllers/Api/V1/OpportunityController.php | 2 +
.../Controllers/CompanyComparisonController.php | 33 +
.../Controllers/CompanyPreparationController.php | 24 +
app/Http/Controllers/CrmController.php | 90 +-
app/Http/Controllers/DashboardController.php | 13 +-
.../DeskRequestCheckpointController.php | 20 +
.../Controllers/DeskServiceEditorController.php | 7 +-
app/Http/Controllers/DocumentMalwareController.php | 30 +-
app/Http/Controllers/EducationController.php | 147 +-
.../EducationDeclarationDraftController.php | 34 +
.../Controllers/ExistingBusinessController.php | 2 +
.../Controllers/InvestmentDiscoveryController.php | 8 +-
app/Http/Controllers/InvestmentGuideController.php | 31 +
.../Controllers/InvestmentPortfolioController.php | 8 +-
.../Controllers/InvestmentWorkspaceController.php | 49 +-
app/Http/Controllers/InvestmentZoneController.php | 4 +
app/Http/Controllers/InvestorPathController.php | 16 +-
app/Http/Controllers/LegacyContentController.php | 2 +
.../OpportunityManagementController.php | 2 +
.../Controllers/OpportunityResourceController.php | 30 +-
.../Controllers/PartnershipExchangeController.php | 206 +-
app/Http/Controllers/PortalProfileController.php | 15 +-
.../PortalProfileInstallationController.php | 14 +
app/Http/Controllers/PortalServicesController.php | 3 +
app/Http/Controllers/ProactiveSupplyController.php | 15 +
.../Controllers/ProductExperimentController.php | 41 +
.../Controllers/ProductUsageMetricsController.php | 19 +
.../PublicBusinessOpportunityController.php | 43 +-
.../Controllers/TechnicalSupportController.php | 45 +
app/Http/Controllers/VirtualDeskController.php | 173 +-
.../VirtualDeskInstallationController.php | 11 +-
.../VirtualDeskOperationsController.php | 86 +-
app/Http/Middleware/CollectPublicSearchMetrics.php | 25 +
.../PublicBusinessOpportunityResource.php | 5 +
app/Jobs/ScanDocumentForMalware.php | 127 +-
app/Models/CrmTask.php | 21 +
app/Services/Ai/AssistiveRules.php | 5 +-
app/Services/Ai/InvestorGuidance.php | 12 +-
app/Services/BusinessFormSchema.php | 41 +-
app/Services/BusinessPointPublication.php | 125 +
app/Services/BusinessProximitySearch.php | 53 +
app/Services/CompanyComparison.php | 208 +
app/Services/CompanyPreparation.php | 285 +
app/Services/CrmNextAction.php | 27 +
app/Services/CrmTaskCreation.php | 76 +
app/Services/CrmTaskPreparations.php | 117 +
app/Services/DashboardWorkQueue.php | 88 +
app/Services/DeskAttachments.php | 116 +
app/Services/DeskPaymentEvidence.php | 46 +
app/Services/DeskRequestCheckpoints.php | 88 +
app/Services/DeskServiceAvailability.php | 47 +
app/Services/DeskServiceEditor.php | 103 +-
app/Services/EducationAccess.php | 21 +
app/Services/EducationDeclarationDrafts.php | 142 +
app/Services/EducationDeclarationRules.php | 29 +
app/Services/EducationReviews.php | 101 +
app/Services/EducationWorkQueue.php | 40 +
app/Services/ExistingBusinessDrafts.php | 3 +-
app/Services/ExistingBusinessIntake.php | 4 +-
app/Services/InvestmentGuides.php | 169 +
app/Services/InvestmentProfileCommands.php | 68 +
app/Services/InvestorPath.php | 4 +-
app/Services/Malware/DocumentScanAttempts.php | 76 +
app/Services/Malware/DocumentScanDelivery.php | 46 +
app/Services/Malware/DocumentScanRetry.php | 78 +
app/Services/OpportunityImageProcessor.php | 18 +-
app/Services/OpportunityProfileWorkflow.php | 12 +
app/Services/OpportunityResourceFileCleanup.php | 65 +
app/Services/OpportunityResources.php | 24 +-
app/Services/OpportunityScanDispatch.php | 50 +
app/Services/OpportunityTechnicalExecution.php | 40 +
app/Services/OpportunityTechnicalIncidents.php | 134 +
app/Services/OpportunityTechnicalSupport.php | 128 +
app/Services/OpportunityWorkflowExecution.php | 28 +
app/Services/OrganizationCountry.php | 28 +-
app/Services/PartnershipAgreementHistory.php | 141 +
app/Services/PartnershipAgreementProjection.php | 84 +
app/Services/PartnershipAgreements.php | 140 +
app/Services/PartnershipCandidacies.php | 188 +
app/Services/PartnershipCandidacyHistory.php | 198 +
app/Services/PartnershipDraftSaves.php | 46 +
app/Services/PartnershipExchange.php | 250 +-
app/Services/PartnershipMilestones.php | 78 +
app/Services/PartnershipNotifications.php | 145 +
app/Services/PartnershipPublication.php | 110 +
app/Services/PartnershipPublicationHistory.php | 195 +
app/Services/PartnershipRoomUploads.php | 69 +
app/Services/PortalAudit.php | 15 +-
app/Services/PortalProfileAccess.php | 4 +
app/Services/PortalProfileAdministration.php | 6 +-
app/Services/PortalProfileInstallation.php | 107 +-
app/Services/ProactiveSupply.php | 79 +-
app/Services/ProductExperiments.php | 307 +
app/Services/ProductUsageMetrics.php | 170 +
app/Services/PublicBusinessLocations.php | 57 +
app/Services/PublicSearchMetrics.php | 144 +
app/Services/TechnicalIncidentHandling.php | 138 +
app/Services/TechnicalSupportProfileSchema.php | 40 +
app/Services/VerifiedDocumentBinary.php | 2 +-
app/Services/VirtualDesk.php | 177 +-
app/Services/VirtualDeskInstallation.php | 3 +
bootstrap/app.php | 2 +
config/bvm-admission-guide.php | 101 +
config/company-comparison.php | 30 +
config/investment-guides.php | 37 +
config/product-experiments.php | 17 +
config/product-metrics.php | 26 +
config/search-metrics.php | 30 +
...6_11_02_230000_education_declaration_drafts.php | 38 +
...3_000000_existing_business_location_sources.php | 27 +
...6_11_03_010000_desk_service_effective_dates.php | 24 +
...026_11_03_020000_create_product_experiments.php | 71 +
...6_11_03_030000_create_product_search_counts.php | 50 +
...1_03_040000_create_desk_request_checkpoints.php | 33 +
...2026_11_03_050000_scope_desk_document_links.php | 26 +
..._03_060000_crm_task_creation_commands_index.php | 17 +
...6_11_03_070000_create_crm_task_preparations.php | 32 +
..._11_03_080000_add_technical_support_profile.php | 26 +
.../unification/local-validation-20261029.json | 51257 +++++++++++++++++++
.../unification/traceability-and-manual.md | 1058 +
docs/investmoz/v2/auditoria-portal/backlog.md | 247 +-
.../v2/auditoria-portal/matriz-requisitos.csv | 393 +-
.../auditoria-portal/visao-consolidada-modulos.md | 184 +-
resources/js/components/business-point-summary.tsx | 131 +
resources/js/components/crm-edit-comparison.tsx | 120 +
resources/js/components/crm-task-creation.tsx | 293 +
resources/js/components/dashboard-work-queue.tsx | 167 +
resources/js/components/desk-attachment-field.tsx | 453 +
resources/js/components/desk-case-state-form.tsx | 124 +
resources/js/components/desk-document-picker.tsx | 175 +
resources/js/components/desk-draft-recovery.tsx | 691 +
.../js/components/desk-internal-operations.tsx | 59 +-
.../js/components/desk-payment-document-form.tsx | 138 +
resources/js/components/desk-service-history.tsx | 12 +
resources/js/components/desk-typed-choice.tsx | 88 +
resources/js/components/education-review.tsx | 229 +
.../js/components/education-submitted-snapshot.tsx | 110 +
.../js/components/funding-criteria-assessment.tsx | 197 +
.../js/components/investment-profile-editor.tsx | 313 +
resources/js/components/investor-guidance.tsx | 44 +-
.../components/opportunity-technical-incidents.tsx | 81 +
.../js/components/organization-country-editor.tsx | 233 +
.../js/components/organization-country-reuse.tsx | 155 +
.../partnership-agreement-instrument.tsx | 86 +
.../js/components/partnership-candidacies.tsx | 730 +
.../js/components/partnership-edit-comparison.tsx | 275 +
.../components/partnership-publication-summary.tsx | 85 +
.../js/components/partnership-publication.tsx | 210 +
.../js/components/partnership-room-comparison.tsx | 175 +
resources/js/components/proactive-supply.tsx | 31 +-
resources/js/components/profile-navigation.tsx | 13 +
resources/js/components/search-usage-report.tsx | 118 +
resources/js/components/similar-businesses.tsx | 250 +-
.../js/components/technical-incident-handling.tsx | 122 +
.../js/components/technical-incident-notice.tsx | 108 +
resources/js/lib/business-locations.ts | 52 +
resources/js/lib/company-comparison.ts | 17 +
resources/js/lib/company-preparation.ts | 72 +
resources/js/lib/crm-record-edit.ts | 213 +
resources/js/lib/crm-task-creation.ts | 371 +
resources/js/lib/crm-task-preparation.ts | 384 +
resources/js/lib/desk-checkpoints.ts | 154 +
resources/js/lib/desk-field-values.ts | 61 +
resources/js/lib/desk-field-visibility.ts | 19 +-
resources/js/lib/education-review.ts | 388 +
resources/js/lib/investment-guides.ts | 34 +
resources/js/lib/investment-profile-editor.ts | 433 +
resources/js/lib/organization-country-reuse.ts | 325 +
resources/js/lib/page-preparation-guard.ts | 28 +
resources/js/lib/partnership-agreement.ts | 63 +
resources/js/lib/partnership-candidacy-edit.ts | 805 +
resources/js/lib/partnership-edit.ts | 862 +
resources/js/lib/partnership-history.ts | 26 +
resources/js/lib/partnership-publication.ts | 465 +
resources/js/lib/partnership-room-delivery.ts | 199 +
resources/js/lib/partnership-room-edit.ts | 845 +
resources/js/lib/product-experiments.ts | 107 +
resources/js/lib/product-usage.ts | 69 +
resources/js/lib/profile-json.ts | 14 +-
resources/js/lib/profile-support-installation.ts | 406 +
resources/js/lib/technical-incident-handling.ts | 368 +
resources/js/lib/technical-incident-labels.ts | 45 +
resources/js/pages/crm/investor.tsx | 116 +-
resources/js/pages/crm/lead.tsx | 207 +-
resources/js/pages/crm/types.ts | 3 +
resources/js/pages/dashboard.tsx | 12 +
resources/js/pages/desk/case.tsx | 240 +-
resources/js/pages/desk/catalogue.tsx | 20 +-
resources/js/pages/desk/request.tsx | 723 +-
resources/js/pages/desk/service-editor.tsx | 337 +-
resources/js/pages/education/declaration.tsx | 154 +
resources/js/pages/education/index.tsx | 728 +-
resources/js/pages/funding/show.tsx | 157 +-
.../js/pages/investment/business-marketplace.tsx | 82 +-
.../js/pages/investment/business-opportunity.tsx | 25 +-
resources/js/pages/investment/business-wizard.tsx | 88 +-
resources/js/pages/investment/business.tsx | 31 +-
.../js/pages/investment/company-comparison.tsx | 386 +
.../js/pages/investment/company-preparation.tsx | 790 +
resources/js/pages/investment/guide.tsx | 676 +
resources/js/pages/investment/profile.tsx | 293 +-
resources/js/pages/investment/zone-attachments.tsx | 18 +-
resources/js/pages/investor-path/index.tsx | 41 +-
resources/js/pages/investor-path/show.tsx | 23 +
resources/js/pages/management/experiments/edit.tsx | 722 +
.../js/pages/management/experiments/index.tsx | 122 +
.../js/pages/management/opportunity-draft.tsx | 8 +
.../js/pages/management/opportunity-resources.tsx | 6 +
.../pages/management/proactive-supply-review.tsx | 419 +
resources/js/pages/management/product-usage.tsx | 267 +
resources/js/pages/management/shared.tsx | 10 +-
.../js/pages/partnerships/agreement-history.tsx | 199 +
resources/js/pages/partnerships/dashboard.tsx | 51 +-
resources/js/pages/partnerships/detail.tsx | 508 +-
resources/js/pages/partnerships/editor.tsx | 824 +-
.../js/pages/partnerships/interest-history.tsx | 350 +
.../js/pages/partnerships/publication-history.tsx | 349 +
resources/js/pages/partnerships/room.tsx | 777 +-
resources/js/pages/partnerships/shared.tsx | 6 +
resources/js/pages/portal/privacy-policy.tsx | 22 +
resources/js/pages/portal/services.tsx | 24 +-
resources/js/pages/profiles/administration.tsx | 33 +-
resources/js/pages/profiles/installation.tsx | 28 +
.../js/pages/profiles/support-installation.tsx | 170 +
resources/js/pages/profiles/technical-incident.tsx | 98 +
resources/js/pages/profiles/technical-support.tsx | 150 +
resources/js/types/profile-access.ts | 2 +
resources/views/desk/receipt-answers.blade.php | 9 +
resources/views/desk/receipt.blade.php | 16 +
routes/investor-path.php | 11 +
routes/partnerships.php | 3 +
routes/virtual-desk.php | 6 +
routes/web.php | 26 +
tests/Feature/BusinessPointPublicationTest.php | 242 +
tests/Feature/BusinessProximitySearchTest.php | 157 +
tests/Feature/BvmAdmissionGuideTest.php | 174 +
tests/Feature/CompanyComparisonTest.php | 199 +
tests/Feature/CompanyPreparationTest.php | 198 +
tests/Feature/CrmIntakeTest.php | 12 +-
tests/Feature/CrmNextActionTest.php | 199 +
tests/Feature/CrmRecordVersionsTest.php | 140 +
tests/Feature/CrmTaskCreationTest.php | 168 +
tests/Feature/CrmTaskPreparationTest.php | 232 +
tests/Feature/DashboardWorkQueueTest.php | 275 +
tests/Feature/DeskAttachmentFieldsTest.php | 409 +
tests/Feature/DeskCaseStateTest.php | 132 +
tests/Feature/DeskClosedLifecycleTest.php | 164 +
tests/Feature/DeskDocumentReuseTest.php | 166 +
tests/Feature/DeskPaymentEvidenceTest.php | 213 +
tests/Feature/DeskRequestCheckpointTest.php | 204 +
tests/Feature/DeskServiceEffectiveDatesTest.php | 198 +
tests/Feature/DeskTypedFieldsTest.php | 247 +
tests/Feature/DocumentScanRetryTest.php | 204 +
tests/Feature/EducationDeclarationDraftsTest.php | 136 +
tests/Feature/EducationWorkQueueTest.php | 294 +
tests/Feature/InvestmentGuidesTest.php | 186 +
tests/Feature/InvestmentProfileCommandsTest.php | 107 +
tests/Feature/OpportunityCanonicalWorkflowTest.php | 120 +
.../Feature/OpportunityTechnicalIncidentsTest.php | 258 +
tests/Feature/OrganizationCountryCommandsTest.php | 130 +
.../OrganizationCountryReuseCommandsTest.php | 148 +
tests/Feature/PartnershipAgreementHistoryTest.php | 256 +
.../Feature/PartnershipAgreementProjectionTest.php | 235 +
tests/Feature/PartnershipAgreementRecoveryTest.php | 232 +
tests/Feature/PartnershipCandidaciesTest.php | 250 +
tests/Feature/PartnershipCandidacyHistoryTest.php | 204 +
tests/Feature/PartnershipDashboardPrivacyTest.php | 324 +
tests/Feature/PartnershipDraftRecoveryTest.php | 184 +
tests/Feature/PartnershipEditingTest.php | 211 +
tests/Feature/PartnershipParticipantsTest.php | 186 +
.../Feature/PartnershipPublicationHistoryTest.php | 223 +
.../Feature/PartnershipPublicationRecoveryTest.php | 189 +
.../Feature/PartnershipPublicationSummaryTest.php | 171 +
tests/Feature/PartnershipRoomRecoveryTest.php | 214 +
tests/Feature/ProactiveSupplyReviewTest.php | 188 +
tests/Feature/ProductExperimentsTest.php | 272 +
tests/Feature/ProductUsageMetricsTest.php | 206 +
tests/Feature/PublicSearchMetricsTest.php | 275 +
tests/Feature/ResourceTechnicalIncidentsTest.php | 247 +
tests/Feature/ScanTechnicalIncidentsTest.php | 422 +
tests/Feature/SupportIncidentNoticesTest.php | 173 +
tests/Feature/SupportProfileInstallationTest.php | 25
+445
View File
@@ -0,0 +1,445 @@
/* Presentation only: catalogue queries, records and permissions retain their contracts. */
.investment-catalogue {
--catalogue-accent: #c9141b;
--catalogue-ink: #17324d;
max-width: 1440px;
color: var(--catalogue-ink);
}
.investment-catalogue > h1 {
font-size: clamp(1.6rem, 3vw, 2.15rem);
letter-spacing: -0.035em;
}
.investment-catalogue .catalogue-intro {
margin-top: 0.65rem;
max-width: 76ch;
color: #526477;
line-height: 1.7;
}
.catalogue-workspace {
display: grid;
gap: 1.5rem;
align-items: start;
}
.catalogue-workspace > *,
.catalogue-results > *,
.catalogue-opportunity > * {
min-width: 0;
}
.catalogue-filters {
border: 1px solid #dce3eb;
border-radius: 1rem;
background: #fff;
overflow: clip;
}
.catalogue-filters-toggle,
.catalogue-filters-heading {
align-items: center;
gap: 0.65rem;
padding: 1rem 1.15rem;
font-weight: 650;
}
.catalogue-filters-toggle {
display: flex;
width: 100%;
text-align: left;
min-height: 52px;
}
.catalogue-filters-toggle > svg:last-child {
margin-left: auto;
transition: transform 0.15s;
}
.catalogue-filters-toggle[aria-expanded='true'] > svg:last-child {
transform: rotate(180deg);
}
.catalogue-filters-heading {
display: none;
}
#catalogue-filter-fields {
padding: 0 1.15rem 1.15rem;
}
.catalogue-filters input:not([type='checkbox']),
.catalogue-filters select {
min-height: 44px;
max-width: 100%;
font-size: 0.875rem;
}
.catalogue-filter-group {
border-top: 1px solid #e5eaf0;
}
.catalogue-filter-group > summary {
display: flex;
align-items: center;
gap: 0.6rem;
min-height: 52px;
padding: 0.65rem 0;
cursor: pointer;
list-style: none;
font-size: 0.875rem;
font-weight: 650;
}
.catalogue-filter-group > summary::-webkit-details-marker {
display: none;
}
.catalogue-filter-group > summary > svg {
margin-left: auto;
flex-shrink: 0;
transition: transform 0.15s;
}
.catalogue-filter-group[open] > summary > svg {
transform: rotate(180deg);
}
.catalogue-filter-group-content {
padding: 0.4rem 0 1rem;
display: grid;
gap: 1rem;
}
.catalogue-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
padding: 0.15rem 0.45rem;
border-radius: 99px;
background: #f0f3f7;
color: #526477;
font-size: 0.75rem;
}
.catalogue-filters .catalogue-filter-group {
margin-top: 0;
}
.catalogue-active-filters:empty {
display: none;
}
.catalogue-active-filters button {
border-radius: 0.5rem;
background: #f5f7fa;
border-color: #dce3eb;
font-size: 0.75rem;
overflow-wrap: anywhere;
}
.catalogue-filter-submit > button:first-of-type {
gap: 0.5rem;
background: var(--catalogue-accent);
min-height: 44px;
}
.catalogue-filter-submit > button:last-child {
min-height: 44px;
color: #526477;
font-size: 0.875rem;
border: 0;
text-decoration: none;
}
.catalogue-results {
display: flex;
flex-direction: column;
gap: 1.25rem;
scroll-margin-top: 2rem;
}
.catalogue-results-heading {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.35rem 1rem;
}
.catalogue-results-heading > h2 {
font-size: 1.125rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.catalogue-results-heading > [role] {
border: 0;
padding: 0;
margin: 0;
color: #526477;
font-size: 0.875rem;
}
.catalogue-results-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.8rem 1.25rem;
padding-bottom: 1.1rem;
border-bottom: 1px solid #dce3eb;
}
.catalogue-view-options {
display: flex;
flex-wrap: wrap;
gap: 0.2rem;
padding: 0.25rem;
border: 1px solid #dce3eb;
border-radius: 0.75rem;
background: #fff;
}
.catalogue-view-button {
display: inline-flex;
gap: 0.4rem;
align-items: center;
justify-content: center;
min-height: 42px;
padding: 0.5rem 0.7rem;
border-radius: 0.5rem;
color: #526477;
font-size: 0.8rem;
}
.catalogue-view-button[aria-pressed='true'] {
background: #17324d;
color: #fff;
}
.catalogue-view-button:not(:disabled):hover {
color: var(--catalogue-accent);
}
.catalogue-view-button[aria-pressed='true']:not(:disabled):hover {
background: var(--catalogue-accent);
color: #fff;
}
.catalogue-view-button:disabled {
opacity: 0.5;
cursor: wait;
}
.catalogue-utility-actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1.25rem;
}
.catalogue-utility-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 44px;
font-size: 0.8rem;
color: #526477;
}
.catalogue-utility-link[aria-disabled='true'] {
opacity: 0.5;
}
.catalogue-cards {
display: grid;
gap: 1rem;
}
.catalogue-opportunity {
display: flex;
flex-direction: column;
border: 1px solid #dce3eb;
border-radius: 1rem;
background: white;
overflow: clip;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.catalogue-opportunity:hover,
.catalogue-opportunity:focus-within {
border-color: #9cabbc;
box-shadow: 0 5px 18px #17324d0a;
}
.catalogue-opportunity-body {
display: flex;
flex: 1;
flex-direction: column;
align-items: start;
gap: 0.85rem;
padding: 1.25rem;
}
.catalogue-sector {
padding: 0.25rem 0.6rem;
background: #f0f4f8;
color: #3d5268;
border-radius: 0.4rem;
font-size: 0.75rem;
font-weight: 600;
}
.catalogue-location {
display: flex;
align-items: flex-start;
gap: 0.4rem;
color: #526477;
font-size: 0.8rem;
line-height: 1.6;
}
.catalogue-location svg {
flex-shrink: 0;
margin-top: 0.2rem;
}
.catalogue-opportunity h2 {
font-size: 1.125rem;
line-height: 1.45;
letter-spacing: -0.015em;
color: var(--catalogue-ink);
overflow-wrap: anywhere;
}
.catalogue-summary {
color: #526477;
font-size: 0.875rem;
line-height: 1.7;
}
.catalogue-opportunity-facts {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.65rem;
width: 100%;
margin-top: auto;
padding-top: 0.5rem;
font-size: 0.875rem;
}
.catalogue-maturity {
padding: 0.25rem 0.6rem;
border-radius: 99px;
background: #f5f6f8;
color: #526477;
font-size: 0.75rem;
}
.catalogue-opportunity-actions {
padding: 0.45rem 1.25rem 0.75rem;
border-top: 1px solid #e5eaf0;
}
.catalogue-opportunity-links {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.25rem 0.75rem;
}
.catalogue-detail-link,
.catalogue-favourite {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 44px;
font-size: 0.8rem;
}
.catalogue-detail-link {
color: var(--catalogue-accent);
font-weight: 650;
}
.catalogue-favourite {
color: #526477;
text-align: left;
}
.catalogue-favourite[aria-pressed='true'] {
color: var(--catalogue-accent);
}
.catalogue-opportunity-choices {
display: grid;
gap: 0.15rem;
padding-top: 0.35rem;
border-top: 1px solid #eef1f5;
}
.catalogue-opportunity-choices label {
display: flex;
align-items: center;
gap: 0.6rem;
min-height: 44px;
font-size: 0.8rem;
line-height: 1.5;
cursor: pointer;
}
.catalogue-opportunity-choices input {
width: 1rem;
height: 1rem;
flex-shrink: 0;
accent-color: var(--catalogue-accent);
}
.catalogue-pagination {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.75rem;
}
.catalogue-pagination a {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.5rem 1rem;
border: 1px solid #dce3eb;
border-radius: 0.5rem;
background: #fff;
font-size: 0.875rem;
}
.catalogue-territorial {
padding-top: 1.5rem;
border-top: 1px solid #dce3eb;
}
.catalogue-territorial > h2 {
font-size: 1.125rem;
font-weight: 700;
}
.catalogue-territorial > p {
margin: 0.5rem 0 1.2rem;
color: #526477;
font-size: 0.875rem;
line-height: 1.7;
}
.catalogue-territorial-links {
display: grid;
gap: 0.75rem;
}
.catalogue-territorial-link {
border: 1px solid #e5eaf0;
border-radius: 0.65rem;
padding: 0.85rem 1rem;
background: #fff;
}
.catalogue-territorial-link > span {
color: #526477;
font-size: 0.75rem;
}
.catalogue-territorial-link h3 {
font-size: 0.875rem;
line-height: 1.6;
}
/* Headings and emphasized catalogue content use Tempo's red. */
.investment-catalogue :is(h1, h2, h3, h4, h5, h6, caption, th, strong),
.investment-catalogue :is(p.font-bold, p.font-semibold),
.investment-catalogue .catalogue-sector,
.investment-catalogue .catalogue-filters-toggle,
.investment-catalogue .catalogue-territorial .catalogue-filter-group > summary {
color: var(--catalogue-accent);
}
.investment-catalogue :is(a, button, summary, input, select):focus-visible {
outline: 2px solid #17324d;
outline-offset: 3px;
}
.investment-catalogue :is(a, button, summary):hover {
text-decoration: underline;
text-underline-offset: 3px;
}
@media (min-width: 640px) {
.catalogue-cards-grid,
.catalogue-territorial-links {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.catalogue-workspace {
grid-template-columns: 290px minmax(0, 1fr);
gap: 2rem;
}
.catalogue-filters-toggle {
display: none;
}
.catalogue-filters-heading {
display: flex;
}
.catalogue-results-heading {
min-height: 48px;
}
.catalogue-cards-list .catalogue-opportunity {
flex-direction: row;
}
.catalogue-cards-list .catalogue-opportunity-actions {
flex: 0 0 235px;
display: flex;
flex-direction: column;
justify-content: center;
border-top: 0;
border-left: 1px solid #e5eaf0;
}
}
@media (prefers-reduced-motion: reduce) {
.investment-catalogue * {
transition: none;
scroll-behavior: auto;
}
}
@@ -0,0 +1,30 @@
import { ChevronDown } from 'lucide-react';
import { useState, type ReactNode } from 'react';
export default function CatalogueFilterGroup({
title,
expandedTitle,
count = 0,
children,
}: {
title: string;
expandedTitle?: string;
count?: number;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<details
className="catalogue-filter-group"
onToggle={(event) => setOpen(event.currentTarget.open)}
>
<summary>
<span>{open && expandedTitle ? expandedTitle : title}</span>
{count > 0 && <span className="catalogue-count">{count}</span>}
<ChevronDown size={16} aria-hidden="true" />
</summary>
<div className="catalogue-filter-group-content">{children}</div>
</details>
);
}
@@ -1,6 +1,9 @@
import { Field, input, button, groupLabels } from '@/pages/investment/shared';
import type { References } from '@/pages/investment/shared';
import { TerritoryLevel, type TerritoryOption } from './investment-territory';
import CatalogueFilterGroup from './catalogue-filter-group';
import { useEffect, useState } from 'react';
import { ChevronDown, Search, SlidersHorizontal } from 'lucide-react';
export const modalityLabels: Record<string, string> = {
investment_project: 'Projecto de investimento',
@@ -83,6 +86,10 @@ export default function OpportunitySearchFilters({
clear: () => void;
errors: Record<string, string>;
}) {
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (Object.keys(errors).length) setExpanded(true);
}, [errors]);
const select = (
key: string,
choices: { value: string; label: string }[],
@@ -217,13 +224,14 @@ export default function OpportunitySearchFilters({
);
return (
<form
className="space-y-5 rounded-xl border bg-white p-4 sm:p-6"
className="catalogue-filters"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
aria-label="Pesquisar oportunidades"
>
<<<<<<< HEAD
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Field label="O que procura">
<input
@@ -338,209 +346,394 @@ export default function OpportunitySearchFilters({
label: c,
})),
'Seleccione uma moeda para comparar montantes. Não se efectuam conversões cambiais.',
=======
<button
type="button"
className="catalogue-filters-toggle"
aria-expanded={expanded}
aria-controls="catalogue-filter-fields"
onClick={() => setExpanded((value) => !value)}
>
<SlidersHorizontal size={18} aria-hidden="true" />
<span>Filtros</span>
{active.length + path.length > 0 && (
<span className="catalogue-count">
{active.length + path.length}
</span>
>>>>>>> origin/main
)}
</div>
<details>
<summary className="cursor-pointer rounded border p-3 font-semibold focus-visible:outline-2">
Mais filtros
</summary>
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[
'corridor_id',
'cluster_id',
'zee_id',
'zfi_id',
'park_id',
'project_id',
'subsector_id',
'chain_id',
].map((key) => select(key, choicesFor(key)))}
<Field label="Data-alvo até">
<ChevronDown size={18} aria-hidden="true" />
</button>
<h2 className="catalogue-filters-heading">
<SlidersHorizontal size={17} aria-hidden="true" />
Filtros
</h2>
<div
id="catalogue-filter-fields"
className={`${expanded ? 'block' : 'hidden'} space-y-5 lg:block`}
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
<Field label="O que procura">
<input
className={input}
type="date"
value={String(data.before)}
onChange={(e) => change({ before: e.target.value })}
value={String(data.q)}
placeholder="Palavras do título ou da descrição"
onChange={(e) => change({ q: e.target.value })}
/>
</Field>
{select('perspective', [
{ value: 'exists', label: 'O que existe' },
{ value: 'needs', label: 'Necessidades identificadas' },
{ value: 'ideas', label: 'Ideias de investimento' },
{
value: 'opportunities',
label: 'Oportunidades cadastradas — disponibilidade na ficha',
},
])}
{select('sector', choicesFor('sector'))}
{select('category', choicesFor('category'))}
</div>
<p className="my-3 text-sm">
Estes filtros procuram associações documentadas. Estar perto
de um parque ou no mesmo distrito não constitui associação,
nem confirma incentivos ou disponibilidade.
</p>
<label className="flex gap-2">
<input
type="checkbox"
checked={Boolean(data.include_national)}
onChange={(e) =>
change({ include_national: e.target.checked })
}
/>
Incluir oportunidades de âmbito nacional
</label>
<details className="mt-3">
<summary className="cursor-pointer font-medium">
Outras características
</summary>
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<CatalogueFilterGroup title="Localização" count={path.length}>
<p className="text-sm leading-relaxed text-slate-600">
A província inclui as oportunidades dos seus distritos,
postos administrativos e localidades. Não é necessário
escolher todos os níveis. O contexto provincial de um
corredor é identificado nos resultados; não confirma uma
implantação distrital.
{path.length > 1 && (
<button type="button" className="ml-2 underline" onClick={() => territory(path.slice(0, 1))}>
Ver toda a província de {path[0].name}
</button>
)}
</p>
<fieldset className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
<legend className="sr-only">Localização</legend>
{(
[
'province_id',
'district_id',
'admin_post_id',
'locality_id',
] as const
).map((key, index) => (
<TerritoryLevel
key={`${key}-${path[index - 1]?.id ?? ''}`}
index={index}
parent={path[index - 1]?.id ?? ''}
selected={path[index]}
endpoint="/api/v1/investment/territory-options"
onChange={(value) =>
territory([
...path.slice(0, index),
...(value ? [value] : []),
])
}
/>
))}
</fieldset>
</CatalogueFilterGroup>
<CatalogueFilterGroup
title="Preparação e investimento"
count={
[
'preparation',
'min_amount',
'max_amount',
'currency',
].filter((key) => data[key]).length
}
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
<Field label="Estado de preparação">
<select
className={input}
value={String(data.preparation ?? '')}
onChange={(e) =>
change({
preparation: e.target.value,
maturity_level: '',
})
}
>
<option value="">Todos os estados</option>
{[
'Etapas de preparação',
'Classificações históricas',
].map((group) => (
<optgroup key={group} label={group}>
{(references.preparationOptions ?? [])
.filter((o) => o.group === group)
.map((o) => (
<option
key={o.value}
value={o.value}
>
{o.label}
</option>
))}
</optgroup>
))}
</select>
<p className="text-sm text-slate-600">
Os registos históricos são apresentados
separadamente e conservam a classificação
indicada na ficha.
</p>
</Field>
{(['min_amount', 'max_amount'] as const).map((key) => (
<Field key={key} label={labels[key]}>
<input
className={input}
type="number"
min="0"
step="0.01"
value={String(data[key])}
onChange={(e) =>
change({ [key]: e.target.value })
}
/>
</Field>
))}
{select(
'currency',
['MZN', 'USD', 'EUR', 'ZAR'].map((c) => ({
value: c,
label: c,
})),
'Seleccione uma moeda para comparar montantes. Não se efectuam conversões cambiais.',
)}
</div>
</CatalogueFilterGroup>
<CatalogueFilterGroup
title="Mais filtros"
expandedTitle="Menos filtros"
count={
active.filter(([key]) =>
[
'corridor_id',
'cluster_id',
'zee_id',
'zfi_id',
'park_id',
'project_id',
'subsector_id',
'chain_id',
'before',
'include_national',
'facets',
].includes(key),
).length
}
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
{[
'education',
'infrastructure',
'labour',
'qualification',
'support',
'impact',
'partnership',
]
.filter((group) =>
references.options.some(
(o) => o.group === group,
),
)
.map((group) => (
<fieldset
key={group}
className="rounded border p-3"
>
<legend>{groupLabels[group]}</legend>
{references.options
.filter((o) => o.group === group)
.map((option) => (
<label
key={option.id}
className="flex gap-2 py-1"
>
<input
type="checkbox"
checked={
Array.isArray(
data.facets,
) &&
data.facets.includes(
option.id,
)
}
onChange={(e) => {
const selected =
'corridor_id',
'cluster_id',
'zee_id',
'zfi_id',
'park_id',
'project_id',
'subsector_id',
'chain_id',
].map((key) => select(key, choicesFor(key)))}
<Field label="Data-alvo até">
<input
className={input}
type="date"
value={String(data.before)}
onChange={(e) =>
change({ before: e.target.value })
}
/>
</Field>
</div>
<p className="my-3 text-sm">
Estes filtros procuram associações documentadas. Estar
perto de um parque ou no mesmo distrito não constitui
associação, nem confirma incentivos ou disponibilidade.
</p>
<label className="flex gap-2">
<input
type="checkbox"
checked={Boolean(data.include_national)}
onChange={(e) =>
change({ include_national: e.target.checked })
}
/>
Incluir oportunidades de âmbito nacional
</label>
<details className="mt-3">
<summary className="cursor-pointer font-medium">
Outras características
</summary>
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
{[
'education',
'infrastructure',
'labour',
'qualification',
'support',
'impact',
'partnership',
]
.filter((group) =>
references.options.some(
(o) => o.group === group,
),
)
.map((group) => (
<fieldset
key={group}
className="rounded border p-3"
>
<legend>{groupLabels[group]}</legend>
{references.options
.filter((o) => o.group === group)
.map((option) => (
<label
key={option.id}
className="flex gap-2 py-1"
>
<input
type="checkbox"
checked={
Array.isArray(
data.facets,
) &&
data.facets.includes(
option.id,
)
? data.facets
: [];
change({
facets: e.target
.checked
? [
...selected,
option.id,
]
: selected.filter(
(id) =>
id !==
}
onChange={(e) => {
const selected =
Array.isArray(
data.facets,
)
? data.facets
: [];
change({
facets: e.target
.checked
? [
...selected,
option.id,
),
});
}}
/>
{option.label}
</label>
))}
</fieldset>
))}
</div>
</details>
</details>
<section
aria-label="Filtros activos"
className="flex flex-wrap gap-2"
>
{path.map((node, index) => (
<button
className="rounded border px-3 py-2"
type="button"
key={node.id}
aria-label={`Remover localização ${node.name}`}
onClick={() => territory(path.slice(0, index))}
>
{node.name} ×
</button>
))}
{active.flatMap(([key, value]) =>
key === 'facets' && Array.isArray(value)
? value.map((id) => (
<button
key={`facet-${id}`}
type="button"
className="rounded border px-3 py-2"
onClick={() =>
change({
facets: value.filter((x) => x !== id),
})
}
>
{' '}
{references.options.find((o) => o.id === id)
?.label ?? 'Critério adicional'}{' '}
×
</button>
))
: [
<button
key={key}
type="button"
className="rounded border px-3 py-2 text-left"
aria-label={`Remover ${labels[key] ?? 'critério adicional'}`}
onClick={() => remove(key)}
>
{labels[key] ?? 'Critério adicional'}:{' '}
{typeof value === 'boolean'
? 'Sim'
: (choicesFor(key).find(
(o) => o.value === String(value),
)?.label ??
([
'q',
'min_amount',
'max_amount',
'currency',
'before',
'province',
'district',
].includes(key)
? String(value)
: 'seleccionado'))}{' '}
×
</button>,
],
)}
</section>
{Object.entries(errors).map(([key, value]) => (
<p key={key} role="alert">
{value}
</p>
))}
<div className="flex flex-wrap items-end gap-4">
{select('sort', [
{ value: 'recent', label: 'Mais recentes' },
{ value: 'title', label: 'Título' },
{
value: 'amount_asc',
label: 'Montante crescente, por moeda',
},
{
value: 'amount_desc',
label: 'Montante decrescente, por moeda',
},
])}
<button className={button}>Pesquisar</button>
<button
type="button"
className="rounded border px-4 py-2 underline"
onClick={clear}
]
: selected.filter(
(
id,
) =>
id !==
option.id,
),
});
}}
/>
{option.label}
</label>
))}
</fieldset>
))}
</div>
</details>
</CatalogueFilterGroup>
<section
aria-label="Filtros activos"
className="catalogue-active-filters flex flex-wrap gap-2"
>
Limpar filtros
</button>
{path.map((node, index) => (
<button
className="rounded border px-3 py-2"
type="button"
key={node.id}
aria-label={`Remover localização ${node.name}`}
onClick={() => territory(path.slice(0, index))}
>
{node.name} ×
</button>
))}
{active.flatMap(([key, value]) =>
key === 'facets' && Array.isArray(value)
? value.map((id) => (
<button
key={`facet-${id}`}
type="button"
className="rounded border px-3 py-2"
onClick={() =>
change({
facets: value.filter(
(x) => x !== id,
),
})
}
>
{' '}
{references.options.find(
(o) => o.id === id,
)?.label ?? 'Critério adicional'}{' '}
×
</button>
))
: [
<button
key={key}
type="button"
className="rounded border px-3 py-2 text-left"
aria-label={`Remover ${labels[key] ?? 'critério adicional'}`}
onClick={() => remove(key)}
>
{labels[key] ?? 'Critério adicional'}:{' '}
{typeof value === 'boolean'
? 'Sim'
: (choicesFor(key).find(
(o) =>
o.value === String(value),
)?.label ??
([
'q',
'min_amount',
'max_amount',
'currency',
'before',
'province',
'district',
].includes(key)
? String(value)
: 'seleccionado'))}{' '}
×
</button>,
],
)}
</section>
{Object.entries(errors).map(([key, value]) => (
<p key={key} role="alert">
{value}
</p>
))}
<div className="catalogue-filter-submit grid gap-3">
{select('sort', [
{ value: 'recent', label: 'Mais recentes' },
{ value: 'title', label: 'Título' },
{
value: 'amount_asc',
label: 'Montante crescente, por moeda',
},
{
value: 'amount_desc',
label: 'Montante decrescente, por moeda',
},
])}
<button className={button}>
<Search size={16} aria-hidden="true" />
Pesquisar
</button>
<button
type="button"
className="rounded border px-4 py-2 underline"
onClick={clear}
>
Limpar filtros
</button>
</div>
</div>
</form>
);
+732 -34
View File
@@ -20,6 +20,19 @@ import type { InvestmentMapArea } from '@/components/interactive-investment-map'
import { type TerritoryOption } from '@/components/investment-territory';
import { Frame, button, card, money, api } from './shared';
import type { Opportunity, References } from './shared';
import {
ArrowUpRight,
Bookmark,
Columns2,
Download,
LayoutGrid,
List,
Map as MapIcon,
MapPin,
Route,
} from 'lucide-react';
import CatalogueFilterGroup from '@/components/catalogue-filter-group';
import '../../../css/investment-catalogue.css';
type Feature = {
geometry: { coordinates: [number, number] };
@@ -63,6 +76,7 @@ export default function Catalogue({
const page = usePage<{ auth: { user: unknown } }>();
const { auth } = page.props;
const [investmentSelection, setInvestmentSelection] = useState<number[]>(
<<<<<<< HEAD
readOpportunitySelection,
);
const [selectionStorageUnavailable, setSelectionStorageUnavailable] =
@@ -71,11 +85,33 @@ export default function Catalogue({
setInvestmentSelection(next);
setSelectionStorageUnavailable(!persistOpportunitySelection(next));
}
=======
() => {
try {
return JSON.parse(
sessionStorage.getItem(
'investment-opportunity-selection',
) ?? '[]',
);
} catch {
return [];
}
},
);
>>>>>>> origin/main
function selectInvestment(id: number, checked: boolean) {
const next = checked
? [...new Set([...investmentSelection, id])]
: investmentSelection.filter((x) => x !== id);
<<<<<<< HEAD
updateInvestmentSelection(next);
=======
setInvestmentSelection(next);
sessionStorage.setItem(
'investment-opportunity-selection',
JSON.stringify(next),
);
>>>>>>> origin/main
}
const form = useForm({
perspective: String(filters.perspective ?? ''),
@@ -362,6 +398,7 @@ export default function Catalogue({
};
return (
<Frame
mainClassName="investment-catalogue"
title={
form.data.education
? 'Oportunidades na Educação'
@@ -379,10 +416,11 @@ export default function Catalogue({
Iniciar ou continuar ideia nesta localização
</Link>
)}
<p>
<p className="catalogue-intro">
Combine os filtros para encontrar oportunidades publicadas.
Nenhum critério é alargado automaticamente.
</p>
<<<<<<< HEAD
<OpportunitySearchFilters
data={form.data}
references={references}
@@ -583,20 +621,61 @@ export default function Catalogue({
'',
) || undefined
}
=======
<div className="catalogue-workspace">
<aside aria-label="Filtros do catálogo">
<OpportunitySearchFilters
data={form.data}
references={references}
path={territoryPath}
change={(patch) =>
form.setData((d) => ({ ...d, ...patch }))
}
territory={changeTerritory}
submit={() => runSearch()}
errors={form.errors}
clear={() => {
setTerritoryPath([]);
form.setData(
(d) =>
Object.fromEntries(
Object.entries(d).map(
([key, value]) => [
key,
key === 'view'
? 'grid'
: key === 'sort'
? 'recent'
: key ===
'territory_basis'
? 'all'
: Array.isArray(value)
? []
: typeof value ===
'boolean'
? false
: '',
],
),
) as unknown as typeof d,
);
}}
>>>>>>> origin/main
/>
)}
{!searchLoading &&
!searchError &&
['map', 'combined'].includes(view) &&
!error &&
mapContext.length > 0 && (
<section
className={card}
aria-label="Estruturas territoriais consultáveis"
>
<h2 className="text-xl font-bold">
Estruturas territoriais e projetos
</aside>
<section
id="catalogue-results"
className="catalogue-results"
aria-label="Oportunidades publicadas"
aria-busy={searchLoading}
>
<header className="catalogue-results-heading">
<h2>
Oportunidades publicadas
{filters.sector && ` · ${references.sectors.find((sector) => sector.slug === filters.sector)?.name ?? filters.sector}`}
{territorySelection.length > 0 && ` · ${territorySelection.map((territory) => territory.name).join(' / ')}`}
</h2>
<<<<<<< HEAD
<p>
Estas fichas continuam consultáveis
independentemente de terem coordenadas. A lista não
@@ -724,15 +803,133 @@ export default function Catalogue({
>
Ver ficha
</Link>
=======
<SearchState
loading={searchLoading}
error={searchError}
total={results.total}
retry={() => runSearch()}
/>
</header>
{!searchLoading &&
!searchError &&
results.total === 0 &&
selectedStructures.length > 0 && (
<section
className={card}
aria-label="Estrutura seleccionada"
>
<p className="font-semibold">
Sem oportunidades publicadas associadas com
estes filtros.
</p>
<p>
A estrutura continua disponível para
consulta. A sua selecção não confirma
benefícios nem disponibilidade para
investimento.
</p>
{selectedStructures.map((record) =>
record.url ? (
<Link
key={record.id}
className="block underline"
href={searchTerritoryHref(
record.url,
page.url,
)}
>
Consultar ficha: {record.name}
</Link>
) : (
<p key={record.id}>
{record.name} — sem ficha pública
detalhada disponível.
</p>
),
)}
</section>
)}
<div className="catalogue-results-toolbar">
{!searchLoading &&
!searchError &&
results.total === 0 &&
(form.data.province_id ||
form.data.district_id ||
form.data.admin_post_id ||
form.data.locality_id) && (
<button
type="button"
className="underline"
onClick={() => {
setTerritoryPath([]);
form.setData((data) => ({
...data,
province_id: '',
district_id: '',
admin_post_id: '',
locality_id: '',
province: '',
district: '',
geo_location_id: '',
}));
}}
>
Pesquisar noutras províncias (remover localização)
</button>
)}
<div
className="catalogue-view-options"
role="group"
aria-label="Apresentação dos resultados"
>
{[
'grid',
'list',
'map',
'combined',
'corridors',
].map((value, i) => {
const Icon = [
LayoutGrid,
List,
MapIcon,
Columns2,
Route,
][i];
return (
>>>>>>> origin/main
<button
aria-pressed={saved.includes(r.id)}
className="underline"
onClick={() => toggle(r.id)}
key={value}
disabled={
searchLoading || !!searchError
}
aria-pressed={view === value}
className="catalogue-view-button"
onClick={() => {
const params = new URLSearchParams(
query(),
);
params.set('view', value);
router.get(
`${catalogueUrl}?${params}`,
{},
{ preserveScroll: true },
);
}}
>
{saved.includes(r.id)
? 'Retirar favorito'
: 'Guardar favorito'}
<Icon size={16} aria-hidden="true" />
{
[
'Grelha',
'Lista',
'Mapa',
'Lista e mapa',
'Por corredor',
][i]
}
</button>
<<<<<<< HEAD
<label className="flex gap-2">
<input
type="checkbox"
@@ -912,25 +1109,526 @@ export default function Catalogue({
page.url,
)}
key={d.id}
className={card}
=======
);
})}
</div>
<div className="catalogue-utility-actions">
<a
className="catalogue-utility-link"
aria-disabled={searchLoading || !!searchError}
tabIndex={
searchLoading || searchError
? -1
: undefined
}
onClick={(event) => {
if (searchLoading || searchError)
event.preventDefault();
}}
href={'/api/v1/investment/export?' + query()}
>
<span className="text-sm">
{d.content.code} ·{' '}
{d.cluster_id
? 'Cluster'
: d.corridor_id
? 'Corredor'
: 'Projecto âncora'}
</span>
<h3 className="mt-2 font-semibold">
{d.title}
</h3>
<Download size={15} aria-hidden="true" />
Exportar CSV (até 1.000)
</a>
<Link
className="catalogue-utility-link"
href="/investidor/manifestacoes"
>
<Bookmark size={15} aria-hidden="true" />
Favoritos e pedidos
</Link>
))}
</div>
</div>
{!searchLoading &&
!searchError &&
['map', 'combined'].includes(view) && (
<div
role={error ? 'alert' : 'status'}
aria-live="polite"
>
{error ? (
<>
Não foi possível apresentar o mapa. A
lista continua disponível.{' '}
<button
className="underline"
onClick={() =>
setMapAttempt(
(value) => value + 1,
)
}
>
Tentar novamente
</button>
</>
) : points === null ? (
'A carregar o mapa…'
) : (
`${points.length} ${points.length === 1 ? 'local' : 'locais'} no mapa; ${unlocated.length} ${unlocated.length === 1 ? 'oportunidade' : 'oportunidades'} sem coordenadas confirmadas.`
)}
</div>
)}
{!searchLoading &&
!searchError &&
['map', 'combined'].includes(view) &&
!error && <Map points={points} areas={areas} />}
{!searchLoading &&
!searchError &&
['map', 'combined'].includes(view) && (
<TerritoryGeometryPreview
territoryId={
form.data.locality_id ||
form.data.admin_post_id ||
form.data.district_id ||
form.data.province_id
}
/>
)}
{!searchLoading &&
!searchError &&
['map', 'combined'].includes(view) &&
!error &&
mapContext.length > 0 && (
<section
>>>>>>> origin/main
className={card}
aria-label="Estruturas territoriais consultáveis"
>
<h2 className="text-xl font-bold">
Estruturas territoriais e projetos
</h2>
<p>
Estas fichas continuam consultáveis
independentemente de terem coordenadas. A
lista não atribui implantação às
oportunidades nem cria pontos no mapa. Até
30 fichas por conjunto; refine a localização
para pesquisar.
</p>
<ul>
{mapContext.map((record) => (
<li key={record.id}>
<Link
className="underline"
href={record.url}
>
{record.kind}: {record.title}
</Link>
</li>
))}
</ul>
</section>
)}
{!searchLoading &&
!searchError &&
['map', 'combined'].includes(view) &&
truncated && (
<p role="alert">
O mapa atingiu o limite de 1.000 registos;
refine os filtros. A cobertura não está
completa.
</p>
)}
{!searchLoading &&
!searchError &&
view === 'map' &&
unlocated.length > 0 && (
<section
className={card}
aria-label="Investimentos a georreferenciar"
>
<h2 className="text-xl font-bold">
{unlocated.length} investimentos sem
coordenadas de local confirmadas
</h2>
<p>
Continuam nos resultados. Seleccionar uma
província não determina a posição exacta do
investimento.
</p>
<ul>
{unlocated.map((record) => (
<li key={record.id}>
<Link
href={record.url}
className="underline"
>
{record.title}
</Link>{' '}
—{' '}
{record.location ||
'Localização por completar'}
</li>
))}
</ul>
</section>
)}
{view === 'corridors' && !searchLoading && !searchError && (
<OpportunityCorridorGroups
groups={corridorGroups}
selected={investmentSelection}
onSelect={selectInvestment}
onClear={() => {
setInvestmentSelection([]);
sessionStorage.removeItem(
'investment-opportunity-selection',
);
}}
onCompare={(ids) =>
void api(
'/api/v1/investment/compare?' +
ids
.map((id) => 'ids[]=' + id)
.join('&'),
)
.then((data) => setComparison(data.records))
.catch((e) => setError(e.message))
}
/>
)}
{view !== 'corridors' &&
!searchLoading &&
!searchError &&
(view !== 'map' || error || points?.length === 0) && (
<div
className={
view === 'grid'
? 'catalogue-cards catalogue-cards-grid'
: 'catalogue-cards catalogue-cards-list'
}
>
{results.data.map((r) => (
<article
key={r.id}
className="catalogue-opportunity"
>
<div className="catalogue-opportunity-body">
<p className="catalogue-sector">
{r.sector?.name}
</p>
<p className="catalogue-location">
<MapPin
size={15}
aria-hidden="true"
/>
<span>
{r.territorial_summary || [r.province, r.district].filter(Boolean).join(' / ') ||
'Localização não indicada'}
</span>
</p>
<h2 className="text-xl font-bold">
<Link
href={`/oportunidades/${r.slug}${query() ? `?${query()}` : ''}`}
>
{r.title}
</Link>
</h2>
<p className="catalogue-summary">
{r.summary}
</p>
<div className="catalogue-opportunity-facts">
<p className="font-semibold">
{money(r)}
</p>
<p className="catalogue-maturity">
{r.proactive
? r.proactive.group ===
'ideas'
? 'Ideia em preparação'
: r.proactive
.group ===
'needs'
? 'Necessidade identificada'
: 'O que existe'
: r.maturity_label}
</p>
</div>
</div>
<div className="catalogue-opportunity-actions">
<div className="catalogue-opportunity-links">
<Link
className="catalogue-detail-link"
href={`/oportunidades/${r.slug}${query() ? `?${query()}` : ''}`}
>
Ver ficha
<ArrowUpRight
size={16}
aria-hidden="true"
/>
</Link>
<button
aria-pressed={saved.includes(
r.id,
)}
className="catalogue-favourite"
onClick={() => toggle(r.id)}
>
<Bookmark
size={16}
aria-hidden="true"
fill={
saved.includes(r.id)
? 'currentColor'
: 'none'
}
/>
{saved.includes(r.id)
? 'Retirar favorito'
: 'Guardar favorito'}
</button>
</div>
<div className="catalogue-opportunity-choices">
<label>
<input
type="checkbox"
checked={investmentSelection.includes(
r.id,
)}
onChange={(e) =>
selectInvestment(
r.id,
e.target
.checked,
)
}
/>
Escolher para preparar
investimento
</label>
<label>
<input
type="checkbox"
checked={selected.includes(
r.id,
)}
disabled={
!selected.includes(
r.id,
) &&
selected.length >= 4
}
onChange={(e) =>
setSelected(
e.target.checked
? [
...selected,
r.id,
]
: selected.filter(
(
id,
) =>
id !==
r.id,
),
)
}
/>
Comparar
</label>
</div>
</div>
</article>
))}
</div>
)}
{!searchLoading && !searchError && !results.total && (
<p className={card}>
Não há oportunidades publicadas com estes filtros no cadastro consultado.
A informação disponível pode ser incompleta. Pode{' '}
<Link href="/apoio" className="underline">
pedir apoio
</Link>
.
</p>
)}
<nav
aria-label="Paginação"
className={
view === 'corridors'
? 'hidden'
: 'catalogue-pagination'
}
>
{results.prev_page_url && (
<Link href={results.prev_page_url}>Anterior</Link>
)}
{results.next_page_url && (
<Link href={results.next_page_url}>Seguinte</Link>
)}
</nav>
{selected.length > 0 && (
<button
className={button}
onClick={() =>
api(
'/api/v1/investment/compare?' +
selected
.map((id) => 'ids[]=' + id)
.join('&'),
)
.then((data) => setComparison(data.records))
.catch((e) => setError(e.message))
}
>
Comparar {selected.length} oportunidades
</button>
)}
{view !== 'corridors' && !!investmentSelection.length && (
<section className={card}>
<p>
{investmentSelection.length} oportunidades
escolhidas, conservadas ao mudar de página.
</p>
<Link
className={button}
href={
'/investimentos/iniciar?' +
investmentSelection
.map((id) => 'opportunities[]=' + id)
.join('&')
}
>
Preparar investimento com as oportunidades
escolhidas
</Link>
<button
className="ml-4 underline"
onClick={() => {
setInvestmentSelection([]);
sessionStorage.removeItem(
'investment-opportunity-selection',
);
}}
>
Limpar escolha
</button>
</section>
)}
{comparison.length > 0 && (
<div ref={comparisonRef} className="overflow-x-auto">
<table className="w-full border-collapse text-left">
<caption className="p-3 text-left font-bold">
Comparação — montantes nas moedas de origem
</caption>
<thead>
<tr>
<th className="border p-3">Campo</th>
{comparison.map(({ record: r }) => (
<th
key={r.id}
className="border p-3"
>
{r.title}
</th>
))}
</tr>
</thead>
<tbody>
{[
'Sector',
'Província',
'Investimento',
'Maturidade',
'Parceria',
'Impacto',
].map((field, i) => (
<tr key={field}>
<th className="border p-3">
{field}
</th>
{comparison.map(({ record: r }) => (
<td
className="border p-3"
key={r.id}
>
{[
r.sector?.name,
r.province,
money(r),
r.maturity_label,
r.financing_modality,
r.expected_impact,
][i] || 'Não indicado'}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{territorial.length > 0 && (
<section
className="catalogue-territorial"
aria-label="Contexto territorial e projectos âncora"
>
<h2 className="text-2xl font-bold">
Contexto territorial e projectos âncora
</h2>
<p>
Fichas do documento de referência; montantes e
metas conservam a natureza declarada na fonte.
</p>
{[
{
title: 'Corredores',
records: territorial.filter(
(d) => !d.cluster_id && d.corridor_id,
),
},
{
title: 'Clusters',
records: territorial.filter(
(d) => d.cluster_id,
),
},
{
title: 'Projectos âncora',
records: territorial.filter(
(d) => !d.cluster_id && !d.corridor_id,
),
},
]
.filter((group) => group.records.length)
.map((group) => (
<CatalogueFilterGroup
key={group.title}
title={group.title}
count={group.records.length}
>
<div className="catalogue-territorial-links">
{group.records.map((d) => (
<Link
href={searchTerritoryHref(
`/carteira/${d.id}`,
page.url,
)}
key={d.id}
className="catalogue-territorial-link"
>
<span className="text-sm">
{d.content.code} ·{' '}
{d.cluster_id
? 'Cluster'
: d.corridor_id
? 'Corredor'
: 'Projecto âncora'}
</span>
<h3 className="mt-2 font-semibold">
{d.title}
</h3>
</Link>
))}
</div>
</CatalogueFilterGroup>
))}
</section>
)}
<CatalogueGuideLinks articles={guides} />
</section>
)}
<CatalogueGuideLinks articles={guides} />
</div>
</Frame>
);
}