fix: make financial institution local presence explicit

This commit is contained in:
Codex
2026-11-15 19:34:01 +02:00
parent bd000d04d2
commit afa02559a1
11 changed files with 689 additions and 763 deletions
@@ -155,6 +155,10 @@ final class FundingProgrammeController extends Controller
'sector' => ['nullable', 'integer', Rule::in(Sector::choices()->whereNotNull('published_at')->pluck('id')->all())],
'certainty' => 'nullable|in:include_unknown,documented', 'state' => 'nullable|in:open,closed,upcoming,unknown']);
$filters = array_filter($filters, fn ($value) => $value !== null && $value !== '');
// A local search excludes unlocated records unless explicitly requested.
if (($filters['location_mode'] ?? '') === 'physical' && ! isset($filters['certainty'])) {
$filters['certainty'] = 'documented';
}
$dossier = $request->user() && ! empty($filters['context']) ? app(FundingDossierPreparation::class)->context($request->user(), $filters['context'], $filters['opportunity'] ?? null) : null;
$explicitLocation = $request->hasAny(['territory', 'province_id', 'district_id', 'admin_post_id', 'locality_id']);
if ($dossier && ! $explicitLocation && empty($filters['territory']) && $dossier['territory']) {
@@ -10224,3 +10224,7 @@ A Tempo autorizou expressamente `3558647e`, incluindo menus, mantendo fecho púb
# Instalação exacta das Ofertas — falha antes da promoção e correcção QA, 14/11/2026
O fast-forward autorizado `96f51096 → 3558647e` foi efectuado sem força ou commits posteriores. Janela com path automático pausado, lock exclusivo e chamada única ao executor existente, recusando qualquer outro SHA. Novo backup/restauro isolado 219 tabelas/417 ficheiros aprovado. O executor passou schema isolado 1/3106 e build, mas parou em tipos: colisão Wayfinder entre a acção GET `reportForm` e o formulário gerado para POST `report`. Sem promoção, migrações, alteração operacional ou reversão; homologação servida permanece `96f51096`, 219 tabelas/417 ficheiros reconferidos intactos. Lock libertado, path original restaurado, fila vazia. Correcção mínima em QA: acção GET `reportPage`, mesmo URL e corpo, com geração real/produção/tipos/PHPStan/Pint e 28 testes/403 asserções aprovados, numa base isolada separada da prévia. Nenhum novo push ou instalação. Relatório: `../v2/auditoria-portal/ofertas-instalacao-privada-incidente-wayfinder-20261114.md`; recibo `outputs/offers-exact-install-20261114/qa-result.json`. Nova revisão correctiva exige autorização expressa antes de integrar/instalar; publicação, correio, pagamentos e decisões jurídicas continuam nos limites anteriores.
### 2026-11-15 — pesquisa local de instituições
AUD-FUND-01: diagnóstico e delta sobre bd000d04 registados em `../v2/auditoria-portal/apresentacao-contexto-qa-20261115.md`. Filtros existentes; corrigidas em código a predefinição física e a apresentação das moradas/incerteza. 10 testes frontend aprovados; API, tipos globais/build e visual da candidata por concluir. Sem instalação ou alteração de dados.
@@ -230,3 +230,14 @@ A captura nativa do Edge recuperou nesta passagem; o conector de abas continua a
Foram observados em computador e em emulação móvel (390 px e 360 × 844): cartões em uma coluna, leitura da variação temporal, abertura da série anual, ano ausente como «Sem dados» e recuperação do detalhe de fontes/originais. A primeira captura móvel detectou palavras comprimidas na tabela original; foi aplicada largura mínima com deslocamento interno, seguida de nova conferência. As capturas são evidência da **prévia de componentes**, não de aceitação autenticada integral ou de utilização num telefone físico.
Continuam pendentes a conferência autenticada dos percursos completos e a aceitação do responsável. Sem instalação no activo, sem alteração do esquema ou de permissões e sem envio externo. A pendência de obter/comparar o bundle fica encerrada por esta reconciliação selectiva.
## 2026-11-15 — localização nas instituições financeiras (AUD-FUND-01)
Base conferida: bd000d04. Na homologação, os selectores de província/distrito/posto/localidade existem. A pesquisa por presença física em Tete com inclusão de desconhecidos devolveu 140 resultados; os primeiros cartões não mostravam moradas nem assinalavam presença por confirmar. Ao seleccionar apenas âmbito documentado, o resultado mudou para instituições com presença cadastrada, incluindo Access Bank, Socremo e seguradoras. Esta observação não certifica que todos os balcões reais estejam cadastrados ou actualizados.
Delta preparado em ramo isolado: modo e território juntos; pesquisa depois dos critérios; modo físico começa com âmbito documentado, mantendo a escolha explícita de desconhecidos e URLs anteriores; o controlador aplica a mesma predefinição quando omitida. Cartões mostram a morada no território escolhido, sem mostrar a sede de outra província como presença local. Desconhecidos incluídos voluntariamente recebem indicação específica. O detalhe conserva todas as moradas existentes e o retorno conserva critérios. Nenhum registo, fonte ou permissão foi alterado. Cobertura de financiamento permanece distinta de presença física.
Verificação: 10 testes frontend aprovados (3 novos), incluindo renderização dos componentes reais, critérios dependentes, retorno explícito de desconhecidos e preservação do detalhe; lint dirigido e git diff --check aprovados. Teste HTTP acrescentado a FundingCriteriaCoherenceTest, não executado neste ambiente sem PHP/PostgreSQL. TypeScript global executado mas bloqueado pela ausência de módulos routes/actions gerados; sem diagnósticos nos ficheiros funding alterados. Não foi executado build global nem conferência visual da candidata. A observação visual refere-se apenas ao activo anterior.
Entrega: patch e bundle a produzir a partir de bd000d04; sem push autenticado ou instalação nesta sessão. Antes de instalar, executar o teste HTTP na base QA isolada já definida no teste, gerar routes/actions, concluir tipos/build e conferir a candidata em computador e móvel. Não repetir testes fiscais ou importar dados para esta correcção.
File diff suppressed because one or more lines are too long
+162 -211
View File
@@ -2,17 +2,15 @@ import {
ClassificationFields,
evidenceLabels,
type Classification,
} from '@/components/funding-classification';
} from "@/components/funding-classification";
import {
fundingPublicPresentation,
publicInstitutionContacts,
publicInstitutionLocations,
} from '@/lib/funding-public-presentation';
import {
financialCategories,
financialTypes,
} from '@/components/funding-classification';
import { card, input, button } from '@/pages/investment/shared';
matchingInstitutionLocations,
} from "@/lib/funding-public-presentation";
import { financialCategories, financialTypes } from "@/components/funding-classification";
import { card, input, button } from "@/pages/investment/shared";
export type FundingSource = {
source_url: string | null;
@@ -33,7 +31,7 @@ export type FundingLocation = FundingSource & {
label: string;
address: string;
territory_id: string;
territory_path?: { name: string }[];
territory_path?: { id?: string; name: string }[];
};
export type FundingProduct = FundingSource & {
code: string;
@@ -57,22 +55,22 @@ export type InstitutionDetails = Classification & {
products: FundingProduct[];
};
export const emptyInstitution: InstitutionDetails = {
institution_key: '',
institution_nature: 'unknown',
institution_key: "",
institution_nature: "unknown",
contacts: [],
locations: [],
products: [],
};
const source: FundingSource = {
source_url: '',
source_reference: '',
checked_on: '',
source_url: "",
source_reference: "",
checked_on: "",
};
function Text({
label,
value,
change,
type = 'text',
type = "text",
}: {
label: string;
value: string;
@@ -85,8 +83,8 @@ function Text({
<input
className={input}
type={type}
value={value ?? ''}
maxLength={type === 'text' ? 1500 : undefined}
value={value ?? ""}
maxLength={type === "text" ? 1500 : undefined}
onChange={(e) => change(e.target.value)}
/>
</label>
@@ -105,24 +103,20 @@ function Sources({
<Text
label="Endereço da fonte"
type="url"
value={value.source_url ?? ''}
value={value.source_url ?? ""}
change={(source_url) => change({ source_url })}
/>
{!value.source_url && (
<>
<Text
label="Documento de origem"
value={value.source_document_name ?? ''}
change={(source_document_name) =>
change({ source_document_name })
}
value={value.source_document_name ?? ""}
change={(source_document_name) => change({ source_document_name })}
/>
<Text
label="SHA-256 do original"
value={value.source_document_sha256 ?? ''}
change={(source_document_sha256) =>
change({ source_document_sha256 })
}
value={value.source_document_sha256 ?? ""}
change={(source_document_sha256) => change({ source_document_sha256 })}
/>
</>
)}
@@ -131,10 +125,8 @@ function Sources({
Situação desta informação
<select
className={input}
value={value.evidence_status ?? 'to_confirm'}
onChange={(e) =>
change({ evidence_status: e.target.value })
}
value={value.evidence_status ?? "to_confirm"}
onChange={(e) => change({ evidence_status: e.target.value })}
>
{Object.entries(evidenceLabels).map(([key, label]) => (
<option key={key} value={key}>
@@ -171,22 +163,20 @@ export function InstitutionFields({
natures: Record<string, string>;
needs: Record<string, string>;
}) {
const update = <K extends 'contacts' | 'locations' | 'products'>(
const update = <K extends "contacts" | "locations" | "products">(
key: K,
index: number,
patch: Partial<InstitutionDetails[K][number]>,
) =>
change({
[key]: value[key].map((row, i) =>
i === index ? { ...row, ...patch } : row,
),
[key]: value[key].map((row, i) => (i === index ? { ...row, ...patch } : row)),
});
return (
<section className={card}>
<h2>Instituição, presença física e produtos</h2>
<p>
A sede ou balcão não determina a cobertura do financiamento. Não
preencha condições desconhecidas por inferência.
A sede ou balcão não determina a cobertura do financiamento. Não preencha condições
desconhecidas por inferência.
</p>
<ClassificationFields value={value} change={change} needs={needs} />
<Text
@@ -199,9 +189,7 @@ export function InstitutionFields({
<select
className={input}
value={value.institution_nature}
onChange={(e) =>
change({ institution_nature: e.target.value })
}
onChange={(e) => change({ institution_nature: e.target.value })}
>
{Object.entries(natures).map(([id, label]) => (
<option key={id} value={id}>
@@ -218,15 +206,13 @@ export function InstitutionFields({
<select
className={input}
value={row.kind}
onChange={(e) =>
update('contacts', i, { kind: e.target.value })
}
onChange={(e) => update("contacts", i, { kind: e.target.value })}
>
{Object.entries({
website: 'Página institucional',
email: 'Email',
phone: 'Telefone',
address: 'Endereço postal',
website: "Página institucional",
email: "Email",
phone: "Telefone",
address: "Endereço postal",
}).map(([id, label]) => (
<option key={id} value={id}>
{label}
@@ -236,19 +222,14 @@ export function InstitutionFields({
<Text
label="Contacto documentado"
value={row.value}
change={(v) => update('contacts', i, { value: v })}
/>
<Sources
value={row}
change={(v) => update('contacts', i, v)}
change={(v) => update("contacts", i, { value: v })}
/>
<Sources value={row} change={(v) => update("contacts", i, v)} />
<button
type="button"
onClick={() =>
change({
contacts: value.contacts.filter(
(_, n) => n !== i,
),
contacts: value.contacts.filter((_, n) => n !== i),
})
}
>
@@ -264,7 +245,7 @@ export function InstitutionFields({
change({
contacts: [
...value.contacts,
{ ...source, kind: 'website', value: '' },
{ ...source, kind: "website", value: "" },
],
})
}
@@ -273,18 +254,14 @@ export function InstitutionFields({
</button>
</details>
<details>
<summary>
Sede e balcões ({value.locations.length}) — Alterar
</summary>
<summary>Sede e balcões ({value.locations.length}) — Alterar</summary>
{value.locations.map((row, i) => (
<fieldset className={card} key={i}>
<legend>Local físico {i + 1}</legend>
<select
className={input}
value={row.role}
onChange={(e) =>
update('locations', i, { role: e.target.value })
}
onChange={(e) => update("locations", i, { role: e.target.value })}
>
<option value="headquarters">Sede</option>
<option value="branch">Balcão</option>
@@ -295,31 +272,25 @@ export function InstitutionFields({
<Text
label="Designação"
value={row.label}
change={(label) =>
update('locations', i, { label })
}
change={(label) => update("locations", i, { label })}
/>
<Text
label="Morada documentada"
value={row.address}
change={(address) =>
update('locations', i, { address })
}
change={(address) => update("locations", i, { address })}
/>
<label>
Local do cadastro
<select
className={input}
value={row.territory_id ?? ''}
value={row.territory_id ?? ""}
onChange={(e) =>
update('locations', i, {
update("locations", i, {
territory_id: e.target.value,
})
}
>
<option value="">
Correspondência por confirmar
</option>
<option value="">Correspondência por confirmar</option>
{territories.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
@@ -327,17 +298,12 @@ export function InstitutionFields({
))}
</select>
</label>
<Sources
value={row}
change={(v) => update('locations', i, v)}
/>
<Sources value={row} change={(v) => update("locations", i, v)} />
<button
type="button"
onClick={() =>
change({
locations: value.locations.filter(
(_, n) => n !== i,
),
locations: value.locations.filter((_, n) => n !== i),
})
}
>
@@ -355,10 +321,10 @@ export function InstitutionFields({
...value.locations,
{
...source,
role: 'headquarters',
label: '',
address: '',
territory_id: '',
role: "headquarters",
label: "",
address: "",
territory_id: "",
},
],
})
@@ -368,23 +334,19 @@ export function InstitutionFields({
</button>
</details>
<details>
<summary>
Programas/produtos ({value.products.length}) — Alterar
</summary>
<summary>Programas/produtos ({value.products.length}) — Alterar</summary>
{value.products.map((row, i) => (
<fieldset className={card} key={row.code}>
<legend>Produto {i + 1}</legend>
<Text
label="Nome documentado"
value={row.title}
change={(title) => update('products', i, { title })}
change={(title) => update("products", i, { title })}
/>
<Text
label="Instrumento"
value={row.instrument}
change={(instrument) =>
update('products', i, { instrument })
}
change={(instrument) => update("products", i, { instrument })}
/>
<label>
Necessidades documentadas
@@ -393,11 +355,8 @@ export function InstitutionFields({
className={input}
value={row.needs}
onChange={(e) =>
update('products', i, {
needs: Array.from(
e.target.selectedOptions,
(o) => o.value,
),
update("products", i, {
needs: Array.from(e.target.selectedOptions, (o) => o.value),
})
}
>
@@ -414,22 +373,18 @@ export function InstitutionFields({
className={input}
value={row.coverage}
onChange={(e) =>
update('products', i, {
update("products", i, {
coverage: e.target.value,
territory_ids: [],
})
}
>
<option value="unknown">Por confirmar</option>
<option value="national">
Nacional, segundo a fonte
</option>
<option value="territorial">
Locais documentados
</option>
<option value="national">Nacional, segundo a fonte</option>
<option value="territorial">Locais documentados</option>
</select>
</label>
{row.coverage === 'territorial' && (
{row.coverage === "territorial" && (
<label>
Locais abrangidos
<select
@@ -437,7 +392,7 @@ export function InstitutionFields({
className={input}
value={row.territory_ids}
onChange={(e) =>
update('products', i, {
update("products", i, {
territory_ids: Array.from(
e.target.selectedOptions,
(o) => o.value,
@@ -456,9 +411,7 @@ export function InstitutionFields({
<Text
label="Explicação da cobertura"
value={row.coverage_text}
change={(coverage_text) =>
update('products', i, { coverage_text })
}
change={(coverage_text) => update("products", i, { coverage_text })}
/>
<label>
Condições — vazio significa por confirmar
@@ -467,7 +420,7 @@ export function InstitutionFields({
value={row.conditions}
maxLength={4000}
onChange={(e) =>
update('products', i, {
update("products", i, {
conditions: e.target.value,
})
}
@@ -476,9 +429,7 @@ export function InstitutionFields({
<Text
label="Montantes/moeda/prazos documentados — se conhecidos"
value={row.amounts}
change={(amounts) =>
update('products', i, { amounts })
}
change={(amounts) => update("products", i, { amounts })}
/>
<label>
Disponibilidade
@@ -486,40 +437,30 @@ export function InstitutionFields({
className={input}
value={row.availability}
onChange={(e) =>
update('products', i, {
update("products", i, {
availability: e.target.value,
})
}
>
<option value="unknown">Por confirmar</option>
<option value="documented">
Oferta identificada na fonte (não é
concessão)
</option>
<option value="closed">
Encerrada segundo a fonte
Oferta identificada na fonte (não é concessão)
</option>
<option value="closed">Encerrada segundo a fonte</option>
</select>
</label>
<Text
label="Canal de contacto do produto"
type="url"
value={row.contact_url}
change={(contact_url) =>
update('products', i, { contact_url })
}
/>
<Sources
value={row}
change={(v) => update('products', i, v)}
change={(contact_url) => update("products", i, { contact_url })}
/>
<Sources value={row} change={(v) => update("products", i, v)} />
<button
type="button"
onClick={() =>
change({
products: value.products.filter(
(_, n) => n !== i,
),
products: value.products.filter((_, n) => n !== i),
})
}
>
@@ -538,16 +479,16 @@ export function InstitutionFields({
{
...source,
code: crypto.randomUUID(),
title: '',
instrument: '',
title: "",
instrument: "",
needs: [],
coverage: 'unknown',
coverage_text: '',
coverage: "unknown",
coverage_text: "",
territory_ids: [],
conditions: '',
amounts: '',
availability: 'unknown',
contact_url: '',
conditions: "",
amounts: "",
availability: "unknown",
contact_url: "",
},
],
})
@@ -562,35 +503,51 @@ export function InstitutionFields({
export function InstitutionView({
value,
compact = false,
physicalTerritory,
}: {
value: InstitutionDetails & { title?: string; summary?: string };
compact?: boolean;
physicalTerritory?: string;
}) {
const presentation = fundingPublicPresentation({
...value,
title: value.title ?? '',
summary: value.summary ?? '',
kind: 'institution',
title: value.title ?? "",
summary: value.summary ?? "",
kind: "institution",
}),
contacts = publicInstitutionContacts(value.contacts),
locations = publicInstitutionLocations(value.locations);
const localPresence = physicalTerritory
? matchingInstitutionLocations(value.locations, physicalTerritory)
: [];
const visibleLocations =
physicalTerritory && compact
? publicInstitutionLocations(
localPresence.map((location) => ({
...location,
address:
location.address?.trim() ||
location.territory_path?.map((node) => node.name).join(" / ") ||
location.location_text ||
location.label,
})),
)
: locations;
const category = (value.institution_categories ?? [])
.map((k) => financialCategories[k])
.filter(Boolean)
.join(' · '),
.join(" · "),
types = (value.institution_types ?? [])
.map((k) => financialTypes[k])
.filter(Boolean)
.join(' · ');
.join(" · ");
const urls = new Set<string>(
compact
? []
: presentation.products
.map((p) => p.url)
.filter((url): url is string => !!url),
: presentation.products.map((p) => p.url).filter((url): url is string => !!url),
);
const sites = contacts
.filter((c) => c.kind === 'website')
.filter((c) => c.kind === "website")
.filter((c) => {
if (urls.has(c.value)) return false;
urls.add(c.value);
@@ -598,11 +555,7 @@ export function InstitutionView({
});
return (
<section className="space-y-4" aria-label="Informação da instituição">
{!compact && (
<h2 className="text-lg font-semibold">
Instituição financeira
</h2>
)}
{!compact && <h2 className="text-lg font-semibold">Instituição financeira</h2>}
<div>
<h3 className="font-semibold">Descrição</h3>
<p>{presentation.description}</p>
@@ -610,13 +563,13 @@ export function InstitutionView({
{(category || types) && (
<p>
{category}
{category && types ? ': ' : ''}
{category && types ? ": " : ""}
{types}
</p>
)}
{['historical', 'merged'].includes(
value.institution_status ?? '',
) && <p>Instituição histórica ou integrada noutra entidade.</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 ? (
@@ -635,77 +588,75 @@ export function InstitutionView({
) : (
<strong>{p.title}</strong>
)}
{p.detail ? ': ' + p.detail : ''}
{p.detail ? ": " + p.detail : ""}
</li>
))}
</ul>
) : presentation.services.length ? (
<p>{presentation.services.join(' · ')}</p>
<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>
{!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>
))}
{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 && (
</ul>
</div>
)}
{physicalTerritory && localPresence.length === 0 && (
<p className="rounded border border-amber-600 p-2">
Presença física por confirmar no local escolhido.
</p>
)}
{visibleLocations.length > 0 && (
<div>
<h3 className="font-semibold">Localização</h3>
<h3 className="font-semibold">
{physicalTerritory && compact
? "Presença no local escolhido"
: "Localização"}
</h3>
<ul className="space-y-1">
{locations.map((l, i) => (
{visibleLocations.map((l, i) => (
<li key={i}>
{l.role === 'headquarters'
? 'Sede: '
: l.role === 'branch'
? 'Balcão: '
: ''}
{l.role === "headquarters"
? "Sede: "
: l.role === "branch"
? "Balcão: "
: ""}
{l.address}
{l.hours ? ' · ' + l.hours : ''}
{l.hours ? " · " + l.hours : ""}
</li>
))}
</ul>
+76 -76
View File
@@ -13,115 +13,101 @@ export type PublicFunding = {
}[];
};
const descriptions: Record<string, string> = {
commercial_bank: 'Instituição de banca comercial.',
investment_bank: 'Instituição especializada em banca de investimento.',
bank: 'Instituição bancária.',
microbank: 'Instituição de microfinanças.',
cooperative: 'Cooperativa de crédito.',
savings: 'Organização de poupança e empréstimo.',
investment_manager:
'Sociedade especializada na gestão de fundos de investimento.',
investment_company: 'Sociedade de investimento.',
pension_manager: 'Entidade gestora de fundos de pensões.',
insurer: 'Instituição do sector segurador.',
reinsurer: 'Instituição especializada em resseguros.',
microinsurer: 'Instituição de microsseguros.',
broker: 'Entidade de corretagem de seguros.',
agent: 'Entidade de mediação de seguros.',
reinsurance_broker: 'Entidade de corretagem de resseguros.',
exchange_house: 'Instituição de serviços cambiais.',
electronic_money: 'Instituição de moeda electrónica.',
interbank: 'Entidade de serviços interbancários.',
supervisor: 'Entidade de supervisão do sistema financeiro.',
commercial_bank: "Instituição de banca comercial.",
investment_bank: "Instituição especializada em banca de investimento.",
bank: "Instituição bancária.",
microbank: "Instituição de microfinanças.",
cooperative: "Cooperativa de crédito.",
savings: "Organização de poupança e empréstimo.",
investment_manager: "Sociedade especializada na gestão de fundos de investimento.",
investment_company: "Sociedade de investimento.",
pension_manager: "Entidade gestora de fundos de pensões.",
insurer: "Instituição do sector segurador.",
reinsurer: "Instituição especializada em resseguros.",
microinsurer: "Instituição de microsseguros.",
broker: "Entidade de corretagem de seguros.",
agent: "Entidade de mediação de seguros.",
reinsurance_broker: "Entidade de corretagem de resseguros.",
exchange_house: "Instituição de serviços cambiais.",
electronic_money: "Instituição de moeda electrónica.",
interbank: "Entidade de serviços interbancários.",
supervisor: "Entidade de supervisão do sistema financeiro.",
};
const services: Record<string, string> = {
equity: 'Capital e participação no investimento',
foreign_exchange: 'Câmbio',
guarantee: 'Garantias',
insurance: 'Seguros',
investment: 'Financiamento do investimento',
investment_management: 'Gestão de investimentos',
payments: 'Serviços de pagamento',
pension: 'Pensões',
working_capital: 'Financiamento de tesouraria',
equity: "Capital e participação no investimento",
foreign_exchange: "Câmbio",
guarantee: "Garantias",
insurance: "Seguros",
investment: "Financiamento do investimento",
investment_management: "Gestão de investimentos",
payments: "Serviços de pagamento",
pension: "Pensões",
working_capital: "Financiamento de tesouraria",
};
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.';
"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.')
? (descriptions[p.institution_types?.[0] ?? ""] ?? "Entidade do sector financeiro.")
: p.summary
.replace(
/\s*Referência informativa, não concessão de financiamento\.?$/,
'',
)
.replace(/Público\/finalidade:/g, 'Soluções para')
.replace(/\.\./g, '.')
.replace(/\s*Referência informativa, não concessão de financiamento\.?$/, "")
.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()),
product.instrument?.trim() && description.startsWith(product.instrument.trim()),
);
if (repeatedInstrument) {
const audience = description.match(
/Soluções para\s+(.+?)(?:\.|$)/i,
)?.[1];
const audience = description.match(/Soluções para\s+(.+?)(?:\.|$)/i)?.[1];
description = audience
? 'Soluções financeiras para ' +
? "Soluções financeiras para " +
audience.charAt(0).toLowerCase() +
audience.slice(1) +
'.'
: (descriptions[p.institution_types?.[0] ?? ''] ?? description);
"."
: (descriptions[p.institution_types?.[0] ?? ""] ?? description);
}
const products = (p.products ?? [])
.filter((x) => x.availability !== 'closed')
.filter((x) => x.availability !== "closed")
.map((x) => ({
title: x.title.startsWith(p.title + ' — ')
title: x.title.startsWith(p.title + " — ")
? x.title.slice(p.title.length + 3)
: x.title,
detail: x.instrument?.trim() ?? '',
detail: x.instrument?.trim() ?? "",
url: x.contact_url || x.source_url || null,
}));
return {
description,
products,
services: [
...new Set(
(p.institution_services ?? [])
.map((s) => services[s])
.filter(Boolean),
),
...new Set((p.institution_services ?? []).map((s) => services[s]).filter(Boolean)),
],
};
}
const normal = (s: string) =>
s
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.replace(/[^a-z0-9]+/g, " ")
.trim();
export function publicInstitutionContacts<
T extends { kind: string; value: string },
>(rows: T[]) {
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.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(/\/$/, ''));
":" +
(row.kind === "phone"
? value.replace(/\D/g, "")
: value.toLowerCase().replace(/\/$/, ""));
if (seen.has(key)) return [];
seen.add(key);
return [{ ...row, value }];
@@ -137,31 +123,27 @@ export function publicInstitutionLocations<
>(rows: T[]) {
const result: T[] = [];
for (const row of [...rows].sort(
(a, b) =>
(a.role === 'headquarters' ? 0 : 1) -
(b.role === 'headquarters' ? 0 : 1),
(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 ?? '');
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;
if (!long.startsWith(short + " ")) return false;
const rest = long.slice(short.length).trim();
const localities = [
row.location_text ?? '',
previous.location_text ?? '',
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 /, ''),
(x) => x.replace(/^cidade de /, "") === rest.replace(/^cidade de /, ""),
)
);
});
@@ -169,3 +151,21 @@ export function publicInstitutionLocations<
}
return result;
}
/** Only a documented point inside the requested territory establishes local presence. */
export function matchingInstitutionLocations<
T extends {
territory_id?: string;
territory_path?: { id?: string; name: string }[];
evidence_status?: string | null;
},
>(locations: T[], territory: string): T[] {
return locations.filter(
(location) =>
!["to_confirm", "historical", "conflict", "unlocated"].includes(
location.evidence_status ?? "reported",
) &&
(location.territory_id === territory ||
location.territory_path?.some((node) => node.id === territory)),
);
}
+141 -184
View File
@@ -1,19 +1,10 @@
import {
InstitutionView,
type InstitutionDetails,
} from '@/components/funding-institution';
import {
financialCategories,
financialTypes,
} from '@/components/funding-classification';
import { Link, useForm } from '@inertiajs/react';
import { Frame, card, button } from '@/pages/investment/shared';
import { type Programme, windowLabels } from './shared';
import { useRef, useState } from 'react';
import {
TerritoryFields,
type TerritoryOption,
} from '@/components/investment-territory';
import { InstitutionView, type InstitutionDetails } from "@/components/funding-institution";
import { financialCategories, financialTypes } from "@/components/funding-classification";
import { Link, useForm } from "@inertiajs/react";
import { Frame, card, button } from "@/pages/investment/shared";
import { type Programme, windowLabels } from "./shared";
import { useRef, useState } from "react";
import { TerritoryFields, type TerritoryOption } from "@/components/investment-territory";
export default function FundingIndex(props: Parameters<typeof Index>[0]) {
return <Index key={JSON.stringify(props.filters)} {...props} />;
@@ -63,33 +54,35 @@ function Index({
territorySelection?: TerritoryOption[];
criteriaErrors?: Record<string, string[]>;
}) {
const [notice, setNotice] = useState('');
const [notice, setNotice] = useState("");
const form = useForm({
category: filters.category ?? '',
institution_type: filters.institution_type ?? '',
location_mode: filters.location_mode ?? 'coverage',
include_historical: filters.include_historical ?? '0',
context: filters.context ?? '',
opportunity: filters.opportunity ?? '',
need: filters.need ?? '',
q: filters.q ?? '',
kind: filters.kind ?? '',
sector: filters.sector ?? '',
territory: filters.territory ?? '',
province_id: filters.province_id ?? '',
district_id: filters.district_id ?? '',
admin_post_id: filters.admin_post_id ?? '',
locality_id: filters.locality_id ?? '',
certainty: filters.certainty ?? 'include_unknown',
state: filters.state ?? '',
category: filters.category ?? "",
institution_type: filters.institution_type ?? "",
location_mode: filters.location_mode ?? "coverage",
include_historical: filters.include_historical ?? "0",
context: filters.context ?? "",
opportunity: filters.opportunity ?? "",
need: filters.need ?? "",
q: filters.q ?? "",
kind: filters.kind ?? "",
sector: filters.sector ?? "",
territory: filters.territory ?? "",
province_id: filters.province_id ?? "",
district_id: filters.district_id ?? "",
admin_post_id: filters.admin_post_id ?? "",
locality_id: filters.locality_id ?? "",
certainty:
filters.certainty ??
(filters.location_mode === "physical" ? "documented" : "include_unknown"),
state: filters.state ?? "",
});
const applied = useRef(JSON.stringify(form.data));
const criteriaChanged = JSON.stringify(form.data) !== applied.current;
const typeAllowed = (category: string, type: string) =>
!category ||
category === 'other' ||
category === "other" ||
!type ||
type === 'other' ||
type === "other" ||
(categoryTypes[category] ?? []).includes(type);
return (
<Frame title="Instituições financeiras, fundos e programas de apoio">
@@ -102,33 +95,25 @@ 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 [&_select]:w-full [&_select]:max-w-full [&_select]:min-w-0 [&>label]:w-full [&>label]:max-w-full [&>label]:min-w-0 sm:[&>label]:w-auto`}
onSubmit={(e) => {
e.preventDefault();
form.get('/fundos');
form.get("/fundos");
}}
>
{Object.keys(criteriaErrors).length > 0 && (
<aside
className="basis-full rounded border border-amber-600 p-3"
role="alert"
>
<h2 className="font-semibold">
Reveja esta combinação de critérios
</h2>
{Object.entries(criteriaErrors).map(
([key, messages]) => (
<p key={key}>{messages.join(' ')}</p>
),
)}
<aside className="basis-full rounded border border-amber-600 p-3" role="alert">
<h2 className="font-semibold">Reveja esta combinação de critérios</h2>
{Object.entries(criteriaErrors).map(([key, messages]) => (
<p key={key}>{messages.join(" ")}</p>
))}
<p>
A pesquisa não foi executada. Corrija os critérios
indicados e volte a pesquisar.
A pesquisa não foi executada. Corrija os critérios indicados e volte a
pesquisar.
</p>
</aside>
)}
@@ -143,16 +128,15 @@ function Index({
</p>
))}
{form.data.kind &&
form.data.kind !== 'institution' &&
form.data.kind !== "institution" &&
(form.data.category ||
form.data.institution_type ||
form.data.location_mode === 'physical') && (
form.data.location_mode === "physical") && (
<aside className="basis-full rounded border p-3">
<p>
Categoria, tipo de instituição e presença física
não identificam um fundo ou programa. Reveja o
tipo de registo ou retire apenas estes critérios
incompatíveis.
Categoria, tipo de instituição e presença física não identificam um
fundo ou programa. Reveja o tipo de registo ou retire apenas estes
critérios incompatíveis.
</p>
<button
type="button"
@@ -160,17 +144,16 @@ function Index({
onClick={() => {
form.setData({
...form.data,
category: '',
institution_type: '',
location_mode: 'coverage',
category: "",
institution_type: "",
location_mode: "coverage",
});
setNotice(
'Critérios de instituição retirados por sua escolha. Local, sector, necessidade e janela foram preservados.',
"Critérios de instituição retirados por sua escolha. Local, sector, necessidade e janela foram preservados.",
);
}}
>
Manter o fundo/programa e rever os critérios de
instituição
Manter o fundo/programa e rever os critérios de instituição
</button>
</aside>
)}
@@ -181,31 +164,24 @@ function Index({
value={form.data.category}
onChange={(e) => {
const category = e.target.value;
const valid = typeAllowed(
category,
form.data.institution_type,
);
const valid = typeAllowed(category, form.data.institution_type);
if (!valid)
setNotice(
'O tipo anterior não pertence à nova categoria e foi retirado. Escolha um dos tipos aplicáveis.',
"O tipo anterior não pertence à nova categoria e foi retirado. Escolha um dos tipos aplicáveis.",
);
form.setData({
...form.data,
category,
institution_type: valid
? form.data.institution_type
: '',
institution_type: valid ? form.data.institution_type : "",
});
}}
>
<option value="">Todas</option>
{Object.entries(financialCategories).map(
([id, label]) => (
<option key={id} value={id}>
{label}
</option>
),
)}
{Object.entries(financialCategories).map(([id, label]) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
</label>
<label>
@@ -213,9 +189,7 @@ function Index({
<select
className="block rounded border p-2"
value={form.data.institution_type}
onChange={(e) =>
form.setData('institution_type', e.target.value)
}
onChange={(e) => form.setData("institution_type", e.target.value)}
>
<option value="">Todos</option>
{Object.entries(financialTypes)
@@ -228,48 +202,27 @@ function Index({
<option key={id} value={id}>
{typeAllowed(form.data.category, id)
? label
: 'Incompatível: ' + label}
: "Incompatível: " + label}
</option>
))}
</select>
</label>
<label>
Localização a pesquisar
<select
className="block rounded border p-2"
value={form.data.location_mode}
onChange={(e) =>
form.setData('location_mode', e.target.value)
}
>
<option value="physical">
Sede, balcão ou presença física
</option>
<option value="coverage">
Cobertura do financiamento/produto
</option>
</select>
</label>
<label>
<input
type="checkbox"
checked={form.data.include_historical === '1'}
checked={form.data.include_historical === "1"}
onChange={(e) =>
form.setData(
'include_historical',
e.target.checked ? '1' : '0',
)
form.setData("include_historical", e.target.checked ? "1" : "0")
}
/>
Incluir instituições históricas/fundidas, identificadas como
tal
Incluir instituições históricas/fundidas, identificadas como tal
</label>
<label>
Necessidade do processo
<select
className="block rounded border p-2"
value={form.data.need}
onChange={(e) => form.setData('need', e.target.value)}
onChange={(e) => form.setData("need", e.target.value)}
>
<option value="">Todas / por identificar</option>
{Object.entries(needs).map(([id, label]) => (
@@ -285,7 +238,7 @@ function Index({
className="block rounded border p-2"
value={form.data.q}
maxLength={150}
onChange={(e) => form.setData('q', e.target.value)}
onChange={(e) => form.setData("q", e.target.value)}
/>
</label>
<label>
@@ -293,7 +246,7 @@ function Index({
<select
className="block rounded border p-2"
value={form.data.kind}
onChange={(e) => form.setData('kind', e.target.value)}
onChange={(e) => form.setData("kind", e.target.value)}
>
<option value="">Todos</option>
{Object.entries(kinds).map(([key, name]) => (
@@ -303,15 +256,12 @@ function Index({
))}
</select>
</label>
<button className={button} disabled={form.processing}>
Pesquisar
</button>
<label>
Sector
<select
className="block rounded border p-2"
value={form.data.sector}
onChange={(e) => form.setData('sector', e.target.value)}
onChange={(e) => form.setData("sector", e.target.value)}
>
<option value="">Todos</option>
{options.sectors.map((item) => (
@@ -322,36 +272,51 @@ function Index({
</select>
</label>
<fieldset className="min-w-0 basis-full">
<legend>
Local de interesse — seleccione até ao nível conhecido
</legend>
<legend>Local de interesse — seleccione até ao nível conhecido</legend>
<label>
Localização a pesquisar
<select
className="block rounded border p-2"
value={form.data.location_mode}
onChange={(e) =>
form.setData({
...form.data,
location_mode: e.target.value,
certainty:
e.target.value === "physical"
? "documented"
: form.data.certainty,
})
}
>
<option value="physical">Sede, balcão ou presença física</option>
<option value="coverage">Cobertura do financiamento/produto</option>
</select>
</label>
<p className="mb-3 text-sm">
Escolha a província e, se necessário, o distrito. Sede e balcões identificam
onde pode contactar a instituição; a cobertura indica onde o financiamento
se aplica.
</p>
<TerritoryFields
initial={territorySelection}
showMap={false}
onChange={(path) =>
form.setData({
...form.data,
territory: path.at(-1)?.id ?? '',
territory: path.at(-1)?.id ?? "",
province_id:
path.find(
(node) =>
node.location_type === 'province',
)?.id ?? '',
path.find((node) => node.location_type === "province")?.id ??
"",
district_id:
path.find(
(node) =>
node.location_type === 'district',
)?.id ?? '',
path.find((node) => node.location_type === "district")?.id ??
"",
admin_post_id:
path.find(
(node) =>
node.location_type === 'admin_post',
)?.id ?? '',
path.find((node) => node.location_type === "admin_post")?.id ??
"",
locality_id:
path.find(
(node) =>
node.location_type === 'locality',
)?.id ?? '',
path.find((node) => node.location_type === "locality")?.id ??
"",
})
}
/>
@@ -361,13 +326,9 @@ function Index({
<select
className="block rounded border p-2"
value={form.data.certainty}
onChange={(e) =>
form.setData('certainty', e.target.value)
}
onChange={(e) => form.setData("certainty", e.target.value)}
>
<option value="include_unknown">
Incluir e assinalar
</option>
<option value="include_unknown">Incluir e assinalar</option>
<option value="documented">
Apenas âmbito documentado para os filtros escolhidos
</option>
@@ -378,7 +339,7 @@ function Index({
<select
className="block rounded border p-2"
value={form.data.state}
onChange={(e) => form.setData('state', e.target.value)}
onChange={(e) => form.setData("state", e.target.value)}
>
<option value="">Qualquer estado</option>
{Object.entries(windowLabels).map(([key, label]) => (
@@ -388,52 +349,55 @@ function Index({
))}
</select>
</label>
<button className={button} disabled={form.processing}>
Pesquisar
</button>
</form>
{criteriaChanged && (
<p role="status">
Alterou os critérios. Clique em Pesquisar para os aplicar.
Os resultados anteriores ficam separados abaixo.
Alterou os critérios. Clique em Pesquisar para os aplicar. Os resultados
anteriores ficam separados abaixo.
</p>
)}
<details
open={
!criteriaChanged && Object.keys(criteriaErrors).length === 0
}
open={!criteriaChanged && Object.keys(criteriaErrors).length === 0}
className="space-y-3"
>
<summary>
{Object.keys(criteriaErrors).length
? 'Pesquisa não executada — critérios inválidos'
? "Pesquisa não executada — critérios inválidos"
: criteriaChanged
? 'Resultados da pesquisa anterior — não correspondem aos critérios por aplicar'
: 'Resultados correspondentes à pesquisa'}
? "Resultados da pesquisa anterior — não correspondem aos critérios por aplicar"
: "Resultados correspondentes à pesquisa"}
</summary>
<p className="text-sm">
Explore as instituições e soluções adequadas ao seu
investimento. Use os filtros para encontrar a informação de
que precisa.
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 &&
Object.keys(criteriaErrors).length === 0 && (
<p>
Não foram encontradas instituições, fundos ou
programas publicados que correspondam a todos os
critérios desta pesquisa.
</p>
)}
{items.data.length === 0 && Object.keys(criteriaErrors).length === 0 && (
<p>
Não foram encontradas instituições, fundos ou programas publicados que
correspondam a todos os critérios desta pesquisa.
</p>
)}
{items.data.map((item) => (
<article className={card} key={item.id}>
<h2 className="text-xl font-semibold">
<Link
className="underline"
href={`/fundos/${item.id}?${new URLSearchParams(Object.entries(filters).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))}`}
href={`/fundos/${item.id}?${new URLSearchParams(Object.entries(filters).filter((entry): entry is [string, string] => typeof entry[1] === "string"))}`}
>
{item.payload.title}
</Link>
</h2>
{item.payload.kind === 'institution' ? (
{item.payload.kind === "institution" ? (
<InstitutionView
compact
physicalTerritory={
filters.location_mode === "physical"
? filters.territory
: undefined
}
value={
item.payload as InstitutionDetails & {
title: string;
@@ -444,30 +408,25 @@ function Index({
) : (
<>
<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>
))}
<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>
{item.payload.windows.map((window) => (
<li key={window.code}>
{window.title}:{' '}
{windowLabels[window.state]}
{window.title}: {windowLabels[window.state]}
</li>
))}
</ul>
@@ -480,12 +439,10 @@ function Index({
<Link
key={index}
href={link.url}
aria-current={link.active ? 'page' : undefined}
aria-current={link.active ? "page" : undefined}
className="underline"
>
{link.label
.replace(/&laquo;/g, '«')
.replace(/&raquo;/g, '»')}
{link.label.replace(/&laquo;/g, "«").replace(/&raquo;/g, "»")}
</Link>
) : null,
)}
+46 -69
View File
@@ -1,16 +1,10 @@
import { fundingPublicPresentation } from '@/lib/funding-public-presentation';
import {
InstitutionView,
type InstitutionDetails,
} from '@/components/funding-institution';
import {
FundingDossierChoice,
type FundingDossier,
} from '@/components/funding-dossier-choice';
import { Link, router } from '@inertiajs/react';
import FundingCriteriaAssessment from '@/components/funding-criteria-assessment';
import { Frame, card, button } from '@/pages/investment/shared';
import { type Programme, windowLabels } from './shared';
import { fundingPublicPresentation } from "@/lib/funding-public-presentation";
import { InstitutionView, type InstitutionDetails } from "@/components/funding-institution";
import { FundingDossierChoice, type FundingDossier } from "@/components/funding-dossier-choice";
import { Link, router } from "@inertiajs/react";
import FundingCriteriaAssessment from "@/components/funding-criteria-assessment";
import { Frame, card, button } from "@/pages/investment/shared";
import { type Programme, windowLabels } from "./shared";
export default function Show({
record,
@@ -35,14 +29,14 @@ export default function Show({
<Link className="underline" href="/fundos/guardados">
A minha selecção
</Link>
<Link
className="underline"
href={`/fundos?${new URLSearchParams(selection)}`}
>
<Link className="underline" href={`/fundos?${new URLSearchParams(selection)}`}>
Voltar aos fundos e programas
</Link>
{payload.kind === 'institution' ? (
{payload.kind === "institution" ? (
<InstitutionView
physicalTerritory={
selection.location_mode === "physical" ? selection.territory : undefined
}
value={
payload as InstitutionDetails & {
title: string;
@@ -58,7 +52,7 @@ export default function Show({
)}
{authenticated && (
<FundingDossierChoice
key={`${record.id}:${record.version_id}:${dossier?.id ?? ''}:${dossier?.opportunity ?? ''}:${selection.territory ?? ''}`}
key={`${record.id}:${record.version_id}:${dossier?.id ?? ""}:${dossier?.opportunity ?? ""}:${selection.territory ?? ""}`}
record={record}
dossier={dossier}
selection={selection}
@@ -66,36 +60,33 @@ export default function Show({
destinations={destinations}
/>
)}
{payload.kind !== 'institution' && (
{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(' / ')}
Local abrangido: {path.map((node) => node.name).join(" / ")}
</p>
))}
<p>
Sectores:{' '}
Sectores:{" "}
{payload.sectors_text ||
(payload.sectors_scope === 'all'
? 'Todos, segundo a fonte'
: payload.sectors_scope === 'unknown'
? 'Por confirmar'
(payload.sectors_scope === "all"
? "Todos, segundo a fonte"
: payload.sectors_scope === "unknown"
? "Por confirmar"
: (payload.sector_snapshots ?? [])
.map((sector) => sector.name)
.join(', '))}
.join(", "))}
</p>
<p>
{payload.coverage === 'national'
? 'Cobertura nacional'
: payload.coverage === 'territorial'
? 'Cobertura territorial'
: 'Cobertura por confirmar'}
{payload.coverage_text
? ` — ${payload.coverage_text}`
: ''}
{payload.coverage === "national"
? "Cobertura nacional"
: payload.coverage === "territorial"
? "Cobertura territorial"
: "Cobertura por confirmar"}
{payload.coverage_text ? ` — ${payload.coverage_text}` : ""}
</p>
{payload.source_url && (
<details>
@@ -108,24 +99,16 @@ export default function Show({
>
Consultar informação da entidade
</a>
<p>
Actualização consultada em{' '}
{payload.source_checked_on}.
</p>
<p>Actualização consultada em {payload.source_checked_on}.</p>
</details>
)}
</>
)}
{(payload.kind !== 'institution' || payload.windows.length > 0) && (
{(payload.kind !== "institution" || payload.windows.length > 0) && (
<section className={card}>
<h2 className="text-xl font-semibold">
Janelas de candidatura
</h2>
<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>
<p>Não há uma janela de candidatura confirmada para este programa.</p>
)}
{payload.windows.map((item) => (
<article key={item.code} className="my-4">
@@ -135,18 +118,16 @@ export default function Show({
{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'}
{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>
<p key={index}>{path.map((node) => node.name).join(" / ")}</p>
))}
<a
className="underline"
@@ -171,13 +152,11 @@ export default function Show({
))}
</section>
)}
{(payload.kind !== 'institution' ||
{(payload.kind !== "institution" ||
payload.criteria.length > 0 ||
payload.windows.some(
(window) => window.criteria.length > 0,
)) && (
payload.windows.some((window) => window.criteria.length > 0)) && (
<FundingCriteriaAssessment
key={record.id + ':' + record.version_id}
key={record.id + ":" + record.version_id}
record={record}
/>
)}
@@ -210,15 +189,13 @@ export default function Show({
<p>
Guardado na versão {saved.version}
{saved.version !== record.version
? '; existe uma versão pública mais recente'
: ''}
? "; existe uma versão pública mais recente"
: ""}
.
</p>
<button
className={button}
onClick={() =>
router.delete(`/fundos/${record.id}/interesse`)
}
onClick={() => router.delete(`/fundos/${record.id}/interesse`)}
>
Remover da minha lista
</button>
@@ -68,6 +68,17 @@ final class FundingCriteriaCoherenceTest extends TestCase
$this->getJson('/api/v1/investment/territory-options?type=district&parent_id='.$this->geo['province'])->assertOk()->assertJsonCount(1, 'data');
}
public function test_physical_search_defaults_to_documented_presence_but_preserves_explicit_unknown_choice(): void
{
$local = $this->publish('qa-local');
$this->publish('qa-unlocated', overrides: ['locations' => []]);
$filters = ['q' => 'SYNTHETIC qa-', 'location_mode' => 'physical', 'territory' => $this->geo['province']];
$this->get('/fundos?'.http_build_query($filters))->assertOk()->assertInertia(fn ($page) => $page
->where('filters.certainty', 'documented')->has('items.data', 1)->where('items.data.0.id', $local));
$this->get('/fundos?'.http_build_query([...$filters, 'certainty' => 'include_unknown']))->assertOk()->assertInertia(fn ($page) => $page
->where('filters.certainty', 'include_unknown')->has('items.data', 2));
}
public function test_contradictory_type_or_record_kind_and_incomplete_hierarchy_are_rejected(): void
{
foreach ([['category' => 'banking', 'institution_type' => 'broker'], ['category' => 'banking', 'institution_type' => 'commercial_bank', 'kind' => 'fund', 'location_mode' => 'physical'], ['district_id' => $this->geo['district']]] as $filters) {
@@ -1,26 +1,26 @@
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 React from 'react';
import ts from 'typescript';
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 React from "react";
import ts from "typescript";
const require = createRequire(import.meta.url);
const nodes = (tree) =>
Array.isArray(tree)
? tree.flatMap(nodes)
: tree && typeof tree === 'object'
: tree && typeof tree === "object"
? [tree, ...nodes(tree.props?.children)]
: [];
const text = (tree) =>
Array.isArray(tree)
? tree.map(text).join('')
? tree.map(text).join("")
: tree?.props
? text(tree.props.children)
: typeof tree === 'string'
: typeof tree === "string"
? tree
: '';
: "";
// Real page event handlers, simulated Inertia transport. Not a browser or visual acceptance.
function harness(filters, errors = {}) {
@@ -32,8 +32,7 @@ function harness(filters, errors = {}) {
return [
memory[id],
(next) => {
memory[id] =
typeof next === 'function' ? next(memory[id]) : next;
memory[id] = typeof next === "function" ? next(memory[id]) : next;
},
];
};
@@ -41,10 +40,7 @@ function harness(filters, errors = {}) {
let form;
const TerritoryFields = () => null;
const module = { exports: {} };
const source = fs.readFileSync(
'resources/js/pages/funding/index.tsx',
'utf8',
);
const source = fs.readFileSync("resources/js/pages/funding/index.tsx", "utf8");
compileFunction(
ts.transpileModule(source, {
compilerOptions: {
@@ -52,10 +48,10 @@ function harness(filters, errors = {}) {
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText,
['require', 'module', 'exports'],
["require", "module", "exports"],
)(
(id) => {
if (id === 'react')
if (id === "react")
return {
...React,
useState: state,
@@ -64,9 +60,9 @@ function harness(filters, errors = {}) {
return value;
},
};
if (id === '@inertiajs/react')
if (id === "@inertiajs/react")
return {
Link: 'a',
Link: "a",
useForm(initial) {
const [data, set] = state(initial);
form = {
@@ -74,11 +70,7 @@ function harness(filters, errors = {}) {
errors: {},
processing: false,
setData(key, value) {
set(
typeof key === 'string'
? { ...data, [key]: value }
: key,
);
set(typeof key === "string" ? { ...data, [key]: value } : key);
},
get(url) {
submitted = { url, data };
@@ -87,25 +79,22 @@ function harness(filters, errors = {}) {
return form;
},
};
if (id === '@/pages/investment/shared')
return { Frame: 'main', card: '', button: '' };
if (id === '@/components/funding-classification')
if (id === "@/pages/investment/shared") return { Frame: "main", card: "", button: "" };
if (id === "@/components/funding-classification")
return {
financialCategories: {
banking: 'Banca',
insurance_intermediation: 'Mediação de seguros',
banking: "Banca",
insurance_intermediation: "Mediação de seguros",
},
financialTypes: {
commercial_bank: 'Banco comercial',
broker: 'Corretora',
other: 'Outro',
commercial_bank: "Banco comercial",
broker: "Corretora",
other: "Outro",
},
};
if (id === '@/components/investment-territory')
return { TerritoryFields };
if (id === '@/components/funding-institution')
return { InstitutionView: 'article' };
if (id === './shared') return { windowLabels: {} };
if (id === "@/components/investment-territory") return { TerritoryFields };
if (id === "@/components/funding-institution") return { InstitutionView: "article" };
if (id === "./shared") return { windowLabels: {} };
return require(id);
},
module,
@@ -116,11 +105,11 @@ function harness(filters, errors = {}) {
criteriaErrors: errors,
items: { data: [], links: [] },
options: { sectors: [], territories: [] },
kinds: { institution: 'Instituição', fund: 'Fundo' },
kinds: { institution: "Instituição", fund: "Fundo" },
management: false,
categoryTypes: {
banking: ['commercial_bank'],
insurance_intermediation: ['broker'],
banking: ["commercial_bank"],
insurance_intermediation: ["broker"],
},
};
let tree;
@@ -131,11 +120,9 @@ function harness(filters, errors = {}) {
return tree;
}
const select = (label) =>
nodes(
nodes(tree).find(
(n) => n.type === 'label' && text(n).startsWith(label),
),
).find((n) => n.type === 'select');
nodes(nodes(tree).find((n) => n.type === "label" && text(n).startsWith(label))).find(
(n) => n.type === "select",
);
render();
return {
render,
@@ -147,76 +134,92 @@ function harness(filters, errors = {}) {
};
}
test('institution category restricts types and removes only an incompatible dependent choice', () => {
test("institution category restricts types and removes only an incompatible dependent choice", () => {
const h = harness({
category: 'insurance_intermediation',
institution_type: 'broker',
sector: '7',
need: 'investment',
territory: 'district-qa',
category: "insurance_intermediation",
institution_type: "broker",
sector: "7",
need: "investment",
territory: "district-qa",
});
h.select('Categoria da instituição').props.onChange({
target: { value: 'banking' },
h.select("Categoria da instituição").props.onChange({
target: { value: "banking" },
});
h.render();
assert.equal(h.form().data.institution_type, '');
assert.equal(h.form().data.institution_type, "");
for (const [key, value] of Object.entries({
sector: '7',
need: 'investment',
territory: 'district-qa',
sector: "7",
need: "investment",
territory: "district-qa",
}))
assert.equal(h.form().data[key], value);
assert.deepEqual(
nodes(h.select('Tipo de instituição'))
.filter((n) => n.type === 'option')
nodes(h.select("Tipo de instituição"))
.filter((n) => n.type === "option")
.map((n) => n.props.value),
['', 'commercial_bank', 'other'],
["", "commercial_bank", "other"],
);
assert.match(text(h.tree()), /tipo anterior não pertence/);
const results = nodes(h.tree()).find((n) => n.type === 'details');
const results = nodes(h.tree()).find((n) => n.type === "details");
assert.equal(results.props.open, false);
assert.match(text(results), /pesquisa anterior/);
});
test('contradictory record criteria remain explicit until the user chooses a correction', () => {
test("contradictory record criteria remain explicit until the user chooses a correction", () => {
const h = harness(
{
kind: 'fund',
category: 'banking',
institution_type: 'commercial_bank',
location_mode: 'physical',
province_id: 'province-qa',
sector: '8',
state: 'closed',
certainty: 'documented',
kind: "fund",
category: "banking",
institution_type: "commercial_bank",
location_mode: "physical",
province_id: "province-qa",
sector: "8",
state: "closed",
certainty: "documented",
},
{ kind: ['Combinação incompatível.'] },
{ kind: ["Combinação incompatível."] },
);
assert.match(text(h.tree()), /A pesquisa não foi executada/);
assert.equal(h.form().data.kind, 'fund');
assert.equal(h.form().data.kind, "fund");
const action = nodes(h.tree()).find(
(n) => n.type === 'button' && /Manter o fundo/.test(text(n)),
(n) => n.type === "button" && /Manter o fundo/.test(text(n)),
);
action.props.onClick();
h.render();
assert.equal(h.form().data.category, '');
assert.equal(h.form().data.institution_type, '');
assert.equal(h.form().data.location_mode, 'coverage');
assert.equal(h.form().data.category, "");
assert.equal(h.form().data.institution_type, "");
assert.equal(h.form().data.location_mode, "coverage");
for (const [key, value] of Object.entries({
kind: 'fund',
province_id: 'province-qa',
sector: '8',
state: 'closed',
certainty: 'documented',
kind: "fund",
province_id: "province-qa",
sector: "8",
state: "closed",
certainty: "documented",
}))
assert.equal(h.form().data[key], value);
nodes(h.tree())
.find((n) => n.type === 'form')
.find((n) => n.type === "form")
.props.onSubmit({ preventDefault() {} });
assert.equal(h.submitted().url, '/fundos');
assert.equal(h.submitted().data.province_id, 'province-qa');
assert.ok(
h.territory(),
'the catalogue consumes the common hierarchical location control',
);
assert.equal(h.submitted().url, "/fundos");
assert.equal(h.submitted().data.province_id, "province-qa");
assert.ok(h.territory(), "the catalogue consumes the common hierarchical location control");
});
test("local search defaults to documented presence and keeps the explicit unknown option and chosen territory", () => {
const h = harness({ territory: "district-qa", sector: "7" });
h.select("Localização a pesquisar").props.onChange({ target: { value: "physical" } });
h.render();
assert.equal(h.form().data.certainty, "documented");
assert.equal(h.form().data.territory, "district-qa");
assert.equal(h.form().data.sector, "7");
h.select("Informação por confirmar").props.onChange({ target: { value: "include_unknown" } });
h.render();
assert.equal(h.form().data.certainty, "include_unknown");
const resumed = harness({
location_mode: "physical",
territory: "district-qa",
certainty: "include_unknown",
});
assert.equal(resumed.form().data.certainty, "include_unknown");
assert.equal(harness({ location_mode: "physical" }).form().data.certainty, "documented");
});
@@ -1,32 +1,26 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { compileFunction } from 'node:vm';
import { createRequire } from 'node:module';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
import * as presentation from '../../resources/js/lib/funding-public-presentation.ts';
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { compileFunction } from "node:vm";
import { createRequire } from "node:module";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ts from "typescript";
import * as presentation from "../../resources/js/lib/funding-public-presentation.ts";
const require = createRequire(import.meta.url),
records = Object.values(
JSON.parse(
fs.readFileSync(
new URL(
'../../database/data/funding-institutions-nov2026.json',
import.meta.url,
),
'utf8',
new URL("../../database/data/funding-institutions-nov2026.json", import.meta.url),
"utf8",
),
).references,
).map((x) => x.draft);
function load(file) {
const module = { exports: {} };
const output = ts.transpileModule(
fs.readFileSync(
new URL('../../resources/js/' + file, import.meta.url),
'utf8',
),
fs.readFileSync(new URL("../../resources/js/" + file, import.meta.url), "utf8"),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
@@ -35,98 +29,89 @@ function load(file) {
},
},
).outputText;
compileFunction(output, ['require', 'module', 'exports'])(
compileFunction(output, ["require", "module", "exports"])(
(name) =>
name === '@/lib/funding-public-presentation'
name === "@/lib/funding-public-presentation"
? presentation
: name === '@/pages/investment/shared'
: name === "@/pages/investment/shared"
? {
Frame: ({ title, children }) =>
React.createElement(
'main',
"main",
null,
React.createElement('h1', null, title),
React.createElement("h1", null, title),
children,
),
card: 'card',
button: 'button',
input: 'input',
card: "card",
button: "button",
input: "input",
}
: name === '@inertiajs/react'
: name === "@inertiajs/react"
? {
Link: ({ children, href }) =>
React.createElement('a', { href }, children),
React.createElement("a", { href }, children),
router: {},
useForm: (data) => ({ data, errors: {}, setData() {}, get() {} }),
}
: name === './shared'
: name === "./shared"
? { windowLabels: {} }
: name === '@/lib/portal-api'
: name === "@/lib/portal-api"
? {
portalRequest: () => {
throw Error(
'Read-only rendering must not send requests',
);
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'
: 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',
)
: name === "@/components/funding-criteria-assessment"
? load("components/funding-criteria-assessment.tsx")
: resolveLocal(name, file),
module,
module.exports,
);
return module.exports;
}
test('all270 imported institutions get readable descriptions and evidenced services without changing the published payload', () => {
test("all270 imported institutions get readable descriptions and evidenced services without changing the published payload", () => {
assert.equal(records.length, 270);
for (const p of records) {
const original = JSON.stringify(p),
m = presentation.fundingPublicPresentation(p);
assert.ok(m.description);
assert.doesNotMatch(
m.description,
/documento fornecido|ficha|SHA-256|Público\/finalidade/,
);
assert.doesNotMatch(m.description, /documento fornecido|ficha|SHA-256|Público\/finalidade/);
assert.equal(JSON.stringify(p), original);
assert.equal(
m.products.length,
(p.products ?? []).filter((x) => x.availability !== 'closed')
.length,
(p.products ?? []).filter((x) => x.availability !== "closed").length,
);
}
});
test('deliberate editorial descriptions survive and missing products are never fabricated', () => {
test("deliberate editorial descriptions survive and missing products are never fabricated", () => {
const m = presentation.fundingPublicPresentation({
title: 'Example',
kind: 'institution',
summary: 'Apoio especializado ao investimento rural.',
title: "Example",
kind: "institution",
summary: "Apoio especializado ao investimento rural.",
products: [],
institution_types: ['insurer'],
institution_types: ["insurer"],
});
assert.equal(m.description, 'Apoio especializado ao investimento rural.');
assert.equal(m.description, "Apoio especializado ao investimento rural.");
assert.deepEqual(m.products, []);
assert.deepEqual(m.services, []);
});
test('actual public cards and detail lead with description/products and keep import metadata out of the visitor text', () => {
const Index = load('pages/funding/index.tsx').default,
Show = load('pages/funding/show.tsx').default;
test("actual public cards and detail lead with description/products and keep import metadata out of the visitor text", () => {
const Index = load("pages/funding/index.tsx").default,
Show = load("pages/funding/show.tsx").default;
for (const raw of [
records[0],
records.find((p) => p.institution_types.includes('investment_manager')),
records.find((p) => p.institution_types.includes("investment_manager")),
]) {
const p = { ...raw, windows: [], documents: [] },
item = {
id: 'synthetic',
version_id: 'synthetic-version',
id: "synthetic",
version_id: "synthetic-version",
version: 1,
payload: p,
};
@@ -152,23 +137,15 @@ test('actual public cards and detail lead with description/products and keep imp
/ficha|documento fornecido|SHA-256|linha de origem|Instituições financeiras em Moçambique\.docx/i,
);
assert.match(html, /Produtos e serviços/);
assert.match(
html,
new RegExp(raw.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
);
assert.match(html, new RegExp(raw.title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
}
const out = new URL(
'../../outputs/funding-public-editorial-20261111/',
import.meta.url,
);
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);
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),
new URL("verification.json", out),
JSON.stringify(
{
publishedSourceRecords: 270,
@@ -189,21 +166,18 @@ test('actual public cards and detail lead with description/products and keep imp
}
});
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,
);
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']) {
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',
id: "synthetic",
version_id: "synthetic-version",
version: 1,
payload: p,
};
@@ -224,8 +198,7 @@ test('Quorus and Moza use one shared clean profile in both list and detail', ()
}),
);
for (const html of [
index.match(/<article class="card">([\s\S]*?)<\/article>/)?.[0] ??
index,
index.match(/<article class="card">([\s\S]*?)<\/article>/)?.[0] ?? index,
detail,
]) {
assert.doesNotMatch(
@@ -235,62 +208,38 @@ test('Quorus and Moza use one shared clean profile in both list and detail', ()
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/,
);
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(/Rua dos Desportistas/g) || []).length,
1,
);
assert.equal(
(
detail.match(
/Tesouraria, investimento, construção e equipamento/g,
) || []
).length,
(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,
);
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'),
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,
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',
role: "branch",
address: "Rua dos Desportistas, número 714",
},
]).length,
2,
@@ -299,10 +248,68 @@ test('contact and location deduplication preserves different branches and origin
});
function resolveLocal(name, parent) {
if (name === './territory-geometry-preview') return { default: () => null };
const relative = name.startsWith('@/') ? name.slice(2) : name.startsWith('.') ? path.posix.join(path.posix.dirname(parent), name) : null;
if (relative) for (const candidate of [relative, relative + '.tsx', relative + '.ts']) {
if (fs.existsSync(new URL('../../resources/js/' + candidate, import.meta.url))) return load(candidate);
}
if (name === "./territory-geometry-preview") return { default: () => null };
const relative = name.startsWith("@/")
? name.slice(2)
: name.startsWith(".")
? path.posix.join(path.posix.dirname(parent), name)
: null;
if (relative)
for (const candidate of [relative, relative + ".tsx", relative + ".ts"]) {
if (fs.existsSync(new URL("../../resources/js/" + candidate, import.meta.url)))
return load(candidate);
}
return require(name);
}
test("physical presence matches descendants, not a broader province or unknown locations", () => {
const points = [
{
territory_id: "district-a",
territory_path: [
{ id: "province", name: "Province" },
{ id: "district-a", name: "A" },
],
evidence_status: "reported",
},
{
territory_id: "province",
territory_path: [{ id: "province", name: "Province" }],
evidence_status: "reported",
},
{ territory_id: "district-a", evidence_status: "to_confirm" },
{ territory_id: "district-a", evidence_status: "historical" },
{ territory_id: "" },
];
assert.deepEqual(presentation.matchingInstitutionLocations(points, "district-a"), [points[0]]);
assert.deepEqual(
presentation.matchingInstitutionLocations(points, "province"),
points.slice(0, 2),
);
assert.deepEqual(presentation.matchingInstitutionLocations(points, "elsewhere"), []);
});
test("compact institutions show local address and explicitly identify unconfirmed physical presence", () => {
const { InstitutionView } = load("components/funding-institution.tsx");
const value = {
...records[0],
locations: [
{
role: "branch",
address: "Balcão de Tete",
territory_id: "tete",
territory_path: [{ id: "tete", name: "Tete" }],
},
{ role: "headquarters", address: "Sede Maputo", territory_id: "maputo" },
],
};
const render = (territory, compact = true) =>
renderToStaticMarkup(
React.createElement(InstitutionView, { value, compact, physicalTerritory: territory }),
);
assert.match(render("tete"), /Balcão de Tete/);
assert.doesNotMatch(render("tete"), /Sede Maputo|Presença física por confirmar/);
assert.match(render("niassa"), /Presença física por confirmar no local escolhido/);
assert.doesNotMatch(render("niassa"), /Balcão de Tete|Sede Maputo/);
assert.match(render("tete", false), /Sede Maputo/);
assert.match(render("tete", false), /Balcão de Tete/);
});