Unify institution profiles and reduce primary menu to six groups

This commit is contained in:
2026-11-11 12:11:52 +02:00
parent ee23a2cc2c
commit eca6719135
15 changed files with 853 additions and 296 deletions
+5
View File
@@ -1,3 +1,8 @@
## Uniformização pública e menu — decisão expressa do proprietário,11/11/2026
Lista e detalhe das instituições financeiras devem usar a mesma apresentação:descrição única,categoria/tipo sem repetição,produtos/serviços,contactos e moradas úteis apenas quando disponíveis. Não apresentar secções vazias,avisos genéricos repetidos,notas de publicação/importação,preços antigos por reconfirmar ou a mesma informação várias vezes. Deduplicar apenas a apresentação,conservando dados e fontes no editor. Usar Quorus e Moza Banco como exemplos de conferência.
Menu principal:Início,Descoberta,Realização,Suporte,Sobre nós,Mais. Descoberta:Concursos e anúncios,Localizações,Instituições financeiras,Catálogos relacionados. Realização:Iniciar investimento,Licenciamento,Acesso à terra. Suporte:Biblioteca,Legislação,Serviços ao investidor,Acompanhamento do projecto,Apoio ao investidor. Preservar conteúdo deSobre nós/Mais,contexto dos dossiers e permissões. Escolha de apresentação:acessos de trabalho antes no nível principal passam paraMais,sem novas concessões.
## Comunicação pública — orientação expressa do proprietário,11/11/2026
Apresentar conteúdos públicos com linguagem profissional,clara e apelativa,centrada na entidade,oportunidade,produtos e serviços úteis ao investidor. Não usar descrições de importação,cadastro,fichas,ficheiros ou documentos geradores como apresentação comercial. Preservar as fontes,versões e rastreabilidade no cadastro/editor;ligações institucionais e datas úteis podem ser consultadas separadamente. Não inventar ofertas,condições,disponibilidade,cobertura ou garantias para preencher lacunas. Esta orientação aplica-se transversalmente aos conteúdos do portal.
@@ -1,3 +1,13 @@
## 11/11/2026 — uniformização Quorus/Moza e menu de seis grupos em QA
Correcção orientada pelos exemplos expressos do proprietário:lista e detalhe reutilizam agora **o mesmo componente público**. Descrição única,categoria/tipo numa linha,produtos/serviços uma vez,contactos e moradas apenas quando disponíveis. Quorus:descrição de corretagem eSeguros uma vez;sem secções vazias ou avisos genéricos. Moza:descrição paraPME,CréditoPME/instrumentos uma vez,umtelefone e umamorada;sem notas de publicação,preços antigos,comissões/garantias por reconfirmar,entidade responsável repetida,sectores/cobertura desconhecidos ou referências institucionais duplicadas. Não inventa dados nem altera condições guardadas. Ligações dos produtos/contactos permanecem acessíveis uma vez;moradas diferentes são preservadas.
Menu principal exactamente **Início–Descoberta–Realização–Suporte–Sobre nós–Mais**. Concursos movido paraDescoberta;Biblioteca eInstituições financeiras com os nomes pedidos;conteúdos deSobre nós/Mais preservados. Acesso de trabalho deslocado paraMais,sem perder permissões;conta/contexto/licenciamentoS08 e filtros preservados. Instruções permanentes actualizadas com o padrão pedido.
**QA:**33verificações dirigidas aprovadas,com três casos novos:exemplos públicos exactos nas duas páginas,deduplicação conservadora e cabeçalho real com seis entradas em quatro combinações(público/autenticado,móvel/computador). Reutilizadas provas dos fluxos de interesse/dossier/editorial e das permissões,não reexecutadas na base. Fontes de270instituições intactas,sem migração/importação/republicação. Artefactos `outputs/funding-uniform-menu-20261111/verification.json` eHTMLs reais emSSR. Não é conferência visual de navegador;tipos/lint/compilação aprovados;pacote Git com aplicação/reversão conferidas no fecho.
**Estado:** implementado em QA,não instalado;homologação mantém `a59142ca` e270instituições publicadas. Restam instalação e conferência visual;não requer seleccionar/publicar novamente instituições. Restante saldo de módulos e fiscal preservado. Próximo trabalho:entregar este conjunto compatível e continuar os resíduos de implementação acordados.
## 11/11/2026 — apresentação financeira profissional instalada ema59142ca
Instalação expressamente autorizada,executada pelo processo Git existente e conferida às11:38:54Africa/Johannesburg(09:38:54UTC): `a59142caa2fc277975be82fb550223ff7d949594`. O directório `/fundos` e o detalhe apresentam descrição e produtos/serviços conhecidos,sem usar texto de importação,fichas ou documento gerador como apresentação. Descrições específicas são preservadas;resumo genérico exacto passa a uma descrição pela natureza publicada. Não são inventadas ofertas para instituições sem produtos específicos identificados. Metadados/fontes/histórico mantidos no editor;ligações institucionais e datas úteis acessíveis separadamente. Orientação transversal registada emAGENTS.
@@ -1,3 +1,13 @@
## 11/11/2026 — uniformização Quorus/Moza e menu de seis grupos em QA
Correcção orientada pelos exemplos expressos do proprietário:lista e detalhe reutilizam agora **o mesmo componente público**. Descrição única,categoria/tipo numa linha,produtos/serviços uma vez,contactos e moradas apenas quando disponíveis. Quorus:descrição de corretagem eSeguros uma vez;sem secções vazias ou avisos genéricos. Moza:descrição paraPME,CréditoPME/instrumentos uma vez,umtelefone e umamorada;sem notas de publicação,preços antigos,comissões/garantias por reconfirmar,entidade responsável repetida,sectores/cobertura desconhecidos ou referências institucionais duplicadas. Não inventa dados nem altera condições guardadas. Ligações dos produtos/contactos permanecem acessíveis uma vez;moradas diferentes são preservadas.
Menu principal exactamente **Início–Descoberta–Realização–Suporte–Sobre nós–Mais**. Concursos movido paraDescoberta;Biblioteca eInstituições financeiras com os nomes pedidos;conteúdos deSobre nós/Mais preservados. Acesso de trabalho deslocado paraMais,sem perder permissões;conta/contexto/licenciamentoS08 e filtros preservados. Instruções permanentes actualizadas com o padrão pedido.
**QA:**33verificações dirigidas aprovadas,com três casos novos:exemplos públicos exactos nas duas páginas,deduplicação conservadora e cabeçalho real com seis entradas em quatro combinações(público/autenticado,móvel/computador). Reutilizadas provas dos fluxos de interesse/dossier/editorial e das permissões,não reexecutadas na base. Fontes de270instituições intactas,sem migração/importação/republicação. Artefactos `outputs/funding-uniform-menu-20261111/verification.json` eHTMLs reais emSSR. Não é conferência visual de navegador;tipos/lint/compilação aprovados;pacote Git com aplicação/reversão conferidas no fecho.
**Estado:** implementado em QA,não instalado;homologação mantém `a59142ca` e270instituições publicadas. Restam instalação e conferência visual;não requer seleccionar/publicar novamente instituições. Restante saldo de módulos e fiscal preservado. Próximo trabalho:entregar este conjunto compatível e continuar os resíduos de implementação acordados.
## 11/11/2026 — apresentação financeira profissional instalada ema59142ca
Instalação expressamente autorizada,executada pelo processo Git existente e conferida às11:38:54Africa/Johannesburg(09:38:54UTC): `a59142caa2fc277975be82fb550223ff7d949594`. O directório `/fundos` e o detalhe apresentam descrição e produtos/serviços conhecidos,sem usar texto de importação,fichas ou documento gerador como apresentação. Descrições específicas são preservadas;resumo genérico exacto passa a uma descrição pela natureza publicada. Não são inventadas ofertas para instituições sem produtos específicos identificados. Metadados/fontes/histórico mantidos no editor;ligações institucionais e datas úteis acessíveis separadamente. Orientação transversal registada emAGENTS.
File diff suppressed because one or more lines are too long
@@ -1,3 +1,13 @@
## 11/11/2026 — uniformização Quorus/Moza e menu de seis grupos em QA
Correcção orientada pelos exemplos expressos do proprietário:lista e detalhe reutilizam agora **o mesmo componente público**. Descrição única,categoria/tipo numa linha,produtos/serviços uma vez,contactos e moradas apenas quando disponíveis. Quorus:descrição de corretagem eSeguros uma vez;sem secções vazias ou avisos genéricos. Moza:descrição paraPME,CréditoPME/instrumentos uma vez,umtelefone e umamorada;sem notas de publicação,preços antigos,comissões/garantias por reconfirmar,entidade responsável repetida,sectores/cobertura desconhecidos ou referências institucionais duplicadas. Não inventa dados nem altera condições guardadas. Ligações dos produtos/contactos permanecem acessíveis uma vez;moradas diferentes são preservadas.
Menu principal exactamente **Início–Descoberta–Realização–Suporte–Sobre nós–Mais**. Concursos movido paraDescoberta;Biblioteca eInstituições financeiras com os nomes pedidos;conteúdos deSobre nós/Mais preservados. Acesso de trabalho deslocado paraMais,sem perder permissões;conta/contexto/licenciamentoS08 e filtros preservados. Instruções permanentes actualizadas com o padrão pedido.
**QA:**33verificações dirigidas aprovadas,com três casos novos:exemplos públicos exactos nas duas páginas,deduplicação conservadora e cabeçalho real com seis entradas em quatro combinações(público/autenticado,móvel/computador). Reutilizadas provas dos fluxos de interesse/dossier/editorial e das permissões,não reexecutadas na base. Fontes de270instituições intactas,sem migração/importação/republicação. Artefactos `outputs/funding-uniform-menu-20261111/verification.json` eHTMLs reais emSSR. Não é conferência visual de navegador;tipos/lint/compilação aprovados;pacote Git com aplicação/reversão conferidas no fecho.
**Estado:** implementado em QA,não instalado;homologação mantém `a59142ca` e270instituições publicadas. Restam instalação e conferência visual;não requer seleccionar/publicar novamente instituições. Restante saldo de módulos e fiscal preservado. Próximo trabalho:entregar este conjunto compatível e continuar os resíduos de implementação acordados.
## 11/11/2026 — apresentação financeira profissional instalada ema59142ca
Instalação expressamente autorizada,executada pelo processo Git existente e conferida às11:38:54Africa/Johannesburg(09:38:54UTC): `a59142caa2fc277975be82fb550223ff7d949594`. O directório `/fundos` e o detalhe apresentam descrição e produtos/serviços conhecidos,sem usar texto de importação,fichas ou documento gerador como apresentação. Descrições específicas são preservadas;resumo genérico exacto passa a uma descrição pela natureza publicada. Não são inventadas ofertas para instituições sem produtos específicos identificados. Metadados/fontes/histórico mantidos no editor;ligações institucionais e datas úteis acessíveis separadamente. Orientação transversal registada emAGENTS.
+156 -126
View File
@@ -1,10 +1,17 @@
import {
ClassificationFields,
ClassificationSummary,
evidenceLabels,
type Classification,
} from '@/components/funding-classification';
import {fundingPublicPresentation} from '@/lib/funding-public-presentation';
import {
fundingPublicPresentation,
publicInstitutionContacts,
publicInstitutionLocations,
} from '@/lib/funding-public-presentation';
import {
financialCategories,
financialTypes,
} from '@/components/funding-classification';
import { card, input, button } from '@/pages/investment/shared';
export type FundingSource = {
@@ -552,135 +559,158 @@ export function InstitutionFields({
</section>
);
}
function Source({value}:{value:FundingSource}){return <>{value.source_url&&<details><summary>Informação institucional</summary><a className="underline" href={value.source_url} target="_blank" rel="noopener noreferrer">Consultar informação da entidade</a><p>Consulta em {value.checked_on}.</p></details>}{value.evidence_status==='historical'&&<p>Informação histórica.</p>}{value.evidence_status==='conflict'&&<p>Informação por esclarecer junto da instituição.</p>}</>;}
export function InstitutionView({ value }: { value: InstitutionDetails }) {
export function InstitutionView({
value,
compact = false,
}: {
value: InstitutionDetails & { title?: string; summary?: string };
compact?: boolean;
}) {
const presentation = fundingPublicPresentation({
...value,
title: value.title ?? '',
summary: value.summary ?? '',
kind: 'institution',
}),
contacts = publicInstitutionContacts(value.contacts),
locations = publicInstitutionLocations(value.locations);
const category = (value.institution_categories ?? [])
.map((k) => financialCategories[k])
.filter(Boolean)
.join(' · '),
types = (value.institution_types ?? [])
.map((k) => financialTypes[k])
.filter(Boolean)
.join(' · ');
const urls = new Set<string>(
compact
? []
: presentation.products
.map((p) => p.url)
.filter((url): url is string => !!url),
);
const sites = contacts
.filter((c) => c.kind === 'website')
.filter((c) => {
if (urls.has(c.value)) return false;
urls.add(c.value);
return true;
});
return (
<section className={card}>
<h2>Instituição financeira</h2>
<ClassificationSummary value={value} />
{!value.institution_types?.length && (
<section className="space-y-4" aria-label="Informação da instituição">
{!compact && (
<h2 className="text-lg font-semibold">
Instituição financeira
</h2>
)}
<div>
<h3 className="font-semibold">Descrição</h3>
<p>{presentation.description}</p>
</div>
{(category || types) && (
<p>
Natureza:{' '}
{
(
{
bank: 'Banco',
credit: 'Instituição de crédito',
microfinance: 'Microfinanças',
investment_fund: 'Fundo de investimento',
development:
'Agência de desenvolvimento/financiamento',
other: 'Outra natureza documentada',
unknown: 'Por confirmar',
} as Record<string, string>
)[value.institution_nature]
}
{category}
{category && types ? ': ' : ''}
{types}
</p>
)}
<h3>Contactos</h3>
{value.contacts.length === 0 && <p>Contactos por confirmar.</p>}
{value.contacts.map((c, i) => (
<article key={i}>
<p>
{c.kind === 'website' ? (
<a
className="underline"
href={c.value}
target="_blank"
rel="noopener noreferrer"
>
{c.value}
</a>
) : (
c.value
)}
</p>
<Source value={c} />
</article>
))}
<h3>Sede e balcões — presença física</h3>
{value.locations.length === 0 && (
<p>Localização física por confirmar.</p>
{['historical', 'merged'].includes(
value.institution_status ?? '',
) && <p>Instituição histórica ou integrada noutra entidade.</p>}
<div>
<h3 className="font-semibold">Produtos e serviços</h3>
{presentation.products.length ? (
<ul className="list-disc space-y-2 pl-5">
{presentation.products.map((p) => (
<li key={p.title}>
{!compact && p.url ? (
<a
className="font-semibold underline"
href={p.url}
target="_blank"
rel="noopener noreferrer"
>
{p.title}
</a>
) : (
<strong>{p.title}</strong>
)}
{p.detail ? ': ' + p.detail : ''}
</li>
))}
</ul>
) : presentation.services.length ? (
<p>{presentation.services.join(' · ')}</p>
) : null}
</div>
{!compact &&
(sites.length > 0 ||
contacts.some((c) => c.kind !== 'website')) && (
<div>
<h3 className="font-semibold">Contactos</h3>
<ul className="space-y-1">
{sites.map((c) => (
<li key={c.value}>
<a
className="underline"
href={c.value}
target="_blank"
rel="noopener noreferrer"
>
Website
</a>
</li>
))}
{contacts
.filter((c) => c.kind !== 'website')
.map((c) => (
<li key={c.kind + c.value}>
{c.kind === 'email' ? (
<a
className="underline"
href={'mailto:' + c.value}
>
{c.value}
</a>
) : c.kind === 'phone' ? (
<a
className="underline"
href={
'tel:' +
c.value.replace(
/[^+0-9]/g,
'',
)
}
>
{c.value}
</a>
) : (
c.value
)}
</li>
))}
</ul>
</div>
)}
{!compact && locations.length > 0 && (
<div>
<h3 className="font-semibold">Localização</h3>
<ul className="space-y-1">
{locations.map((l, i) => (
<li key={i}>
{l.role === 'headquarters'
? 'Sede: '
: l.role === 'branch'
? 'Balcão: '
: ''}
{l.address}
{l.hours ? ' · ' + l.hours : ''}
</li>
))}
</ul>
</div>
)}
{value.locations.map((l, i) => (
<article key={i}>
<p>
{l.role === 'headquarters'
? 'Sede'
: l.role === 'branch'
? 'Balcão'
: 'Ponto de contacto institucional'}
: {l.label && !/^(Morada|Localização).*(documento|fonte)/i.test(l.label) ? l.label+' · ' : ''}{l.address}
</p>
<p>
{l.territory_path?.map((n) => n.name).join(' / ') ||
'Localização detalhada a confirmar'}
</p>
{l.location_text && (
<p>Local indicado: {l.location_text}</p>
)}
{l.hours && (
<p>
Horário de atendimento:{' '}
{l.hours}
</p>
)}
<Source value={l} />
</article>
))}
<h3>Produtos e serviços</h3>
{value.products.length === 0 && (
<p>
{fundingPublicPresentation({...value,title:"",kind:"institution",summary:""}).services.join(" · ") || "Consulte as soluções disponíveis junto da instituição."}
</p>
)}
{value.products.map((p) => (
<article className={card} key={p.code}>
<h4>{p.title}</h4>
<p>{p.instrument}</p>
<p>
Cobertura do financiamento:{' '}
{p.coverage === 'national'
? 'Nacional, segundo a fonte'
: p.coverage === 'territorial'
? 'Locais documentados'
: 'Por confirmar'}{' '}
· {p.coverage_text}
</p>
{p.territory_paths?.map((path, i) => (
<p key={i}>{path.map((n) => n.name).join(' / ')}</p>
))}
<p>Condições: {p.conditions || 'Por confirmar'}</p>
<p>
Montantes/moeda/prazos: {p.amounts || 'Por confirmar'}
</p>
<p>
Disponibilidade:{' '}
{p.availability === 'unknown'
? 'Por confirmar'
: p.availability === 'closed'
? 'Encerrada segundo a fonte'
: 'Oferta identificada na fonte; confirmar condições actuais com a instituição'}
</p>
{p.contact_url && (
<a
href={p.contact_url}
className="underline"
target="_blank"
rel="noopener noreferrer"
>
Contactar/consultar o produto
</a>
)}
<Source value={p} />
</article>
))}
<p>
Uma referência não é elegibilidade confirmada, aprovação nem
garantia de financiamento. O portal não envia pedidos ou dados à
instituição.
</p>
</section>
);
}
@@ -200,11 +200,9 @@ export function PortalGroupedMenu() {
{groups
.filter((group) =>
[
'procurement-area',
'discovery',
'realization',
'support',
'work',
].includes(group.id),
)
.map((group) => (
@@ -260,7 +258,9 @@ export function PortalGroupedMenu() {
<DropdownMenuTrigger
className="tempo-nav-trigger"
data-current-group={
institutionalCurrent || undefined
institutionalCurrent ||
current === 'work' ||
undefined
}
>
Mais
@@ -310,6 +310,17 @@ export function PortalGroupedMenu() {
</DropdownMenuItem>
),
)}
{groups
.filter((group) => group.id === 'work')
.map((group) => (
<Fragment key={group.id}>
<DropdownMenuSeparator />
<DropdownMenuLabel>
{t(group.label)}
</DropdownMenuLabel>
{items(group.children ?? [])}
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
</li>
+100 -4
View File
@@ -1,4 +1,4 @@
type PublicFunding = {
export type PublicFunding = {
title: string;
kind: string;
summary: string;
@@ -8,6 +8,8 @@ type PublicFunding = {
title: string;
instrument?: string | null;
availability?: string;
contact_url?: string | null;
source_url?: string | null;
}[];
};
const descriptions: Record<string, string> = {
@@ -45,9 +47,10 @@ const services: Record<string, string> = {
};
export function fundingPublicPresentation(p: PublicFunding) {
const generic =
!p.summary.trim() ||
p.summary.trim() ===
'Instituição identificada no documento fornecido. Categoria, localização e contactos são informativos; produtos, disponibilidade e condições não documentados permanecem por confirmar.';
const description = generic
'Instituição identificada no documento fornecido. Categoria, localização e contactos são informativos; produtos, disponibilidade e condições não documentados permanecem por confirmar.';
let description = generic
? (descriptions[p.institution_types?.[0] ?? ''] ??
'Entidade do sector financeiro.')
: p.summary
@@ -58,9 +61,31 @@ export function fundingPublicPresentation(p: PublicFunding) {
.replace(/Público\/finalidade:/g, 'Soluções para')
.replace(/\.\./g, '.')
.trim();
const repeatedInstrument = (p.products ?? []).some(
(product) =>
product.instrument?.trim() &&
description.startsWith(product.instrument.trim()),
);
if (repeatedInstrument) {
const audience = description.match(
/Soluções para\s+(.+?)(?:\.|$)/i,
)?.[1];
description = audience
? 'Soluções financeiras para ' +
audience.charAt(0).toLowerCase() +
audience.slice(1) +
'.'
: (descriptions[p.institution_types?.[0] ?? ''] ?? description);
}
const products = (p.products ?? [])
.filter((x) => x.availability !== 'closed')
.map((x) => ({ title: x.title, detail: x.instrument?.trim() ?? '' }));
.map((x) => ({
title: x.title.startsWith(p.title + ' — ')
? x.title.slice(p.title.length + 3)
: x.title,
detail: x.instrument?.trim() ?? '',
url: x.contact_url || x.source_url || null,
}));
return {
description,
products,
@@ -73,3 +98,74 @@ export function fundingPublicPresentation(p: PublicFunding) {
],
};
}
const normal = (s: string) =>
s
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
export function publicInstitutionContacts<
T extends { kind: string; value: string },
>(rows: T[]) {
const seen = new Set<string>();
return rows.flatMap((row) => {
const value =
row.kind === 'phone'
? row.value.replace(/\s*Não encontrado\.?$/i, '').trim()
: row.value.trim();
if (!value) return [];
const key =
row.kind +
':' +
(row.kind === 'phone'
? value.replace(/\D/g, '')
: value.toLowerCase().replace(/\/$/, ''));
if (seen.has(key)) return [];
seen.add(key);
return [{ ...row, value }];
});
}
export function publicInstitutionLocations<
T extends {
role: string;
address?: string | null;
location_text?: string;
territory_path?: { name: string }[];
},
>(rows: T[]) {
const result: T[] = [];
for (const row of [...rows].sort(
(a, b) =>
(a.role === 'headquarters' ? 0 : 1) -
(b.role === 'headquarters' ? 0 : 1),
)) {
if (!row.address?.trim()) continue;
const address = normal(row.address);
const duplicate = result.some((previous) => {
const other = normal(previous.address ?? '');
if (address === other) return true;
const short = address.length < other.length ? address : other,
long = address.length < other.length ? other : address;
if (!long.startsWith(short + ' ')) return false;
const rest = long.slice(short.length).trim();
const localities = [
row.location_text ?? '',
previous.location_text ?? '',
...(row.territory_path ?? []).map((x) => x.name),
...(previous.territory_path ?? []).map((x) => x.name),
].map(normal);
return (
localities.includes(rest) ||
localities.some(
(x) =>
x.replace(/^cidade de /, '') ===
rest.replace(/^cidade de /, ''),
)
);
});
if (!duplicate) result.push(row);
}
return result;
}
+27 -26
View File
@@ -478,40 +478,41 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
: null)
: null;
return uniqueNavigationDestinations([
...(access.procurement
? [
{
id: 'procurement-area',
label: 'Concursos e anúncios',
children: [
link(
'procurement',
'Consultar concursos e anúncios',
'/concursos',
),
...(access.authenticated
? [
link(
'procurement-watched',
'Os concursos que acompanho',
'/concursos/acompanhar',
),
]
: []),
],
},
]
: []),
{
id: 'discovery',
label: 'Descoberta',
children: [
...(access.procurement
? [
{
id: 'procurement-area',
label: 'Concursos e anúncios',
children: [
link(
'procurement',
'Consultar concursos e anúncios',
'/concursos',
),
...(access.authenticated
? [
link(
'procurement-watched',
'Os concursos que acompanho',
'/concursos/acompanhar',
),
]
: []),
],
},
]
: []),
link('locations', 'Localizações', '/oportunidades?view=map'),
...(access.funding
? [
link(
'funding',
'Instituições financeiras e seguros',
'Instituições financeiras',
'/fundos',
),
...(access.authenticated
@@ -580,7 +581,7 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
id: 'support',
label: 'Suporte',
children: [
link('documents', 'Biblioteca de documentos', '/documentos'),
link('documents', 'Biblioteca', '/documentos'),
link('legislation', 'Legislação', '/legislacao'),
link(
'services',
+35 -26
View File
@@ -1,4 +1,7 @@
import {fundingPublicPresentation} from '@/lib/funding-public-presentation';
import {
InstitutionView,
type InstitutionDetails,
} from '@/components/funding-institution';
import {
financialCategories,
financialTypes,
@@ -68,8 +71,9 @@ export default function Index({
A minha selecção
</Link>
<p>
Encontre bancos, seguradoras, fundos e outros parceiros financeiros.
Explore as soluções e os contactos adequados ao seu investimento.
Encontre bancos, seguradoras, fundos e outros parceiros
financeiros. Explore as soluções e os contactos adequados ao seu
investimento.
</p>
<form
className={`${card} flex flex-wrap gap-4`}
@@ -253,10 +257,9 @@ export default function Index({
</label>
</form>
<p className="text-sm">
Sector e local filtram o âmbito geral do programa. Cada chamada
pode ter uma cobertura mais restrita. O estado indica que existe
pelo menos uma janela nesse estado; não confirma elegibilidade
nem candidatura aberta no local escolhido.
Explore as instituições e soluções adequadas ao seu
investimento. Use os filtros para encontrar a informação de que
precisa.
</p>
{items.data.length === 0 && (
<p>
@@ -274,27 +277,33 @@ export default function Index({
{item.payload.title}
</Link>
</h2>
<p>{fundingPublicPresentation(item.payload).description}</p>
{item.payload.kind==='institution' && <div><h3 className="font-semibold">Produtos e serviços</h3>{fundingPublicPresentation(item.payload).products.length ? <ul>{fundingPublicPresentation(item.payload).products.map(p=><li key={p.title}>{p.title}{p.detail ? ': '+p.detail : ''}</li>)}</ul> : <p>{fundingPublicPresentation(item.payload).services.join(' · ') || 'Consulte as soluções disponíveis junto da instituição.'}</p>}</div>}
<p>
{(item.payload.institution_categories ?? [])
.map((k) => financialCategories[k] ?? k)
.join(' · ')}{' '}
·{' '}
{(item.payload.institution_types ?? [])
.map((k) => financialTypes[k] ?? k)
.join(' · ')}
</p>
{item.payload.kind!=='institution' && <p>Entidade gestora: {item.payload.manager_name}</p>}
{Object.entries(item.filter_explanation ?? {}).map(
([key, message]) => (
<p key={key}>{message}</p>
),
{item.payload.kind === 'institution' ? (
<InstitutionView
compact
value={
item.payload as InstitutionDetails & {
title: string;
summary: string;
}
}
/>
) : (
<>
<p>{item.payload.summary}</p>
<p>Entidade gestora: {item.payload.manager_name}</p>
{Object.entries(item.filter_explanation ?? {}).map(
([key, message]) => (
<p key={key}>{message}</p>
),
)}
</>
)}
{item.payload.kind==='institution' && item.payload.windows.length===0 ? null : item.payload.windows.length === 0 ? (
{item.payload.kind === 'institution' &&
item.payload.windows.length === 0 ? null : item.payload
.windows.length === 0 ? (
<p>
Não existe uma janela de candidatura confirmada
para este programa.
Não existe uma janela de candidatura confirmada para
este programa.
</p>
) : (
<ul>
+124 -85
View File
@@ -1,4 +1,4 @@
import {fundingPublicPresentation} from '@/lib/funding-public-presentation';
import { fundingPublicPresentation } from '@/lib/funding-public-presentation';
import {
InstitutionView,
type InstitutionDetails,
@@ -41,9 +41,20 @@ export default function Show({
>
Voltar aos fundos e programas
</Link>
<section><h2 className="text-xl font-semibold">Descrição</h2><p>{fundingPublicPresentation(payload).description}</p></section>
{payload.kind === 'institution' && (
<InstitutionView value={payload as InstitutionDetails} />
{payload.kind === 'institution' ? (
<InstitutionView
value={
payload as InstitutionDetails & {
title: string;
summary: string;
}
}
/>
) : (
<section>
<h2 className="text-xl font-semibold">Descrição</h2>
<p>{fundingPublicPresentation(payload).description}</p>
</section>
)}
{authenticated && (
<FundingDossierChoice
@@ -55,90 +66,121 @@ export default function Show({
destinations={destinations}
/>
)}
<p>
Entidade responsável: {payload.manager_name}.
</p>
{(payload.territory_paths ?? []).map((path, index) => (
<p key={index}>
Local abrangido: {path.map((node) => node.name).join(' / ')}
</p>
))}
<p>
Sectores:{' '}
{payload.sectors_text ||
(payload.sectors_scope === 'all'
? 'Todos, segundo a fonte'
: payload.sectors_scope === 'unknown'
? 'Por confirmar'
: (payload.sector_snapshots ?? [])
.map((sector) => sector.name)
.join(', '))}
</p>
<p>
{payload.coverage === 'national'
? 'Cobertura nacional'
: payload.coverage === 'territorial'
? 'Cobertura territorial'
: 'Cobertura por confirmar'}
{payload.coverage_text ? ` — ${payload.coverage_text}` : ''}
</p>
{payload.source_url && <details><summary>Informação institucional</summary><a className="underline" href={payload.source_url} target="_blank" rel="noopener noreferrer">Consultar informação da entidade</a><p>Actualização consultada em {payload.source_checked_on}.</p></details>}
{(payload.kind!=='institution'||payload.windows.length>0) && ( <section className={card}>
<h2 className="text-xl font-semibold">
Janelas de candidatura
</h2>
{payload.windows.length === 0 && (
<p>
Não há uma janela de candidatura confirmada para este programa.
</p>
)}
{payload.windows.map((item) => (
<article key={item.code} className="my-4">
<h3 className="font-semibold">{item.title}</h3>
<p>{windowLabels[item.state]}</p>
{item.start_on && <p>Abertura: {item.start_on}</p>}
{item.end_on && <p>Data-limite: {item.end_on}</p>}
{item.coverage_text && <p>{item.coverage_text}</p>}
<p>
{item.coverage === 'programme'
? 'Mesma cobertura do programa'
: item.coverage === 'national'
? 'Chamada nacional'
: item.coverage === 'unknown'
? 'Cobertura da chamada por confirmar'
: 'Chamada territorial'}
{payload.kind !== 'institution' && (
<>
{' '}
<p>Entidade responsável: {payload.manager_name}.</p>
{(payload.territory_paths ?? []).map((path, index) => (
<p key={index}>
Local abrangido:{' '}
{path.map((node) => node.name).join(' / ')}
</p>
{(item.territory_paths ?? []).map((path, index) => (
<p key={index}>
{path.map((node) => node.name).join(' / ')}
</p>
))}
<a
className="underline"
href={item.source_url}
target="_blank"
rel="noopener noreferrer"
>
{item.source_reference}
</a>
<p>Fonte consultada em {item.checked_on}.</p>
{item.application_url && (
))}
<p>
Sectores:{' '}
{payload.sectors_text ||
(payload.sectors_scope === 'all'
? 'Todos, segundo a fonte'
: payload.sectors_scope === 'unknown'
? 'Por confirmar'
: (payload.sector_snapshots ?? [])
.map((sector) => sector.name)
.join(', '))}
</p>
<p>
{payload.coverage === 'national'
? 'Cobertura nacional'
: payload.coverage === 'territorial'
? 'Cobertura territorial'
: 'Cobertura por confirmar'}
{payload.coverage_text
? ` — ${payload.coverage_text}`
: ''}
</p>
{payload.source_url && (
<details>
<summary>Informação institucional</summary>
<a
className="underline"
href={item.application_url}
href={payload.source_url}
target="_blank"
rel="noopener noreferrer"
>
Consultar o canal indicado pela entidade
Consultar informação da entidade
</a>
)}
</article>
))}
</section>)}
{(payload.kind!=='institution'||payload.criteria.length>0||payload.windows.some(window=>window.criteria.length>0)) && ( <FundingCriteriaAssessment
key={record.id + ':' + record.version_id}
record={record}
/>)}
<p>
Actualização consultada em{' '}
{payload.source_checked_on}.
</p>
</details>
)}
</>
)}
{(payload.kind !== 'institution' || payload.windows.length > 0) && (
<section className={card}>
<h2 className="text-xl font-semibold">
Janelas de candidatura
</h2>
{payload.windows.length === 0 && (
<p>
Não há uma janela de candidatura confirmada para
este programa.
</p>
)}
{payload.windows.map((item) => (
<article key={item.code} className="my-4">
<h3 className="font-semibold">{item.title}</h3>
<p>{windowLabels[item.state]}</p>
{item.start_on && <p>Abertura: {item.start_on}</p>}
{item.end_on && <p>Data-limite: {item.end_on}</p>}
{item.coverage_text && <p>{item.coverage_text}</p>}
<p>
{item.coverage === 'programme'
? 'Mesma cobertura do programa'
: item.coverage === 'national'
? 'Chamada nacional'
: item.coverage === 'unknown'
? 'Cobertura da chamada por confirmar'
: 'Chamada territorial'}
</p>
{(item.territory_paths ?? []).map((path, index) => (
<p key={index}>
{path.map((node) => node.name).join(' / ')}
</p>
))}
<a
className="underline"
href={item.source_url}
target="_blank"
rel="noopener noreferrer"
>
{item.source_reference}
</a>
<p>Fonte consultada em {item.checked_on}.</p>
{item.application_url && (
<a
className="underline"
href={item.application_url}
target="_blank"
rel="noopener noreferrer"
>
Consultar o canal indicado pela entidade
</a>
)}
</article>
))}
</section>
)}
{(payload.kind !== 'institution' ||
payload.criteria.length > 0 ||
payload.windows.some(
(window) => window.criteria.length > 0,
)) && (
<FundingCriteriaAssessment
key={record.id + ':' + record.version_id}
record={record}
/>
)}
{payload.documents.length > 0 && (
<section className={card}>
<h2>Documentos associados</h2>
@@ -162,10 +204,7 @@ export default function Show({
)}
<section className={card}>
<h2>Guardar interesse</h2>
<p>
Guarde esta opção para retomar a preparação do investimento. Não envia uma
candidatura nem contacta a entidade.
</p>
{saved ? (
<>
<p>
+4 -7
View File
@@ -84,14 +84,11 @@ test("actual institution view keeps physical address separate from product cover
const html = renderToStaticMarkup(
React.createElement(InstitutionView, { value: details }),
);
assert.match(html, /Sede: SYNTHETIC Maputo office/);
assert.match(html, /Nacional, segundo a fonte/);
assert.match(html, /Condições: Por confirmar/);
assert.match(html, /Disponibilidade: Por confirmar/);
assert.match(html, /Sede: SYNTHETIC address/);
assert.match(html, /SYNTHETIC QA investment/);
assert.match(html, /https:\/\/example.invalid\/contact/);
assert.match(html, /https:\/\/example\.invalid\/source/);
assert.match(html, /Consultar informação da entidade/);
assert.match(html, /não é elegibilidade confirmada/);
assert.doesNotMatch(html,/Por confirmar|não é elegibilidade|SHA-256/);
});
test("adding a physical location does not fill product financing coverage", () => {
const { InstitutionFields } = load("components/funding-institution.tsx");
@@ -60,16 +60,26 @@ function load(file) {
}
: name === './shared'
? { windowLabels: {} }
: name === '@/lib/portal-api' ? {portalRequest:()=>{throw Error('Read-only rendering must not send requests');}} : name === '@/components/funding-classification'
? load('components/funding-classification.tsx')
: name === '@/components/funding-institution'
? load('components/funding-institution.tsx')
: name === '@/components/funding-dossier-choice'
? { FundingDossierChoice: () => null }
: name ===
'@/components/funding-criteria-assessment'
? load('components/funding-criteria-assessment.tsx')
: require(name),
: name === '@/lib/portal-api'
? {
portalRequest: () => {
throw Error(
'Read-only rendering must not send requests',
);
},
}
: name === '@/components/funding-classification'
? load('components/funding-classification.tsx')
: name === '@/components/funding-institution'
? load('components/funding-institution.tsx')
: name === '@/components/funding-dossier-choice'
? { FundingDossierChoice: () => null }
: name ===
'@/components/funding-criteria-assessment'
? load(
'components/funding-criteria-assessment.tsx',
)
: require(name),
module,
module.exports,
);
@@ -146,8 +156,143 @@ test('actual public cards and detail lead with description/products and keep imp
new RegExp(raw.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
);
}
const out=new URL('../../outputs/funding-public-editorial-20261111/',import.meta.url);fs.mkdirSync(out,{recursive:true});const name=p.products.length?'bank-with-products':'investment-manager';fs.writeFileSync(new URL(name+'-index.html',out),index);fs.writeFileSync(new URL(name+'-detail.html',out),detail);fs.writeFileSync(new URL('verification.json',out),JSON.stringify({publishedSourceRecords:270,descriptionsPreserved:true,productsNotFabricated:true,actualPublicComponents:true,metadataRemovedFromPresentation:true,privateEditorialFieldsUnchanged:true,activeDataWrites:0,visualBrowserReview:false},null,2));
const out = new URL(
'../../outputs/funding-public-editorial-20261111/',
import.meta.url,
);
fs.mkdirSync(out, { recursive: true });
const name = p.products.length
? 'bank-with-products'
: 'investment-manager';
fs.writeFileSync(new URL(name + '-index.html', out), index);
fs.writeFileSync(new URL(name + '-detail.html', out), detail);
fs.writeFileSync(
new URL('verification.json', out),
JSON.stringify(
{
publishedSourceRecords: 270,
descriptionsPreserved: true,
productsNotFabricated: true,
actualPublicComponents: true,
metadataRemovedFromPresentation: true,
privateEditorialFieldsUnchanged: true,
activeDataWrites: 0,
visualBrowserReview: false,
},
null,
2,
),
);
assert.match(detail, /Descrição/);
if (p.products.length) assert.match(detail, /Soluções PME/);
}
});
test('Quorus and Moza use one shared clean profile in both list and detail', () => {
const Index = load('pages/funding/index.tsx').default,
Show = load('pages/funding/show.tsx').default;
const out = new URL(
'../../outputs/funding-uniform-menu-20261111/',
import.meta.url,
);
fs.mkdirSync(out, { recursive: true });
for (const title of ['Quorus', 'Moza Banco']) {
const raw = records.find((p) => p.title === title);
assert.ok(raw, title);
const p = { ...raw, windows: [], documents: [] },
item = {
id: 'synthetic',
version_id: 'synthetic-version',
version: 1,
payload: p,
};
const index = renderToStaticMarkup(
React.createElement(Index, {
items: { data: [item], links: [] },
filters: {},
kinds: {},
options: { territories: [], sectors: [] },
management: false,
}),
);
const detail = renderToStaticMarkup(
React.createElement(Show, {
record: item,
authenticated: false,
saved: null,
}),
);
for (const html of [
index.match(/<article class="card">([\s\S]*?)<\/article>/)?.[0] ??
index,
detail,
]) {
assert.doesNotMatch(
html,
/Por confirmar|por confirmar|correspondência confirmada|Instituição identificada|Entidade responsável|Sectores:|Cobertura|garantia de financiamento|Não encontrado|ficha|Sede ou contacto indicado|Informação institucional/,
);
assert.equal((html.match(/Produtos e serviços/g) || []).length, 1);
assert.equal((html.match(/Descrição/g) || []).length, 1);
}
if (title === 'Quorus') {
assert.equal(
(detail.match(/Entidade de corretagem de seguros/g) || [])
.length,
1,
);
assert.doesNotMatch(
detail,
/<h3[^>]*>Contactos|<h3[^>]*>Localização/,
);
} else {
assert.equal((detail.match(/21 342 000/g) || []).length, 1);
assert.equal(
(detail.match(/Rua dos Desportistas/g) || []).length,
1,
);
assert.equal(
(
detail.match(
/Tesouraria, investimento, construção e equipamento/g,
) || []
).length,
1,
);
assert.doesNotMatch(detail, /500\.000|30\.000\.000|Prime|spread/);
}
fs.writeFileSync(
new URL(title.replaceAll(' ', '-') + '-index.html', out),
index,
);
fs.writeFileSync(
new URL(title.replaceAll(' ', '-') + '-detail.html', out),
detail,
);
}
});
test('contact and location deduplication preserves different branches and original records', () => {
const moza = records.find((p) => p.title === 'Moza Banco'),
original = JSON.stringify(moza);
assert.equal(
presentation
.publicInstitutionContacts(moza.contacts)
.filter((c) => c.kind === 'phone').length,
1,
);
assert.equal(
presentation.publicInstitutionLocations(moza.locations).length,
1,
);
assert.equal(
presentation.publicInstitutionLocations([
...moza.locations,
{
...moza.locations[0],
role: 'branch',
address: 'Rua dos Desportistas, número 714',
},
]).length,
2,
);
assert.equal(JSON.stringify(moza), original);
});
+4 -4
View File
@@ -417,7 +417,7 @@ void test('installed concours has its own primary group and preserves public and
authenticated,
procurement: true,
});
const group = groups.find((g) => g.id === 'procurement-area');
const group = groups.find((g) => g.id === 'discovery').children.find(g=>g.id==='procurement-area');
assert.equal(group.label, 'Concursos e anúncios');
assert.equal(
destinations(groups).filter((h) => h === '/concursos').length,
@@ -427,15 +427,15 @@ void test('installed concours has its own primary group and preserves public and
JSON.stringify(groups.find((g) => g.id === 'discovery')).includes(
'/concursos',
),
false,
true,
);
assert.equal(
currentNavigationGroup(groups, '/concursos?q=obras'),
'procurement-area',
'discovery',
);
assert.equal(
currentNavigationGroup(groups, '/concursos/fixture'),
'procurement-area',
'discovery',
);
assert.equal(
destinations(group.children).includes('/concursos/acompanhar'),
@@ -0,0 +1,194 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import { compileFunction } from 'node:vm';
import ts from 'typescript';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import * as navigation from '../../resources/js/lib/portal-navigation.ts';
import { searchTerritoryHref } from '../../resources/js/lib/search-territory.ts';
const require = createRequire(import.meta.url);
let state, mobile;
const primitive =
(tag) =>
({ children, asChild, className, ...props }) =>
asChild
? React.cloneElement(children, { className })
: React.createElement(
tag,
{
className,
...Object.fromEntries(
Object.entries(props).filter(
([k]) =>
k.startsWith('aria-') ||
k.startsWith('data-'),
),
),
},
children,
);
const source = fs.readFileSync(
new URL(
'../../resources/js/components/portal-grouped-menu.tsx',
import.meta.url,
),
'utf8',
),
module = { exports: {} };
const output = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText;
compileFunction(output, ['require', 'module', 'exports'])(
(name) =>
name === '@/components/portal-product-identity'
? {}
: name === '@inertiajs/react'
? {
usePage: () => state,
Link: ({ children, href, className, ...props }) =>
React.createElement(
'a',
{
href,
className,
...Object.fromEntries(
Object.entries(props).filter(
([k]) =>
k.startsWith('aria-') ||
k.startsWith('data-'),
),
),
},
children,
),
router: {},
}
: name === 'lucide-react'
? new Proxy({}, { get: () => () => null })
: name === '@/hooks/use-mobile'
? { useIsMobile: () => mobile }
: name === '@/components/navigation-context'
? { useNavigationContext: () => ({ view: null }) }
: name === '@/components/tempo-brand'
? { useTempoUi: () => ({ t: (x) => x, language: 'pt' }) }
: name === '@/components/tempo-header-tools'
? { TempoLanguageSelector: () => null }
: name === '@/components/ui/dropdown-menu'
? new Proxy(
{},
{
get: (_, key) =>
primitive(
String(key).includes('Trigger')
? 'button'
: 'div',
),
},
)
: name === '@/lib/portal-navigation'
? navigation
: name === '@/lib/search-territory'
? { searchTerritoryHref }
: name === '@/routes'
? { logout: () => '/logout' }
: require(name),
module,
module.exports,
);
test('actual header has exactly the requested six primary entries on desktop and mobile for public and signed-in users', () => {
const out = new URL(
'../../outputs/funding-uniform-menu-20261111/',
import.meta.url,
);
fs.mkdirSync(out, { recursive: true });
for (const authenticated of [false, true])
for (mobile of [false, true]) {
state = {
url: '/',
props: {
auth: {
user: authenticated
? {
name: 'SYNTHETIC QA',
email: 'qa@example.invalid',
}
: null,
canManagePortal: false,
},
procurementEnabled: true,
fundingEnabled: true,
},
};
const html = renderToStaticMarkup(
React.createElement(module.exports.PortalGroupedMenu),
);
const nav = html.match(
/<nav[^>]*aria-label="Áreas do portal"[\s\S]*?<\/nav>/,
)?.[0];
assert.ok(nav);
const labels = [
...nav.matchAll(
/<(?:a|button)[^>]*class="tempo-nav-trigger"[^>]*>([\s\S]*?)<\/(?:a|button)>/g,
),
].map((m) => m[1].replace(/<[^>]*>/g, '').trim());
assert.deepEqual(labels, [
'Início',
'Descoberta',
'Realização',
'Suporte',
'Sobre nós',
'Mais',
]);
for (const href of [
'/concursos',
'/fundos',
'/documentos',
'/legislacao',
'/sobre',
'/contacto',
'/acesso-gestao',
])
assert.ok(html.includes('href="' + href + '"'), href);
assert.ok(html.includes('step=S08'));
assert.equal(html.includes('/management/'), false);
fs.writeFileSync(
new URL(
'header-' + authenticated + '-' + mobile + '.html',
out,
),
html,
);
}
fs.writeFileSync(
new URL('verification.json', out),
JSON.stringify(
{
synthetic: true,
sharedInstitutionProfile: true,
suppliedExamples: ['Quorus', 'Moza Banco'],
sourceRowsPreserved: true,
primaryEntries: [
'Início',
'Descoberta',
'Realização',
'Suporte',
'Sobre nós',
'Mais',
],
actualHeaderRendered: true,
publicAndSignedIn: true,
mobileAndDesktopBranches: true,
visualBrowserReview: false,
activeDataWrites: 0,
},
null,
2,
),
);
});