Files
Investidor-V2/resources/js/pages/investment/guide.tsx
T

677 lines
27 KiB
TypeScript

import { useRef, useState } from 'react';
import { Link, router } from '@inertiajs/react';
import { Frame, card, input, button } from './shared';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import { createOperationKey } from '@/lib/operation-key';
import { guideChoices, guideUrl } from '@/lib/investment-guides';
type Guide = {
slug: string;
version: string;
sha256: string;
title: string;
reviewed_on: string;
scope: string;
selection_label?: string;
selection_hint?: string;
cards_title?: string;
eligibility_title?: string;
preparation_title?: string;
requirements?: GuideRequirement[];
documents?: {
id: string;
title: string;
text: string;
paths: string[];
citations: GuideCitation[];
}[];
handoffs?: { title: string; url: string; note: string }[];
instruments: Record<
string,
{
title: string;
description: string;
risks: string[];
prepare: string[];
sources: string[];
requirements?: GuideRequirement[];
}
>;
sources: Record<
string,
{
title: string;
url: string;
edition: string;
locator: string;
consulted_on: string;
sha256: string | null;
valid_until?: string;
}
>;
eligibility: {
text: string;
sources: string[];
citations?: GuideCitation[];
}[];
preparation: {
title: string;
text: string;
sources: string[];
responsible?: string;
citations?: GuideCitation[];
}[];
next: string[];
limits: string[];
};
type GuideCitation = { source: string; reference: string; page: number };
type GuideRequirement = {
id: string;
text: string;
kind: 'requirement' | 'exception' | 'gap';
citations: GuideCitation[];
};
type Props = {
guide: Guide;
instruments: string[];
note: string;
case: { id: string; title: string; version: number } | null;
saved: {
revision: number;
at: string;
note: string;
instruments: string[];
guide: Guide;
context: { revision: number };
} | null;
canWrite: boolean;
historical: boolean;
olderEdition: boolean;
currentAvailable: boolean;
workingCopy: boolean;
contextChanged: boolean;
unavailableInstruments: string[];
history: { id: string; revision: number; at: string; edition: string }[];
historyNext: number | null;
};
export default function InvestmentGuide(props: Props) {
return (
<GuideView
key={`${props.case?.id}:${props.guide.sha256}:${props.saved?.revision}:${props.historical}:${props.workingCopy}:${props.instruments.join(',')}`}
{...props}
/>
);
}
function GuideView(props: Props) {
const { guide, saved, historical, olderEdition } = props;
const dossier = props.case;
const [selected, setSelected] = useState(props.instruments);
const [note, setNote] = useState(props.note);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [recovery, setRecovery] = useState<Props | null>(null);
const [version, setVersion] = useState(dossier?.version ?? 0);
const form = useRef<HTMLFormElement>(null);
const operation = useRef(createOperationKey());
const base = dossier
? `/investimentos/${dossier.id}/guias/${guide.slug}`
: `/guias-investimento/${guide.slug}`;
const readOnly =
historical || olderEdition || (!!dossier && !props.canWrite);
const visible = selected.length ? selected : Object.keys(guide.instruments);
function sourceLinks(keys: string[]) {
return (
<p className="text-sm">
Fontes:{' '}
{keys.map((key) => (
<span key={key}>
<a className="mr-3 underline" href={`#source-${key}`}>
{guide.sources[key]?.title ?? key}
</a>
</span>
))}
</p>
);
}
function citations(items: GuideCitation[] = []) {
return (
<ul className="text-sm">
{items.map((item) => (
<li key={`${item.source}:${item.reference}:${item.page}`}>
<a
className="underline"
href={`${guide.sources[item.source].url}#page=${item.page}`}
target="_blank"
rel="noopener noreferrer"
>
{guide.sources[item.source].title} ·{' '}
{item.reference} · p. {item.page} do PDF
</a>
</li>
))}
</ul>
);
}
function requirements(items: GuideRequirement[] = []) {
return (
<ul className="space-y-3">
{items.map((item) => (
<li key={item.id} className="rounded border p-3">
<p>
<strong>
{
{
gap: 'Por esclarecer',
exception:
'Regra com dispensa ou condição específica',
requirement: 'Conferir requisito',
}[item.kind]
}
:
</strong>{' '}
{item.text}
</p>
{citations(item.citations)}
</li>
))}
</ul>
);
}
function choose() {
router.visit(
guideUrl(
'/investimentos',
{
slug: guide.slug,
sha256: guide.sha256,
instruments: guideChoices(form.current!),
},
true,
),
);
}
async function save() {
if (!dossier || busy || readOnly) return;
setBusy(true);
setError('');
const values = new FormData(form.current!);
const currentNote = values.get('note');
const chosen = guideChoices(form.current!);
const text = typeof currentNote === 'string' ? currentNote : note;
setNote(text);
setSelected(chosen);
try {
await profileJson(base, {
version,
operation_key: operation.current,
sha256: guide.sha256,
instruments: chosen,
note: text,
});
router.visit(base, { preserveState: false });
} catch (e) {
setError(
e instanceof Error
? e.message
: 'Não foi possível guardar. A nota continua nesta página.',
);
if (
e instanceof ProfileValidationError &&
Object.values(e.fields).length
)
setError(Object.values(e.fields).join(' '));
} finally {
setBusy(false);
}
}
async function recover() {
setBusy(true);
try {
setRecovery(await profileJson(base, {}, 'GET'));
} catch (e) {
setError(
e instanceof Error
? e.message
: 'Não foi possível consultar o dossier.',
);
} finally {
setBusy(false);
}
}
return (
<Frame title={guide.title}>
{dossier && (
<p>
Dossier:{' '}
<Link
className="underline"
href={`/investimentos/${dossier.id}`}
>
{dossier.title}
</Link>
</p>
)}
<p>{guide.scope}</p>
<p>
Edição {guide.version} · Conteúdo conferido em{' '}
{guide.reviewed_on}.
</p>
{saved && (
<p>
{historical
? 'Referência histórica'
: 'Última referência guardada'}
: revisão {saved.revision} · {saved.at} · contexto{' '}
{saved.context.revision}.
{props.workingCopy &&
' Esta preparação ainda não substitui a referência guardada.'}
</p>
)}
{props.contextChanged && (
<p>
Os dados de contexto disponíveis nesta consulta diferem dos
utilizados na referência. Confira a sua pertinência; a
edição guardada conserva o contexto anterior.
</p>
)}
{props.unavailableInstruments.length > 0 && (
<p>
Esta edição deixou de incluir:{' '}
{props.unavailableInstruments.join(', ')}. A selecção e o
conteúdo anteriores continuam na referência guardada e no
histórico.
</p>
)}
{(historical || olderEdition) && (
<p>
Consulta preservada da edição utilizada.{' '}
{props.currentAvailable ? (
<Link className="underline" href={`${base}?current=1`}>
Preparar uma nova referência com o guia actual
</Link>
) : (
'O guia foi retirado da consulta actual; esta referência continua disponível.'
)}
</p>
)}
<form
ref={form}
onSubmit={(e) => {
e.preventDefault();
if (dossier) void save();
else choose();
}}
className={card}
>
<fieldset disabled={busy || readOnly} className="space-y-3">
<legend className="font-semibold">
{guide.selection_label ??
'Que instrumentos pretende aprofundar?'}
</legend>
<p>
{guide.selection_hint ??
'Sem selecção, consulte o conjunto. Pode combinar instrumentos; a selecção organiza a consulta e não é uma recomendação.'}
</p>
{Object.entries(guide.instruments).map(([code, item]) => (
<label key={code} className="block">
<input
type="checkbox"
name="instruments[]"
value={code}
checked={selected.includes(code)}
onChange={(e) =>
setSelected((old) =>
e.target.checked
? [...old, code]
: old.filter((v) => v !== code),
)
}
/>{' '}
{item.title}
</label>
))}
{dossier && (
<label className="block">
Questões a discutir nesta preparação (opcional)
<textarea
className={input}
name="note"
value={note}
maxLength={3000}
onChange={(e) => setNote(e.target.value)}
/>
<span className="text-sm">
A nota fica privada no dossier; reutilize os
dados e documentos já registados.
</span>
</label>
)}
<button type="submit" className={button}>
{dossier
? 'Guardar referência no dossier'
: 'Escolher dossier para preparar'}
</button>
</fieldset>
</form>
{error && (
<div role="alert" className={card}>
<p>{error}</p>
<button
className={button}
type="button"
disabled={busy}
onClick={recover}
>
Conferir a referência actual sem perder a nota
</button>
</div>
)}
{recovery && (
<section className={card}>
<h2 className="font-semibold">
Referência actualmente guardada
</h2>
<p>
Dossier: {recovery.case?.title} · Edição{' '}
{recovery.guide.version} · revisão{' '}
{recovery.saved?.revision ?? 'ainda não guardada'}
</p>
<p>
{recovery.instruments
.map(
(code) =>
recovery.guide.instruments[code]?.title ??
code,
)
.join(', ') || 'Consulta do conjunto'}
</p>
<p className="whitespace-pre-wrap">
{recovery.note || 'Sem nota'}
</p>
{recovery.canWrite &&
recovery.case &&
recovery.currentAvailable &&
!recovery.olderEdition &&
recovery.guide.sha256 === guide.sha256 && (
<button
className={button}
type="button"
onClick={() => {
setVersion(recovery.case!.version);
operation.current = createOperationKey();
setRecovery(null);
setError('');
}}
>
Conferi: manter a minha preparação numa nova
revisão
</button>
)}
{(recovery.guide.sha256 !== guide.sha256 ||
recovery.olderEdition ||
!recovery.currentAvailable) && (
<p>
O guia utilizado foi alterado. Conserve a nota e
abra o guia actual numa nova página para comparar as
edições.
</p>
)}
</section>
)}
<section className={card}>
<h2 className="text-lg font-semibold">
{guide.cards_title ?? 'Instrumentos, riscos e preparação'}
</h2>
{guide.requirements && (
<div className="my-4">
<h3 className="font-semibold">
Conferência comum aos percursos deste guião
</h3>
{requirements(guide.requirements)}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
{visible.map((code) => {
const item = guide.instruments[code];
return (
item && (
<article
className="space-y-2 rounded border p-3"
key={code}
>
<h3 className="font-semibold">
{item.title}
</h3>
<p>{item.description}</p>
<h4 className="font-medium">
Riscos e pontos a conferir
</h4>
<ul className="list-disc pl-5">
{item.risks.map((text) => (
<li key={text}>{text}</li>
))}
</ul>
{item.prepare.length > 0 && (
<>
<h4 className="font-medium">
Preparar para discussão
</h4>
<ul className="list-disc pl-5">
{item.prepare.map((text) => (
<li key={text}>{text}</li>
))}
</ul>
</>
)}
{sourceLinks(item.sources)}
{requirements(item.requirements)}
</article>
)
);
})}
</div>
</section>
{guide.documents && (
<section className={card}>
<h2 className="text-lg font-semibold">
Documentos dos percursos seleccionados
</h2>
<p>
Os documentos comuns aparecem uma vez; as condições
específicas de cada segmento devem ser conservadas.
Reutilize os documentos do dossier e confira a sua
actualidade com o operador.
</p>
{guide.documents
.filter((doc) =>
doc.paths.some((code) => visible.includes(code)),
)
.map((doc) => (
<article key={doc.id} className="my-4">
<h3 className="font-semibold">{doc.title}</h3>
<p>{doc.text}</p>
<p className="text-sm">
Percursos:{' '}
{doc.paths
.filter((code) =>
visible.includes(code),
)
.map(
(code) =>
guide.instruments[code].title,
)
.join(' · ')}
</p>
{citations(doc.citations)}
</article>
))}
</section>
)}
<section className={card}>
<h2 className="text-lg font-semibold">
{guide.eligibility_title ??
'Elegibilidade: confirmar com a entidade'}
</h2>
{guide.eligibility.map((item) => (
<div key={item.text}>
<p>{item.text}</p>
{sourceLinks(item.sources)}
{citations(item.citations)}
</div>
))}
</section>
<section className={card}>
<h2 className="text-lg font-semibold">
{guide.preparation_title ?? 'Dados e documentos a preparar'}
</h2>
<p>
Referência de preparação; a lista exigida em cada operação é
definida pela entidade destinatária.
</p>
{guide.preparation.map((item) => (
<article className="my-3" key={item.title}>
<h3 className="font-semibold">{item.title}</h3>
<p>{item.text}</p>
{item.responsible && (
<p>Intervenientes: {item.responsible}</p>
)}
{sourceLinks(item.sources)}
{citations(item.citations)}
</article>
))}
{dossier && (
<Link
className="underline"
href={`/balcao-virtual/dossiers/${dossier.id}`}
>
Reutilizar ou anexar documentos neste dossier
</Link>
)}
</section>
<section className={card}>
<h2 className="text-lg font-semibold">Próximos passos</h2>
<ol className="list-decimal pl-5">
{guide.next.map((text) => (
<li key={text}>{text}</li>
))}
</ol>
<ul className="mt-4 list-disc pl-5">
{guide.limits.map((text) => (
<li key={text}>{text}</li>
))}
</ul>
{guide.handoffs?.map((handoff) => (
<p key={handoff.url} className="my-3">
<a
className="underline"
href={handoff.url}
target="_blank"
rel="noopener noreferrer"
>
{handoff.title}
</a>{' '}
· {handoff.note}
</p>
))}
<p>
<Link
className="underline"
href={
dossier
? `/investimentos/${dossier.id}/guias/${guide.slug === 'bolsa' ? 'capitalizacao' : 'bolsa'}`
: `/guias-investimento/${guide.slug === 'bolsa' ? 'capitalizacao' : 'bolsa'}`
}
>
{guide.slug === 'bolsa'
? 'Consultar a preparação de capitalização'
: 'Consultar o guião de admissão à Bolsa'}
</Link>
</p>
<Link
className="underline"
href={
dossier
? `/investimentos/${dossier.id}`
: '/investimentos'
}
>
Continuar a preparação no percurso existente
</Link>
</section>
<section className={card}>
<h2 className="text-lg font-semibold">
Fontes e edição consultada
</h2>
{Object.entries(guide.sources).map(([key, source]) => (
<article className="my-3" key={key} id={`source-${key}`}>
<h3>
<a
className="underline"
href={source.url}
target="_blank"
rel="noopener noreferrer"
>
{source.title}
</a>
</h3>
<p>
{source.edition} · {source.locator} · consulta{' '}
{source.consulted_on}
</p>
{source.valid_until && (
<p>
Período indicado até {source.valid_until}; fonte
histórica, sem aplicação como mínimo actual.
</p>
)}
{source.sha256 && (
<details>
<summary>
Identificação do exemplar consultado
</summary>
<p className="break-all">
SHA256: {source.sha256}
</p>
<p>
A fonte externa pode ser actualizada; a
referência guardada conserva os dados da
edição utilizada.
</p>
</details>
)}
</article>
))}
</section>
{dossier && (
<section className={card}>
<h2 className="text-lg font-semibold">
Histórico de referências
</h2>
<ul>
{props.history.map((h) => (
<li key={h.id}>
<Link
className="underline"
href={`${base}?revision=${h.id}`}
>
Revisão {h.revision} · {h.edition} · {h.at}
</Link>
</li>
))}
</ul>
{props.historyNext && (
<Link
className="underline"
href={`${base}?history_before=${props.historyNext}`}
>
Referências anteriores
</Link>
)}
</section>
)}
</Frame>
);
}