fix(investor): simplify preparation flow, profile requests and scoped zone search
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-28 21:26:59 +02:00
parent 54d1b2a543
commit 502e3ec4d5
23 changed files with 573 additions and 101 deletions
+5 -2
View File
@@ -63,10 +63,13 @@ class InvestmentZones
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$territoryIds = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
$query->where(function ($matches) use ($territoryIds, $management): void {
$ancestorIds = array_column($path, 'id');
$query->where(function ($matches) use ($territoryIds, $ancestorIds, $management): void {
$matches->whereIn('primary_territory_id', $territoryIds)
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('investment_zone_locations as zl')
->whereColumn('zl.zone_id', 'investment_zones.id')->whereIn('zl.geo_location_id', $territoryIds)
->whereColumn('zl.zone_id', 'investment_zones.id')->whereIn('zl.scope_type', ['site', 'administrative', 'legal_scope'])
->where(fn ($area) => $area->whereIn('zl.geo_location_id', $territoryIds)
->orWhere(fn ($coverage) => $coverage->where('zl.scope_type', 'legal_scope')->whereIn('zl.geo_location_id', $ancestorIds)))
->where('zl.validation_status', 'validated')->where(fn ($valid) => $valid->whereNull('zl.valid_from')->orWhereDate('zl.valid_from', '<=', today()))
->where(fn ($valid) => $valid->whereNull('zl.valid_until')->orWhereDate('zl.valid_until', '>=', today())));
if (! $management) {
@@ -1780,3 +1780,22 @@ A Tempo esclareceu que a combinação província/sector deve apresentar as oport
Correcção de apresentação em QA: título dos resultados explicita sector e hierarquia escolhidos; removidos os cartões educativos e o botão sectorial sem localização da área de resultados. Os guias mantêm-se como títulos ligados em apoio opcional fechado por defeito. A remoção da localização é uma acção expressamente identificada como pesquisa noutras províncias. O estado vazio refere o cadastro consultado e a cobertura incompleta, sem fabricar oportunidades nem alargar automaticamente filtros.
Dois testes SSR do novo apoio opcional passaram, bem como tipos e compilação. As provas territoriais nacionais 6/617 e os quatro testes do selector são reutilizados; não mudou a consulta PHP ou o controlo territorial neste delta. Mantêm-se histórico, publicações, permissões, fontes e base interna. Este candidato continua sem instalação, dependente de autenticação SSH; a sessão real permanece sem prova visual. Relatório existente: `catalogue-province-coverage-qa-20261028.json`, campo `catalogue_result_presentation`. Requisitos V2-07/V2-10 actualizados na mesma matriz; L8 permanece parcial.
28/10 - Before investor usability checks: accepted catalogue candidate54d1b2a5 remains in QA,not installed;active menus6e8de43a and earlier supply proofs preserved. Two Tempo attachments identify duplicate profile navigation,an exposed long canonical form and ZEE/ZFI/park searches that keep prior results until manual submit. Prepare presentation-only adaptive field groups and safe save-before-resource navigation;verify territorial search/count/paging and documented scope without republishing records or changing permissions. Reuse prior proofs;existing V2-07/V2-10 scope retained.
## 28/10/2026 — usabilidade do investidor e consulta territorial, QA
Pedidos da Tempo e exemplos recebidos: menu/ficha de oportunidade, zonas/parques, pedido de Promotor, Os meus investimentos, documentos/formulários e recusa autenticada em Turismo. Inventário anterior registado antes dos testes: menus 6e8de43a e base interna instalados; candidato territorial 54d1b2a5 ainda em QA; originais, dados, relações, histórico, aprovações e provas anteriores preservados.
Delta QA: acções do investidor em primeiro plano, administração em detalhe consultável; campos do contrato existente agrupados por tarefa e modalidade, com navegação livre e todos os campos montados para conservar edições. Os grupos mostram as pendências da última gravação e os erros existentes; não criam seis ou oito formulários obrigatórios. Guardar usa os mesmos endpoints/ETags; abrir recursos pela preparação guarda primeiro e regressa à mesma oportunidade/grupo. Contactos e detalhes de disponibilidade ficam opcionais; códigos técnicos de categorias e campos conhecidos têm nomes em português. Só um contexto único de promotor é escolhido automaticamente.
Zonas: selecção territorial aplica pesquisa sem esperar por outro clique; a lista anterior deixa de aparecer durante carregamento/erro. Totais, intervalo visível e páginas identificados, sem apresentar a primeira página como o conjunto completo. «Todo o território» remove expressamente a localização, preservando os restantes critérios visíveis. Abrangência jurídica documentada e validada inclui os níveis descendentes; influência não se torna implantação. Não são criadas localizações, direitos, publicações ou certificados.
Pedido de Promotor: representação/modalidades com nomes claros; pesquisa de ficheiros e selecção expressa dos comprovativos, preservando os escolhidos ao pesquisar. Mantidos propriedade, análise antimalware e verificações existentes; legislação/imagem/teste não se torna comprovativo por estar disponível. Os meus investimentos destaca a próxima acção e permite abrir fases/etapas; fontes, códigos e acompanhamento interno ficam preservados em detalhes.
Formulários: entradas directas para os campos já existentes da preparação do pedido de investimento e Terra/DUAT; pedido de apoio identificado separadamente. Originais oficiais e guias continuam consultáveis, sem os confundir com um formulário preenchível ou converter leis em campos/cálculos. Não se declara executada a totalidade dos formulários oficiais do acervo.
Provas delimitadas: 8 testes frontend de contrato/SSR e 3 testes isolados/12 verificações, zero falhas; tipos, compilação e sintaxe PHP passaram. Dados sintéticos revertidos em QA. Provas nacionais/selector/catálogo anteriores reutilizadas. Turismo: leitura anónima activa HTTP200 e QA visitante/conta sem Promotor HTTP200; a recusa da sessão real relatada não foi reproduzida, nem atribuída a uma permissão sem prova. A conferência autenticada e visual continua pendente. Este registo não instala o candidato nem encerra a implementação transversal. L8 permanece parcial.
Evidência: `investor-usability-qa-20261028.json`. Actualizados V2-01,V2-07,V2-10 na matriz existente; sem nova matriz ou backlog.
@@ -0,0 +1,58 @@
{
"date": "2026-10-28",
"status": "QA_implemented_and_delta_verified_not_installed",
"runtime_baseline": "6e8de43ae7fec29b0766d95e209a8cb8baf0d88c",
"prepared_catalogue_parent": "54d1b2a5",
"Tempo_requests": [
"Readable investor work menu and adaptive filling flow",
"ZEE/ZFI/park results must match explicit territory selection;all means the available set",
"Promoter request must use readable representation/modality labels and usable evidence selection",
"Investigate authenticated denial of public Tourism sector",
"Simplify My investments and present useful next actions",
"Expose fillable forms rather than a guide or legislative catalogue"
],
"implemented": {
"work_menu": "Investor actions remain primary;existing access/audit/profile setup destinations move into optional administration details;no permission change",
"filling": "Schema fields grouped adaptively by task;free navigation,all fields stay mounted,unknown future keys retained;server missing paths and errors point to groups;not mandatory sequential forms",
"preservation": "Deliberate values retained between groups;save uses existing endpoints and ETags;resource navigation saves first and returns to the same opportunity/group;only unique promoter context is auto-selected",
"profile_request": "Portuguese visible representation/modality labels;owned clean-file criteria unchanged;file search and explicit selected proofs replace the unbounded list;availability does not prove evidentiary suitability",
"zones": "Territory and other filter changes apply search;old records hidden while loading/error;all and filtered totals plus pagination explicit;existing public visibility preserved;validated legal coverage includes its child area,while influence is not treated as a site",
"my_investments": "Next action remains prominent;other phases/stages expandable;source/code history and internal follow-up remain consultable rather than filling the main page",
"forms": "Direct entries to existing private investment preparation fields and Terra/DUAT;support request separately labelled;originals/guides optional;no law is converted into a form or executable calculation"
},
"QA": {
"frontend_contract_and_SSR_tests": 8,
"frontend_failures": 0,
"isolated_feature_tests": 3,
"feature_assertions": 12,
"feature_failures": 0,
"database": "proactive_investment_20261027_test",
"port": 55441,
"transactions_rolled_back": true,
"types": "passed",
"build": "passed",
"PHP_syntax": "passed",
"accepted_national_selector_and_catalogue_proofs": "reused"
},
"Tourism_incident": {
"reported_by_Tempo": "authenticated session saw access denial at /sectores/turismo",
"anonymous_live_HTTP": 200,
"isolated_guest_and_no_promoter_account_HTTP": 200,
"exact_real_session_cause": "not demonstrated;do not infer missing role or claim correction",
"private_permissions_changed": false,
"next": "Confer the actual authenticated session and related logs;public read is not real-session proof"
},
"limits": [
"Not installed in this QA report",
"SSR and controlled event tests are not browser keyboard/touch/layout evidence",
"Existing form adaptations are exposed;this does not implement every official form in the document collection",
"No new roles,publications,source edits,schema changes or fiscal activation",
"L8 remains partial"
],
"restriction_origins": {
"UX_and_selected_scope": "Tempo current express instructions",
"data_and_permissions_preservation": "permanent project instructions",
"grouping_and_collapsed_details": "implementation choice,not new organisational requirement",
"document_ownership_scan_and_existing_workflows": "prior contracts preserved"
}
}
@@ -7338,3 +7338,22 @@ A Tempo esclareceu que a combinação província/sector deve apresentar as oport
Correcção de apresentação em QA: título dos resultados explicita sector e hierarquia escolhidos; removidos os cartões educativos e o botão sectorial sem localização da área de resultados. Os guias mantêm-se como títulos ligados em apoio opcional fechado por defeito. A remoção da localização é uma acção expressamente identificada como pesquisa noutras províncias. O estado vazio refere o cadastro consultado e a cobertura incompleta, sem fabricar oportunidades nem alargar automaticamente filtros.
Dois testes SSR do novo apoio opcional passaram, bem como tipos e compilação. As provas territoriais nacionais 6/617 e os quatro testes do selector são reutilizados; não mudou a consulta PHP ou o controlo territorial neste delta. Mantêm-se histórico, publicações, permissões, fontes e base interna. Este candidato continua sem instalação, dependente de autenticação SSH; a sessão real permanece sem prova visual. Relatório existente: `catalogue-province-coverage-qa-20261028.json`, campo `catalogue_result_presentation`. Requisitos V2-07/V2-10 actualizados na mesma matriz; L8 permanece parcial.
28/10 - Before investor usability checks: accepted catalogue candidate54d1b2a5 remains in QA,not installed;active menus6e8de43a and earlier supply proofs preserved. Two Tempo attachments identify duplicate profile navigation,an exposed long canonical form and ZEE/ZFI/park searches that keep prior results until manual submit. Prepare presentation-only adaptive field groups and safe save-before-resource navigation;verify territorial search/count/paging and documented scope without republishing records or changing permissions. Reuse prior proofs;existing V2-07/V2-10 scope retained.
## 28/10/2026 — usabilidade do investidor e consulta territorial, QA
Pedidos da Tempo e exemplos recebidos: menu/ficha de oportunidade, zonas/parques, pedido de Promotor, Os meus investimentos, documentos/formulários e recusa autenticada em Turismo. Inventário anterior registado antes dos testes: menus 6e8de43a e base interna instalados; candidato territorial 54d1b2a5 ainda em QA; originais, dados, relações, histórico, aprovações e provas anteriores preservados.
Delta QA: acções do investidor em primeiro plano, administração em detalhe consultável; campos do contrato existente agrupados por tarefa e modalidade, com navegação livre e todos os campos montados para conservar edições. Os grupos mostram as pendências da última gravação e os erros existentes; não criam seis ou oito formulários obrigatórios. Guardar usa os mesmos endpoints/ETags; abrir recursos pela preparação guarda primeiro e regressa à mesma oportunidade/grupo. Contactos e detalhes de disponibilidade ficam opcionais; códigos técnicos de categorias e campos conhecidos têm nomes em português. Só um contexto único de promotor é escolhido automaticamente.
Zonas: selecção territorial aplica pesquisa sem esperar por outro clique; a lista anterior deixa de aparecer durante carregamento/erro. Totais, intervalo visível e páginas identificados, sem apresentar a primeira página como o conjunto completo. «Todo o território» remove expressamente a localização, preservando os restantes critérios visíveis. Abrangência jurídica documentada e validada inclui os níveis descendentes; influência não se torna implantação. Não são criadas localizações, direitos, publicações ou certificados.
Pedido de Promotor: representação/modalidades com nomes claros; pesquisa de ficheiros e selecção expressa dos comprovativos, preservando os escolhidos ao pesquisar. Mantidos propriedade, análise antimalware e verificações existentes; legislação/imagem/teste não se torna comprovativo por estar disponível. Os meus investimentos destaca a próxima acção e permite abrir fases/etapas; fontes, códigos e acompanhamento interno ficam preservados em detalhes.
Formulários: entradas directas para os campos já existentes da preparação do pedido de investimento e Terra/DUAT; pedido de apoio identificado separadamente. Originais oficiais e guias continuam consultáveis, sem os confundir com um formulário preenchível ou converter leis em campos/cálculos. Não se declara executada a totalidade dos formulários oficiais do acervo.
Provas delimitadas: 8 testes frontend de contrato/SSR e 3 testes isolados/12 verificações, zero falhas; tipos, compilação e sintaxe PHP passaram. Dados sintéticos revertidos em QA. Provas nacionais/selector/catálogo anteriores reutilizadas. Turismo: leitura anónima activa HTTP200 e QA visitante/conta sem Promotor HTTP200; a recusa da sessão real relatada não foi reproduzida, nem atribuída a uma permissão sem prova. A conferência autenticada e visual continua pendente. Este registo não instala o candidato nem encerra a implementação transversal. L8 permanece parcial.
Evidência: `investor-usability-qa-20261028.json`. Actualizados V2-01,V2-07,V2-10 na matriz existente; sem nova matriz ou backlog.
File diff suppressed because one or more lines are too long
@@ -2,5 +2,5 @@ import { Link, usePage } from '@inertiajs/react';
export default function ContentManagementEntry() {
const { contentManagementHref } = usePage<{ contentManagementHref?: string | null }>().props;
return contentManagementHref ? <Link className="inline-block rounded-lg border px-4 py-3 font-semibold underline" href={contentManagementHref}>Gestão de conteúdos e oportunidades</Link> : null;
return contentManagementHref ? <Link className="inline-block rounded-lg border px-4 py-3 font-semibold underline" href={contentManagementHref}>Área de trabalho</Link> : null;
}
@@ -1,4 +1,4 @@
import { Link } from '@inertiajs/react';
import { Link, usePage } from '@inertiajs/react';
import { useState } from 'react';
import { fiscalPreparationGuide } from '@/lib/fiscal-preparation-guide';
@@ -8,6 +8,8 @@ export function JourneyDocumentResources({
stages?: string[];
}) {
const [stage, setStage] = useState('');
const context=new URL(usePage().url,'https://portal.invalid').searchParams.get('context');
const preparationHref=context&&/^[a-f0-9-]{36}$/i.test(context)?`/investimentos/${context}/contexto/editar#preparation-questions`:'/investimentos';
function destination(path: string, type?: string) {
const query = new URLSearchParams();
if (stage) query.set('stage[0]', stage);
@@ -20,8 +22,24 @@ export function JourneyDocumentResources({
className="space-y-3 rounded-xl border border-emerald-200 bg-emerald-50 p-4"
>
<h2 className="font-semibold text-emerald-950">
Fontes e guias para a jornada
Preencher formulários e preparar documentos
</h2>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded border bg-white p-3"><h3 className="font-semibold">Pedido de investimento</h3>
<p>Preencha a preparação digital no dossier existente, reutilizando os dados já guardados.</p>
<Link className="underline" href={preparationHref}>Abrir o formulário no meu dossier</Link>
</div>
<div className="rounded border bg-white p-3"><h3 className="font-semibold">Terra e DUAT</h3>
<p>Prepare os dados do pedido com os campos do formulário existente.</p>
<Link className="underline" href="/terra-duat">Preencher ou continuar pedido de terra</Link>
</div>
<div className="rounded border bg-white p-3"><h3 className="font-semibold">Pedido de apoio do investidor</h3>
<p>Identifique-se e preencha o pedido de apoio. Este formulário não é um pedido de autorização de investimento.</p>
<Link className="underline" href="/formularios/investidor">Preencher pedido de apoio</Link>
</div>
</div>
<p>Preparação digital e exemplar oficial são apresentados separadamente. Um guia ou diploma não é um formulário preenchível.</p>
<details><summary className="cursor-pointer font-medium">Consultar exemplares, fontes e guias</summary>
<p>
Prepare as etapas em paralelo. A disponibilidade para consulta,
a situação jurídica e a utilização numa regra ou cálculo são
@@ -143,6 +161,7 @@ export function JourneyDocumentResources({
<Link className="underline" href="/documentos">
Explorar todo o acervo autorizado
</Link>
</details>
</section>
);
}
@@ -7,6 +7,9 @@ import { valueText } from '@/lib/value-text';
import OpportunityParcelGeometry from './opportunity-parcel-geometry';
import TerritoryGeometryPreview from './territory-geometry-preview';
import { control } from '@/pages/management/shared';
import OpportunityFillFlow from './opportunity-fill-flow';
import { opportunityFillGroups } from '@/lib/opportunity-fill-flow';
import { modalityLabels } from './opportunity-search-filters';
type Data = Record<string, unknown>;
type Schema = {
@@ -241,6 +244,11 @@ const labels: Record<string, string> = {
assessment_status: 'Estado da avaliação',
environmental_category: 'Categoria ambiental',
direct_jobs: 'Empregos diretos',
indirect_jobs: 'Empregos indirectos',
female_percentage: 'Participação de mulheres (%)',
youth_percentage: 'Participação de jovens (%)',
social_risks: 'Riscos sociais',
mitigation_plan: 'Plano de mitigação',
employment_type: 'Tipo de emprego',
geometry: 'Geometria da parcela',
start_date: 'Data de início',
@@ -621,6 +629,8 @@ export default function OpportunityCanonicalFields({
sectors,
disabled = false,
errors = {},
onOpenResources,
missingPaths,
}: {
value: Data;
onChange: (v: Data) => void;
@@ -631,6 +641,8 @@ export default function OpportunityCanonicalFields({
sectors: { id: number; name: string }[];
disabled?: boolean;
errors?: Record<string, string>;
onOpenResources?: (group: string) => void;
missingPaths?: string[];
}) {
const initiative = (value.initiative ?? {}) as Data;
const isInitiative = category === 'investment_project' && ['program', 'portfolio'].includes(valueText(initiative.kind ?? ''));
@@ -815,7 +827,7 @@ export default function OpportunityCanonicalFields({
: ['classified_other'];
return (
<fieldset disabled={disabled} className="space-y-4 rounded border p-4">
<legend>Contrato da oportunidade — {category}</legend>
<legend>Preparar oportunidade — {modalityLabels[category] ?? 'Modalidade por escolher'}</legend>
<p>
O rascunho pode ficar incompleto. A submissão verifica os campos
aplicáveis e os recursos guardados. Validação interna não
@@ -870,7 +882,7 @@ export default function OpportunityCanonicalFields({
</>}
</section>
)}
{[...new Set([...common, ...specialized])].map((key) => {
<OpportunityFillFlow groups={opportunityFillGroups([...new Set([...common, ...specialized])].filter((key) => !ignoredFields.includes(key)))} errors={errors} missingPaths={missingPaths} onOpenResources={onOpenResources} renderField={(key) => {
if (ignoredFields.includes(key)) return null;
if (key === 'relations' && options.classification) {
@@ -1167,7 +1179,7 @@ export default function OpportunityCanonicalFields({
onChange={(v) => set(key, v)}
/>
) : null;
})}
}} />
</fieldset>
);
}
@@ -0,0 +1,51 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { opportunityErrorGroup, opportunityMissingGroups, type FillGroup } from '@/lib/opportunity-fill-flow';
export default function OpportunityFillFlow({groups,errors,renderField,onOpenResources,missingPaths=[]}:{
groups:FillGroup[];errors:Record<string,string>;renderField:(field:string)=>ReactNode;onOpenResources?:(group:string)=>void;
missingPaths?:string[];
}) {
const [active,setActive]=useState(groups[0]?.id??'');
const groupIds=groups.map(group=>group.id).join(',');
useEffect(()=>{
const saved=window.location.hash.replace('#fill-','');
if(groupIds.split(',').includes(saved))setActive(saved);
},[groupIds]);
const heading=useRef<HTMLHeadingElement|null>(null);
const errorGroup=opportunityErrorGroup(groups,errors);
const missing=opportunityMissingGroups(groups,missingPaths);
const errorKey=JSON.stringify(errors);
useEffect(()=>{if(errorGroup)setActive(errorGroup);},[errorGroup,errorKey]);
const index=Math.max(0,groups.findIndex(group=>group.id===active));
const current=groups[index];
if(!current)return null;
const go=(id:string)=>{
setActive(id);
window.history.replaceState(window.history.state,'',`#fill-${id}`);
requestAnimationFrame(()=>heading.current?.focus());
};
return <div className="space-y-4">
<p>Pode escolher qualquer grupo, guardar incompleto e retomar. As informações já preenchidas mantêm-se ao avançar e regressar.</p>
<nav aria-label="Preencher a oportunidade" className="flex flex-wrap gap-2">
{groups.map(group=><a key={group.id} href={`#fill-${group.id}`} aria-current={current.id===group.id?'step':undefined}
className={`rounded border px-3 py-2 text-left ${current.id===group.id?'bg-primary text-primary-foreground':'bg-background'}`}
onClick={event=>{event.preventDefault();go(group.id);}}>{group.title}{opportunityErrorGroup([group],errors)?' — verificar':missing.some(item=>item.id===group.id)?' — dados em falta':''}</a>)}
</nav>
{missing.length>0&&<p>Pendências da última gravação: {missing.map(group=>group.title).join(' · ')}. Guarde para actualizar esta indicação.</p>}
<h2 ref={heading} tabIndex={-1} className="text-xl font-semibold">{current.title}</h2>
<p>{current.hint}</p>
{groups.map(group=><section key={group.id} id={`fill-${group.id}`} hidden={current.id!==group.id}
aria-label={group.title} className="space-y-4">
{group.fields.map(field=><div key={field}>{renderField(field)}</div>)}
{onOpenResources&&group.fields.some(field=>['locations','documents','images'].includes(field))&&
<button type="button" className="rounded border px-4 py-2 font-medium" onClick={()=>onOpenResources(group.id)}>Guardar e abrir documentos, imagens e localizações</button>}
</section>)}
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-3">
<span className="text-sm">Grupo {index+1} de {groups.length} · navegação livre</span>
<div className="flex gap-3">
{index>0&&<a href={`#fill-${groups[index-1].id}`} className="rounded border px-4 py-2" onClick={event=>{event.preventDefault();go(groups[index-1].id);}}>Anterior</a>}
{index<groups.length-1&&<a href={`#fill-${groups[index+1].id}`} className="rounded border px-4 py-2" onClick={event=>{event.preventDefault();go(groups[index+1].id);}}>Continuar: {groups[index+1].title}</a>}
</div>
</div>
</div>;
}
@@ -73,6 +73,8 @@ export function PreparationSummary({
<h2 className="text-xl font-bold">
Preparar o pedido de investimento privado
</h2>
{caseId&&<p><Link className="inline-block rounded border px-4 py-2 font-semibold" href={`/investimentos/${caseId}/contexto/editar#preparation-questions`}>Preencher ou continuar o formulário</Link></p>}
<details><summary className="cursor-pointer font-medium">Consultar situação, documentos e fundamentos</summary>
{!data.enabled ? (
<p>
Use os dados deste dossier para identificar o pedido e os
@@ -219,10 +221,11 @@ export function PreparationSummary({
className="underline"
href={`/investimentos/${caseId}/contexto/editar#preparation-questions`}
>
Completar apenas os dados em falta ou editar declarações
Editar os dados do formulário
</Link>
</p>
)}
</details>
</section>
);
}
+12 -2
View File
@@ -28,7 +28,7 @@ export function profileLinks(access: ProfileAccess | null | undefined) {
...(access.administration
? [
{
title: 'Instalação dos perfis 040000',
title: 'Configuração dos perfis',
href: '/management/profile-install',
},
]
@@ -41,16 +41,26 @@ export default function ProfileNavigation() {
profileAccess?: ProfileAccess | null;
}>().props;
if (!profileAccess) return null;
const links = profileLinks(profileAccess);
const workHrefs = [profileAccess.mine, profileAccess.create, profileAccess.review].filter(Boolean);
const work = links.filter((link) => workHrefs.includes(link.href));
const access = links.filter((link) => !workHrefs.includes(link.href));
return (
<nav
aria-label="Perfil e oportunidades"
className="flex flex-wrap gap-4 rounded border p-4"
>
{profileLinks(profileAccess).map((link) => (
{work.map((link) => (
<Link key={link.href} href={link.href} className="underline">
{link.title}
</Link>
))}
{access.length > 0 && <details className="basis-full">
<summary className="cursor-pointer font-medium">O meu acesso e administração</summary>
<div className="mt-3 flex flex-wrap gap-4">{access.map((link) =>
<Link key={link.href} href={link.href} className="underline">{link.title}</Link>
)}</div>
</details>}
</nav>
);
}
@@ -0,0 +1,33 @@
import { useState } from 'react';
export default function ProfileProofPicker({documents,value,onChange,disabled=false}:{
documents:{id:string;file_name:string}[];value:string[];onChange:(ids:string[])=>void;disabled?:boolean;
}) {
const [query,setQuery]=useState('');
const [showAll,setShowAll]=useState(false);
const [limit,setLimit]=useState(10);
const normalize=(text:string)=>text.normalize('NFD').replace(/\p{Diacritic}/gu,'').toLocaleLowerCase();
const matches=documents.filter(document=>normalize(document.file_name).includes(normalize(query)));
const toggle=(id:string)=>onChange(value.includes(id)?value.filter(item=>item!==id):[...value,id]);
return <fieldset disabled={disabled} className="space-y-3 rounded border p-4">
<legend>Escolher comprovativos</legend>
<p className="text-sm">Escolha os seus documentos que sustentam este pedido. A lista contém ficheiros próprios com análise antimalware concluída; não confirma a sua adequação como comprovativo.</p>
<p role="status">{value.length} comprovativos escolhidos</p>
{value.map(id=><div key={id} className="flex items-start justify-between gap-3 rounded border p-2">
<span className="min-w-0 break-words">{documents.find(document=>document.id===id)?.file_name??'Ficheiro seleccionado indisponível nesta consulta'}</span>
<button type="button" className="underline" onClick={()=>toggle(id)}>Retirar</button>
</div>)}
<label className="block">Pesquisar nos meus ficheiros
<input type="search" className="mt-1 w-full rounded border p-2" value={query} onChange={event=>{setQuery(event.target.value);setLimit(10);}} />
</label>
<button type="button" className="underline" onClick={()=>setShowAll(!showAll)}>{showAll?'Fechar lista':'Ver ficheiros disponíveis'}</button>
{(query||showAll)&&<div className="space-y-2">
<p role="status">{matches.length} ficheiros encontrados · a mostrar até {Math.min(limit,matches.length)}</p>
{matches.slice(0,limit).map(document=><label key={document.id} className="flex gap-2 rounded border p-2">
<input type="checkbox" checked={value.includes(document.id)} onChange={()=>toggle(document.id)} />
<span className="min-w-0 break-words">{document.file_name}</span>
</label>)}
{matches.length>limit&&<button type="button" className="underline" onClick={()=>setLimit(limit+10)}>Mostrar mais ficheiros</button>}
</div>}
</fieldset>;
}
+1 -1
View File
@@ -50,7 +50,7 @@ function count(props: Record<string, unknown>): number | null {
}
export default function SearchFeedback() {
const page = usePage();
const handledLocally = page.component === 'investment/catalogue';
const handledLocally = ['investment/catalogue', 'investment/zones'].includes(page.component);
const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle');
const latest = useRef<{
url: string;
+40
View File
@@ -0,0 +1,40 @@
export type FillGroup = { id: string; title: string; hint: string; fields: string[] };
const definitions: Omit<FillGroup, 'fields'>[] = [
{id:'idea',title:'Ideia e actividade',hint:'Identifique o investimento e descreva a necessidade que pretende responder.'},
{id:'people',title:'Promotor e contactos',hint:'Reutilize os contactos guardados e escolha o que pode ser divulgado.'},
{id:'territory',title:'Localização e enquadramento',hint:'Indique o território conhecido e as associações documentadas que se aplicam.'},
{id:'operation',title:'Actividade e impacto',hint:'Complete os produtos, recursos, mercados e impactos relevantes para esta oportunidade.'},
{id:'finance',title:'Investimento e financiamento',hint:'Preencha os valores conhecidos. O rascunho pode continuar incompleto.'},
{id:'evidence',title:'Documentos e imagens',hint:'Reutilize os recursos guardados na mesma oportunidade.'},
{id:'specific',title:'Dados específicos',hint:'Preencha os detalhes aplicáveis à modalidade escolhida.'},
{id:'review',title:'Revisão e divulgação',hint:'Reveja riscos, condições e visibilidade antes das acções autorizadas de submissão.'},
];
const fields: Record<string,string[]> = {
idea:['official_title','sector_id','subsector_id','economic_activity_id','executive_summary','demand_statement','opportunity_type','maturity_stage','enterprise_size'],
people:['promoter_type','internal_contact_id','publish_contact','public_contact_id'],
territory:['location_mode','geographic_scope','multi_district_rationale','locations','relations'],
operation:['investment_modalities','investor_profiles','products','assets','inputs','markets','competitive_advantage','employment','impact_indicators'],
finance:['total_investment','currency','investment_breakdown','secured_financing','funding_sought','financial_indicators'],
evidence:['documents','evidences','images'],
review:['publication_languages','visibility','review_due_date','truth_declaration','milestones','licenses','environmental_social','risks'],
};
/** Presentation groups only: retain every applicable field exactly once, including future schema keys. */
export function opportunityFillGroups(available: string[]): FillGroup[] {
const unique=[...new Set(available)];
return definitions.map(group=>({...group,fields:unique.filter(field=>{
const owner=Object.keys(fields).find(key=>fields[key].includes(field))??'specific';
return owner===group.id;
})})).filter(group=>group.fields.length>0);
}
export function opportunityErrorGroup(groups: FillGroup[], errors: Record<string,string>): string | undefined {
return groups.find(group=>group.fields.some(field=>Object.keys(errors).some(path=>path===`canonical.${field}`||path.startsWith(`canonical.${field}.`))))?.id;
}
export function opportunityMissingGroups(groups:FillGroup[], paths:string[]):FillGroup[] {
return groups.filter(group=>group.fields.some(field=>paths.some(path=>{
const normalized=path.replace(/^canonical\./,'');
return normalized===field||normalized.startsWith(`${field}.`);
})));
}
@@ -0,0 +1,11 @@
const territoryKeys=['province_id','district_id','admin_post_id','locality_id'];
/** No fallback scope: an explicit empty path means all; a selected path replaces its old descendants. */
export function territorialSearchParams(entries: Iterable<[string,FormDataEntryValue]>, path?: {id:string;location_type:string}[]): URLSearchParams {
const params=new URLSearchParams();
for(const [key,value] of entries)if(typeof value==='string'&&value!==''&&key!=='page')params.set(key,value);
if(path){
territoryKeys.forEach(key=>params.delete(key));
for(const node of path){const key=`${node.location_type}_id`;if(territoryKeys.includes(key))params.set(key,node.id);}
}
return params;
}
+49 -19
View File
@@ -3,7 +3,9 @@ import InvestmentZoneInstallation, {
} from '@/components/investment-zone-installation';
import { Link, router, usePage } from '@inertiajs/react';
import { searchTerritoryHref } from '@/lib/search-territory';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { territorialSearchParams } from '@/lib/territorial-search-params';
import CatalogueFilterContext from '@/components/catalogue-filter-context';
import { catalogueDetailHref } from '@/lib/catalogue-context';
import {
TerritoryFields,
@@ -77,6 +79,10 @@ export default function Zones({
total: number;
next_page_url: string | null;
prev_page_url: string | null;
from?: number | null;
to?: number | null;
current_page?: number;
last_page?: number;
};
filters: {
q?: string;
@@ -94,6 +100,30 @@ export default function Zones({
const pageUrl = usePage().url;
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const searchForm=useRef<HTMLFormElement|null>(null);
const searchTimer=useRef<ReturnType<typeof setTimeout>|undefined>(undefined);
const cancelSearch=useRef<(()=>void)|undefined>(undefined);
const searchSequence=useRef(0);
const [searching,setSearching]=useState(false);
const [searchError,setSearchError]=useState('');
const appliedKey=JSON.stringify([filters,territorySelection]);
useEffect(()=>{searchForm.current?.reset();},[appliedKey]);
useEffect(()=>()=>{clearTimeout(searchTimer.current);cancelSearch.current?.();},[]);
const runSearch=(path?:TerritoryOption[])=>{
clearTimeout(searchTimer.current);cancelSearch.current?.();
if(!searchForm.current)return;
const sequence=++searchSequence.current;
const params=territorialSearchParams(new FormData(searchForm.current),path);
setSearching(true);setSearchError('');
router.get(`${management?'/management/investment/zones':'/zonas'}?${params}`,{}, {
preserveState:true,preserveScroll:true,replace:true,
onCancelToken:token=>{cancelSearch.current=()=>token.cancel();},
onError:()=>{if(sequence===searchSequence.current)setSearchError('A pesquisa não foi concluída. Confira os filtros e tente novamente.');},
onNetworkError:()=>{if(sequence===searchSequence.current)setSearchError('Ligação indisponível. Os filtros escolhidos foram preservados.');return false;},
onHttpException:()=>{if(sequence===searchSequence.current)setSearchError('Não foi possível consultar esta selecção.');return false;},
onFinish:()=>{if(sequence===searchSequence.current)setSearching(false);},
});
};
const exportParams = new URLSearchParams();
['province_id', 'district_id', 'admin_post_id', 'locality_id'].forEach((field, index) => {
if (territorySelection[index]) exportParams.set(field, territorySelection[index].id);
@@ -160,22 +190,18 @@ export default function Zones({
</Link>
</nav>
</details>
<CatalogueFilterContext />
<form
ref={searchForm}
method="get"
onChange={(event)=>{
if(!event.target.getAttribute('name'))return;
clearTimeout(searchTimer.current);cancelSearch.current?.();searchSequence.current++;
setSearching(true);setSearchError('');searchTimer.current=setTimeout(()=>runSearch(),350);
}}
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const url = new URL(form.action);
const params = new URLSearchParams();
for (const [name, value] of new FormData(form))
if (typeof value === 'string')
params.append(name, value);
url.search = params.toString();
router.get(
url.toString(),
{},
{ preserveState: true, preserveScroll: true },
);
runSearch();
}}
className={card + ' grid gap-4 md:grid-cols-3'}
>
@@ -307,10 +333,14 @@ export default function Zones({
)}
</div>
</details>
<TerritoryFields initial={territorySelection} />
<TerritoryFields initial={territorySelection} onChange={path=>runSearch(path)} />
<button type="button" className="underline" onClick={()=>runSearch([])}>Todo o território (remover localização)</button>
<button className={button}>Pesquisar</button>
</form>
{!management && (
<h2 className="text-xl font-semibold">{searching?'A pesquisar a selecção…':searchError?'Pesquisa por concluir':`${records.total} registos disponíveis · ${territorySelection.length?territorySelection.map(node=>node.name).join(' / '):'Todo o território'}`}</h2>
{!searching&&!searchError&&records.total>0&&<p>A mostrar {records.from??1} a {records.to??records.data.length} de {records.total}. Página {records.current_page??1} de {records.last_page??1}.</p>}
{searchError&&<p role="alert">{searchError}</p>}
{!management && !searching && !searchError && (
<a
className="underline"
href={`/api/v2/investment/zones/export.csv?${exportParams.toString()}`}
@@ -318,12 +348,12 @@ export default function Zones({
Exportar resultados filtrados (CSV)
</a>
)}
{!records.total && (
{!searching && !searchError && !records.total && (
<p className={card}>
Não foram encontrados resultados para esta pesquisa.
</p>
)}
{records.data.map((zone) => (
{!searching && !searchError && records.data.map((zone) => (
<article className={card} key={zone.id}>
<h2 className="text-xl font-bold">
<Link
@@ -451,10 +481,10 @@ export default function Zones({
</article>
))}
<nav aria-label="Paginação" className="flex gap-4 underline">
{records.prev_page_url && (
{!searching && !searchError && records.prev_page_url && (
<Link href={records.prev_page_url}>Anterior</Link>
)}
{records.next_page_url && (
{!searching && !searchError && records.next_page_url && (
<Link href={records.next_page_url}>Seguinte</Link>
)}
</nav>
+15 -16
View File
@@ -171,10 +171,8 @@ function Stage({ step, path }: { step: Step; path: Path }) {
];
const states = path.canWrite ? ownerStates : reviewStates;
return (
<article id={step.code} className="space-y-3 rounded-xl border p-4">
<h3 className="font-semibold">
{step.code} · {step.title}
</h3>
<details id={step.code} open={path.next?.code===step.code} className="space-y-3 rounded-xl border p-4">
<summary className="cursor-pointer font-semibold">{step.title} · {pathStates[step.state]}</summary>
<p>{pathStates[step.state]}</p>
{step.context_review_required && (
<label className="block rounded border border-amber-600 bg-amber-50 p-3">
@@ -235,17 +233,18 @@ function Stage({ step, path }: { step: Step; path: Path }) {
: action === 'operate'
? 'Operação'
: 'Execução'}{' '}
aguarda {gaps.join(', ')}. Pode continuar a preparar.
aguarda {gaps.map(code=>path.steps.find(item=>item.code===code)?.title??code).join(', ')}. Pode continuar a preparar.
</p>
))}
<p className="text-xs">
<details className="text-xs"><summary className="cursor-pointer underline">Fonte e referência desta etapa</summary><p>
Referência interna {step.code}.{' '}
Documento original, parágrafo {step.source_paragraph}.{' '}
{step.detail.scope === 'internal_validation'
? 'Conferência interna; não equivale a decisão externa.'
: step.detail.scope === 'external_document_recorded'
? 'Acto externo registado a partir de documento.'
: 'Preparação declarada pelo promotor.'}
</p>
</p></details>
{step.preparation_allowed && (path.canWrite || path.canReview) && (
<details>
<summary className="cursor-pointer underline">
@@ -405,7 +404,7 @@ function Stage({ step, path }: { step: Step; path: Path }) {
</form>
</details>
)}
</article>
</details>
);
}
@@ -718,10 +717,10 @@ export default function InvestmentPath(path: Path) {
</section>
)}
{Object.entries(path.phases).filter(([number]) => path.steps.some((step) => step.phase === Number(number) && step.preparation_allowed)).map(([number, label]) => (
<section className="space-y-3" key={number}>
<h2 className="text-lg font-semibold">
<details open={path.steps.some(step=>step.phase===Number(number)&&step.code===path.next?.code)} className="space-y-3 rounded border p-4" key={number}>
<summary className="cursor-pointer text-lg font-semibold">
Fase {number} · {label}
</h2>
</summary>
<div className="grid items-start gap-3 md:grid-cols-2">
{path.steps
.filter((step) => step.phase === Number(number) && step.preparation_allowed)
@@ -733,7 +732,7 @@ export default function InvestmentPath(path: Path) {
/>
))}
</div>
</section>
</details>
))}
<section className="space-y-3">
<h2 className="text-lg font-semibold">
@@ -819,10 +818,10 @@ export default function InvestmentPath(path: Path) {
Anexar ou conferir no Balcão
</Link>
</section>
<section className="space-y-3 rounded-xl border p-4">
<h2 className="text-lg font-semibold">
<details className="space-y-3 rounded-xl border p-4">
<summary className="cursor-pointer text-lg font-semibold">
Acompanhamento interno
</h2>
</summary>
{path.canWrite && (
<form
className="grid min-w-0 gap-3 [&_input]:max-w-full [&_input]:min-w-0 [&_label]:min-w-0 [&_select]:max-w-full [&_select]:min-w-0"
@@ -983,7 +982,7 @@ export default function InvestmentPath(path: Path) {
</li>
))}
</ul>
</section>
</details>
</main>
);
}
@@ -141,6 +141,7 @@ export default function OpportunityDraft({
canEditDraft?: boolean;
}>().props;
const proactiveEntryUrl=usePage().url;
const uniquePromoterContext=profileAccess?.contexts.length===1?profileAccess.contexts[0]:undefined;
const [data, setData] = useState<Values>({
...values,
...(!record && !values.proactive && new URLSearchParams(proactiveEntryUrl.split('?')[1]??'').get('analysis')==='proactive' ? {proactive:newProactive()} : {}),
@@ -150,10 +151,10 @@ export default function OpportunityDraft({
? (profileOrganization ?? null)
: profileAccess?.internalPromoter
? null
: (profileAccess?.contexts[0]?.organization_id ?? null),
: (uniquePromoterContext?.organization_id ?? null),
opportunity_category:
values.opportunity_category ??
profileAccess?.contexts[0]?.type ??
uniquePromoterContext?.type ??
'',
}
: {}),
@@ -173,10 +174,11 @@ export default function OpportunityDraft({
const unavailable = Boolean(
record?.deleted_at || record?.status === 'archived',
);
const hasCanonicalPreparation = profileMode && canonicalOptions && Boolean(data.opportunity_category) && !data.proactive;
const archiveAvailable = Boolean(usePage().props.opportunityManagement);
const set = (key: string, value: string) =>
setData((d) => ({ ...d, [key]: value }));
async function send(action: 'save' | 'delete' | 'archive' | 'restore') {
async function send(action: 'save' | 'delete' | 'archive' | 'restore', resourcesGroup?: string) {
if (busy) return;
setBusy(true);
setErrors({});
@@ -224,8 +226,10 @@ export default function OpportunityDraft({
}
setMessage(result.message);
if (result.etag) setToken(result.etag);
if (!record) router.visit(`${basePath}/${result.id}/edit`);
if (resourcesGroup) router.visit(`${basePath}/${record?.id ?? result.id}/resources?return_group=${encodeURIComponent(resourcesGroup)}`);
else if (!record) router.visit(`${basePath}/${result.id}/edit${window.location.hash.startsWith('#fill-') ? window.location.hash : ''}`);
else if (action !== 'save') router.reload();
else if (profileMode) router.reload({only:['canonicalResponse','etag','history','workflowState','profileContext']});
} catch {
setMessage(
'Resposta indisponível. Os dados foram preservados; confira o registo no servidor antes de repetir.',
@@ -237,10 +241,8 @@ export default function OpportunityDraft({
document.querySelector<HTMLSelectElement>(
'[data-association-index][aria-invalid="true"]',
);
(
invalidAssociation ??
document.getElementById('opportunity-result')
)?.focus();
if (invalidAssociation) invalidAssociation.focus();
else document.getElementById('opportunity-result')?.focus({preventScroll:true});
});
}
}
@@ -252,9 +254,7 @@ export default function OpportunityDraft({
: 'Nova oportunidade'
}
>
<Link href={basePath}>
{profileMode ? 'Minhas oportunidades' : 'Voltar à gestão'}
</Link>
{!profileMode && <Link href={basePath}>Voltar à gestão</Link>}
{promotionHref && <Link className="ml-3 underline" href={promotionHref}>Divulgação promocional — Em preparação</Link>}
{!!profileContext?.corridor_agribusiness && (
<section className="my-4 space-y-2 rounded border p-4">
@@ -273,11 +273,11 @@ export default function OpportunityDraft({
</Link>
)}
{profileMode && (
<section
aria-label="Âmbito desta entrega"
<details
aria-label="Disponibilidade por modalidade"
className="rounded border p-3"
>
<h2>Âmbito desta entrega</h2>
<summary className="cursor-pointer font-medium">Modalidades disponíveis e condições de submissão</summary>
<p>
Seis categorias base: projeto de investimento,
imobiliário, fundiário, venda de negócio, parceria e
@@ -306,7 +306,7 @@ export default function OpportunityDraft({
certifica a viabilidade, a titularidade ou a decisão
externa de cada caso.
</p>
</section>
</details>
)}
{profileMode && record && (
<ClassificationReview id={record.id} refreshToken={token} />
@@ -336,8 +336,8 @@ export default function OpportunityDraft({
}));
}}
>
{profileAccess?.internalPromoter && (
<option value="">
{(profileAccess?.internalPromoter || !uniquePromoterContext) && (
<option value="|">
Selecionar o promotor real da oportunidade
</option>
)}
@@ -823,7 +823,7 @@ export default function OpportunityDraft({
)}
{!readOnly && (
<>
<Field
{!hasCanonicalPreparation && <Field
label="Motivo da alteração"
error={errors.reason}
>
@@ -834,8 +834,8 @@ export default function OpportunityDraft({
minLength={20}
maxLength={1000}
/>
</Field>
{!unavailable && (
</Field>}
{!unavailable && !hasCanonicalPreparation && (
<button className={button} disabled={busy}>
Guardar rascunho
</button>
@@ -893,7 +893,7 @@ export default function OpportunityDraft({
</>
)}
</form>
{record && (
{record && (!profileMode || readOnly || unavailable || !canonicalOptions || !data.opportunity_category) && (
<Link
href={
resourcesInstalled
@@ -911,10 +911,10 @@ export default function OpportunityDraft({
Associações territoriais e produtivas existentes
</Link>
)}
{profileMode && canonicalOptions && (
{profileMode && canonicalOptions && Boolean(data.opportunity_category) && (
<section>
{record && !readOnly && !unavailable && (
<OpportunityContactForm
<details><summary className="cursor-pointer font-medium">Acrescentar um contacto à oportunidade</summary><OpportunityContactForm
id={record.id}
etag={token ?? ''}
organization={
@@ -940,7 +940,7 @@ export default function OpportunityDraft({
},
}));
}}
/>
/></details>
)}
<OpportunityCanonicalFields
value={
@@ -962,6 +962,7 @@ export default function OpportunityDraft({
],
}}
errors={errors}
missingPaths={canonicalResponse?.completion.missing_paths}
category={valueText(data.opportunity_category ?? '')}
organization={
(data.organization_id ?? null) as string | null
@@ -969,16 +970,23 @@ export default function OpportunityDraft({
actorId={auth.user.id}
sectors={sectors}
disabled={busy || readOnly || unavailable}
onOpenResources={!readOnly && !unavailable && resourcesInstalled ? (group) => void send('save',group) : undefined}
/>
{!readOnly && !unavailable && (
{!readOnly && !unavailable && hasCanonicalPreparation && (
<div className="sticky bottom-0 space-y-3 rounded border bg-background p-4 shadow-sm">
<Field label="O que alterou nesta preparação" error={errors.reason}>
<textarea className={control} value={reason} onChange={(event) => setReason(event.target.value)} minLength={20} maxLength={1000} />
</Field>
<button
type="button"
className={button}
disabled={busy}
onClick={() => void send('save')}
>
Guardar contrato no rascunho
Guardar preparação
</button>
<p className="text-sm">Pode guardar incompleto e continuar mais tarde em Minhas oportunidades.</p>
</div>
)}
{Object.entries(errors)
.filter(([key]) => key.startsWith('canonical'))
@@ -179,6 +179,8 @@ export default function OpportunityResources({
readOnly?: boolean;
countries?: { code: string; name: string }[];
}>().props;
const returnGroup = new URLSearchParams(usePage().url.split('?')[1] ?? '').get('return_group');
const returnHash = ['territory', 'evidence'].includes(returnGroup ?? '') ? `#fill-${returnGroup}` : '#fill-evidence';
const [kind, setKind] = useState<Kind>('locations');
const [editing, setEditing] = useState<Resource | null>(null);
const [payload, setPayload] = useState<Payload>({ country_code: 'MZ' });
@@ -307,7 +309,7 @@ export default function OpportunityResources({
!!opportunity.deleted_at || opportunity.status === 'archived';
return (
<Management title="Imagens, documentos e localizações">
<Link href={`${basePath}/${opportunity.id}/edit`}>
<Link href={`${basePath}/${opportunity.id}/edit${returnHash}`}>
Regressar ao rascunho #{opportunity.id}
</Link>
<p>
+17 -23
View File
@@ -4,6 +4,11 @@ import { useState } from 'react';
import ProfileNavigation from '@/components/profile-navigation';
import { profileJson } from '@/lib/profile-json';
import { button, control } from '@/pages/management/shared';
import { modalityLabels } from '@/components/opportunity-search-filters';
import ProfileProofPicker from '@/components/profile-proof-picker';
const representations: Record<string,string> = {individual:'Em nome próprio',company:'Empresa',public_entity:'Entidade pública',association:'Associação',partner:'Parceiro ou representante'};
const requestStates: Record<string,string> = {pending:'Em análise',approved:'Aprovado',rejected:'Devolvido ou recusado',cancelled:'Cancelado'};
type RequestEntry = {
id: string;
@@ -67,9 +72,8 @@ export default function RequestProfile({
Solicitar perfil de Promotor
</h1>
<p>
A aprovação confirma identidade, representação e comprovativos.
Só os tipos aprovados ficam disponíveis; o pedido não ativa o
perfil.
Indique em nome de quem vai actuar, as modalidades que pretende preparar e os comprovativos do pedido.
Pode acompanhar a decisão nesta página. A submissão do pedido não activa o perfil.
</p>
<form
className="max-w-2xl space-y-4"
@@ -79,7 +83,7 @@ export default function RequestProfile({
}}
>
<label className="block">
Representação
Em nome de quem vai actuar?
<select
className={control}
value={representation}
@@ -92,7 +96,7 @@ export default function RequestProfile({
'association',
'partner',
].map((v) => (
<option key={v}>{v}</option>
<option key={v} value={v}>{representations[v]}</option>
))}
</select>
</label>
@@ -115,9 +119,10 @@ export default function RequestProfile({
</label>
)}
<fieldset>
<legend>Tipos pretendidos</legend>
<legend>Que oportunidades pretende preparar?</legend>
<div className="grid gap-2 sm:grid-cols-2">
{types.map((type) => (
<label key={type} className="block">
<label key={type} className="flex gap-2 rounded border p-3">
<input
type="checkbox"
checked={selected.includes(type)}
@@ -125,23 +130,12 @@ export default function RequestProfile({
setSelected(toggle(selected, type))
}
/>{' '}
{type}
</label>
))}
</fieldset>
<fieldset>
<legend>Comprovativos próprios analisados</legend>
{documents.map((d) => (
<label key={d.id} className="block">
<input
type="checkbox"
checked={proofs.includes(d.id)}
onChange={() => setProofs(toggle(proofs, d.id))}
/>{' '}
{d.file_name}
{modalityLabels[type] ?? 'Outra modalidade'}
</label>
))}
</div>
</fieldset>
<ProfileProofPicker documents={documents} value={proofs} onChange={setProofs} disabled={busy} />
{!documents.length && (
<p>
Sem comprovativos próprios disponíveis. Pode anexar ao
@@ -156,7 +150,7 @@ export default function RequestProfile({
</p>
)}
<label className="block">
Fundamentação
Explique o seu pedido
<textarea
required
minLength={20}
@@ -180,7 +174,7 @@ export default function RequestProfile({
{requests.map((r) => (
<article key={r.id} className="border-b py-3">
<p>
{r.id} — {r.status}
Pedido de {new Date(r.created_at).toLocaleDateString('pt-MZ')} — {requestStates[r.status] ?? 'Estado por consultar'}
</p>
<p>
{r.decision_reason ??
@@ -0,0 +1,53 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
final class InvestorUsabilityScopeTest extends TestCase
{
use DatabaseTransactions;
protected function setUp():void{parent::setUp();\Tests\Support\ProactiveInvestmentFixture::guard();$this->withoutVite();}
public function test_public_sector_is_consultable_by_guest_and_accounts_without_promoter_grants():void
{
config(['portal.homologation_only'=>false,'portal.legacy_content_enabled'=>false,'portal.legacy_content_qa_enabled'=>false]);
$this->get('/sectores/turismo')->assertOk();
$actor=User::factory()->create();
$this->actingAs($actor)->get('/sectores/turismo')->assertOk();
$this->assertSame(0,DB::table('portal_role_assignments')->where('user_id',$actor->id)->count());
}
public function test_zones_all_equals_visible_set_and_province_and_district_never_fall_back_to_all():void
{
$zones=app(InvestmentZones::class);$geo=app(InvestmentGeography::class);
$province='059b02ca-7095-577a-8705-4d4c46896747';
$district=$geo->publicReferences()->where('parent_id',$province)->where('location_type','district')->first();
$template=$zones->visible()->firstOrFail();
$make=function($territory,$published=true)use($template){$zone=$template->replicate();$zone->id=(string)Str::uuid();$zone->code='SYNTHETIC-'.Str::random(12);$zone->name='SYNTHETIC QA zone scope';$zone->primary_territory_id=$territory;$zone->publication_status=$published?'published':'draft';$zone->save();return$zone;};
$inside=$make($district->id);$outside=$make('9e42dac8-fd9b-523f-bf59-71a7d3aa0fb8');$private=$make($district->id,false);
$all=$zones->search([])->orderBy('id')->pluck('id')->all();
$this->assertSame($zones->visible()->orderBy('id')->pluck('id')->all(),$all);
foreach([['province_id'=>$province],['province_id'=>$province,'district_id'=>$district->id]]as$selection){
$ids=$zones->search($selection)->pluck('id')->all();
$this->assertContains($inside->id,$ids);$this->assertNotContains($outside->id,$ids);$this->assertNotContains($private->id,$ids);
}
}
public function test_documented_legal_scope_covers_child_area_but_influence_does_not_become_an_installation():void
{
$zones=app(InvestmentZones::class);$geo=app(InvestmentGeography::class);$province='059b02ca-7095-577a-8705-4d4c46896747';
$district=$geo->publicReferences()->where('parent_id',$province)->where('location_type','district')->first();
$editor=User::factory()->create();$reviewer=User::factory()->create();$template=$zones->visible()->firstOrFail();$ids=[];
foreach(['legal_scope','influence']as$scope){
$zone=$template->replicate();$zone->id=(string)Str::uuid();$zone->code='SYNTHETIC-'.Str::random(12);$zone->name='SYNTHETIC QA legal coverage';$zone->primary_territory_id='9e42dac8-fd9b-523f-bf59-71a7d3aa0fb8';$zone->save();$ids[$scope]=$zone->id;
DB::table('investment_zone_locations')->insert(['id'=>(string)Str::uuid(),'zone_id'=>$zone->id,'geo_location_id'=>$scope==='legal_scope'?$province:$district->id,'scope_type'=>$scope,'source_reference'=>'SYNTHETIC isolated QA scope fixture','validation_status'=>'validated','editor_id'=>$editor->id,'reviewer_id'=>$reviewer->id,'valid_from'=>now()->subDay(),'version_no'=>1,'created_at'=>now(),'updated_at'=>now()]);
}
$matches=$zones->search(['province_id'=>$province,'district_id'=>$district->id])->pluck('id')->all();
$this->assertContains($ids['legal_scope'],$matches);$this->assertNotContains($ids['influence'],$matches);
}
}
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import fs from 'node:fs';
import {createRequire} from 'node:module';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import ts from 'typescript';
import * as flow from '../../resources/js/lib/opportunity-fill-flow.ts';
import { fiscalPreparationGuide } from '../../resources/js/lib/fiscal-preparation-guide.ts';
const require=createRequire(import.meta.url);
function load(path,mocks={},exportName='default',extra=''){
const source=fs.readFileSync(new URL(path,import.meta.url),'utf8');
const output=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
const module={exports:{}};new Function('require','module','exports',output+extra)(name=>mocks[name]??(name.startsWith('@/')?{default:()=>null}:require(name)),module,module.exports);return module.exports[exportName];
}
function stateHooks(){const memory=[];let cursor=0;return{hooks:{...React,useState(initial){const i=cursor++;if(!(i in memory))memory[i]=typeof initial==='function'?initial():initial;return[memory[i],next=>memory[i]=typeof next==='function'?next(memory[i]):next];},useRef:()=>({current:null}),useEffect:()=>{}},reset:()=>cursor=0};}
const flat=node=>!node||typeof node!=='object'?[]:[node,...React.Children.toArray(node.props?.children).flatMap(flat)];
test('free navigation keeps every field mounted and reports resource work in the current group',()=>{
const state=stateHooks();const Component=load('../../resources/js/components/opportunity-fill-flow.tsx',{'react':state.hooks,'@/lib/opportunity-fill-flow':flow});
const groups=flow.opportunityFillGroups(['official_title','locations','documents']);const saved={official_title:'Deliberate user title',locations:['saved-site']};
const opened=[];let tree;
const render=()=>{state.reset();tree=Component({groups,errors:{},renderField:field=>React.createElement('input',{name:field,value:JSON.stringify(saved[field]??''),readOnly:true}),onOpenResources:group=>opened.push(group)});};
const priorWindow=globalThis.window,priorFrame=globalThis.requestAnimationFrame;
globalThis.window={history:{state:{existing:'inertia-state'},replaceState(state,_title,url){assert.deepEqual(state,{existing:'inertia-state'});this.url=url;}}};globalThis.requestAnimationFrame=callback=>callback();
try{
render();assert.equal(flat(tree).filter(node=>node.type==='input').length,3);
flat(tree).find(node=>node.type==='a'&&node.props.href==='#fill-territory').props.onClick({preventDefault(){}});render();
const sections=flat(tree).filter(node=>node.type==='section');
assert.equal(sections.find(node=>node.props.id==='fill-territory').props.hidden,false);
assert.equal(sections.find(node=>node.props.id==='fill-idea').props.hidden,true);
assert.equal(flat(tree).filter(node=>node.type==='input').length,3);
flat(tree).find(node=>node.type==='button').props.onClick();assert.deepEqual(opened,['territory']);
assert.equal(saved.official_title,'Deliberate user title');assert.deepEqual(saved.locations,['saved-site']);
assert.equal(globalThis.window.history.url,'#fill-territory');
}finally{globalThis.window=priorWindow;globalThis.requestAnimationFrame=priorFrame;}
});
test('profile request renders readable labels without dumping every available document into the form',()=>{
const Picker=load('../../resources/js/components/profile-proof-picker.tsx');
const Page=load('../../resources/js/pages/profiles/request.tsx',{
'@/lib/operation-key':{createOperationKey:()=> 'synthetic-key'},
'@inertiajs/react':{Head:()=>null,Link:({href,children})=>React.createElement('a',{href},children),router:{}},
'@/components/profile-navigation':{default:()=>null},'@/lib/profile-json':{},
'@/pages/management/shared':{button:'button',control:'control'},
'@/components/opportunity-search-filters':{modalityLabels:{investment_project:'Projecto de investimento',land_rights:'Direitos fundiários'}},
'@/components/profile-proof-picker':{default:Picker},
});
const html=renderToStaticMarkup(React.createElement(Page,{requests:[],types:['investment_project','land_rights'],organizations:[],documents:Array.from({length:80},(_,i)=>({id:`doc-${i}`,file_name:`UNSELECTED-DOCUMENT-${i}.txt`}))}));
assert.match(html,/Em nome próprio/);assert.match(html,/Entidade pública/);assert.match(html,/Projecto de investimento/);assert.match(html,/Direitos fundiários/);
assert.doesNotMatch(html,/UNSELECTED-DOCUMENT/);assert.match(html,/Pesquisar nos meus ficheiros/);
const visible=html.replace(/<[^>]+>/g,'');assert.doesNotMatch(visible,/investment_project|land_rights|public_entity/);
});
test('proof selection is explicit and retained when the filename search changes',()=>{
const state=stateHooks();const Component=load('../../resources/js/components/profile-proof-picker.tsx',{'react':state.hooks});
const documents=[{id:'identity',file_name:'Documento próprio'},{id:'law',file_name:'Lei.pdf'}];const values=[];let tree;
const render=()=>{state.reset();tree=Component({documents,value:values,onChange:ids=>{values.splice(0,values.length,...ids);}});};
render();assert.equal(flat(tree).filter(node=>node.type==='input'&&node.props.type==='checkbox').length,0);
flat(tree).find(node=>node.type==='input').props.onChange({target:{value:'próprio'}});render();
flat(tree).find(node=>node.type==='input'&&node.props.type==='checkbox').props.onChange();render();assert.deepEqual(values,['identity']);
flat(tree).find(node=>node.type==='input'&&node.props.type==='search').props.onChange({target:{value:'Lei'}});render();
assert.deepEqual(values,['identity']);assert.equal(flat(tree).filter(node=>node.type==='input'&&node.props.type==='checkbox')[0].props.checked,false);
});
test('document entry leads to existing fillable preparation and keeps sources optional',()=>{
const context='10000000-0000-4000-8000-000000000001';
const inertia={Link:({href,children})=>React.createElement('a',{href},children),usePage:()=>({url:`/documentos?context=${context}`})};
const Hub=load('../../resources/js/components/journey-document-resources.tsx',{'@inertiajs/react':inertia,'@/lib/fiscal-preparation-guide':{fiscalPreparationGuide}},'JourneyDocumentResources');
const hub=renderToStaticMarkup(React.createElement(Hub,{}));
assert.match(hub,new RegExp(`/investimentos/${context}/contexto/editar#preparation-questions`));assert.match(hub,/href="\/terra-duat"/);
assert.match(hub,/Preparação digital e exemplar oficial/);assert.doesNotMatch(hub,/<details[^>]*\bopen\b/);
const Editor=load('../../resources/js/components/private-investment-preparation.tsx',{'@inertiajs/react':inertia,'@/pages/investment/shared':{card:'card',input:'input'}},'PreparationEditor');
const html=renderToStaticMarkup(React.createElement(Editor,{data:{questions:[{key:'investment_preparation',label:'Preparar pedido?',options:{yes:'Sim',no:'Não'},source:'SYNTHETIC QA'},{key:'declared_amount',label:'Valor do investimento',type:'number',source:'SYNTHETIC QA'}],requirements:[],enabled:true,mode:'authorization',missing_fields:['declared_amount']},fields:{investment_preparation:'yes'},changes:{},documents:[],links:{},onChange(){},onLink(){},disabled:false}));
assert.match(html,/<input/);assert.match(html,/Valor do investimento/);assert.match(html,/id="preparation-questions"/);
});
test('investment stages expose readable next actions while internal codes and sources remain consultable',()=>{
const useForm=data=>({data,errors:{},processing:false,setData(){},transform(){return this;},post(){}});
const Stage=load('../../resources/js/pages/investor-path/show.tsx',{'@inertiajs/react':{useForm,Link:({href,children})=>React.createElement('a',{href},children)},'@/lib/investor-path':{pathStates:{not_started:'Por iniciar'}}},'Stage','\nmodule.exports.Stage=Stage;');
const step={code:'S01',title:'Sistematizar a ideia',state:'not_started',detail:{scope:'promoter_preparation'},preparation_allowed:true,action_gaps:{},source_paragraph:22,applicability:'applicable'};
const html=renderToStaticMarkup(React.createElement(Stage,{step,path:{case:{id:'synthetic',version:1},next:{code:'S01'},canWrite:true,canReview:false,steps:[step],documents:[]}}));
assert.match(html,/<details[^>]*id="S01"[^>]*open/);assert.match(html,/<summary[^>]*>Sistematizar a ideia/);
assert.doesNotMatch(html,/<summary[^>]*>S01/);assert.match(html,/Fonte e referência desta etapa/);
});
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {opportunityFillGroups,opportunityErrorGroup,opportunityMissingGroups} from '../../resources/js/lib/opportunity-fill-flow.ts';
import {territorialSearchParams} from '../../resources/js/lib/territorial-search-params.ts';
test('adaptive groups keep every applicable schema field once, including new specialized fields',()=>{
const keys=['official_title','locations','sector_id','documents','currency','real_estate','future_schema_field','currency'];
const groups=opportunityFillGroups(keys);
assert.deepEqual(groups.flatMap(group=>group.fields).sort(),[...new Set(keys)].sort());
assert.ok(groups.find(group=>group.id==='specific').fields.includes('future_schema_field'));
assert.deepEqual(opportunityFillGroups(['official_title']).map(group=>group.id),['idea']);
assert.deepEqual(opportunityFillGroups([]),[]);
});
test('saved pending fields and validation errors point to the correct existing group without inventing applicability',()=>{
const groups=opportunityFillGroups(['official_title','locations','total_investment']);
assert.equal(opportunityErrorGroup(groups,{'canonical.total_investment':'Valor em falta'}),'finance');
assert.equal(opportunityErrorGroup(groups,{'canonical.locations.0':'Local em falta'}),'territory');
assert.deepEqual(opportunityMissingGroups(groups,['locations.0','canonical.total_investment']).map(group=>group.id),['territory','finance']);
assert.equal(opportunityErrorGroup(groups,{reason:'Motivo em falta'}),undefined);
});
test('territorial search replaces old descendants while retaining all selected non-territorial filters',()=>{
const entries=[['province_id','old'],['district_id','old-child'],['locality_id','old-leaf'],['category','zee'],['q','Nacala'],['page','5']];
const scoped=territorialSearchParams(entries,[{id:'new',location_type:'province'}]);
assert.equal(scoped.get('province_id'),'new');assert.equal(scoped.has('district_id'),false);assert.equal(scoped.has('locality_id'),false);
assert.equal(scoped.get('category'),'zee');assert.equal(scoped.get('q'),'Nacala');assert.equal(scoped.has('page'),false);
const all=territorialSearchParams(entries,[]);assert.equal(all.has('province_id'),false);assert.equal(all.get('category'),'zee');
const unchanged=territorialSearchParams(entries);assert.equal(unchanged.get('district_id'),'old-child');
});