677 lines
27 KiB
TypeScript
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>
|
|
);
|
|
}
|