692 lines
28 KiB
TypeScript
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>
|
|
);
|
|
}
|