fix: make financial institution local presence explicit
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(/«/g, '«')
|
||||
.replace(/»/g, '»')}
|
||||
{link.label.replace(/«/g, "«").replace(/»/g, "»")}
|
||||
</Link>
|
||||
) : null,
|
||||
)}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user