ajuste da estetica em oportunidades de investimento

This commit is contained in:
2026-10-29 12:48:31 +02:00
parent 838ad941a2
commit 996b5b08cd
7 changed files with 1819 additions and 706 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."
}
}
@@ -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
+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',
@@ -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