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 } >; paused: boolean; pendingWork?: boolean; onBusy: (busy: boolean) => void; onBlocked: (blocked: boolean) => void; onRecover: ( payload: DeskAnswers, reason: string, request: CheckpointRequest, ) => void; }; const commonLabels: Record = { 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([]); 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(null), pending = useRef(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(null); const [choices, setChoices] = useState>({}); 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 (

Gravação automática do rascunho

{message}

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.

{unsaved && state !== 'compare' && (

Existem alterações nesta página ainda por guardar automaticamente.

)} {state === 'error' && ( <>

Se a sessão expirou, mantenha esta página e autentique-se noutro separador antes de tentar novamente.

)} {state === 'unavailable' && ( )} {comparison && recovery && ( <>

Pedido actual: versão {comparison.request.version}.{' '} {comparison.checkpoint && ( <> Rascunho automático: versão{' '} {comparison.checkpoint.version}, guardado em{' '} {comparison.checkpoint.updated_at}. )}

{!comparison.request.can_edit ? (

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.

) : ( <>
{recovery.rows.map((row) => ( ))}
Comparação das respostas a recuperar
Campo Nesta página Guardado automaticamente Pedido actual Escolha
{props.fields[row.key] ?.label ?? commonLabels[row.key] ?? row.key} {display(row.page, row.key)} {display( row.saved, row.key, )} {display( row.current, row.key, )}
{comparison.checkpoint && ( )}

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.

)} )} {state === 'error' && message && (

A gravação automática está interrompida. {message} {message && pending.current && ' A tentativa anterior é conservada para repetição sem duplicação.'}

)}
); }