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

778 lines
32 KiB
TypeScript

import { createOperationKey } from '@/lib/operation-key';
import DeskCaseStateForm from '@/components/desk-case-state-form';
import { useRef, useState } from 'react';
import DeskDocumentPicker, {
type ReusableDeskDocument,
} from '@/components/desk-document-picker';
import { Link, router, useForm, usePage } from '@inertiajs/react';
import { Frame, card, input, button } from '@/pages/investment/shared';
import {
InvestorGuidance,
type Guidance,
} from '@/components/investor-guidance';
import {
DocumentReview,
type DeskDocument,
} from '@/components/desk-internal-operations';
type Stage = {
title: string;
requirements: string;
status: string;
entity: string;
dependencies: string[];
evidence: string | null;
};
function LinkReview({
link,
}: {
link: { id: string; version: number; url: string; contact: string | null };
}) {
const form = useForm({
version: link.version,
available: false,
evidence: '',
contact: link.contact ?? '',
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.post(`/management/balcao/ligacoes/${link.id}/conferencia`);
}}
className="space-y-2 border p-3"
>
<p>
Conferência manual de disponibilidade: {link.url}. Não envia
dados do dossier nem integra sistemas externos.
</p>
<label className="flex gap-2">
<input
type="checkbox"
checked={form.data.available}
onChange={(e) =>
form.setData('available', e.target.checked)
}
/>
Verifiquei que a ligação está disponível
</label>
<label>
Evidência e data da consulta
<textarea
className={input}
required
minLength={10}
value={form.data.evidence}
onChange={(e) => form.setData('evidence', e.target.value)}
/>
</label>
<label>
Contacto alternativo com fonte
<input
className={input}
value={form.data.contact}
onChange={(e) => form.setData('contact', e.target.value)}
/>
</label>
<button className={button} disabled={form.processing}>
Registar a minha conferência da ligação
</button>
{Object.entries(form.errors).map(([key, value]) => (
<p key={key} role="alert">
{value}
</p>
))}
</form>
);
}
type Event = {
id: string;
action: string;
name: string;
created_at: string;
payload: Record<string, unknown>;
};
export default function Case({
case: dossier,
canWrite,
canChangeState = false,
activity = null,
manager,
requests,
services,
documents,
workflow,
stepStates,
states,
guidance,
events,
links,
legacyRequest,
}: {
case: {
id: string;
title: string;
version: number;
closed_at?: string | null;
workflow: Record<string, Stage>;
investor_id?: string;
project_id?: string;
journey_id?: string;
};
canWrite: boolean;
canChangeState?: boolean;
activity?: {
open_requests: number | null;
open_tasks: number | null;
pending_payments: number | null;
} | null;
manager: boolean;
requests: { id: string; status: string; protocol: string | null }[];
services: {
id: string;
title: string;
version: number;
effective_from?: string | null;
}[];
documents: DeskDocument[];
links: {
id: string;
entity: string;
purpose: string;
contact: string | null;
status: string;
verified_at: string | null;
version: number;
url: string;
monitor: { available: boolean; reason: string; contact: string };
}[];
legacyRequest: {
id: number;
reference: string;
status: string;
next_action: string | null;
next_action_due_at: string | null;
responsible_name: string | null;
} | null;
workflow?: Record<string, Stage>;
stepStates: Record<string, string>;
states: Record<string, string>;
guidance: Guidance | null;
events: Event[];
}) {
const actor = usePage<{ auth: { user: { id: number } } }>().props.auth.user;
const [documentOrigin, setDocumentOrigin] =
useState<ReusableDeskDocument | null>(null);
const uploadInput = useRef<HTMLInputElement | null>(null);
const requestForm = useForm({
service_version_id: services[0]?.id ?? '',
client_key: createOperationKey(),
opportunity: new URLSearchParams(usePage().url.split('?')[1] ?? '').get(
'opportunity',
),
});
const stageForm = useForm({
version: dossier.version,
stage: 'E0',
status: 'preparing',
reason: '',
evidence: '',
document_link_id: '',
valid_until: '',
exception: false,
});
const upload = useForm<{
version: number;
file: File | null;
document_id: string;
category: string;
source_reference: string;
consent: boolean;
}>({
version: dossier.version,
file: null,
document_id: '',
category: 'project_evidence',
source_reference: '',
consent: false,
});
return (
<Frame title={dossier.title}>
{dossier.closed_at && (
<p role="status">
Dossier encerrado. Os pedidos, documentos e o histórico
continuam disponíveis para consulta.
</p>
)}
{legacyRequest && (
<section className={card}>
<h2 className="text-xl font-bold">
Pedido anterior associado — sem nova submissão
</h2>
<p>
{legacyRequest.reference} · {legacyRequest.status} ·
Responsável:{' '}
{legacyRequest.responsible_name ?? 'Por atribuir'}
</p>
<p>
{legacyRequest.next_action ??
'A aguardar orientação do responsável'}{' '}
· Prazo interno:{' '}
{legacyRequest.next_action_due_at ?? 'Não definido'}
</p>
{manager && (
<Link
href={`/management/interests/${legacyRequest.id}`}
className="underline"
>
Continuar no registo original do pedido e CRM
</Link>
)}
</section>
)}
<Link href="/balcao-virtual/painel" className="underline">
Voltar ao Balcão Virtual
</Link>
{canChangeState && (
<DeskCaseStateForm
dossier={dossier}
activity={activity}
disabled={
upload.processing ||
stageForm.processing ||
requestForm.processing
}
onChanged={() => {
upload.reset('file', 'consent');
if (uploadInput.current) uploadInput.current.value = '';
setDocumentOrigin(null);
}}
/>
)}
<p>
Um dossier, os mesmos pedidos, documentos, conversas e tarefas.
Projecto existente:{' '}
{dossier.project_id ? 'associado' : 'ainda não associado'} ·
Investidor:{' '}
{dossier.investor_id
? 'conta existente associada'
: 'por associar'}
.
</p>
<a
className="underline"
href={`/balcao-virtual/dossiers/${dossier.id}/pre-dossier`}
target="_blank"
rel="noopener noreferrer"
>
Pré-dossiê dos dados guardados — imprimir/guardar PDF (novo
separador)
</a>
{guidance ? (
<InvestorGuidance
caseId={dossier.id}
guidance={guidance}
manager={manager}
actorId={actor.id}
requests={requests}
/>
) : (
<p>A extensão de orientação aguarda instalação autenticada.</p>
)}
<section className="space-y-3">
<h2 className="text-2xl font-bold">
Pedido de apoio no fluxo existente
</h2>
<p>
A recomendação ajuda a preparar. Só a sua submissão cria um
pedido recebido; a resposta é assinada pelo responsável.
</p>
{requests.map((r) => (
<Link
key={r.id}
className={card + ' block'}
href={`/balcao-virtual/pedidos/${r.id}`}
>
{r.protocol ?? 'Continuar rascunho de apoio'} —{' '}
{states[r.status]}
</Link>
))}
{canWrite && (
<form
className={card + ' space-y-3'}
onSubmit={(e) => {
e.preventDefault();
if (
!services.some(
(service) =>
service.id ===
requestForm.data.service_version_id,
)
)
return;
requestForm.post(
`/balcao-virtual/dossiers/${dossier.id}/pedidos`,
);
}}
>
<label>
Serviço publicado
<select
className={input}
required
value={requestForm.data.service_version_id}
onChange={(e) =>
requestForm.setData(
'service_version_id',
e.target.value,
)
}
>
<option value="">Seleccione</option>
{requestForm.data.service_version_id &&
!services.some(
(service) =>
service.id ===
requestForm.data.service_version_id,
) && (
<option
value={
requestForm.data
.service_version_id
}
disabled
>
A versão seleccionada já não está na
lista. Escolha o formulário actual.
</option>
)}
{services.map((s) => (
<option key={s.id} value={s.id}>
{s.title} — v{s.version}
{s.effective_from
? ` · desde ${s.effective_from}`
: ''}
</option>
))}
</select>
</label>
<button
className={button}
disabled={
requestForm.processing ||
!services.some(
(service) =>
service.id ===
requestForm.data.service_version_id,
)
}
>
Preparar rascunho de pedido
</button>
<button
type="button"
className="underline"
disabled={requestForm.processing}
onClick={() =>
router.reload({
only: ['services'],
})
}
>
Actualizar versões disponíveis
</button>
{!services.length && (
<p>
Sem serviços publicados. A orientação e os
dossiers continuam disponíveis.
</p>
)}
{Object.entries(requestForm.errors).map(([k, v]) => (
<p key={k} role="alert">
{v}
</p>
))}
</form>
)}
</section>
<section className="space-y-3">
<h2 className="text-2xl font-bold">
Etapas documentais do Balcão
</h2>
<p>
Estes estados descrevem preparação e conferência no portal.
A conclusão interna não significa que uma instituição emitiu
aprovação, registo ou licença. Os requisitos do plano são
afirmações da fonte; a aplicabilidade deve ser documentada
no ato específico.
</p>
{Object.entries(workflow ?? dossier.workflow).map(
([code, s]) => (
<article className={card} key={code}>
<h3 className="font-bold">
{code} — {s.title}
</h3>
<p>
{stepStates[s.status]} · Entidade: {s.entity}
</p>
<p>{s.requirements}</p>
<p>
Dependências:{' '}
{s.dependencies.join(', ') ||
'Nenhuma nesta etapa'}{' '}
· Evidência: {s.evidence ?? 'Por documentar'}
</p>
</article>
),
)}
{!dossier.closed_at && (canWrite || manager) && (
<form
className={card + ' space-y-3'}
onSubmit={(e) => {
e.preventDefault();
stageForm.setData('version', dossier.version);
stageForm.transform((d) => ({
...d,
version: dossier.version,
}));
stageForm.post(
`/balcao-virtual/dossiers/${dossier.id}/etapas`,
);
}}
>
<label>
Etapa
<select
className={input}
value={stageForm.data.stage}
onChange={(e) =>
stageForm.setData('stage', e.target.value)
}
>
{Object.entries(dossier.workflow).map(
([c, s]) => (
<option key={c} value={c}>
{c} — {s.title}
</option>
),
)}
</select>
</label>
<label>
Estado
<select
className={input}
value={stageForm.data.status}
onChange={(e) =>
stageForm.setData('status', e.target.value)
}
>
{Object.entries(stepStates)
.filter(
([s]) =>
manager ||
![
'completed',
'not_applicable',
].includes(s),
)
.map(([s, label]) => (
<option key={s} value={s}>
{label}
</option>
))}
</select>
</label>
<label>
Motivo
<textarea
className={input}
required
minLength={3}
value={stageForm.data.reason}
onChange={(e) =>
stageForm.setData('reason', e.target.value)
}
/>
</label>
<label>
Evidência / referência
<input
className={input}
value={stageForm.data.evidence}
onChange={(e) =>
stageForm.setData(
'evidence',
e.target.value,
)
}
/>
</label>
<label>
Documento de suporte já associado ao dossier
<select
className={input}
value={stageForm.data.document_link_id}
onChange={(e) =>
stageForm.setData(
'document_link_id',
e.target.value,
)
}
>
<option value="">
Sem suporte seleccionado
</option>
{documents.map((document) => (
<option
key={document.id}
value={document.id}
>
{document.file_name} · versão{' '}
{document.version_no ?? 'por confirmar'}{' '}
· {document.source_reference}
{document.valid_until
? ` · validade declarada ${document.valid_until}`
: ''}
</option>
))}
</select>
</label>
<p>
Conclusão e não aplicabilidade exigem revisão por
outra pessoa autorizada e evidência documental.
</p>
<button
className={button}
disabled={stageForm.processing}
>
Registar etapa
</button>
{Object.entries(stageForm.errors).map(([k, v]) => (
<p key={k} role="alert">
{v}
</p>
))}
</form>
)}
</section>
<section className="space-y-3">
<h2 className="text-2xl font-bold">
Documentos privados reutilizados
</h2>
{documents.map((d) => (
<article key={d.id} className={card}>
<p>
{d.file_name} · {d.category} ·{' '}
{d.malware_scan_status}
</p>
<p>{d.source_reference}</p>
{d.status === 'active' &&
d.malware_scan_status === 'clean' && (
<a
className="underline"
href={`/balcao-virtual/dossiers/${dossier.id}/documentos/${d.id}`}
>
Descarregar com verificação de autorização e
hash
</a>
)}
</article>
))}
{canWrite && (
<form
className={card + ' space-y-3'}
onSubmit={(e) => {
e.preventDefault();
upload.transform((d) => ({
...d,
version: dossier.version,
file: documentOrigin ? null : d.file,
document_id: documentOrigin?.document_id || '',
reuse_link_id: documentOrigin?.id || '',
source_reference:
documentOrigin?.source_reference?.trim()
? undefined
: d.source_reference,
}));
upload.post(
`/balcao-virtual/dossiers/${dossier.id}/documentos`,
{
onSuccess: () => {
upload.reset(
'file',
'consent',
'source_reference',
);
if (uploadInput.current)
uploadInput.current.value = '';
setDocumentOrigin(null);
},
},
);
}}
>
<DeskDocumentPicker
caseId={dossier.id}
disabled={upload.processing}
selected={documentOrigin}
onSelect={(document) => {
setDocumentOrigin(document);
upload.setData('consent', false);
if (document) {
upload.setData('file', null);
if (uploadInput.current)
uploadInput.current.value = '';
}
}}
/>
{!documentOrigin && (
<label>
Ficheiro privado
<input
className={input}
type="file"
ref={uploadInput}
accept=".pdf,.png,.jpg,.jpeg,.txt"
onChange={(e) =>
upload.setData(
'file',
e.target.files?.[0] ?? null,
)
}
/>
</label>
)}
{documentOrigin?.source_reference?.trim() ? (
<p>
Referência já registada:{' '}
{documentOrigin.source_reference}. Será
conservada com a validade declarada e a versão
do original.
</p>
) : (
<label>
Proveniência e referência
<input
className={input}
required
value={upload.data.source_reference}
onChange={(e) =>
upload.setData(
'source_reference',
e.target.value,
)
}
/>
</label>
)}
<label>
Assunto da evidência — não equivale a validação
<select
className={input}
value={upload.data.category}
onChange={(e) =>
upload.setData('category', e.target.value)
}
>
<option value="project_evidence">
Documento geral do projecto
</option>
<option value="market_evidence">Mercado</option>
<option value="location_evidence">
Localização
</option>
<option value="resource_evidence">
Recursos
</option>
<option value="funding_evidence">
Financiamento
</option>
</select>
</label>
<label className="flex gap-2">
<input
type="checkbox"
checked={upload.data.consent}
onChange={(e) =>
upload.setData('consent', e.target.checked)
}
/>
Autorizo associar este documento ao dossier privado.
</label>
<button className={button} disabled={upload.processing}>
Associar documento
</button>
{Object.entries(upload.errors).map(([k, v]) => (
<p key={k} role="alert">
{v}
</p>
))}
</form>
)}
</section>
{!dossier.closed_at && manager && !canWrite && (
<DocumentReview
caseId={dossier.id}
version={dossier.version}
documents={documents}
/>
)}
<section className={card}>
<h2 className="text-xl font-bold">
Ligações institucionais e alternativas
</h2>
<p>
O encaminhamento não transmite o dossier. Ligações
indisponíveis não impedem os serviços internos.
</p>
{links.map((link) => (
<article key={link.id}>
<p>
{link.entity} — {link.purpose} ·{' '}
{link.monitor.available
? `Disponibilidade conferida internamente em ${link.verified_at}; não valida conteúdo ou decisão oficial`
: 'Ligação por verificar ou indisponível'}
</p>
<p>Contacto alternativo: {link.monitor.contact}</p>
<a
className="underline"
href={`/balcao-virtual/encaminhar/${link.id}?case_id=${dossier.id}`}
>
Abrir encaminhamento
</a>{' '}
·{' '}
<a
className="underline"
href={`/balcao-virtual/encaminhar/${link.id}?case_id=${dossier.id}&unavailable=1`}
>
Ligação indisponível: continuar internamente
</a>
{manager && <LinkReview link={link} />}
</article>
))}
{!links.length && (
<p>
Sem ligações verificadas. Prepare os documentos e
solicite apoio no pedido existente.
</p>
)}
</section>
<details className={card}>
<summary>Histórico de autoria, decisões e resultados</summary>
{events.map((event) => (
<article key={event.id} className="mt-3 border-t pt-3">
<p>
{event.action} · {event.name} · {event.created_at}
</p>
<pre className="overflow-auto text-xs break-all whitespace-pre-wrap">
{JSON.stringify(event.payload, null, 2)}
</pre>
</article>
))}
</details>
</Frame>
);
}