Wire grounded private investment preparation into QA dossier path

This commit is contained in:
2026-10-22 10:00:23 +02:00
parent 52fbb1468a
commit a1fbda06ad
16 changed files with 1566 additions and 87 deletions
@@ -1,24 +1,232 @@
import {
PreparationSummary,
type Preparation,
} from '@/components/private-investment-preparation';
import { Link } from '@inertiajs/react';
type Snapshot = {context_revision:number;path?:{definition_version:number;steps:Array<{code:string;title:string;applicability:string;reason:string;state:string;context_review_required?:boolean}>}; zone?:{title:string;href:string;version:number|string;fields?:Record<string,string>;analysis?:{conditions?:Array<{id:string;label:string;knowledge:string;source_reference?:string;valid_from?:string;valid_until?:string}>;source?:{title:string;url:string;diploma?:string;article?:string;valid_from?:string;valid_until?:string}|null}}|null;fields?:Record<string,string>};
export function InvestmentContextAssessment({raw,contextId}:{raw:unknown;contextId?:string|null}) {
let snapshot:Snapshot|null=null;
try {const value=JSON.parse(String(raw ?? ''));if(value.version===1) snapshot=value;} catch { /* Missing context remains explicit. */ }
if(!snapshot) return null;
const fields=snapshot.fields ?? {};const zone=snapshot.zone;
const regime=zone?.fields?.investment_regime ?? fields.investment_regime;
const names:Record<string,string>={zee:'ZEE',zfi:'ZFI',industrial_park:'Parque industrial'};
return <section className="space-y-2 rounded border border-amber-400 p-3" aria-label="Aplicabilidade ao investimento">
<h3 className="font-semibold">Aplicabilidade ao investimento · contexto v{snapshot.context_revision}</h3>
<p>Origem do investidor: {{national:'nacional',foreign:'estrangeiro',mixed:'misto'}[fields.investor_origin] ?? 'informação em falta'}. Residência fiscal declarada: {fields.tax_residency_country || 'informação em falta'}. Estas condições são distintas; uma não determina a outra.</p>
{zone ? <><p><strong>Potencialmente aplicável — análise específica de {names[regime ?? ''] ?? 'zona seleccionada'}.</strong> <Link className="underline" href={zone.href}>{zone.title} · versão {zone.version} · consultar ficha e fundamentos</Link>.</p>
<p>A selecção identifica a referência consultada; não confirma o reconhecimento da entidade, actividade autorizada, licença ou acesso a benefícios. Consulte as condições documentadas na ficha e reúna a autorização ou evidência correspondente no dossier.</p>
{zone.analysis?.source && <p>Fundamento cadastrado: <a className="underline" href={zone.analysis.source.url}>{zone.analysis.source.diploma || zone.analysis.source.title} {zone.analysis.source.article}</a>. Período cadastrado: {zone.analysis.source.valid_from || 'início por indicar'} a {zone.analysis.source.valid_until || 'sem termo indicado'}. A cobertura do investimento exige correspondência com estas condições.</p>}
{zone.analysis?.conditions?.length ? <ul className="list-disc pl-5">{zone.analysis.conditions.map(condition=><li key={condition.id}><strong>{condition.label}</strong>: {condition.knowledge==='documented'?'condição documentada; cumprimento pelo investimento por confirmar':condition.knowledge==='disputed'?'condição com informação divergente':'informação em falta'}. {condition.source_reference && <>Fundamento: {condition.source_reference}.</>} {condition.valid_from && <>Desde {condition.valid_from}.</>} {condition.valid_until && <>Até {condition.valid_until}.</>}</li>)}</ul> : <p>Condições específicas: informação estruturada em falta nesta referência. A ficha e os documentos continuam consultáveis; não se presume cumprimento.</p>}
<p role="status">Benefício fiscal deste enquadramento: indeterminado. Não existe neste consumidor uma regra executável ligada ao reconhecimento e condições da zona. No simulador, o IVA disponível corresponde apenas ao cenário geral sem benefício, sobre vendas declaradas compatíveis; não simula o regime especial. O cenário com benefício e o apuramento final estão indisponíveis.</p></>
: <p>Enquadramento geral de preparação: sem ZEE, ZFI ou parque seleccionado. Isso não confirma a elegibilidade para uma operação fiscal. O cálculo mantém o âmbito e as exclusões da versão publicada.</p>}
<p>Confirmado neste quadro significa apenas dado recuperado do dossier; declarações e referências não são decisões jurídicas. Requisitos específicos não documentados permanecem indeterminados.</p>
{snapshot.path && <details><summary>Passos adaptados pelo percurso existente · versão {snapshot.path.definition_version}</summary><ul className="list-disc pl-5">{snapshot.path.steps.map(step=><li key={step.code}>{step.code} — {step.title}: {step.context_review_required?'informação alterada; rever fundamento':step.applicability==='not_needed_for_preparation'?'não aplicável à preparação declarada':step.applicability==='undetermined'?'informação em falta':['completed','approved'].includes(step.state)?'preparação confirmada no percurso; não é decisão fiscal':'potencialmente aplicável'}. {step.reason}</li>)}</ul></details>}
{contextId && <Link className="underline" href={`/investimentos/${contextId}/contexto/editar`}>Alterar contexto e comparar impacto antes de guardar</Link>}
</section>;
type Snapshot = {
preparation?: Preparation | null;
context_revision: number;
path?: {
definition_version: number;
steps: Array<{
code: string;
title: string;
applicability: string;
reason: string;
state: string;
context_review_required?: boolean;
}>;
};
zone?: {
title: string;
href: string;
version: number | string;
fields?: Record<string, string>;
analysis?: {
conditions?: Array<{
id: string;
label: string;
knowledge: string;
source_reference?: string;
valid_from?: string;
valid_until?: string;
}>;
source?: {
title: string;
url: string;
diploma?: string;
article?: string;
valid_from?: string;
valid_until?: string;
} | null;
};
} | null;
fields?: Record<string, string>;
};
export function InvestmentContextAssessment({
raw,
contextId,
}: {
raw: unknown;
contextId?: string | null;
}) {
let snapshot: Snapshot | null = null;
try {
const value = JSON.parse(String(raw ?? ''));
if (value.version === 1) snapshot = value;
} catch {
/* Missing context remains explicit. */
}
if (!snapshot) return null;
const fields = snapshot.fields ?? {};
const zone = snapshot.zone;
const regime = zone?.fields?.investment_regime ?? fields.investment_regime;
const names: Record<string, string> = {
zee: 'ZEE',
zfi: 'ZFI',
industrial_park: 'Parque industrial',
};
return (
<section
className="space-y-2 rounded border border-amber-400 p-3"
aria-label="Aplicabilidade ao investimento"
>
<h3 className="font-semibold">
Aplicabilidade ao investimento · contexto v
{snapshot.context_revision}
</h3>
<p>
Origem do investidor:{' '}
{{
national: 'nacional',
foreign: 'estrangeiro',
mixed: 'misto',
}[fields.investor_origin] ?? 'informação em falta'}
. Residência fiscal declarada:{' '}
{fields.tax_residency_country || 'informação em falta'}. Estas
condições são distintas; uma não determina a outra.
</p>
{zone ? (
<>
<p>
<strong>
Potencialmente aplicável — análise específica de{' '}
{names[regime ?? ''] ?? 'zona seleccionada'}.
</strong>{' '}
<Link className="underline" href={zone.href}>
{zone.title} · versão {zone.version} · consultar
ficha e fundamentos
</Link>
.
</p>
<p>
A selecção identifica a referência consultada; não
confirma o reconhecimento da entidade, actividade
autorizada, licença ou acesso a benefícios. Consulte as
condições documentadas na ficha e reúna a autorização ou
evidência correspondente no dossier.
</p>
{zone.analysis?.source && (
<p>
Fundamento cadastrado:{' '}
<a
className="underline"
href={zone.analysis.source.url}
>
{zone.analysis.source.diploma ||
zone.analysis.source.title}{' '}
{zone.analysis.source.article}
</a>
. Período cadastrado:{' '}
{zone.analysis.source.valid_from ||
'início por indicar'}{' '}
a{' '}
{zone.analysis.source.valid_until ||
'sem termo indicado'}
. A cobertura do investimento exige correspondência
com estas condições.
</p>
)}
{zone.analysis?.conditions?.length ? (
<ul className="list-disc pl-5">
{zone.analysis.conditions.map((condition) => (
<li key={condition.id}>
<strong>{condition.label}</strong>:{' '}
{condition.knowledge === 'documented'
? 'condição documentada; cumprimento pelo investimento por confirmar'
: condition.knowledge === 'disputed'
? 'condição com informação divergente'
: 'informação em falta'}
.{' '}
{condition.source_reference && (
<>
Fundamento:{' '}
{condition.source_reference}.
</>
)}{' '}
{condition.valid_from && (
<>Desde {condition.valid_from}.</>
)}{' '}
{condition.valid_until && (
<>Até {condition.valid_until}.</>
)}
</li>
))}
</ul>
) : (
<p>
Condições específicas: informação estruturada em
falta nesta referência. A ficha e os documentos
continuam consultáveis; não se presume cumprimento.
</p>
)}
<p role="status">
Benefício fiscal deste enquadramento: indeterminado. Não
existe neste consumidor uma regra executável ligada ao
reconhecimento e condições da zona. No simulador, o IVA
disponível corresponde apenas ao cenário geral sem
benefício, sobre vendas declaradas compatíveis; não
simula o regime especial. O cenário com benefício e o
apuramento final estão indisponíveis.
</p>
</>
) : (
<p>
Enquadramento geral de preparação: sem ZEE, ZFI ou parque
seleccionado. Isso não confirma a elegibilidade para uma
operação fiscal. O cálculo mantém o âmbito e as exclusões da
versão publicada.
</p>
)}
<p>
Confirmado neste quadro significa apenas dado recuperado do
dossier; declarações e referências não são decisões jurídicas.
Requisitos específicos não documentados permanecem
indeterminados.
</p>
{snapshot.path && (
<details>
<summary>
Passos adaptados pelo percurso existente · versão{' '}
{snapshot.path.definition_version}
</summary>
<ul className="list-disc pl-5">
{snapshot.path.steps.map((step) => (
<li key={step.code}>
{step.code} — {step.title}:{' '}
{step.context_review_required
? 'informação alterada; rever fundamento'
: step.applicability ===
'not_needed_for_preparation'
? 'não aplicável à preparação declarada'
: step.applicability === 'undetermined'
? 'informação em falta'
: ['completed', 'approved'].includes(
step.state,
)
? 'preparação confirmada no percurso; não é decisão fiscal'
: 'potencialmente aplicável'}
. {step.reason}
</li>
))}
</ul>
</details>
)}
{snapshot.preparation?.enabled && (
<PreparationSummary
data={snapshot.preparation}
caseId={contextId ?? undefined}
/>
)}
{contextId && (
<Link
className="underline"
href={`/investimentos/${contextId}/contexto/editar`}
>
Alterar contexto e comparar impacto antes de guardar
</Link>
)}
</section>
);
}
@@ -0,0 +1,457 @@
import { Link } from '@inertiajs/react';
import { card, input } from '@/pages/investment/shared';
export type PreparationDocument = {
document_id: string;
file_name: string;
status: string;
malware_scan_status: string;
};
export type Preparation = {
version: string;
enabled: boolean;
mode: string;
period_covered: boolean;
scope_covered: boolean;
reason: string[];
missing_fields: string[];
missing_labels?: string[];
authority: string;
next: string;
calculation_notice: string;
notice: string;
source: { url: string; sha256: string };
authority_source: { reference: string; url: string };
questions: Array<{
key: string;
label: string;
type?: string;
options?: Record<string, string>;
when?: { field: string; equals: string };
source: string;
help?: string;
}>;
requirements: Array<{
key: string;
label: string;
state: string;
required: boolean;
document_id: string | null;
document: PreparationDocument | null;
stage: string;
reason: string;
source: { reference: string; url: string; period: string };
}>;
};
const states: Record<string, string> = {
not_applicable: 'Não aplicável neste contexto',
indeterminate: 'Informação em falta para decidir',
missing: 'Documento por associar',
associated: 'Documento associado — conteúdo por conferir',
};
export const preparationMode = (mode: string) =>
({
authorization: 'Pedido de autorização',
registration: 'Pedido de mero registo',
indeterminate: 'Enquadramento por determinar',
})[mode] ?? mode;
export function PreparationSummary({
data,
caseId,
}: {
data: Preparation | null | undefined;
caseId?: string;
}) {
if (!data) return null;
return (
<section
id="private-investment-preparation"
className={card + ' space-y-3'}
aria-label="Preparação adaptada do investimento privado"
>
<h2 className="text-xl font-bold">
Preparar o pedido de investimento privado
</h2>
{!data.enabled ? (
<p>
Use os dados deste dossier para identificar o pedido e os
documentos aplicáveis. A preparação é opcional e não atribui
benefícios.
</p>
) : (
<>
<p>
<strong>{preparationMode(data.mode)}</strong> · recorte
de 2026 ·{' '}
{data.version.replace(
'private-investment-preparation-',
'',
)}
</p>
<p>{data.next}</p>
{data.missing_fields.length > 0 && (
<p>
Informação em falta:{' '}
{(
data.missing_labels ??
data.missing_fields.map(
(key) =>
data.questions?.find(
(q) => q.key === key,
)?.label ??
(
{
currency: 'Moeda do projecto',
expected_start_date:
'Data prevista de início',
entity_stage:
'Situação da entidade',
} as Record<string, string>
)[key] ??
key,
)
).join('; ')}
.
</p>
)}
{!data.period_covered && (
<p>
Este incremento cobre pedidos com início previsto em
2026. Uma data em falta ou fora desse recorte não
permite determinar o pedido por este lote; os
restantes trabalhos do dossier continuam
disponíveis.
</p>
)}
{!data.scope_covered && (
<p>
Declare a actividade efectiva. Prospecção,
pesquisa/produção extractiva e investimento público
ou exclusivamente social/sem fins lucrativos seguem
enquadramentos próprios, fora deste lote.
</p>
)}
{data.reason.map((reason) => (
<p key={reason}>{reason}</p>
))}
<p>
Encaminhamento indicativo: {data.authority}.{' '}
<a
className="underline"
href={data.authority_source.url}
>
{data.authority_source.reference}
</a>
</p>
<ul className="space-y-3">
{data.requirements
.filter((r) => r.state !== 'not_applicable')
.map((r) => (
<li key={r.key} className="rounded border p-3">
<strong>{r.label}</strong> —{' '}
{states[r.state]}.{' '}
{r.document?.file_name && (
<span>
Ficheiro: {r.document.file_name}
.{' '}
</span>
)}
<p>
{r.reason}{' '}
<a
className="underline"
href={r.source.url}
>
Ver fundamento: {r.source.reference}
</a>{' '}
· {r.source.period}.
</p>
{caseId && (
<Link
className="underline"
href={`/investimentos/${caseId}/contexto/editar#preparation-documents`}
>
Rever dados ou associar documento do
dossier
</Link>
)}
</li>
))}
</ul>
<details>
<summary>
Exigências que não se aplicam — histórico preservado
</summary>
{data.requirements
.filter((r) => r.state === 'not_applicable')
.map((r) => (
<p key={r.key}>
{r.label}: {r.reason}{' '}
{r.document_id && (
<>
Associação anterior mantida
{r.document?.file_name
? `: ${r.document.file_name}`
: ''}
.
</>
)}{' '}
<a
className="underline"
href={r.source.url}
>
{r.source.reference}
</a>
</p>
))}
</details>
<p>{data.calculation_notice}</p>
<p>{data.notice}</p>
</>
)}
<a className="underline" href={data.source.url}>
Consultar o exemplar oficial e os fundamentos
</a>
{caseId && (
<p>
<Link
className="underline"
href={`/investimentos/${caseId}/contexto/editar#preparation-questions`}
>
Completar apenas os dados em falta ou editar declarações
</Link>
</p>
)}
</section>
);
}
export function PreparationEditor({
data,
fields,
changes,
documents,
links,
onChange,
onLink,
disabled,
}: {
data: Preparation;
fields: Record<string, string>;
changes: Record<string, string>;
documents: PreparationDocument[];
links: Record<string, string | null>;
onChange: (key: string, value: string) => void;
onLink: (key: string, value: string | null) => void;
disabled: boolean;
}) {
const values = { ...fields, ...changes };
const active = values.investment_preparation === 'yes';
const excluded = ['extractive', 'public_nonprofit'].includes(
values.investment_activity_scope,
);
const applicable = (q: Preparation['questions'][number]) =>
!q.when || values[q.when.field] === q.when.equals;
// Keep a field in place while the user edits it; collapse only recovered answers.
const known = (q: Preparation['questions'][number]) =>
!!fields[q.key] && fields[q.key] !== 'unknown';
const render = (q: Preparation['questions'][number]) => (
<label className="grid gap-1" key={q.key} htmlFor={'prep-' + q.key}>
{q.label}
{q.options ? (
<select
id={'prep-' + q.key}
className={input}
disabled={disabled}
value={values[q.key] ?? ''}
onChange={(e) => onChange(q.key, e.target.value)}
>
<option value="">Por indicar</option>
{Object.entries(q.options).map(([value, text]) => (
<option key={value} value={value}>
{text}
</option>
))}
</select>
) : (
<input
id={'prep-' + q.key}
className={input}
disabled={disabled}
type="text"
inputMode="decimal"
value={values[q.key] ?? ''}
onChange={(e) => onChange(q.key, e.target.value)}
/>
)}
<span className="text-sm">
{q.help} Fundamento: {q.source}.
</span>
</label>
);
const questions = data.questions.filter(
(q) =>
q.key !== 'investment_preparation' &&
applicable(q) &&
(!excluded || q.key === 'investment_activity_scope'),
);
const needed = questions.filter(
(q) =>
!known(q) &&
(!data.enabled ||
data.mode !== 'authorization' ||
data.missing_fields.includes(q.key)),
);
return (
<section
id="preparation-questions"
className={card + ' space-y-4'}
aria-label="Dados do pedido de investimento privado"
>
<h2 className="text-xl font-bold">
Dados em falta para preparar o pedido
</h2>
<p>
Valores conhecidos são reutilizados. As alterações só são
gravadas depois da comparação de impacto. Nacionalidade e
residência não determinam a origem do capital nem a modalidade
da empresa.
</p>
{render(data.questions[0])}
{active && (
<>
{needed.map(render)}
<details>
<summary>
Dados já declarados — consultar ou alterar
</summary>
<div className="space-y-3">
{questions.filter(known).map(render)}
</div>
</details>
<p>
Data, moeda e situação da entidade usam as declarações
acima.{' '}
{data.missing_fields.filter((k) =>
[
'currency',
'expected_start_date',
'entity_stage',
].includes(k),
).length > 0 &&
'Complete esses campos em falta antes de comparar.'}
</p>
<div id="preparation-documents" className="space-y-3">
<h3 className="font-bold">
Associar documentos já existentes no dossier
</h3>
<p>
Carregue documentos pelo Balcão Virtual do mesmo
dossier. A associação não aprova o conteúdo nem
substitui a análise do pedido.
</p>
{data.requirements
.filter((r) => r.required)
.map((r) => (
<label className="grid gap-1" key={r.key}>
{r.label}
<select
className={input}
disabled={disabled}
value={
Object.hasOwn(links, r.key)
? (links[r.key] ?? '')
: (r.document_id ?? '')
}
onChange={(e) =>
onLink(
r.key,
e.target.value || null,
)
}
>
<option value="">Por associar</option>
{documents.map((doc) => (
<option
key={doc.document_id}
value={doc.document_id}
>
{doc.file_name} ·{' '}
{{
quarantined:
'Em quarentena',
active: 'Disponível',
deleted: 'Removido',
}[doc.status] ??
'Estado por conferir'}
</option>
))}
</select>
<span>
{r.reason}{' '}
<a
className="underline"
href={r.source.url}
>
{r.source.reference}
</a>
</span>
</label>
))}
</div>
</>
)}
<p>
Compare o impacto para actualizar o enquadramento, as perguntas
e as exigências. Uma exigência que deixe de ser aplicável
conserva o documento anterior no histórico.
</p>
</section>
);
}
export function PreparationImpact({
before,
after,
}: {
before: Preparation;
after: Preparation;
}) {
return (
<div
className="space-y-2 rounded border border-amber-400 p-3"
aria-label="Impacto no pedido de investimento"
>
<h3 className="font-bold">Impacto no pedido e nos documentos</h3>
<p>
{preparationMode(before.mode)} → {preparationMode(after.mode)}.
</p>
<p>Próximo passo: {after.next}.</p>
<ul className="list-disc pl-5">
{after.requirements
.filter(
(r) =>
before.requirements.find((b) => b.key === r.key)
?.state !== r.state,
)
.map((r) => (
<li key={r.key}>
{r.label}:{' '}
{
states[
before.requirements.find(
(b) => b.key === r.key,
)?.state ?? 'indeterminate'
]
}{' '}
→ {states[r.state]}.{' '}
{r.document_id && 'Vínculo documental preservado.'}
</li>
))}
</ul>
<p>{after.calculation_notice}</p>
<PreparationSummary data={after} />
</div>
);
}
+21
View File
@@ -1,3 +1,7 @@
import type {
Preparation,
PreparationDocument,
} from '@/components/private-investment-preparation';
function csrfToken(): string {
return decodeURIComponent(
document.cookie
@@ -56,6 +60,8 @@ export type DossierContext = {
dependency_hash?: string;
};
export type ContextView = {
private_investment?: Preparation | null;
preparation_documents?: PreparationDocument[];
simulator_context_revision?: number | null;
case: { id: string; title: string; version: number };
context: DossierContext;
@@ -73,6 +79,7 @@ export type ContextView = {
notice: string;
};
export type ContextPlan = {
private_investment?: { before: Preparation; after: Preparation } | null;
preview_hash: string;
version: number;
revision: number;
@@ -119,6 +126,20 @@ export async function contextApi<T>(
return data as T;
}
export const contextFieldNames: Record<string, string> = {
investment_form: 'Tipo do investimento',
investment_preparation: 'Preparação do pedido',
investment_activity_scope: 'Natureza da actividade',
implementation_mode: 'Modalidade da empresa',
capital_origin: 'Origem dos capitais',
site_tenure: 'Utilização das instalações',
project_concession: 'PPP ou concessão empresarial',
significant_implications: 'Implicações significativas',
land_extension: 'Extensão de terra',
forest_concession: 'Concessão florestal',
forest_concession_hectares: 'Área florestal em hectares',
total_investment: 'Valor total do investimento',
area_required: 'Área de terra requerida',
area_unit: 'Unidade da área',
title: 'Título do investimento',
territory_id: 'Localização documentada',
location_source: 'Origem da localização',
+98 -15
View File
@@ -279,15 +279,75 @@ function makeSections(
if (draft.context_applicability_snapshot) {
try {
const context = JSON.parse(String(draft.context_applicability_snapshot));
const context = JSON.parse(
String(draft.context_applicability_snapshot),
);
if (context.version === 1) {
const section = sections.get('sources');
const zone = context.zone;
section?.rows.push({field:'Contexto do investimento',value:`Revisão ${context.context_revision}; ${zone ? `${zone.title}, versão ${zone.version}` : 'sem ZEE, ZFI ou parque seleccionado'}`,origin:'Referência do mesmo dossier no momento da gravação',classification:'Contexto versionado; não é reconhecimento jurídico'});
section?.paragraphs.push(zone ? 'Benefício fiscal da zona indeterminado: falta regra executável ligada às condições e reconhecimento. O IVA apresentado é apenas cenário geral sem benefício; não representa o regime especial nem o apuramento final.' : 'Cenário geral de preparação; sem confirmação automática de elegibilidade fiscal.');
if(zone?.href) section?.rows.push({field:'Ficha e fundamentos da zona',value:zone.href,origin:`Versão consultada ${zone.version}`,classification:'Referência consultável; condições por verificar'});
section?.rows.push({
field: 'Contexto do investimento',
value: `Revisão ${context.context_revision}; ${zone ? `${zone.title}, versão ${zone.version}` : 'sem ZEE, ZFI ou parque seleccionado'}`,
origin: 'Referência do mesmo dossier no momento da gravação',
classification:
'Contexto versionado; não é reconhecimento jurídico',
});
section?.paragraphs.push(
zone
? 'Benefício fiscal da zona indeterminado: falta regra executável ligada às condições e reconhecimento. O IVA apresentado é apenas cenário geral sem benefício; não representa o regime especial nem o apuramento final.'
: 'Cenário geral de preparação; sem confirmação automática de elegibilidade fiscal.',
);
const preparation = context.preparation;
if (preparation?.enabled) {
const modes: Record<string, string> = {
authorization: 'Pedido de autorização',
registration: 'Pedido de mero registo',
indeterminate: 'Enquadramento por determinar',
};
section?.rows.push({
field: 'Preparação do investimento privado',
value: modes[preparation.mode] ?? 'Por determinar',
origin: `Contexto congelado v${context.context_revision}; ${preparation.version}`,
classification:
'Declarações e requisitos de preparação; sem aprovação jurídica',
});
section?.paragraphs.push(
preparation.next,
preparation.calculation_notice,
preparation.notice,
);
for (const requirement of preparation.requirements ?? [])
section?.rows.push({
field: requirement.label,
value:
requirement.state === 'not_applicable'
? 'Não aplicável; histórico preservado'
: requirement.state === 'associated'
? `Associado: ${requirement.document?.file_name ?? 'documento do dossier'}; conteúdo por conferir`
: requirement.state === 'missing'
? 'Documento por associar'
: 'Informação em falta para decidir',
origin: `${requirement.reason} ${requirement.source?.reference ?? ''}; ${requirement.source?.url ?? ''}`,
classification:
'Preparação informativa — simulação, sem submissão à Administração',
});
}
if (zone?.href)
section?.rows.push({
field: 'Ficha e fundamentos da zona',
value: zone.href,
origin: `Versão consultada ${zone.version}`,
classification:
'Referência consultável; condições por verificar',
});
}
} catch { sections.get('sources')?.paragraphs.push('Não foi possível recuperar o contexto guardado; o enquadramento territorial permanece indeterminado.'); }
} catch {
sections
.get('sources')
?.paragraphs.push(
'Não foi possível recuperar o contexto guardado; o enquadramento territorial permanece indeterminado.',
);
}
}
for (const [sectionName, field, value, origin] of rows.slice(1)) {
@@ -359,9 +419,23 @@ function makeSections(
}
const finance = sections.get('finance');
if (finance && ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(currentCalculation?.snapshot?.scenario_kind)) && currentCalculation?.snapshot) {
for (const [field, value] of fiscalQaRows(currentCalculation.snapshot)) {
finance.rows.push({ field, value, origin: 'Snapshot fiscal preservado; sem recálculo', classification: 'Cálculo exacto; ver estado da regra no snapshot; não é saldo a pagar' });
if (
finance &&
['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(
String(currentCalculation?.snapshot?.scenario_kind),
) &&
currentCalculation?.snapshot
) {
for (const [field, value] of fiscalQaRows(
currentCalculation.snapshot,
)) {
finance.rows.push({
field,
value,
origin: 'Snapshot fiscal preservado; sem recálculo',
classification:
'Cálculo exacto; ver estado da regra no snapshot; não é saldo a pagar',
});
}
}
if (finance && currentCalculation) {
@@ -436,13 +510,22 @@ function makeSections(
},
{
field: 'Fórmula e valores usados',
value:
['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(snapshot?.scenario_kind))
? `Base tributável declarada × taxa cadastrada ÷ 100; cálculo decimal exacto, sem arredondamento. Ver regra e proveniência acima.`
: snapshot && typeof snapshot.formula_version === 'string'
? `Fórmula ${snapshot.formula_version}; base ${displayValue(snapshot.base, 'não disponível')}; taxa ${displayValue(snapshot.rate_percent, 'não disponível')}%; redução ${displayValue(snapshot.reduction_percent, 'não disponível')}%; moeda ${displayValue(snapshot.currency, 'não indicada')}`
: 'A fórmula não foi identificada no snapshot; ver explicação da execução',
origin: ['fiscal_rule_qa_preview', 'fiscal_rule_published'].includes(String(snapshot?.scenario_kind)) ? (snapshot?.scenario_kind === 'fiscal_rule_published' ? 'Versão publicada na admissão; sem validação jurídica da operação' : 'Snapshot QA de regra em rascunho; sem activação ou validação jurídica da operação') : 'Snapshot guardado; sem aplicação de regra legal',
value: [
'fiscal_rule_qa_preview',
'fiscal_rule_published',
].includes(String(snapshot?.scenario_kind))
? `Base tributável declarada × taxa cadastrada ÷ 100; cálculo decimal exacto, sem arredondamento. Ver regra e proveniência acima.`
: snapshot && typeof snapshot.formula_version === 'string'
? `Fórmula ${snapshot.formula_version}; base ${displayValue(snapshot.base, 'não disponível')}; taxa ${displayValue(snapshot.rate_percent, 'não disponível')}%; redução ${displayValue(snapshot.reduction_percent, 'não disponível')}%; moeda ${displayValue(snapshot.currency, 'não indicada')}`
: 'A fórmula não foi identificada no snapshot; ver explicação da execução',
origin: [
'fiscal_rule_qa_preview',
'fiscal_rule_published',
].includes(String(snapshot?.scenario_kind))
? snapshot?.scenario_kind === 'fiscal_rule_published'
? 'Versão publicada na admissão; sem validação jurídica da operação'
: 'Snapshot QA de regra em rascunho; sem activação ou validação jurídica da operação'
: 'Snapshot guardado; sem aplicação de regra legal',
classification: 'Reutilização do resultado existente',
},
{
+180 -26
View File
@@ -1,3 +1,7 @@
import {
PreparationEditor,
PreparationImpact,
} from '@/components/private-investment-preparation';
import { InvestmentContextAssessment } from '@/components/investment-context-assessment';
import ContextDocuments from '@/components/context-documents';
import { Link } from '@inertiajs/react';
@@ -36,6 +40,7 @@ export default function ContextEditor({
}) {
const navigation = useNavigationContext();
const [view, setView] = useState(initial);
const [preparation, setPreparation] = useState(initial.private_investment);
const [options, setOptions] = useState(initialOptions);
const [query, setQuery] = useState('');
const [changes, setChanges] = useState<Partial<Record<Slot, string>>>(() =>
@@ -49,6 +54,9 @@ export default function ContextEditor({
const [declarations, setDeclarations] = useState<Record<string, string>>(
{},
);
const [documentLinks, setDocumentLinks] = useState<
Record<string, string | null>
>({});
const [replace, setReplace] = useState<string[]>([]);
const [plan, setPlan] = useState<ContextPlan | null>(null);
const [busy, setBusy] = useState(false);
@@ -59,6 +67,32 @@ export default function ContextEditor({
string,
unknown
> | null>(null);
const displayValue = (
field: string,
value: string | null | undefined,
): string => {
if (value === null || value === undefined || value === '')
return 'não informado';
const question = view.private_investment?.questions.find(
(q) => q.key === field,
);
return (
question?.options?.[value] ??
(
{
being_formed: 'Em constituição',
existing: 'Existente',
national: 'Nacional',
foreign: 'Estrangeiro',
mixed: 'Misto',
yes: 'Sim',
no: 'Não',
unknown: 'Por determinar',
} as Record<string, string>
)[value] ??
value
);
};
const selected = (slot: Slot) =>
Object.hasOwn(changes, slot)
? (changes[slot] ?? '')
@@ -77,6 +111,7 @@ export default function ContextEditor({
selection: changes,
declarations,
replace_fields: replace,
document_links: documentLinks,
};
try {
const result = await contextApi<ContextPlan>(
@@ -85,6 +120,9 @@ export default function ContextEditor({
);
setRequestBody(body);
setPlan(result);
setPreparation(
result.private_investment?.after ?? view.private_investment,
);
} catch (e) {
setError(
e instanceof Error ? e.message : 'Não foi possível comparar.',
@@ -111,9 +149,11 @@ export default function ContextEditor({
`/investimentos/${view.case.id}/contexto`,
);
setView(next);
setPreparation(next.private_investment);
navigation.refresh();
setChanges({});
setDeclarations({});
setDocumentLinks({});
setReplace([]);
setPlan(null);
setOperation(createOperationKey());
@@ -132,7 +172,10 @@ export default function ContextEditor({
}
return (
<Frame title="Contexto do investimento">
<InvestmentContextAssessment contextId={view.case.id} raw={view.prefill.simulator?.context_applicability_snapshot} />
<InvestmentContextAssessment
contextId={view.case.id}
raw={view.prefill.simulator?.context_applicability_snapshot}
/>
<ContextDocuments source={view.context.selection.opportunity} />
<div className="space-y-5 [overflow-wrap:anywhere]">
<Link
@@ -337,17 +380,107 @@ export default function ContextEditor({
Estes campos pertencem ao processo. Alterá-los não muda
o cadastro da oportunidade nem o perfil da organização.
</p>
<p>Origem do investidor, residência fiscal e constituição da entidade são declarações distintas. A localização não confirma benefícios. A prévia identifica o impacto antes de guardar.</p>
<p>
Origem do investidor, residência fiscal e constituição
da entidade são declarações distintas. A localização não
confirma benefícios. A prévia identifica o impacto antes
de guardar.
</p>
{[
['currency','Moeda do projecto',null],
['expected_start_date','Data prevista de início',null],
['investor_origin','Origem do investidor',[['national','Nacional'],['foreign','Estrangeiro'],['mixed','Misto']]],
['person_type','Tipo de entidade',[['singular','Pessoa singular'],['collective','Pessoa colectiva'],['consortium','Consórcio']]],
['entity_stage','Situação da entidade',[['existing','Existente'],['being_formed','Em constituição']]],
['tax_residency_country','País de residência fiscal declarado (ISO de duas letras)',null],
].map(([key,label,choices]) => <label className="grid gap-1" key={String(key)}>{String(label)}{Array.isArray(choices)
? <select className={input} value={declarations[String(key)] ?? view.context.fields[String(key)] ?? ''} onChange={e=>{setDeclarations({...declarations,[String(key)]:e.target.value});setPlan(null);}}><option value="">Por indicar</option>{choices.map(([value,text])=><option key={value} value={value}>{text}</option>)}</select>
: <input className={input} type={key==='expected_start_date'?'date':'text'} maxLength={key==='currency'?3:key==='tax_residency_country'?2:10} value={declarations[String(key)] ?? view.context.fields[String(key)] ?? ''} onChange={e=>{setDeclarations({...declarations,[String(key)]:e.target.value});setPlan(null);}} />}</label>)}
['currency', 'Moeda do projecto', null],
[
'expected_start_date',
'Data prevista de início',
null,
],
[
'investor_origin',
'Origem do investidor',
[
['national', 'Nacional'],
['foreign', 'Estrangeiro'],
['mixed', 'Misto'],
],
],
[
'person_type',
'Tipo de entidade',
[
['singular', 'Pessoa singular'],
['collective', 'Pessoa colectiva'],
['consortium', 'Consórcio'],
],
],
[
'entity_stage',
'Situação da entidade',
[
['existing', 'Existente'],
['being_formed', 'Em constituição'],
],
],
[
'tax_residency_country',
'País de residência fiscal declarado (ISO de duas letras)',
null,
],
].map(([key, label, choices]) => (
<label className="grid gap-1" key={String(key)}>
{String(label)}
{Array.isArray(choices) ? (
<select
className={input}
value={
declarations[String(key)] ??
view.context.fields[String(key)] ??
''
}
onChange={(e) => {
setDeclarations({
...declarations,
[String(key)]: e.target.value,
});
setPlan(null);
}}
>
<option value="">Por indicar</option>
{choices.map(([value, text]) => (
<option key={value} value={value}>
{text}
</option>
))}
</select>
) : (
<input
className={input}
type={
key === 'expected_start_date'
? 'date'
: 'text'
}
maxLength={
key === 'currency'
? 3
: key === 'tax_residency_country'
? 2
: 10
}
value={
declarations[String(key)] ??
view.context.fields[String(key)] ??
''
}
onChange={(e) => {
setDeclarations({
...declarations,
[String(key)]: e.target.value,
});
setPlan(null);
}}
/>
)}
</label>
))}
{['project_title', 'project_summary'].map((k) => (
<label className="grid gap-1" key={k}>
{contextFieldNames[k]}
@@ -379,18 +512,7 @@ export default function ContextEditor({
declarações do utilizador. Autorize somente os
campos que pretende substituir pelo cadastro.
</p>
<p>Origem do investidor, residência fiscal e constituição da entidade são declarações distintas. A localização não confirma benefícios. A prévia identifica o impacto antes de guardar.</p>
{[
['currency','Moeda do projecto',null],
['expected_start_date','Data prevista de início',null],
['investor_origin','Origem do investidor',[['national','Nacional'],['foreign','Estrangeiro'],['mixed','Misto']]],
['person_type','Tipo de entidade',[['singular','Pessoa singular'],['collective','Pessoa colectiva'],['consortium','Consórcio']]],
['entity_stage','Situação da entidade',[['existing','Existente'],['being_formed','Em constituição']]],
['tax_residency_country','País de residência fiscal declarado (ISO de duas letras)',null],
].map(([key,label,choices]) => <label className="grid gap-1" key={String(key)}>{String(label)}{Array.isArray(choices)
? <select className={input} value={declarations[String(key)] ?? view.context.fields[String(key)] ?? ''} onChange={e=>{setDeclarations({...declarations,[String(key)]:e.target.value});setPlan(null);}}><option value="">Por indicar</option>{choices.map(([value,text])=><option key={value} value={value}>{text}</option>)}</select>
: <input className={input} type={key==='expected_start_date'?'date':'text'} maxLength={key==='currency'?3:key==='tax_residency_country'?2:10} value={declarations[String(key)] ?? view.context.fields[String(key)] ?? ''} onChange={e=>{setDeclarations({...declarations,[String(key)]:e.target.value});setPlan(null);}} />}</label>)}
{['project_title', 'project_summary'].map((k) => (
{['project_title', 'project_summary'].map((k) => (
<label className="block" key={k}>
<input
type="checkbox"
@@ -411,6 +533,30 @@ export default function ContextEditor({
</label>
))}
</details>
{view.private_investment && (
<PreparationEditor
data={preparation ?? view.private_investment}
fields={view.context.fields}
changes={declarations}
documents={view.preparation_documents ?? []}
links={documentLinks}
disabled={busy || !view.can_write}
onChange={(key, value) => {
setDeclarations({
...declarations,
[key]: value,
});
setPlan(null);
}}
onLink={(key, value) => {
setDocumentLinks({
...documentLinks,
[key]: value,
});
setPlan(null);
}}
/>
)}
<button
className={button}
type="button"
@@ -467,10 +613,15 @@ export default function ContextEditor({
x.field
] ?? x.field}
:{' '}
{x.before ??
'não informado'}{' '}
{displayValue(
x.field,
x.before,
)}{' '}
→{' '}
{x.after ?? 'não informado'}
{displayValue(
x.field,
x.after,
)}
</p>
))}
{Object.values(plan.selection).map(
@@ -530,6 +681,9 @@ export default function ContextEditor({
</tbody>
</table>
</div>
{plan.private_investment && (
<PreparationImpact {...plan.private_investment} />
)}
<p>{plan.notice}</p>
<div className="flex flex-wrap gap-3">
<button
+31
View File
@@ -1,3 +1,7 @@
import {
PreparationSummary,
type Preparation,
} from '@/components/private-investment-preparation';
import ContextDocuments from '@/components/context-documents';
import type { DossierContext } from '@/lib/investor-context';
import FintechInvestment, {
@@ -27,6 +31,7 @@ type Step = {
source_paragraph: number;
};
type Path = {
private_investment?: Preparation | null;
fintech: FintechView | null;
investment_context: DossierContext | null;
zone_contexts?: {
@@ -180,6 +185,28 @@ function Stage({ step, path }: { step: Step; path: Path }) {
independente.
</label>
)}
{path.private_investment?.enabled && (
<div className="space-y-2">
{path.private_investment.requirements
.filter((r) => r.stage === step.code)
.map((r) => (
<p className="text-sm" key={r.key}>
<strong>{r.label}:</strong>{' '}
{r.state === 'not_applicable'
? 'não exigido neste contexto; histórico preservado'
: r.state === 'associated'
? 'documento associado; conteúdo por conferir'
: r.state === 'missing'
? 'documento por associar'
: 'aplicabilidade por determinar'}
. {r.reason}{' '}
<a className="underline" href={r.source.url}>
{r.source.reference}
</a>
</p>
))}
</div>
)}
{step.reason && (
<p className="text-sm">Fundamento: {step.reason}</p>
)}
@@ -415,6 +442,10 @@ export default function InvestmentPath(path: Path) {
<ContextDocuments
source={path.investment_context?.selection.opportunity}
/>
<PreparationSummary
data={path.private_investment}
caseId={path.case.id}
/>
{path.investment_context && (
<section className="space-y-2 rounded border border-emerald-300 bg-emerald-50 p-4">
<h2 className="font-semibold">