628 lines
25 KiB
TypeScript
628 lines
25 KiB
TypeScript
import { createOperationKey } from '@/lib/operation-key';
|
|
import { Link, 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,
|
|
manager,
|
|
requests,
|
|
services,
|
|
documents,
|
|
workflow,
|
|
stepStates,
|
|
states,
|
|
guidance,
|
|
events,
|
|
links,
|
|
legacyRequest,
|
|
}: {
|
|
case: {
|
|
id: string;
|
|
title: string;
|
|
version: number;
|
|
workflow: Record<string, Stage>;
|
|
investor_id?: string;
|
|
project_id?: string;
|
|
journey_id?: string;
|
|
};
|
|
canWrite: boolean;
|
|
manager: boolean;
|
|
requests: { id: string; status: string; protocol: string | null }[];
|
|
services: { id: string; title: string; version: number }[];
|
|
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 requestForm = useForm({
|
|
service_version_id: services[0]?.id ?? '',
|
|
client_key: createOperationKey(),
|
|
});
|
|
const stageForm = useForm({
|
|
version: dossier.version,
|
|
stage: 'E0',
|
|
status: 'preparing',
|
|
reason: '',
|
|
evidence: '',
|
|
document_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}>
|
|
{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>
|
|
<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>
|
|
{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();
|
|
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>
|
|
{services.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.title} — v{s.version}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button
|
|
className={button}
|
|
disabled={
|
|
requestForm.processing || !services.length
|
|
}
|
|
>
|
|
Preparar rascunho de pedido
|
|
</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>
|
|
),
|
|
)}
|
|
{(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>
|
|
Identificador do documento de suporte
|
|
<input
|
|
className={input}
|
|
value={stageForm.data.document_id}
|
|
onChange={(e) =>
|
|
stageForm.setData(
|
|
'document_id',
|
|
e.target.value,
|
|
)
|
|
}
|
|
/>
|
|
</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,
|
|
}));
|
|
upload.post(
|
|
`/balcao-virtual/dossiers/${dossier.id}/documentos`,
|
|
);
|
|
}}
|
|
>
|
|
<label>
|
|
Ficheiro privado
|
|
<input
|
|
className={input}
|
|
type="file"
|
|
accept=".pdf,.png,.jpg,.jpeg,.txt"
|
|
onChange={(e) =>
|
|
upload.setData(
|
|
'file',
|
|
e.target.files?.[0] ?? null,
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Ou identificador de documento já autorizado
|
|
<input
|
|
className={input}
|
|
value={upload.data.document_id}
|
|
onChange={(e) =>
|
|
upload.setData(
|
|
'document_id',
|
|
e.target.value,
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<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>
|
|
{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>
|
|
);
|
|
}
|