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

1216 lines
54 KiB
TypeScript

import { DossierPrefill } from '@/components/dossier-prefill';
import { createOperationKey } from '@/lib/operation-key';
import { deskFieldApplies } from '@/lib/desk-field-visibility';
import {
deskEmpty,
deskChoices,
deskScalar,
mergeDeskPrefill,
type DeskAnswer,
} from '@/lib/desk-field-values';
import DeskTypedChoice from '@/components/desk-typed-choice';
import DeskDraftRecovery from '@/components/desk-draft-recovery';
import DeskAttachmentField, {
type AttachmentInventory,
} from '@/components/desk-attachment-field';
import { useState } from 'react';
import type { Field } from '@/pages/desk/service-editor';
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';
const emptyDocuments: DeskDocument[] = [];
export default function RequestPage({
request: initialRequest,
case: dossier,
definition,
owner,
manager,
analyst,
fieldsEditable,
operationsEditable = !['closed', 'cancelled'].includes(
initialRequest.status,
),
states,
transitions,
findings,
messages,
analysts,
territoryPaths,
events,
documents,
reusableDocuments = emptyDocuments,
payments,
caseVersion,
countries,
toolPrefill,
boundContextRevision = 0,
serviceEffectiveFrom,
returnReasons = {},
}: {
returnReasons?: Record<string, string>;
serviceEffectiveFrom?: string | null;
boundContextRevision?: number;
request: {
id: string;
version: number;
status: string;
payload: Record<string, DeskAnswer>;
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;
operationsEditable?: 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[];
reusableDocuments?: 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 [recoveredRequest, setRecoveredRequest] = useState<
typeof initialRequest | null
>(null);
const request =
recoveredRequest?.id === initialRequest.id &&
recoveredRequest.version > initialRequest.version
? recoveredRequest
: initialRequest;
const [checkpointBusy, setCheckpointBusy] = useState(false);
const [checkpointBlocked, setCheckpointBlocked] = useState(false);
const [attachmentInventory, setAttachmentInventory] = useState<
| (AttachmentInventory & {
sourceDocuments: DeskDocument[];
sourceReusable: DeskDocument[];
})
| null
>(null);
const [attachmentBusy, setAttachmentBusy] = useState<
Record<string, boolean>
>({});
const filesBusy = Object.values(attachmentBusy).some(Boolean);
const inventoryNewer =
attachmentInventory && attachmentInventory.case_version > caseVersion;
const inventorySame =
attachmentInventory && attachmentInventory.case_version === caseVersion;
const currentInventory = {
case_version: Math.max(
attachmentInventory?.case_version ?? 0,
caseVersion,
),
documents:
attachmentInventory &&
(inventoryNewer ||
(inventorySame &&
attachmentInventory.sourceDocuments === documents))
? attachmentInventory.documents
: documents,
reusable_documents:
attachmentInventory &&
(inventoryNewer ||
(inventorySame &&
attachmentInventory.sourceReusable === reusableDocuments))
? (attachmentInventory.reusable_documents ?? emptyDocuments)
: reusableDocuments,
};
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[],
return_reason_code: 'unspecified',
});
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 &&
['draft', 'returned'].includes(request.status);
return (
<Frame title={definition.title}>
{serviceEffectiveFrom && (
<p>
Entrada em vigor do formulário deste pedido:{' '}
{serviceEffectiveFrom}. O pedido conserva a versão com que
foi criado.
</p>
)}
<Link
className="underline"
href={`/balcao-virtual/dossiers/${dossier.id}`}
>
Voltar ao dossier {dossier.title}
</Link>
<p>{definition.result}</p>
{canEdit && (
<DeskDraftRecovery
key={`${request.id}:${request.version}`}
id={request.id}
version={request.version}
payload={form.data.payload}
initial={request.payload}
reason={form.data.reason}
fields={Object.fromEntries(
Object.entries(definition.fields).map(
([key, field]) => [
key,
field.type === 'attachment'
? {
...field,
options: Object.fromEntries(
currentInventory.documents.map(
(document) => [
document.id,
document.file_name,
],
),
),
}
: field,
],
),
)}
paused={form.processing}
pendingWork={filesBusy}
onBusy={setCheckpointBusy}
onBlocked={setCheckpointBlocked}
onRecover={(payload, reason, current) => {
setRecoveredRequest({
...request,
...current,
payload: current.payload,
});
form.setData((d) => ({
...d,
payload,
reason,
version: current.version,
consent: false,
context_reviewed: false,
context_revision: 0,
}));
}}
/>
)}
{owner && (
<DossierPrefill
contextId={dossier.id}
consumer="desk"
persistInheritance={request.status !== 'returned'}
values={form.data.payload}
boundRevision={boundContextRevision}
readOnly={!canEdit}
allowedFields={[
...Object.keys(definition.fields),
'context_opportunity',
].filter(
(k) =>
!['consent', 'attachment'].includes(
definition.fields[k]?.type,
) &&
(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: mergeDeskPrefill(
d.payload,
patch,
definition.fields,
replace,
),
}))
}
/>
)}
<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 ||
checkpointBusy ||
checkpointBlocked ||
filesBusy ||
!deskEmpty(form.data.payload.description)
}
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();
if (
checkpointBusy ||
checkpointBlocked ||
filesBusy ||
!canEdit
)
return;
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={deskScalar(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 do pedido (opcional)
</label>
<CountrySelect
id="declared-origin-country"
label="País de origem do pedido (opcional)"
error={form.errors['payload.declared_origin_country_code']}
countries={countries}
value={deskScalar(
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>
Indique o país de onde apresenta este pedido, se desejar.
Este campo é distinto do país do requerente.
</p>
{Object.entries(definition.fields)
.filter(([key]) =>
deskFieldApplies(
key,
definition.fields,
form.data.payload,
),
)
.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>
) : f.type === 'attachment' ? (
<div key={key}>
<DeskAttachmentField
caseId={dossier.id}
requestId={request.id}
caseVersion={currentInventory.case_version}
fieldKey={key}
field={f}
value={form.data.payload[key]}
documents={currentInventory.documents}
reusableDocuments={
currentInventory.reusable_documents
}
disabled={
!canEdit ||
form.processing ||
(request.status === 'returned' &&
!request.editable_fields.includes(
key,
))
}
uploading={filesBusy}
onBusy={(value) =>
setAttachmentBusy((state) => ({
...state,
[key]: value,
}))
}
onChange={(value) =>
form.setData('payload', {
...form.data.payload,
[key]: value,
})
}
onInventory={(result, select) => {
setAttachmentInventory({
...result,
reusable_documents:
result.reusable_documents ??
currentInventory.reusable_documents,
sourceDocuments: documents,
sourceReusable: reusableDocuments,
});
const linkId = result.link_id;
if (select && linkId)
form.setData((d) => {
const ids = deskChoices(
d.payload[key],
);
const uploaded =
result.documents.find(
(document) =>
document.id ===
linkId,
);
const alreadySelected =
ids.includes(linkId) ||
(!!uploaded &&
ids.some(
(id) =>
result.documents.find(
(
document,
) =>
document.id ===
id,
)
?.document_id ===
uploaded.document_id,
));
return {
...d,
payload: {
...d.payload,
[key]:
alreadySelected ||
ids.length >=
(f.max_files ??
1)
? ids
: [
...ids,
linkId,
],
},
};
});
}}
/>
{Object.entries(form.errors)
.filter(
([path]) =>
path === `payload.${key}` ||
path.startsWith(`payload.${key}.`),
)
.map(([path, error]) => (
<p role="alert" key={path}>
{error}
</p>
))}
</div>
) : ['multiselect', 'consent'].includes(f.type) ? (
<div key={key}>
<DeskTypedChoice
fieldKey={key}
field={f}
value={form.data.payload[key]}
disabled={
!canEdit ||
form.processing ||
(request.status === 'returned' &&
!request.editable_fields.includes(
key,
))
}
onChange={(value) =>
form.setData('payload', {
...form.data.payload,
[key]: value,
})
}
/>
{Object.entries(form.errors)
.filter(
([path]) =>
path === `payload.${key}` ||
path.startsWith(`payload.${key}.`),
)
.map(([path, error]) => (
<p key={path} role="alert">
{error}
</p>
))}
</div>
) : (
<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={deskScalar(
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={deskScalar(
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={deskScalar(
form.data.payload[key],
)}
onChange={(e) =>
form.setData('payload', {
...form.data.payload,
[key]: e.target.value,
})
}
/>
)}
</label>
),
)}
{canEdit && (
<>
{request.status === 'returned' && (
<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 ||
checkpointBusy ||
checkpointBlocked ||
filesBusy
}
>
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 ||
checkpointBusy ||
checkpointBlocked ||
filesBusy ||
!form.data.consent
}
onClick={() => {
if (
checkpointBusy ||
checkpointBlocked ||
filesBusy ||
!canEdit
)
return;
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>
{operationsEditable &&
owner &&
['draft', 'returned'].includes(request.status) && (
<form
className={card + ' space-y-3'}
onSubmit={(event) => {
event.preventDefault();
if (
decision.processing ||
form.processing ||
checkpointBusy ||
filesBusy
)
return;
decision.transform((data) => ({
...data,
version: request.version,
status: 'cancelled',
}));
decision.post(
`/balcao-virtual/pedidos/${request.id}/estado`,
{
onSuccess: (page) => {
const saved = page.props.request as
| typeof initialRequest
| undefined;
if (
saved?.id === request.id &&
saved.status === 'cancelled'
) {
form.setData(
'payload',
saved.payload,
);
form.setData(
'version',
saved.version,
);
form.setData('reason', '');
form.setData('consent', false);
setRecoveredRequest(null);
}
},
},
);
}}
>
<h2 className="text-xl font-bold">
Cancelar este pedido
</h2>
<p>
O cancelamento conserva a última versão guardada, os
documentos e o histórico. O dossier continua
disponível; alterações por guardar não são
submetidas.
</p>
<label className="block">
Motivo do cancelamento
<textarea
className={input}
required
minLength={3}
maxLength={5000}
value={decision.data.reason}
onChange={(event) =>
decision.setData(
'reason',
event.target.value,
)
}
/>
</label>
<button
className={button}
disabled={
decision.processing ||
form.processing ||
checkpointBusy ||
filesBusy
}
>
Confirmar cancelamento deste pedido
</button>
{Object.entries(decision.errors).map(([key, error]) => (
<p role="alert" key={key}>
{error}
</p>
))}
</form>
)}
{operationsEditable &&
!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>Devolução para correcção</legend>
<label className="block">
Categoria do motivo (opcional, para
indicadores)
<select
className={input}
value={decision.data.return_reason_code}
onChange={(e) =>
decision.setData(
'return_reason_code',
e.target.value,
)
}
>
{Object.entries(returnReasons).map(
([code, label]) => (
<option key={code} value={code}>
{label}
</option>
),
)}
</select>
</label>
<p>Campos autorizados para correcção</p>
{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>
)}
{operationsEditable &&
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>
))}
{operationsEditable && (
<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>
{operationsEditable && !owner && (manager || analyst) && (
<DocumentReview
caseId={dossier.id}
version={caseVersion}
documents={documents}
requestId={request.id}
/>
)}
<RequestOperations
requestId={request.id}
owner={owner && operationsEditable}
reviewer={operationsEditable && !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>
)}
{e.action === 'request_returned' && (
<p>
Categoria:{' '}
{typeof e.payload.return_reason_label ===
'string'
? e.payload.return_reason_label
: 'Sem classificação registada'}
</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>
);
}