454 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|