fix(investor): simplify preparation flow, profile requests and scoped zone search
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user