Files
Investidor-V2/resources/js/components/desk-attachment-field.tsx
T

454 lines
18 KiB
TypeScript

import { useRef, useState } from 'react';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import { deskChoices } from '@/lib/desk-field-values';
import type { DeskDocument } from './desk-internal-operations';
import DeskDocumentPicker, {
type ReusableDeskDocument,
} from './desk-document-picker';
export type AttachmentField = {
label: string;
required: boolean;
mime_types?: string[];
max_size_kb?: number;
max_files?: number;
};
export type AttachmentInventory = {
documents: DeskDocument[];
case_version: number;
link_id?: string | null;
reused_link?: boolean;
reusable_documents?: DeskDocument[];
};
type Props = {
caseId: string;
requestId: string;
caseVersion: number;
fieldKey: string;
field: AttachmentField;
value: unknown;
documents: DeskDocument[];
reusableDocuments?: DeskDocument[];
disabled: boolean;
uploading: boolean;
onChange: (ids: string[]) => void;
onBusy: (busy: boolean) => void;
onInventory: (result: AttachmentInventory, select: boolean) => void;
};
const formatLabels: Record<string, string> = {
'application/pdf': 'PDF',
'image/png': 'PNG',
'image/jpeg': 'JPEG',
'text/plain': 'Texto simples',
};
export default function DeskAttachmentField(props: Props) {
const latest = useRef(props);
const fileInput = useRef<HTMLInputElement | null>(null);
latest.current = props;
const [file, setFile] = useState<File | null>(null),
[source, setSource] = useState(''),
[consent, setConsent] = useState(false);
const [busy, setBusy] = useState(false),
[message, setMessage] = useState(''),
[errors, setErrors] = useState<Record<string, string>>({});
const [reuseId, setReuseId] = useState('');
const [remoteOrigin, setRemoteOrigin] =
useState<ReusableDeskDocument | null>(null);
const reusable = [
...new Map(
(props.reusableDocuments ?? [])
.filter(
(doc) =>
!props.documents.some(
(current) =>
current.document_id === doc.document_id,
),
)
.map((doc) => [
JSON.stringify([
doc.document_id,
doc.source_reference,
doc.stage ?? null,
doc.valid_until ?? null,
]),
doc,
]),
).values(),
];
const origin = remoteOrigin ?? reusable.find((doc) => doc.id === reuseId);
const selected = deskChoices(props.value);
const unique = [
...new Map(
props.documents.map((doc) => [
doc.document_id,
props.documents.find(
(d) =>
d.document_id === doc.document_id &&
selected.includes(d.id),
) ?? doc,
]),
).values(),
];
async function refresh() {
if (busy) return;
setBusy(true);
try {
const data = await profileJson(
`/balcao-virtual/pedidos/${props.requestId}`,
{},
'GET',
);
if (
!Array.isArray(data.documents) ||
!Number.isInteger(data.caseVersion)
)
throw new Error(
'Não foi possível confirmar a lista dos documentos. As respostas desta página foram conservadas.',
);
latest.current.onInventory(
{
documents: data.documents,
case_version: data.caseVersion,
reusable_documents: data.reusableDocuments,
},
false,
);
setMessage(
'Estado e lista dos documentos actualizados. As respostas desta página foram conservadas.',
);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'Não foi possível actualizar os documentos.',
);
} finally {
setBusy(false);
}
}
async function upload() {
if (
props.disabled ||
props.uploading ||
busy ||
(!file && !origin) ||
!consent ||
(!origin?.source_reference?.trim() && !source.trim())
)
return;
if (
!origin &&
file &&
file.size > (props.field.max_size_kb ?? 10240) * 1024
) {
setErrors({
file: 'O ficheiro excede o tamanho máximo desta pergunta.',
});
return;
}
setBusy(true);
props.onBusy(true);
setErrors({});
setMessage('A enviar o documento privado…');
const data = new FormData();
data.set('version', String(props.caseVersion));
data.set('request_id', props.requestId);
data.set('field_key', props.fieldKey);
if (origin) {
data.set('document_id', origin.document_id);
if (origin.id) data.set('reuse_link_id', origin.id);
if (!origin.source_reference?.trim())
data.set('source_reference', source);
} else if (file) {
data.set('file', file);
data.set('source_reference', source);
}
data.set('consent', '1');
try {
const result: AttachmentInventory = await profileJson(
`/balcao-virtual/dossiers/${props.caseId}/documentos`,
data,
);
if (
!Array.isArray(result.documents) ||
!Number.isInteger(result.case_version) ||
typeof result.link_id !== 'string' ||
!result.documents.some(
(document) => document.id === result.link_id,
)
)
throw new Error(
'O envio não confirmou o documento associado. Actualize a lista antes de repetir; o ficheiro seleccionado continua nesta página.',
);
latest.current.onInventory(result, true);
setFile(null);
if (fileInput.current) fileInput.current.value = '';
setConsent(false);
setReuseId('');
setRemoteOrigin(null);
setMessage(
result.reused_link
? 'Este documento já estava associado. Conservou-se a referência registada; pode seleccioná-lo nesta pergunta.'
: 'Documento associado ao pedido. A análise de segurança e a selecção nesta pergunta são apresentadas abaixo.',
);
} catch (error) {
setMessage(
error instanceof Error
? error.message
: 'O envio não foi confirmado. Actualize a lista antes de repetir.',
);
if (error instanceof ProfileValidationError)
setErrors(error.fields);
} finally {
setBusy(false);
latest.current.onBusy(false);
}
}
return (
<fieldset
className="space-y-2 rounded border p-3"
disabled={props.disabled}
>
<legend>
{props.field.label}
{props.field.required ? ' *' : ''}
</legend>
<p>
Seleccione documentos já associados a este dossier ou envie um
ficheiro. O mesmo original pode apoiar várias perguntas, sem
novo envio.
</p>
<p>
Formatos:{' '}
{(props.field.mime_types ?? [])
.map((mime) => formatLabels[mime] ?? mime)
.join(', ')}
. Até {props.field.max_size_kb} KB por ficheiro e{' '}
{props.field.max_files} ficheiro(s) seleccionados.
</p>
{unique.map((doc) => {
const checked = selected.includes(doc.id);
const mime = doc.media_type?.split(';')[0].trim().toLowerCase();
const known =
!!mime &&
doc.size_bytes !== null &&
doc.size_bytes !== undefined;
const fits =
(!mime || props.field.mime_types?.includes(mime)) &&
(doc.size_bytes === null ||
doc.size_bytes === undefined ||
doc.size_bytes <=
(props.field.max_size_kb ?? 0) * 1024);
return (
<label className="block" key={doc.id}>
<input
type="checkbox"
checked={checked}
disabled={
!checked &&
(!fits ||
selected.length >=
(props.field.max_files ?? 1))
}
onChange={(event) =>
props.onChange(
event.target.checked
? [...selected, doc.id]
: selected.filter(
(id) =>
!props.documents.some(
(document) =>
document.id === id &&
document.document_id ===
doc.document_id,
),
),
)
}
/>{' '}
{doc.file_name} · versão{' '}
{doc.version_no ?? 'por confirmar'} ·{' '}
{doc.malware_scan_status === 'clean' &&
doc.status === 'active'
? 'Análise de segurança concluída'
: 'Análise ou disponibilidade pendente'}
{!fits && ' · Não satisfaz os limites desta pergunta'}
{!known &&
' · Formato ou tamanho por conferir ao submeter'}
<span className="block text-sm">
{doc.source_reference}
</span>
{doc.status === 'active' &&
doc.malware_scan_status === 'clean' && (
<a
className="block underline"
href={`/balcao-virtual/dossiers/${props.caseId}/documentos/${doc.id}`}
>
Consultar documento autorizado
</a>
)}
</label>
);
})}
{selected
.filter((id) => !props.documents.some((doc) => doc.id === id))
.map((id) => (
<p key={id}>
Existe uma selecção cuja disponibilidade precisa de ser
actualizada.{' '}
<button
type="button"
onClick={() =>
props.onChange(
selected.filter((value) => value !== id),
)
}
>
Retirar esta selecção
</button>
</p>
))}
{!unique.length && (
<p>
Ainda não existem documentos associados a este pedido ou ao
dossier para seleccionar.
</p>
)}
<button
type="button"
disabled={busy}
onClick={() => void refresh()}
>
Actualizar documentos disponíveis
</button>
<p>
Pode guardar o rascunho enquanto a análise está pendente. Para
submeter uma pergunta aplicável, os suportes seleccionados têm
de estar disponíveis, autorizados e com análise de segurança
válida; isso não os torna documentos oficialmente aprovados.
</p>
<div className="space-y-2 border-t pt-3">
<h3>Enviar novo suporte para esta pergunta</h3>
<p>
Escolher um ficheiro ou um documento de origem não o associa
ao pedido. Confirme a associação nesta pergunta antes de
submeter.
</p>
{reusable.length > 0 && (
<label className="block">
Ou reutilizar um documento autorizado de outro pedido
deste dossier
<select
value={reuseId}
disabled={busy || props.uploading}
onChange={(event) => {
setReuseId(event.target.value);
setRemoteOrigin(null);
setConsent(false);
if (event.target.value) {
setFile(null);
if (fileInput.current)
fileInput.current.value = '';
}
}}
>
<option value="">Enviar um novo ficheiro</option>
{reusable.map((doc) => (
<option key={doc.id} value={doc.id}>
{doc.file_name} · versão{' '}
{doc.version_no ?? 'por confirmar'} ·{' '}
{doc.source_reference ||
'Referência por completar'}
{doc.stage ? ` · origem ${doc.stage}` : ''}
{doc.valid_until
? ` · validade declarada ${doc.valid_until}`
: ''}
</option>
))}
</select>
</label>
)}
<DeskDocumentPicker
caseId={props.caseId}
disabled={busy || props.uploading || props.disabled}
selected={remoteOrigin}
onSelect={(document) => {
setRemoteOrigin(document);
setReuseId('');
setConsent(false);
if (document) {
setFile(null);
if (fileInput.current) fileInput.current.value = '';
}
}}
/>
{!origin && (
<label className="block">
Ficheiro
<input
type="file"
ref={fileInput}
accept={props.field.mime_types?.join(',')}
disabled={busy || props.uploading}
onChange={(event) =>
setFile(event.target.files?.[0] ?? null)
}
/>
</label>
)}
{!origin || !origin.source_reference?.trim() ? (
<label className="block">
Proveniência e referência do documento
<input
value={source}
maxLength={3000}
disabled={busy || props.uploading}
onChange={(event) => setSource(event.target.value)}
/>
</label>
) : (
<p>
Referência já registada: {origin.source_reference}. Será
conservada com a versão deste original.
</p>
)}
<label className="block">
<input
type="checkbox"
checked={consent}
disabled={busy || props.uploading}
onChange={(event) => setConsent(event.target.checked)}
/>{' '}
Autorizo associar este documento ao pedido privado.
</label>
<button
type="button"
disabled={
busy ||
props.uploading ||
(!file && !origin) ||
!consent ||
(!origin?.source_reference?.trim() && !source.trim())
}
onClick={() => void upload()}
>
{origin
? 'Reutilizar documento nesta pergunta'
: 'Associar ficheiro a esta pergunta'}
</button>
</div>
{message && (
<p role="status" aria-live="polite">
{message}
</p>
)}
{Object.entries(errors).map(([key, error]) => (
<p role="alert" key={key}>
{error}
</p>
))}
</fieldset>
);
}