Files
Investidor-V2/resources/js/pages/desk/request.tsx
T

789 lines
33 KiB
TypeScript

import { DossierPrefill } from '@/components/dossier-prefill';
import { createOperationKey } from '@/lib/operation-key';
import { Link, useForm } from '@inertiajs/react';
import { Frame, card, input, button } from '@/pages/investment/shared';
import CountrySelect, { type Country } from '@/components/country-select';
import {
TerritoryFields,
type TerritoryOption,
} from '@/components/investment-territory';
import {
DocumentReview,
RequestOperations,
type DeskDocument,
type DeskPayment,
} from '@/components/desk-internal-operations';
type Field = {
label: string;
type: string;
required: boolean;
options?: Record<string, string>;
when?: { field: string; equals: string };
};
export default function RequestPage({
request,
case: dossier,
definition,
owner,
manager,
analyst,
fieldsEditable,
states,
transitions,
findings,
messages,
analysts,
territoryPaths,
events,
documents,
payments,
caseVersion,
countries,
toolPrefill,
boundContextRevision = 0,
}: {
boundContextRevision?: number;
request: {
id: string;
version: number;
status: string;
payload: Record<string, string>;
protocol: string | null;
editable_fields: string[];
analyst_id: number | null;
due_at: string | null;
};
case: { id: string; title: string };
definition: {
title: string;
fields: Record<string, Field>;
result: string;
};
owner: boolean;
manager: boolean;
analyst: boolean;
fieldsEditable: boolean;
states: Record<string, string>;
transitions: string[];
findings: {
code: string;
explanation: string;
recommendation: string;
severity: string;
}[];
messages: {
id: string;
body: string;
name: string;
kind: string;
created_at: string;
}[];
analysts: { id: number; name: string }[];
territoryPaths: Record<string, TerritoryOption[]>;
documents: DeskDocument[];
payments: DeskPayment[];
caseVersion: number;
countries: Country[];
toolPrefill: {
description: string;
draft_id: string;
draft_version: number;
snapshot_sha256: string;
} | null;
events: {
id: string;
action: string;
name: string;
created_at: string;
payload: Record<string, unknown>;
}[];
}) {
const form = useForm({
context_revision: 0,
context_reviewed: false,
version: request.version,
payload: request.payload,
consent: false,
reason: '',
submit: false,
});
const decision = useForm({
version: request.version,
status: transitions[0] ?? '',
reason: '',
editable_fields: [] as string[],
});
const assignment = useForm({
version: request.version,
analyst_id: String(request.analyst_id ?? ''),
reason: '',
due_at: '',
});
const message = useForm({ body: '', client_key: createOperationKey() });
const canEdit = owner && fieldsEditable;
return (
<Frame title={definition.title}>
<Link
className="underline"
href={`/balcao-virtual/dossiers/${dossier.id}`}
>
Voltar ao dossier {dossier.title}
</Link>
<p>{definition.result}</p>
{owner && (
<DossierPrefill
contextId={dossier.id}
consumer="desk"
persistInheritance={request.status !== 'returned'}
values={form.data.payload}
boundRevision={boundContextRevision}
readOnly={!canEdit}
allowedFields={Object.keys(definition.fields).filter(
(k) =>
request.status !== 'returned' ||
request.editable_fields.includes(k),
)}
onReview={(revision, reviewed) =>
form.setData((d) => ({
...d,
context_revision: revision,
context_reviewed: reviewed,
}))
}
onApply={(patch, replace) =>
form.setData((d) => ({
...d,
payload: {
...d.payload,
...Object.fromEntries(
Object.entries(patch)
.filter(
([k]) =>
replace ||
!d.payload[k]?.trim(),
)
.map(([k, v]) => [
k,
typeof v === 'string' ? v : '',
]),
),
},
}))
}
/>
)}
<section className={card}>
<h2 className="text-xl font-bold">
{request.protocol
? 'Pedido submetido pelo investidor'
: 'Rascunho — ainda não submetido'}
</h2>
<p>
{states[request.status]} · Protocolo:{' '}
{request.protocol ?? 'Ainda sem protocolo'}
</p>
<p>
Responsável real:{' '}
{request.analyst_id ?? 'A aguardar atribuição'} · Prazo
interno: {request.due_at ?? 'Não definido'}
</p>
{request.protocol && (
<a
className="underline"
href={`/balcao-virtual/pedidos/${request.id}/recibo?format=html`}
>
Consultar e imprimir recibo interno
</a>
)}
</section>
{canEdit && toolPrefill && (
<section
className={card + ' space-y-3'}
aria-label="Descrição guardada no projeto"
>
<h2 className="text-xl font-bold">
Reutilizar dados do projeto
</h2>
<p>
A descrição está vazia neste pedido. Pode reutilizar o
resumo já guardado na ferramenta deste dossier, versão{' '}
{toolPrefill.draft_version}.
</p>
<blockquote className="rounded border p-3 whitespace-pre-wrap">
{toolPrefill.description}
</blockquote>
<p>
Esta ação guarda o rascunho com os dados preenchidos
nesta página. Reveja-os antes de submeter.
</p>
<button
type="button"
className={button}
disabled={
form.processing ||
!!form.data.payload.description?.trim()
}
onClick={() => {
form.transform((data) => ({
...data,
version: request.version,
submit: false,
payload: {
...data.payload,
description: toolPrefill.description,
},
tool_prefill: {
draft_id: toolPrefill.draft_id,
draft_version: toolPrefill.draft_version,
snapshot_sha256:
toolPrefill.snapshot_sha256,
},
}));
form.put(`/balcao-virtual/pedidos/${request.id}`, {
onSuccess: () =>
form.setData('payload', {
...form.data.payload,
description: toolPrefill.description,
}),
});
}}
>
Reutilizar descrição guardada e guardar rascunho
</button>
</section>
)}
<section className={card + ' space-y-3'}>
<h2 className="text-xl font-bold">
Verificações automáticas de preparação
</h2>
{findings.map((f) => (
<p key={f.code}>
{f.explanation} {f.recommendation} (
{f.severity === 'error'
? 'Contradição nos valores declarados'
: 'Informação / evidência pendente'}
)
</p>
))}
<p>
A análise automática não é uma resposta do responsável nem
uma aprovação oficial.
</p>
</section>
<form
className={card + ' space-y-3'}
onSubmit={(e) => {
e.preventDefault();
form.transform((d) => ({
...d,
version: request.version,
submit: false,
}));
form.put(`/balcao-virtual/pedidos/${request.id}`);
}}
>
<label className="block" htmlFor="request-country">
País do requerente (opcional)
</label>
<CountrySelect
id="request-country"
label="País do requerente (opcional)"
error={form.errors['payload.country']}
countries={countries}
value={form.data.payload.country ?? ''}
allowLegacyValue
onChange={(value) =>
form.setData('payload', {
...form.data.payload,
country: value,
})
}
disabled={
!canEdit ||
(request.status === 'returned' &&
!request.editable_fields.includes('country'))
}
/>
<label className="block" htmlFor="declared-origin-country">
País de origem declarado desta solicitação
</label>
<CountrySelect
id="declared-origin-country"
label="País de origem declarado desta solicitação"
error={form.errors['payload.declared_origin_country_code']}
countries={countries}
value={form.data.payload.declared_origin_country_code ?? ''}
disabled={
!canEdit ||
form.processing ||
(request.status === 'returned' &&
!request.editable_fields.includes(
'declared_origin_country_code',
))
}
onChange={(value) =>
form.setData('payload', {
...form.data.payload,
declared_origin_country_code: value,
})
}
/>
<p>
A origem não é deduzida do IP nem do país de residência.
Pedidos antigos sem declaração mantêm “não informado”.
</p>
{Object.entries(definition.fields)
.filter(
([, f]) =>
!f.when ||
form.data.payload[f.when.field] === f.when.equals,
)
.map(([key, f]) =>
f.type === 'territory' ? (
<fieldset
key={key}
className="space-y-2 rounded border p-3"
>
<legend>
{f.label}
{f.required ? ' *' : ''}
</legend>
<TerritoryFields
initial={territoryPaths[key]}
disabled={
!canEdit ||
form.processing ||
(request.status === 'returned' &&
!request.editable_fields.includes(
key,
))
}
onChange={(path) =>
form.setData('payload', {
...form.data.payload,
[key]: path.at(-1)?.id ?? '',
})
}
/>
{form.errors[`payload.${key}`] && (
<p role="alert">
{form.errors[`payload.${key}`]}
</p>
)}
</fieldset>
) : (
<label className="block" key={key}>
{f.label}
{f.required ? ' *' : ''}
{f.type === 'select' ? (
<select
className={input}
disabled={
!canEdit ||
(request.status === 'returned' &&
!request.editable_fields.includes(
key,
))
}
value={form.data.payload[key] ?? ''}
onChange={(e) =>
form.setData('payload', {
...form.data.payload,
[key]: e.target.value,
})
}
>
<option value="">Por preencher</option>
{Object.entries(f.options ?? {}).map(
([v, l]) => (
<option key={v} value={v}>
{l}
</option>
),
)}
</select>
) : f.type === 'textarea' ? (
<textarea
className={input}
disabled={
!canEdit ||
(request.status === 'returned' &&
!request.editable_fields.includes(
key,
))
}
value={form.data.payload[key] ?? ''}
onChange={(e) =>
form.setData('payload', {
...form.data.payload,
[key]: e.target.value,
})
}
/>
) : (
<input
type={
f.type === 'date'
? 'date'
: f.type === 'number'
? 'number'
: 'text'
}
step={
f.type === 'number'
? '0.01'
: undefined
}
className={input}
disabled={
!canEdit ||
(request.status === 'returned' &&
!request.editable_fields.includes(
key,
))
}
value={form.data.payload[key] ?? ''}
onChange={(e) =>
form.setData('payload', {
...form.data.payload,
[key]: e.target.value,
})
}
/>
)}
</label>
),
)}
{canEdit && (
<>
<label>
Justificação de correcção
<textarea
className={input}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</label>
<button className={button} disabled={form.processing}>
Guardar rascunho
</button>
<label className="flex gap-2">
<input
type="checkbox"
checked={form.data.consent}
onChange={(e) =>
form.setData('consent', e.target.checked)
}
/>
Revi o conteúdo e autorizo a submissão para análise
interna.
</label>
<button
type="button"
className={button}
disabled={form.processing || !form.data.consent}
onClick={() => {
form.transform((d) => ({
...d,
version: request.version,
submit: true,
}));
form.put(
`/balcao-virtual/pedidos/${request.id}`,
);
}}
>
Submeter este pedido
</button>
</>
)}
{Object.entries(form.errors).map(([k, v]) => (
<p key={k} role="alert">
{v}
</p>
))}
</form>
{!owner && (manager || analyst) && transitions.length > 0 && (
<form
className={card + ' space-y-3'}
onSubmit={(e) => {
e.preventDefault();
decision.transform((d) => ({
...d,
version: request.version,
}));
decision.post(
`/balcao-virtual/pedidos/${request.id}/estado`,
);
}}
>
<h2 className="text-xl font-bold">
Resposta / análise do responsável
</h2>
<label>
Estado
<select
className={input}
value={decision.data.status}
onChange={(e) =>
decision.setData('status', e.target.value)
}
>
{transitions
.filter(
(s) =>
manager ||
![
'accepted',
'rejected',
'closed',
].includes(s),
)
.map((s) => (
<option value={s} key={s}>
{states[s]}
</option>
))}
</select>
</label>
<label>
Fundamento da resposta
<textarea
className={input}
required
minLength={3}
value={decision.data.reason}
onChange={(e) =>
decision.setData('reason', e.target.value)
}
/>
</label>
{decision.data.status === 'returned' && (
<fieldset>
<legend>Campos autorizados para correcção</legend>
{Object.entries({
...definition.fields,
country: { label: 'País do requerente' },
declared_origin_country_code: {
label: 'Origem declarada da solicitação',
},
}).map(([k, f]) => (
<label className="block" key={k}>
<input
type="checkbox"
checked={decision.data.editable_fields.includes(
k,
)}
onChange={(e) =>
decision.setData(
'editable_fields',
e.target.checked
? [
...decision.data
.editable_fields,
k,
]
: decision.data.editable_fields.filter(
(v) => v !== k,
),
)
}
/>{' '}
{f.label}
</label>
))}
</fieldset>
)}
<button className={button} disabled={decision.processing}>
Registar resposta fundamentada
</button>
{Object.entries(decision.errors).map(([k, v]) => (
<p role="alert" key={k}>
{v}
</p>
))}
</form>
)}
{manager &&
!['draft', 'closed', 'cancelled'].includes(request.status) && (
<form
className={card + ' space-y-3'}
onSubmit={(e) => {
e.preventDefault();
assignment.transform((d) => ({
...d,
version: request.version,
}));
assignment.post(
`/balcao-virtual/pedidos/${request.id}/responsavel`,
);
}}
>
<h2 className="text-xl font-bold">
Atribuir responsável existente
</h2>
<label>
Responsável
<select
className={input}
required
value={assignment.data.analyst_id}
onChange={(e) =>
assignment.setData(
'analyst_id',
e.target.value,
)
}
>
<option value="">Seleccione</option>
{analysts.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</label>
<label>
Prazo interno
<input
className={input}
type="datetime-local"
value={assignment.data.due_at}
onChange={(e) =>
assignment.setData('due_at', e.target.value)
}
/>
</label>
<label>
Motivo
<textarea
className={input}
required
value={assignment.data.reason}
onChange={(e) =>
assignment.setData('reason', e.target.value)
}
/>
</label>
<button
className={button}
disabled={assignment.processing}
>
Atribuir acompanhamento
</button>
{Object.entries(assignment.errors).map(([k, v]) => (
<p role="alert" key={k}>
{v}
</p>
))}
</form>
)}
<section className={card + ' space-y-3'}>
<h2 className="text-xl font-bold">
Conversa do pedido existente
</h2>
{messages.map((m) => (
<article key={m.id} className="rounded border p-3">
<p>
{m.kind === 'responsible_response'
? 'Resposta de responsável'
: m.kind === 'investor_message'
? 'Mensagem do investidor'
: 'Mensagem histórica — tipo original não registado'}{' '}
— {m.name} · {m.created_at}
</p>
<p className="break-words whitespace-pre-wrap">
{m.body}
</p>
</article>
))}
{!['closed', 'cancelled'].includes(request.status) && (
<form
onSubmit={(e) => {
e.preventDefault();
message.post(
`/balcao-virtual/pedidos/${request.id}/mensagens`,
{
onSuccess: () => {
message.reset('body');
message.setData(
'client_key',
createOperationKey(),
);
},
},
);
}}
>
<label>
Mensagem privada
<textarea
className={input}
disabled={message.processing}
value={message.data.body}
required
onChange={(e) =>
message.setData('body', e.target.value)
}
/>
</label>
<button
className={button}
disabled={message.processing}
>
Enviar esta mensagem no pedido
</button>
{Object.entries(message.errors).map(([k, v]) => (
<p role="alert" key={k}>
{v}
</p>
))}
</form>
)}
</section>
{!owner && (manager || analyst) && (
<DocumentReview
caseId={dossier.id}
version={caseVersion}
documents={documents}
requestId={request.id}
/>
)}
{!['closed', 'cancelled'].includes(request.status) && (
<RequestOperations
requestId={request.id}
owner={owner}
reviewer={!owner && (manager || analyst)}
documents={documents}
payments={payments}
/>
)}
<section className={card}>
<h2 className="text-xl font-bold">Respostas e histórico</h2>
{events.map((e) => (
<article className="mt-3 border-t pt-3" key={e.id}>
<p>
{e.action} · {e.name} · {e.created_at}
</p>
{typeof e.payload.reason === 'string' && (
<p>{e.payload.reason}</p>
)}
{[
'document_reviewed',
'payment_reviewed',
'manual_communication_recorded',
].includes(e.action) && (
<pre className="text-xs break-all whitespace-pre-wrap">
{JSON.stringify(e.payload, null, 2)}
</pre>
)}
</article>
))}
</section>
</Frame>
);
}