import { router } from '@inertiajs/react'; import { useEffect, useRef, useState } from 'react'; import { portalRequest, PortalApiError } from './portal-api'; import { createOperationKey } from './operation-key'; export type HandlingState = { version: number; status: 'open' | 'in_progress' | 'resolved'; assignee_user_id: number | null; assignee_name: string | null; updated_at: string | null; resolved_at: string | null; }; export type Incident = { reference: string; operation: string; observed_version: number; occurred_at: string; error_code: string; handling: HandlingState; }; export type Command = 'claim' | 'release' | 'note' | 'resolve' | 'reopen'; export type HandlingProps = { incident: Incident; actorId: number; commands: { key: string; version: number }[]; }; type Attempt = { values: { version: number; action: Command; note: string; operation_key: string; }; generation: number; }; const object = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); const integer = (v: unknown, min = 0): v is number => typeof v === 'number' && Number.isSafeInteger(v) && v >= min; const validState = (v: unknown): v is HandlingState => object(v) && integer(v.version) && ['open', 'in_progress', 'resolved'].includes(String(v.status)) && (v.assignee_user_id === null || integer(v.assignee_user_id, 1)) && (v.assignee_name === null || typeof v.assignee_name === 'string') && (v.updated_at === null || typeof v.updated_at === 'string') && (v.resolved_at === null || typeof v.resolved_at === 'string'); export function useTechnicalIncidentHandling(props: HandlingProps) { const { incident, actorId, commands } = props; const [note, setNote] = useState(''), [base, setBase] = useState(() => ({ ...incident.handling })), [busy, setBusy] = useState(false), [uncertain, setUncertain] = useState(false), [refreshing, setRefreshing] = useState(false), [blocked, setBlocked] = useState(false), [needsRefresh, setNeedsRefresh] = useState(false), [notice, setNotice] = useState(''), [error, setError] = useState(''); const pending = useRef(null), activeRequest = useRef(null), liveNote = useRef(note), generation = useRef(0), confirmed = useRef(null), alive = useRef(true), origin = useRef({ actorId, reference: incident.reference }), scope = useRef({ actorId, reference: incident.reference }); if ( scope.current.actorId !== actorId || scope.current.reference !== incident.reference ) scope.current = { actorId, reference: incident.reference }; liveNote.current = note; const valid = integer(actorId, 1) && actorId === origin.current.actorId && incident.reference === origin.current.reference && validState(incident.handling) && Array.isArray(commands) && commands.every( (item) => typeof item.key === 'string' && integer(item.version, 1), ); const stale = base.version !== incident.handling.version; function change(value: string) { generation.current++; liveNote.current = value; setNote(value); setError(''); } function acknowledge(attempt: Attempt, version: number) { if (pending.current !== attempt) return; pending.current = null; activeRequest.current = null; setBusy(false); setUncertain(false); setNeedsRefresh(true); confirmed.current = version; if (generation.current === attempt.generation) { liveNote.current = ''; setNote(''); } setNotice( 'Tratamento registado. As alterações posteriores da nota foram conservadas.', ); setError(''); } function read(value: unknown) { if ( !object(value) || value.actorId !== actorId || !object(value.incident) || value.incident.reference !== incident.reference || !validState(value.incident.handling) || !Array.isArray(value.commands) ) return false; const attempt = pending.current; if ( attempt && value.commands.some( (c) => object(c) && c.key === attempt.values.operation_key && c.version === attempt.values.version + 1, ) && value.incident.handling.version >= attempt.values.version + 1 ) acknowledge(attempt, attempt.values.version + 1); if (confirmed.current !== null) { if (value.incident.handling.version === confirmed.current) setBase({ ...value.incident.handling }); if (value.incident.handling.version >= confirmed.current) { confirmed.current = null; setNeedsRefresh(false); } } setBlocked(false); return true; } useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); useEffect(() => { if (valid) read(props); }, [incident, commands, actorId]); function refresh() { if (refreshing) return; const epoch = scope.current; setRefreshing(true); router.reload({ only: ['incident', 'history', 'commands', 'actorId'], onSuccess: (page) => { if ( alive.current && scope.current === epoch && !read(page.props) ) setError( 'Não foi possível confirmar esta consulta. A nota preparada foi conservada.', ); }, onError: () => { if (alive.current && scope.current === epoch) setError( 'Não foi possível actualizar o incidente. A nota foi conservada.', ); }, onFinish: () => { if (alive.current && scope.current === epoch) setRefreshing(false); }, }); } async function send(action: Command, retry = false) { if ( !valid || blocked || refreshing || activeRequest.current || (!retry && (stale || needsRefresh || pending.current)) ) return; let attempt = pending.current; if (!attempt) { if ( liveNote.current.trim().length < 10 || liveNote.current.length > 3000 ) { setError('Preencha uma nota com 10 a 3000 caracteres.'); return; } try { attempt = { values: { version: base.version, action, note: liveNote.current, operation_key: createOperationKey().toLowerCase(), }, generation: generation.current, }; } catch (e) { setError( e instanceof Error ? e.message : 'Não foi possível preparar o registo.', ); return; } pending.current = attempt; } const epoch = scope.current; activeRequest.current = attempt; setBusy(true); setUncertain(true); setError(''); try { const result = await portalRequest( `/suporte-tecnico/incidentes/${incident.reference}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(attempt.values), }, ); if ( !alive.current || scope.current !== epoch || pending.current !== attempt ) return; if ( !object(result) || result.reference !== incident.reference || result.actor_id !== actorId || result.operation_key !== attempt.values.operation_key || result.result_version !== attempt.values.version + 1 || !validState(result.handling) || result.handling.version < result.result_version ) throw new Error( 'A confirmação recebida não corresponde a esta tentativa. Actualize o incidente.', ); acknowledge(attempt, result.result_version); refresh(); } catch (e) { if ( !alive.current || scope.current !== epoch || pending.current !== attempt ) return; if (e instanceof PortalApiError && e.status === 422) { pending.current = null; setUncertain(false); setError( e.errors.note?.join(' ') || 'Este tratamento não pode ser aplicado ao estado actual. Actualize os dados e reveja a nota.', ); } else if ( e instanceof PortalApiError && (e.status === 403 || e.status === 401 || e.status === 419) ) { setBlocked(true); setError( 'O acesso ou a sessão precisam de confirmação. A nota e a tentativa original foram conservadas.', ); } else setError( e instanceof PortalApiError && e.status === 409 ? 'O incidente mudou. Actualize e compare o estado antes de preparar outra tentativa.' : e instanceof Error ? e.message : 'Resultado sem confirmação. Conserve a tentativa original.', ); } finally { if ( alive.current && scope.current === epoch && activeRequest.current === attempt ) { activeRequest.current = null; setBusy(false); } } } function adopt() { if (!valid || !stale || busy || refreshing || needsRefresh || blocked) return; const attempt = pending.current; if (attempt) { if ( incident.handling.version <= attempt.values.version || commands.some((c) => c.key === attempt.values.operation_key) ) return; pending.current = null; setUncertain(false); } setBase({ ...incident.handling }); setError(''); setNotice( 'Estado actual confirmado. Reveja a nota conservada antes de a enviar.', ); } const guard = useRef(false); guard.current = !!note || uncertain; useEffect(() => { const remove = router.on('before', (event) => { if ( guard.current && event.detail.visit.method === 'get' && !event.detail.visit.preserveState && !window.confirm( 'Existe uma nota preparada ou um envio sem confirmação. Sair desta página?', ) ) event.preventDefault(); }); const unload = (event: BeforeUnloadEvent) => { if (guard.current) { event.preventDefault(); event.returnValue = ''; } }; window.addEventListener('beforeunload', unload); return () => { remove(); window.removeEventListener('beforeunload', unload); }; }, []); return { note, change, base, stale, busy, uncertain, refreshing, notice, error, refresh, send, adopt, blocked: !valid || blocked || busy || refreshing || needsRefresh || stale || uncertain, canRetry: valid && !blocked && !busy && !refreshing && !!pending.current, canAdopt: valid && !blocked && !busy && !refreshing && !needsRefresh && stale && (!pending.current || incident.handling.version > pending.current.values.version), pendingAction: pending.current?.values.action, }; }