Files
Investidor-V2/resources/js/components/investor-guidance.tsx
T

895 lines
41 KiB
TypeScript

import { Link, router, usePage } from '@inertiajs/react';
import type { RequestPayload } from '@inertiajs/core';
import { useState } from 'react';
import {
guidanceFinance,
guidanceFinancialTools,
} from '@/lib/investor-guidance-finance';
import { input, button, card } from '@/pages/investment/shared';
type Recommendation = {
code: string;
kind: string;
classification: string;
explanation: string;
href: string;
source: {
identifier: string;
reference: string;
date: string;
nature: string;
};
limitations: string[];
review: {
decision: string;
reason: string;
date: string;
stale: boolean;
} | null;
};
export type Guidance = {
version: number;
fingerprint: string;
rules_version: string;
computed_at: string;
can_write: boolean;
can_update_tasks?: boolean;
facts: Record<string, string>;
financial_draft: Record<string, unknown> | null;
consulted: {
module: string;
id: string;
version: number;
date: string;
status: string;
}[];
unavailable: string[];
catalogue_checks: {
code: string;
classification: string;
explanation: string;
source: Record<string, unknown> | null;
}[];
phases: {
id: string;
objective: string;
known: { field: string; value: string; status: string }[];
questions: string[];
evidence: {
field: string;
state: string;
documents: {
document_id: string;
name: string;
state: string;
version: number;
valid_until: string | null;
review: {
author: string;
date: string;
what_checked: string;
evidence: string;
scope: string;
} | null;
}[];
}[];
recommendations: Recommendation[];
risks: {
code: string;
classification: string;
explanation: string;
restriction: boolean;
}[];
}[];
tasks: {
id: string;
title: string;
status: string;
due_at: string;
version_no: number;
assignee_name: string;
assignee_user_id: number;
accepted_at: string;
external_sync_status: string;
desk_request_id: string | null;
}[];
crm: { internal: string; external: string; reason: string };
calculator_versions: Record<string, string>;
};
const fields: Record<string, string> = {
project_title: 'Título',
project_summary: 'Objectivo do projecto',
activity: 'Actividade e produtos',
target_customers: 'Clientes ou beneficiários',
market_evidence: 'Referência da evidência de mercado',
location: 'Localização em análise',
location_evidence: 'Referência e dúvidas sobre o local',
resource_plan: 'Recursos e equipa necessários',
resource_evidence: 'Evidência de disponibilidade dos recursos',
funding_evidence: 'Evidência do financiamento',
expected_start_date: 'Data prevista de início',
ready_date: 'Data de disponibilidade dos recursos',
schedule: 'Calendário e dependências',
demand_quantity: 'Procura declarada (quantidade, não previsão)',
capacity_quantity: 'Capacidade declarada na mesma unidade e período',
quantity_unit: 'Unidade comum da procura e capacidade',
quantity_period: 'Período comum da procura e capacidade',
resources_required: 'Quantidade de recursos necessários declarados',
resources_available: 'Quantidade de recursos disponíveis declarados',
resource_unit: 'Unidade comum dos recursos',
funding_required: 'Financiamento necessário declarado',
funding_committed: 'Financiamento comprometido declarado',
funding_currency: 'Moeda comum do financiamento',
funding_ready_date: 'Data declarada de disponibilidade do financiamento',
completion_date: 'Data declarada de conclusão',
};
const decisionLabels: Record<string, string> = {
accepted: 'Aceite pelo investidor',
rejected: 'Rejeitada pelo investidor',
corrected: 'Contestada / correcção solicitada',
};
const evidenceLabels: Record<string, string> = {
absent: 'Sem informação',
investor_declared_not_verified: 'Declaração por conferir',
attached_unchecked: 'Documento associado, por conferir',
internally_verified: 'Conferido internamente no âmbito descrito',
contradiction: 'Contradição documentada',
issue_identified: 'Problema documental identificado',
expired: 'Validade registada terminada',
superseded: 'Ficheiro ou versão substituída — rever',
technical_unavailable: 'Evidência indisponível',
};
export function InvestorGuidance({
caseId,
guidance,
manager = false,
actorId,
requests = [],
}: {
caseId: string;
guidance: Guidance;
manager?: boolean;
actorId?: number;
requests?: { id: string; protocol: string | null }[];
}) {
const requestedPhase = new URLSearchParams(
usePage().url.split('?')[1] ?? '',
).get('phase');
const [phaseId, setPhaseId] = useState(
guidance.phases.some((p) => p.id === requestedPhase)
? requestedPhase!
: guidance.phases[0].id,
);
const [facts, setFacts] = useState(guidance.facts);
const [reason, setReason] = useState('');
const [due, setDue] = useState('');
const [requestId, setRequestId] = useState('');
const [consent, setConsent] = useState(false);
const [query, setQuery] = useState('');
const [sources, setSources] = useState<{
status: string;
consulted: {
title: string;
content: string;
source_url: string;
source_date: string;
nature: string;
}[];
} | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const post = (path: string, data: RequestPayload) => {
setErrors({});
setBusy(true);
router.post(`/balcao-virtual/dossiers/${caseId}/${path}`, data, {
preserveScroll: true,
onError: setErrors,
onFinish: () => setBusy(false),
});
};
const phase =
guidance.phases.find((p) => p.id === phaseId) ?? guidance.phases[0];
const finance = guidanceFinance(guidance.financial_draft);
const financialTools = guidanceFinancialTools(guidance.financial_draft);
return (
<section
className="space-y-5"
aria-label="Acompanhamento contextual do investidor"
>
<h2 className="text-2xl font-bold">Acompanhamento do projecto</h2>
<p>
Orientação por regras internas. Pode regressar a qualquer fase,
corrigir pressupostos e explorar alternativas. Não representa
decisão oficial.
</p>
<p className="text-sm">
Versão {guidance.rules_version} · Avaliado em{' '}
{guidance.computed_at}. IA externa: não configurada; nenhum
modelo externo consultado.
</p>
<nav
aria-label="Fases do percurso"
className="flex flex-wrap gap-2"
>
{guidance.phases.map((p) => (
<button
key={p.id}
type="button"
aria-pressed={p.id === phase.id}
className="rounded border p-2"
onClick={() => setPhaseId(p.id)}
>
{p.objective}
</button>
))}
</nav>
<article className={card + ' space-y-4'}>
<h3 className="text-xl font-semibold">{phase.objective}</h3>
<h4 className="font-semibold">
Informação declarada pelo investidor
</h4>
{phase.known.length ? (
<dl>
{phase.known.map((f) => (
<div key={f.field}>
<dt className="font-medium">
{fields[f.field] ?? f.field}
</dt>
<dd className="break-words">{f.value}</dd>
</div>
))}
</dl>
) : (
<p>Ainda sem informação declarada nesta fase.</p>
)}
<h4 className="font-semibold">
Evidência e alcance da conferência
</h4>
{phase.evidence.map((e) => (
<section
key={e.field}
className="space-y-2 rounded border p-3"
>
<p className="font-medium">
{fields[e.field]}:{' '}
{evidenceLabels[e.state] ?? e.state}
</p>
{e.documents.map((d) => (
<article
key={d.document_id}
className="text-sm break-words"
>
<p>
{d.name} · versão {d.version} ·{' '}
{evidenceLabels[d.state] ?? d.state}
{d.valid_until && (
<>
{' '}
· Validade registada:{' '}
{d.valid_until}
</>
)}
</p>
{d.review && (
<>
<p>
Verificador: {d.review.author} ·
Data: {d.review.date}
</p>
<p>
O que verificou:{' '}
{d.review.what_checked}
</p>
<p>
Evidência utilizada:{' '}
{d.review.evidence}
</p>
<p>
Alcance: conferência documental
interna dos pressupostos indicados.
Não equivale a aprovação, registo ou
licença institucional.
</p>
</>
)}
</article>
))}
{e.state === 'internally_verified' && (
<p className="text-sm">
A conferência sustenta apenas o âmbito descrito.
Disponibilidade futura, procura e decisão
institucional continuam a exigir evidência
própria.
</p>
)}
</section>
))}
<h4 className="font-semibold">Dúvidas por resolver</h4>
{phase.questions.length ? (
<ul className="list-disc pl-5">
{phase.questions.map((q) => (
<li key={q}>{fields[q] ?? q}</li>
))}
</ul>
) : (
<p>
Confirmar evidências e dependências com o responsável;
preenchimento não equivale a validação.
</p>
)}
<h4 className="font-semibold">Riscos identificados</h4>
{phase.risks.map((r) => (
<p key={r.code}>
{r.classification === 'documented_contradiction'
? 'Contradição documentada'
: r.classification === 'declared_contradiction'
? 'Contradição nos pressupostos declarados'
: 'Informação insuficiente'}
: {r.explanation} Este alerta orienta a preparação.
</p>
))}
{!phase.risks.length && (
<p>
Nenhuma incompatibilidade comprovada por estas regras.
Os dados externos não foram verificados.
</p>
)}
{phase.recommendations.map((r) => (
<section
key={r.code}
className="space-y-3 rounded border p-4"
>
<h4 className="font-semibold">
Recomendação automática
</h4>
<p>{r.explanation}</p>
<p className="text-sm">
Fundamento: {r.source.identifier}, {r.source.date} (
{r.source.nature}). Referência: {r.source.reference}
.
</p>
<ul className="list-disc pl-5 text-sm">
{r.limitations.map((l) => (
<li key={l}>{l}</li>
))}
</ul>
<Link className="underline" href={r.href}>
Executar próxima acção no módulo existente
</Link>
{r.review && (
<p>
{decisionLabels[r.review.decision]}:{' '}
{r.review.reason}.{' '}
{r.review.stale
? 'O contexto mudou; reveja a recomendação.'
: 'Revisão do contexto actual.'}
</p>
)}
{guidance.can_write && (
<>
<label className="block">
Motivo da aceitação, rejeição ou contestação
<textarea
className={input}
value={reason}
onChange={(e) =>
setReason(e.target.value)
}
/>
</label>
<div className="flex flex-wrap gap-2">
{(
[
'accepted',
'rejected',
'corrected',
] as const
).map((decision) => (
<button
type="button"
key={decision}
disabled={
busy || reason.trim().length < 3
}
className="rounded border p-2 disabled:opacity-50"
onClick={() =>
post('recomendacoes', {
code: r.code,
context_hash:
guidance.fingerprint,
decision,
reason,
})
}
>
{decision === 'accepted'
? 'Aceitar recomendação'
: decision === 'rejected'
? 'Rejeitar recomendação'
: 'Contestar / corrigir'}
</button>
))}
</div>
{r.review?.decision === 'accepted' &&
!r.review.stale && (
<fieldset className="space-y-3 rounded border p-3">
<legend>
Assumir uma tarefa no CRM
interno
</legend>
<p>
A tarefa será atribuída a si. Um
responsável autorizado pode
depois reassumir ou redistribuir
o acompanhamento.
</p>
<label>
Associar ao pedido existente
<select
className={input}
value={requestId}
onChange={(e) =>
setRequestId(
e.target.value,
)
}
>
<option value="">
Acompanhamento do
dossier
</option>
{requests.map((r) => (
<option
value={r.id}
key={r.id}
>
{r.protocol ??
'Rascunho existente'}
</option>
))}
</select>
</label>
<label className="block">
Prazo interno previsto
<input
className={input}
type="datetime-local"
value={due}
onChange={(e) =>
setDue(e.target.value)
}
/>
</label>
<label className="flex gap-2">
<input
type="checkbox"
checked={consent}
onChange={(e) =>
setConsent(
e.target.checked,
)
}
/>
Assumo esta acção e autorizo o
seu registo no acompanhamento
interno.
</label>
<button
className={button}
type="button"
disabled={
busy || !due || !consent
}
onClick={() =>
post('tarefas', {
code: r.code,
context_hash:
guidance.fingerprint,
due_at: due,
consent,
request_id:
requestId || null,
})
}
>
Registar tarefa assumida
</button>
</fieldset>
)}
</>
)}
</section>
))}
</article>
<section className={card + ' space-y-3'}>
<h3 className="text-xl font-semibold">
Coerência com referências actuais
</h3>
{guidance.catalogue_checks.map((check) => (
<article key={check.code}>
<p>
{check.classification ===
'documented_incompatibility'
? 'Incompatibilidade documentada no catálogo'
: check.classification ===
'catalogue_consistent'
? 'Coerência no catálogo'
: 'Informação insuficiente'}
: {check.explanation}
</p>
{check.source && (
<p className="text-xs">
Fonte efectivamente consultada:{' '}
{JSON.stringify(check.source)} · avaliação{' '}
{guidance.computed_at}.
</p>
)}
</article>
))}
{!guidance.catalogue_checks.length && (
<p>
Sem selecções autorizadas suficientes para comparar
actividade/sector e hierarquia territorial. Procura,
infraestrutura, recursos e financiamento requerem
evidência própria.
</p>
)}
</section>
{guidance.can_write && (
<details className={card}>
<summary className="cursor-pointer font-semibold">
Corrigir pressupostos ou registar uma alternativa
</summary>
<form
className="mt-4 grid gap-3 sm:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
post('pressupostos', {
version: guidance.version,
assumptions: Object.fromEntries(
Object.keys(fields).map((key) => [
key,
facts[key] ?? '',
]),
),
});
}}
>
<p className="sm:col-span-2">
As correcções são declarações do investidor no
dossier. Reveja o plano no simulador para alterar
cálculos; os dados oficiais e os módulos de origem
são preservados.
</p>
{Object.entries(fields).map(([key, label]) => (
<label key={key}>
{label}
<input
type={
key.endsWith('_date') ? 'date' : 'text'
}
className={input}
maxLength={3000}
value={facts[key] ?? ''}
onChange={(e) =>
setFacts({
...facts,
[key]: e.target.value,
})
}
/>
</label>
))}
<button className={button} disabled={busy}>
Guardar pressupostos corrigidos
</button>
</form>
</details>
)}
<section className={card + ' space-y-3'}>
<h3 className="text-xl font-semibold">
Cálculos existentes e cenários
</h3>
<details>
<summary>Versões das ferramentas determinísticas</summary>
{Object.entries(guidance.calculator_versions).map(
([name, hash]) => (
<p className="text-xs break-all" key={name}>
{name} · SHA-256 do código: {hash}
</p>
),
)}
</details>
{financialTools && (
<details>
<summary>
Empréstimo, orçamento, financiamento e ponto de
equilíbrio — ferramentas existentes, snapshot v
{financialTools.snapshotVersion}
</summary>
<p>
Fonte: {financialTools.calculator}. Os erros e
indeterminações mantêm-se separados por cálculo.
</p>
<table className="w-full text-left">
<thead>
<tr>
<th>Componente</th>
<th>Valor / resultado</th>
<th>Pressuposto / limite</th>
</tr>
</thead>
<tbody>
{financialTools.rows.map((row, i) => (
<tr key={i}>
<td>{row[1]}</td>
<td>{row[2]}</td>
<td>{row[3]}</td>
</tr>
))}
</tbody>
</table>
<pre className="overflow-auto text-xs whitespace-pre-wrap">
{JSON.stringify(
financialTools.assumptions,
null,
2,
)}
</pre>
</details>
)}
<p>{finance.reason}</p>
{finance.result && (
<>
<p>
Snapshot v{finance.snapshotVersion}; calculadora{' '}
{finance.calculator}. Os resultados usam exactamente
o snapshot autorizado do simulador.
</p>
<dl>
<dt>Facturação estimada</dt>
<dd>
{finance.result.totalRevenue}{' '}
{finance.assumptions.currency}
</dd>
<dt>Resultado antes de impostos</dt>
<dd>
{finance.result.totalProfitBeforeTax}{' '}
{finance.assumptions.currency}
</dd>
<dt>Caixa final</dt>
<dd>
{finance.result.endingCash}{' '}
{finance.assumptions.currency}
</dd>
</dl>
{finance.scenarios ? (
<p>
Caixa final — favorável:{' '}
{finance.scenarios.favorable.result.endingCash};
desfavorável:{' '}
{
finance.scenarios.unfavorable.result
.endingCash
}
. São cenários de pressupostos, sem
probabilidade atribuída.
</p>
) : (
<p>
Cenários alternativos indeterminados: complete
os respectivos pressupostos no simulador.
</p>
)}
<details>
<summary>
Pressupostos usados pela calculadora
</summary>
<pre className="max-h-80 overflow-auto text-xs break-all whitespace-pre-wrap">
{JSON.stringify(finance.assumptions, null, 2)}
</pre>
</details>
</>
)}
<p>
Componentes fiscais: indeterminadas neste acompanhamento.
Consulte o resultado fiscal e as suas versões no simulador.
</p>
<Link className="underline" href="/simulador-fiscal">
Abrir o simulador e explorar alternativas
</Link>
</section>
<section className={card + ' space-y-3'}>
<h3 className="text-xl font-semibold">
Tarefas assumidas e respostas
</h3>
<p>{guidance.crm.reason}</p>
{guidance.tasks.map((task) => (
<article
className="space-y-2 rounded border p-3"
key={task.id}
>
<p>{task.title}</p>
<p>
Estado: {task.status} · Responsável:{' '}
{task.assignee_name} · Prazo interno: {task.due_at}
</p>
<p>
Assumida em {task.accepted_at} · Sincronização
externa: pendente de autorização.
</p>
{task.desk_request_id && (
<Link
className="underline"
href={`/balcao-virtual/pedidos/${task.desk_request_id}`}
>
Consultar o pedido e a resposta do responsável
</Link>
)}
{guidance.can_update_tasks !== false &&
(manager || actorId === task.assignee_user_id) && (
<>
<label className="block">
Resultado ou motivo
<textarea
className={input}
value={reason}
onChange={(e) =>
setReason(e.target.value)
}
/>
</label>
<div className="flex flex-wrap gap-2">
{[
'in_progress',
'done',
'cancelled',
].map((status) => (
<button
key={status}
type="button"
className="rounded border p-2"
disabled={
busy ||
reason.trim().length < 3
}
onClick={() =>
post(`tarefas/${task.id}`, {
version:
task.version_no,
status,
reason,
})
}
>
{status === 'in_progress'
? 'Iniciar'
: status === 'done'
? 'Registar conclusão'
: 'Cancelar tarefa'}
</button>
))}
</div>
</>
)}
</article>
))}
{!guidance.tasks.length && (
<p>
Sem tarefas assumidas. As sugestões do assistente ainda
não são compromissos.
</p>
)}
</section>
<section className={card + ' space-y-3'}>
<h3 className="text-xl font-semibold">
Fontes efectivamente consultadas
</h3>
{guidance.consulted.map((s) => (
<p key={s.module}>
{s.module} · versão {s.version} · {s.date} · declaração
do investidor, sem confirmação externa.
</p>
))}
{guidance.unavailable.length > 0 && (
<p>
Parte do contexto ligado não está disponível com as suas
permissões actuais.
</p>
)}
<form
onSubmit={(e) => {
e.preventDefault();
setBusy(true);
setErrors({});
void fetch(
`/balcao-virtual/dossiers/${caseId}/fontes`,
{
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie
.split('; ')
.find((v) =>
v.startsWith('XSRF-TOKEN='),
)
?.slice(11) ?? '',
),
},
body: JSON.stringify({ query }),
},
)
.then(async (r) => {
if (!r.ok)
throw new Error(
'Pesquisa indisponível; a orientação interna continua disponível.',
);
setSources(await r.json());
})
.catch((e: unknown) =>
setErrors({
search:
e instanceof Error
? e.message
: 'Pesquisa indisponível.',
}),
)
.finally(() => setBusy(false));
}}
>
<label>
Pesquisar excertos de fontes públicas autorizadas
<input
className={input}
value={query}
minLength={3}
maxLength={300}
onChange={(e) => setQuery(e.target.value)}
required
/>
</label>
<button className={button} disabled={busy}>
Consultar fontes indexadas
</button>
</form>
{sources && (
<>
<p>
{sources.status === 'evidence_found'
? 'Excertos encontrados'
: sources.status === 'unavailable'
? 'Pesquisa IA indisponível; orientação por regras mantida.'
: 'Sem evidência nas fontes indexadas. Não implica inexistência de fonte.'}
</p>
{sources.consulted.map((s, i) => (
<article key={i}>
<p>
{s.title} · {s.source_date} · {s.nature}
</p>
<blockquote>{s.content}</blockquote>
<a
className="underline"
href={s.source_url}
rel="noreferrer"
>
Consultar origem
</a>
</article>
))}
</>
)}
</section>
{Object.keys(errors).length > 0 && (
<div role="alert" className="rounded border border-red-600 p-3">
{Object.entries(errors).map(([k, v]) => (
<p key={k}>{v}</p>
))}
</div>
)}
</section>
);
}