891 lines
41 KiB
TypeScript
891 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;
|
|
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>
|
|
)}
|
|
{(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>
|
|
);
|
|
}
|