Files
Investidor-V2/resources/js/components/desk-draft-recovery.tsx
T

692 lines
28 KiB
TypeScript

import { router } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import { profileJson, ProfileValidationError } from '@/lib/profile-json';
import { createOperationKey } from '@/lib/operation-key';
import {
checkpointRecovery,
checkpointSignature,
checkpointSame,
type CheckpointView,
type DeskAnswers,
type RecoveryChoice,
type CheckpointRequest,
type CheckpointInput,
} from '@/lib/desk-checkpoints';
import type { DeskAnswer } from '@/lib/desk-field-values';
type Props = {
id: string;
version: number;
payload: DeskAnswers;
initial: DeskAnswers;
reason: string;
fields: Record<
string,
{ label: string; type?: string; options?: Record<string, string> }
>;
paused: boolean;
pendingWork?: boolean;
onBusy: (busy: boolean) => void;
onBlocked: (blocked: boolean) => void;
onRecover: (
payload: DeskAnswers,
reason: string,
request: CheckpointRequest,
) => void;
};
const commonLabels: Record<string, string> = {
country: 'País do requerente',
declared_origin_country_code: 'País de origem',
context_opportunity: 'Contexto da oportunidade',
};
export default function DeskDraftRecovery(props: Props) {
const latest = useRef(props);
latest.current = props;
const alive = useRef(true),
busy = useRef(false),
edited = useRef<string[]>([]);
const previous = useRef(props.payload),
base = useRef(props.initial);
const previousReason = useRef(props.reason),
reasonEdited = useRef(false),
failedSignature = useRef('');
const recoveryPending = useRef<{
body: CheckpointInput;
request: CheckpointRequest;
payload: DeskAnswers;
reason: string;
} | null>(null);
const view = useRef<CheckpointView | null>(null),
pending = useRef<CheckpointInput | null>(null);
const saved = useRef(
checkpointSignature({ ...props.payload, __reason: props.reason }),
);
const force = useRef(false);
const [state, setState] = useState<
'loading' | 'ready' | 'saving' | 'error' | 'compare' | 'unavailable'
>('loading');
const [message, setMessage] = useState('A recuperar o estado do rascunho…');
const [comparison, setComparison] = useState<CheckpointView | null>(null);
const [choices, setChoices] = useState<Record<string, RecoveryChoice>>({});
const [reasonChoice, setReasonChoice] = useState<'page' | 'saved'>('page');
const [tick, setTick] = useState(0);
const signature = checkpointSignature({
...props.payload,
__reason: props.reason,
});
const unsaved =
props.pendingWork ||
force.current ||
signature !== saved.current ||
pending.current !== null ||
recoveryPending.current !== null;
const markBusy = (value: boolean) => {
busy.current = value;
latest.current.onBusy(value);
};
const compare = (data: CheckpointView) => {
view.current = data;
setComparison(data);
setChoices({});
setReasonChoice(
latest.current.reason || reasonEdited.current ? 'page' : 'saved',
);
setState('compare');
latest.current.onBlocked(true);
setMessage(
'Compare o que está nesta página, o rascunho recuperado e o pedido actual.',
);
};
useEffect(() => {
alive.current = true;
void profileJson(
`/balcao-virtual/pedidos/${props.id}/rascunho`,
{},
'GET',
)
.then((data: CheckpointView) => {
if (!alive.current) return;
view.current = data;
if (!data.installed) {
setState('unavailable');
setMessage(
'Gravação automática ainda não instalada. Pode continuar a guardar manualmente.',
);
return;
}
const different =
data.checkpoint &&
(checkpointSignature(data.checkpoint.payload) !==
checkpointSignature(latest.current.payload) ||
data.checkpoint.reason !== latest.current.reason);
if (
data.request.version !== props.version ||
!data.request.can_edit ||
different
)
compare(data);
else {
setState('ready');
setMessage(
data.checkpoint
? 'Rascunho recuperável no servidor.'
: 'A gravação automática acompanha as alterações.',
);
}
})
.catch(() => {
if (alive.current) {
setState('unavailable');
setMessage(
'Não foi possível consultar a recuperação. As edições desta página e a gravação manual continuam disponíveis.',
);
}
});
return () => {
alive.current = false;
};
}, [props.id, props.version]);
useEffect(() => {
const keys = new Set([
...Object.keys(previous.current),
...Object.keys(props.payload),
]);
for (const key of keys)
if (
!checkpointSame(previous.current[key], props.payload[key]) &&
!edited.current.includes(key)
)
edited.current.push(key);
previous.current = props.payload;
if (previousReason.current !== props.reason)
reasonEdited.current = true;
previousReason.current = props.reason;
}, [props.payload, props.reason]);
async function persist() {
const p = latest.current,
current = view.current;
if (
busy.current ||
p.paused ||
!current?.installed ||
!current.request.can_edit ||
state === 'compare'
)
return;
if (
!pending.current &&
!force.current &&
checkpointSignature({ ...p.payload, __reason: p.reason }) ===
saved.current
)
return;
const body = pending.current ?? {
version: current.checkpoint?.version ?? 0,
request_version: current.request.version,
operation_key: createOperationKey(),
payload: structuredClone(p.payload),
edited_keys: [...edited.current],
reason: p.reason,
};
pending.current = body;
markBusy(true);
setState('saving');
setMessage('A guardar automaticamente…');
try {
const data: CheckpointView = await profileJson(
`/balcao-virtual/pedidos/${p.id}/rascunho`,
body,
);
if (!alive.current) return;
if (
!data.checkpoint ||
data.request.id !== p.id ||
data.checkpoint.version !== body.version + 1 ||
data.checkpoint.request_version !== body.request_version
)
throw new Error(
'A resposta da gravação não confirmou a versão esperada. Compare com o servidor antes de continuar.',
);
view.current = data;
pending.current = null;
force.current = false;
saved.current = checkpointSignature({
...body.payload,
__reason: body.reason,
});
setState('ready');
setMessage(
'Guardado automaticamente no servidor. Ainda não foi submetido.',
);
setTick((v) => v + 1);
} catch (error) {
if (!alive.current) return;
failedSignature.current = checkpointSignature({
...body.payload,
__reason: body.reason,
});
if (
error instanceof ProfileValidationError &&
Object.keys(error.fields).length
)
pending.current = null;
setState('error');
setMessage(
error instanceof Error
? error.message
: 'A gravação falhou. As alterações continuam nesta página.',
);
} finally {
if (alive.current) markBusy(false);
}
}
useEffect(() => {
if (
state === 'error' &&
pending.current === null &&
signature !== failedSignature.current
) {
setState('ready');
return;
}
if (state !== 'ready' || props.paused || !unsaved) return;
const timer = setTimeout(() => void persist(), 2000);
return () => clearTimeout(timer);
}, [signature, state, props.paused, tick]);
useEffect(() => {
const leave = (event: BeforeUnloadEvent) => {
if (unsaved) {
event.preventDefault();
event.returnValue = '';
}
};
const stop = router.on('before', (event) => {
if (
event.detail.visit.method === 'get' &&
unsaved &&
!window.confirm(
'Existem alterações ainda não guardadas automaticamente. Pretende sair desta página?',
)
)
event.preventDefault();
});
window.addEventListener('beforeunload', leave);
return () => {
stop();
window.removeEventListener('beforeunload', leave);
};
}, [unsaved]);
async function refresh() {
if (busy.current) return;
markBusy(true);
try {
const data: CheckpointView = await profileJson(
`/balcao-virtual/pedidos/${props.id}/rascunho`,
{},
'GET',
);
if (alive.current) {
pending.current = null;
recoveryPending.current = null;
if (data.installed) compare(data);
else {
setState('unavailable');
setMessage(
'Gravação automática ainda não instalada. Pode continuar a guardar manualmente.',
);
}
}
} catch (error) {
if (alive.current)
setMessage(
error instanceof Error
? error.message
: 'Não foi possível comparar.',
);
} finally {
if (alive.current) markBusy(false);
}
}
const allowed = comparison
? comparison.request.status === 'returned'
? comparison.request.editable_fields
: [
...new Set([
...Object.keys(props.fields),
'country',
'declared_origin_country_code',
'context_reuse',
'context_opportunity',
'context_opportunity_snapshot',
]),
]
: [];
const recovery = comparison
? checkpointRecovery(
base.current,
props.payload,
edited.current,
comparison.request.payload,
comparison.checkpoint,
allowed,
choices,
)
: null;
async function adopt(useSaved: boolean) {
if (
busy.current ||
!comparison ||
!comparison.request.can_edit ||
(useSaved && recovery!.conflicts.length)
)
return;
const payload = useSaved
? recovery!.payload
: { ...comparison.request.payload };
if (!useSaved)
for (const key of allowed)
if (Object.hasOwn(props.payload, key))
payload[key] = props.payload[key];
const intent = useSaved
? recovery!.intent
: allowed.filter(
(key) =>
!checkpointSame(
payload[key],
comparison.request.payload[key],
),
);
const reason =
useSaved && reasonChoice === 'saved'
? (comparison.checkpoint?.reason ?? '')
: props.reason;
const attempt = recoveryPending.current ?? {
body: {
version: comparison.checkpoint?.version ?? 0,
request_version: comparison.request.version,
operation_key: createOperationKey(),
payload,
edited_keys: intent,
reason,
},
request: comparison.request,
payload,
reason,
};
recoveryPending.current = attempt;
markBusy(true);
try {
const data: CheckpointView = await profileJson(
`/balcao-virtual/pedidos/${props.id}/rascunho`,
attempt.body,
);
if (!alive.current) return;
if (
!data.checkpoint ||
data.request.id !== props.id ||
data.checkpoint.version !== attempt.body.version + 1 ||
data.checkpoint.request_version !== attempt.body.request_version
)
throw new Error(
'A recuperação não confirmou a versão esperada. Compare com o servidor antes de continuar.',
);
previous.current = attempt.payload;
base.current = attempt.request.payload;
view.current = data;
edited.current = attempt.body.edited_keys;
pending.current = null;
recoveryPending.current = null;
force.current = false;
saved.current = checkpointSignature({
...attempt.payload,
__reason: attempt.reason,
});
setComparison(null);
setState('ready');
latest.current.onBlocked(false);
latest.current.onRecover(
attempt.payload,
attempt.reason,
attempt.request,
);
setMessage(
'Rascunho recuperado e guardado no servidor, sem submeter.',
);
setTick((v) => v + 1);
} catch (error) {
if (alive.current)
setMessage(
error instanceof Error
? error.message
: 'A recuperação não foi guardada. Repita a mesma tentativa ou compare novamente.',
);
} finally {
if (alive.current) markBusy(false);
}
}
const display = (value: DeskAnswer | undefined, key: string) =>
value === null || value === undefined || value === ''
? 'Por preencher'
: Array.isArray(value)
? value
.map(
(code) =>
props.fields[key]?.options?.[code] ??
(props.fields[key]?.type === 'attachment'
? 'Documento por recuperar na lista actual'
: code),
)
.join(' · ')
: props.fields[key]?.type === 'consent'
? value === 'accepted'
? 'Aceite'
: value === 'declined'
? 'Recusado'
: String(value)
: (props.fields[key]?.options?.[String(value)] ??
String(value));
return (
<section
className="space-y-3 rounded border p-3"
aria-label="Recuperação do rascunho"
>
<h2 className="font-bold">Gravação automática do rascunho</h2>
<p role="status" aria-live="polite">
{message}
</p>
<p>
As respostas ficam cifradas no servidor para a sua conta,
incluindo valores ainda incompletos. Esta gravação não submete,
não revê o contexto e não autoriza a submissão.
</p>
{unsaved && state !== 'compare' && (
<p>
Existem alterações nesta página ainda por guardar
automaticamente.
</p>
)}
{state === 'error' && (
<>
<button type="button" onClick={() => void persist()}>
Tentar novamente a mesma gravação
</button>
<button type="button" onClick={() => void refresh()}>
Comparar com o servidor
</button>
<p>
Se a sessão expirou, mantenha esta página e
autentique-se noutro separador antes de tentar
novamente.
</p>
</>
)}
{state === 'unavailable' && (
<button type="button" onClick={() => void refresh()}>
Voltar a consultar a recuperação
</button>
)}
{comparison && recovery && (
<>
<p>
Pedido actual: versão {comparison.request.version}.{' '}
{comparison.checkpoint && (
<>
Rascunho automático: versão{' '}
{comparison.checkpoint.version}, guardado em{' '}
{comparison.checkpoint.updated_at}.
</>
)}
</p>
{!comparison.request.can_edit ? (
<p>
O pedido já não permite edição. As respostas desta
página são conservadas aqui; pode consultar o pedido
actual antes de sair.
</p>
) : (
<>
<div className="overflow-x-auto">
<table>
<caption>
Comparação das respostas a recuperar
</caption>
<thead>
<tr>
<th>Campo</th>
<th>Nesta página</th>
<th>Guardado automaticamente</th>
<th>Pedido actual</th>
<th>Escolha</th>
</tr>
</thead>
<tbody>
{recovery.rows.map((row) => (
<tr key={row.key}>
<th>
{props.fields[row.key]
?.label ??
commonLabels[row.key] ??
row.key}
</th>
<td className="whitespace-pre-wrap">
{display(row.page, row.key)}
</td>
<td className="whitespace-pre-wrap">
{display(
row.saved,
row.key,
)}
</td>
<td className="whitespace-pre-wrap">
{display(
row.current,
row.key,
)}
</td>
<td>
<label>
Resposta a conservar{' '}
{row.conflict &&
!choices[row.key] &&
'(escolha necessária)'}
<select
disabled={
busy.current ||
recoveryPending.current !==
null
}
value={
choices[
row.key
] ??
(row.conflict
? ''
: row.choice)
}
onChange={(e) =>
setChoices(
(c) => ({
...c,
[row.key]:
e
.target
.value as RecoveryChoice,
}),
)
}
>
<option
value=""
disabled
>
Escolha uma
resposta
</option>
<option value="page">
Nesta página
</option>
{comparison.checkpoint && (
<option value="saved">
Guardado
automaticamente
</option>
)}
<option value="current">
Pedido actual
</option>
</select>
</label>
</td>
</tr>
))}
</tbody>
</table>
</div>
{comparison.checkpoint && (
<label>
Justificação de correcção a recuperar
<select
disabled={
busy.current ||
recoveryPending.current !== null
}
value={reasonChoice}
onChange={(e) =>
setReasonChoice(
e.target.value === 'saved'
? 'saved'
: 'page',
)
}
>
<option value="page">
Nesta página:{' '}
{props.reason || 'Por preencher'}
</option>
<option value="saved">
Guardada:{' '}
{comparison.checkpoint.reason ||
'Por preencher'}
</option>
</select>
</label>
)}
<p>
Em pedidos devolvidos, apenas os campos
autorizados são recuperados. A autorização de
submissão e a revisão do contexto têm de ser
confirmadas de novo.
</p>
<button
type="button"
disabled={
recovery.conflicts.length > 0 ||
busy.current
}
onClick={() => void adopt(true)}
>
Recuperar as respostas escolhidas
</button>
<button
type="button"
disabled={
busy.current ||
recoveryPending.current !== null
}
onClick={() => void adopt(false)}
>
Manter esta página e substituir o rascunho
automático
</button>
<button
type="button"
disabled={busy.current}
onClick={() => void refresh()}
>
Actualizar a comparação
</button>
</>
)}
</>
)}
{state === 'error' && message && (
<p role="alert">
A gravação automática está interrompida. {message}
{message &&
pending.current &&
' A tentativa anterior é conservada para repetição sem duplicação.'}
</p>
)}
</section>
);
}