ajuste da estetica em oportunidades de investimento
This commit is contained in:
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -7103,3 +7103,26 @@ L8 permanece parcial. A avaliação real do percurso proactivo continua a aguard
|
||||
| Pagamento/titular/uso anterior e correspondência temporal | Pagamento em2027 afecto a2026 sem suporte: anexar/identificar correspondência expressa; sem presumir transporte ou utilização zero. |
|
||||
|
||||
**L8 permanece parcial.** Crédito internacional apenas emQA; PEC instalado em`e182d1d7` mantém o âmbito demonstrado. Songo eGurué privados; L3,ISPC,câmbio,L5–L7 eSGFTEMPO preservados. Avaliação de utilização do percurso proactivo continua a aguardar retorno da equipa. Sem instalação, publicação ou nova estimativa global nesta passagem.
|
||||
|
||||
|
||||
## 29/10/2026 — ergonomia do catálogo: inventário antes da verificação
|
||||
|
||||
Origem: pedido expresso da Tempo para analisar alternativas e reorganizar a página sem alterar conteúdos. Escolha de implementação: painel lateral no computador, filtros expansíveis no móvel, grupos por finalidade e cartões com hierarquia visual; corredores, clusters e projectos âncora agrupados em secções expansíveis. Não introduz regra organizacional. Alternativas ponderadas: formulário integral no topo conserva o bloqueio vertical; janela sobreposta afasta o contexto dos resultados; painel responsivo permite consulta lado a lado e mantém os campos montados ao recolher.
|
||||
|
||||
Inventário do concluído consultado antes de nova ronda: selectores públicos independentes dos resultados e provas 22/10; agronegócio e escolha múltipla/dossier 23/10; vista por corredor e percurso proactivo 27/10; registos de reposição territorial e carteira mantidos. Preservar pesquisa combinada AND, filtros simples/avançados e geográficos, classificação histórica, montantes/moedas sem conversão, estados/erros, cinco vistas, CSV, favoritos/pedidos, comparação, escolha para investimento, paginação, guias e todas as fichas territoriais. Não existe eliminação funcional confirmada neste delta de código; não se infere disponibilidade activa pela apresentação local. O título dos resultados e o aviso provincial fornecidos no pedido estavam ausentes no componente local e são recuperados textualmente.
|
||||
|
||||
Comparação delimitada: só componentes de apresentação e CSS; schema, dados, configuração, fontes, relações e permissões sem alteração. Não se ligou à base activa nem se declarou perda por leitura sem actor. A candidata não modifica aceitação anterior ou limitações de outros incrementos. Matriz existente: RF-MN-010, RF-PII-003, RNF-EDU-008 e MASTER-471012ab-0124; sem nova matriz.
|
||||
|
||||
Verificação do delta por realizar: compilação/tipos, apresentação 320/390/768/1024/1440px, teclado, expansão sem perda de campos, vistas, conservação das acções/conteúdo e ausência de transbordo horizontal. Preparação local, sem instalação ou alteração de dados activos.
|
||||
|
||||
### Resultado do delta de apresentação — 29/10/2026
|
||||
|
||||
Concluído localmente. TypeScript e build isolado aprovados, sem instalação. Navegador Chromium com a aplicação React compilada e respostas HTTP de prévia locais, sem base de dados nem pedidos externos:320/390/768/1024/1440px sem transbordo horizontal; campos expandidos cabem no painel em320/390/1024px. Conferidas integralmente as descrições e avisos territoriais dos12cartões da amostra em cada largura, teclado para expandir, erro de moeda, recolher/reabrir/recarregar sem perder pesquisa, selectores hierárquicos, escolha persistente para dossier, limite/comparação de4, cinco vistas e acesso às29fichas(6corredores,8clusters,15âncoras). Zero erros JavaScript. Capturas efectivamente inspeccionadas:1440/1024/390/320px, filtros expandidos390px e lista1440px.
|
||||
|
||||
Comparação estrutural:33blocos preexistentes de lógica da página e5auxiliares dos filtros preservados; JSX/callbacks revistos no delta e nas interacções acima. Os avisos de ausência, a consulta pública e as ligações anteriores mantêm-se. Favoritos autenticados, backend de pesquisa/mapa, guias e permissões conservam os contratos/provas anteriores; esta prévia não é nova demonstração desses serviços nem da disponibilidade activa. Mapa exercitado com ausência explícita de coordenadas, sem criar pontos.
|
||||
|
||||
Recibo:docs/investmoz/unification/catalogue-layout-qa-20261029.json. Evidência local:outputs/catalogue-layout-20261029/. Nenhuma redução funcional pretendida; todas as fichas estão nos grupos expansíveis. Sem publicação, dados alterados, migrações, configuração ou permissões novas. Não encerra os critérios globais das quatro linhas da matriz.
|
||||
|
||||
Ajuste solicitado pela Tempo em29/10/2026: o controlo dos filtros avançados alterna entre “Mais filtros” (recolhido) e “Menos filtros” (aberto). O estado segue a abertura nativa da secção; os campos e escolhas continuam montados. Prova dirigida no componente React real em Chromium: clique/teclado, ambos os textos, valor editado e contagem conservados; sem errosJavaScript. Recibo local:outputs/catalogue-layout-20261029/toggle-receipt.json. Sem nova ronda global, instalação ou alteração de dados.
|
||||
|
||||
Ajuste visual solicitado pela Tempo em29/10/2026: títulos, cabeçalhos, rótulos de grupos e conteúdo do catálogo em negrito apresentados em vermelho; os botões com fundo colorido mantêm texto legível. CSS validado por análise sintáctica; alteração local de apresentação, sem instalação.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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',
|
||||
@@ -50,14 +53,19 @@ const labels: Record<string, string> = {
|
||||
publication_state: 'Critério herdado',
|
||||
};
|
||||
const specialisedEmpty: Record<string, string> = {
|
||||
corridor_id: 'Não existem corredores públicos cadastrados para esta localização.',
|
||||
cluster_id: 'Não existem clusters públicos cadastrados para esta localização.',
|
||||
corridor_id:
|
||||
'Não existem corredores públicos cadastrados para esta localização.',
|
||||
cluster_id:
|
||||
'Não existem clusters públicos cadastrados para esta localização.',
|
||||
zee_id: 'Não existem ZEE públicas cadastradas para esta localização.',
|
||||
zfi_id: 'Não existem ZFI públicas cadastradas para esta localização.',
|
||||
park_id: 'Não existem parques industriais públicos cadastrados para esta localização.',
|
||||
project_id: 'Não existem projectos âncora públicos cadastrados para esta localização.',
|
||||
park_id:
|
||||
'Não existem parques industriais públicos cadastrados para esta localização.',
|
||||
project_id:
|
||||
'Não existem projectos âncora públicos cadastrados para esta localização.',
|
||||
subsector_id: 'Não existem actividades disponíveis no catálogo público.',
|
||||
chain_id: 'Não existem cadeias de valor públicas cadastradas para esta localização.',
|
||||
chain_id:
|
||||
'Não existem cadeias de valor públicas cadastradas para esta localização.',
|
||||
};
|
||||
export default function OpportunitySearchFilters({
|
||||
data,
|
||||
@@ -78,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 }[],
|
||||
@@ -87,8 +99,14 @@ export default function OpportunitySearchFilters({
|
||||
<select
|
||||
className={input}
|
||||
aria-label={labels[key]}
|
||||
aria-describedby={specialisedEmpty[key] ? `filter-help-${key}` : undefined}
|
||||
disabled={Boolean(specialisedEmpty[key]) && choices.length === 0 && !data[key]}
|
||||
aria-describedby={
|
||||
specialisedEmpty[key] ? `filter-help-${key}` : undefined
|
||||
}
|
||||
disabled={
|
||||
Boolean(specialisedEmpty[key]) &&
|
||||
choices.length === 0 &&
|
||||
!data[key]
|
||||
}
|
||||
value={String(data[key] ?? '')}
|
||||
onChange={(e) =>
|
||||
change({
|
||||
@@ -100,9 +118,13 @@ export default function OpportunitySearchFilters({
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{data[key] && !choices.some((o) => o.value === String(data[key])) && (
|
||||
<option value={String(data[key])}>Selecção indisponível neste contexto — remova o filtro</option>
|
||||
)}
|
||||
{data[key] &&
|
||||
!choices.some((o) => o.value === String(data[key])) && (
|
||||
<option value={String(data[key])}>
|
||||
Selecção indisponível neste contexto — remova o
|
||||
filtro
|
||||
</option>
|
||||
)}
|
||||
{choices.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
@@ -114,7 +136,9 @@ export default function OpportunitySearchFilters({
|
||||
{choices.length === 0
|
||||
? specialisedEmpty[key]
|
||||
: `${choices.length} opções públicas disponíveis, mesmo sem oportunidades associadas.`}
|
||||
{key === 'subsector_id' && choices.length > 0 && ' Classificação de actividades; não confirma a existência de investimentos nesta localização.'}
|
||||
{key === 'subsector_id' &&
|
||||
choices.length > 0 &&
|
||||
' Classificação de actividades; não confirma a existência de investimentos nesta localização.'}
|
||||
</p>
|
||||
)}
|
||||
{hint && <p className="text-sm text-slate-600">{hint}</p>}
|
||||
@@ -200,306 +224,394 @@ 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"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Field label="O que procura">
|
||||
<input
|
||||
className={input}
|
||||
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>
|
||||
<fieldset className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<legend className="mb-2 font-semibold">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>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<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.',
|
||||
<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>
|
||||
)}
|
||||
</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.
|
||||
</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>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user