import { useEffect, useRef, useState } from 'react'; import { profileJson, ProfileValidationError } from './profile-json'; import { createOperationKey } from './operation-key'; export type TaskValues = { title: string; due_at: string; assignee_user_id: string; }; export type TaskGeneration = Record; export type TaskAttempt = { values: TaskValues & { client_key: string }; generation: TaskGeneration; }; export type TaskPreparationSnapshot = { values: TaskValues; generation: TaskGeneration; pending: TaskAttempt | null; }; export type TaskPreparationView = { installed: boolean; version: number; snapshot: TaskPreparationSnapshot | null; operation_key: string | null; unavailable: boolean; updated_at: string | null; }; type Save = { version: number; operation_key: string; snapshot: TaskPreparationSnapshot; }; const fields = ['title', 'due_at', 'assignee_user_id'] as const; const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); const integer = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; const values = (value: unknown): value is TaskValues => object(value) && fields.every((field) => typeof value[field] === 'string'); const generation = (value: unknown): value is TaskGeneration => object(value) && fields.every((field) => integer(value[field])); function snapshot(value: unknown): value is TaskPreparationSnapshot { return ( object(value) && values(value.values) && generation(value.generation) && (value.pending === null || (object(value.pending) && object(value.pending.values) && typeof value.pending.values.client_key === 'string' && values(value.pending.values) && generation(value.pending.generation))) ); } function read(value: unknown): TaskPreparationView | null { return object(value) && typeof value.installed === 'boolean' && integer(value.version) && typeof value.unavailable === 'boolean' && (value.snapshot === null || snapshot(value.snapshot)) && (value.operation_key === null || typeof value.operation_key === 'string') && (value.updated_at === null || typeof value.updated_at === 'string') ? (value as TaskPreparationView) : null; } const signature = (value: TaskPreparationSnapshot) => JSON.stringify({ values: fields.map((field) => value.values[field]), generation: fields.map((field) => value.generation[field]), pending: value.pending ? { values: [ ...fields.map((field) => value.pending?.values[field]), value.pending.values.client_key, ], generation: fields.map( (field) => value.pending?.generation[field], ), } : null, }); export function taskDeadlineUtc(value: string): string { const date = new Date(value); return value && !Number.isNaN(date.getTime()) ? date.toISOString() : value; } export function taskDeadlineLocal(value: string): string { const date = new Date(value); return value && !Number.isNaN(date.getTime()) ? new Date(date.getTime() - date.getTimezoneOffset() * 60000) .toISOString() .slice(0, 16) : value; } export function useCrmTaskPreparation({ leadId, actorId, view, getSnapshot, onRecover, }: { leadId: string; actorId: number; view: unknown; getSnapshot: () => TaskPreparationSnapshot; onRecover: (value: TaskPreparationSnapshot) => boolean; }) { const incoming = read(view); const latest = useRef({ getSnapshot, onRecover }); latest.current = { getSnapshot, onRecover }; const alive = useRef(true), busy = useRef(false); const base = useRef(incoming); const initial = getSnapshot(); const different = incoming?.snapshot && (incoming.snapshot.pending !== null || fields.some( (field) => incoming.snapshot?.values[field] !== initial.values[field], )); const saved = useRef( different && incoming.snapshot ? signature(incoming.snapshot) : signature(initial), ); const saveAttempt = useRef(null); const [comparison, setComparison] = useState( different ? incoming : null, ); const [choices, setChoices] = useState< Partial< Record< keyof TaskValues, { source: 'page' | 'saved'; generation: number } > > >({}); const [state, setState] = useState< 'ready' | 'saving' | 'error' | 'unavailable' >(incoming?.unavailable || (!!view && !incoming) ? 'unavailable' : 'ready'); const [memoryOnly, setMemoryOnly] = useState(false); const [message, setMessage] = useState( incoming?.unavailable || (!!view && !incoming) ? 'A preparação guardada não pôde ser lida. Actualize para recuperar o acesso ou continue só nesta página; os dados guardados ficam preservados.' : '', ); const [, setTick] = useState(0); const current = getSnapshot(), currentSignature = signature(current); const enabled = !!incoming?.installed && !memoryOnly; useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); function response(value: unknown): TaskPreparationView | null { return object(value) && value.lead_id === leadId && value.actor_id === actorId ? read(value.preparation) : null; } function confirmed(next: TaskPreparationView, attempt: Save) { if ( next.version !== attempt.version + 1 || next.operation_key !== attempt.operation_key || !next.snapshot || signature(next.snapshot) !== signature(attempt.snapshot) ) return false; base.current = next; saved.current = signature(attempt.snapshot); saveAttempt.current = null; setState('ready'); setMessage( 'Preparação guardada no servidor. Pode retomá-la nesta conta.', ); return true; } useEffect(() => { if (!incoming || !incoming.installed || memoryOnly || busy.current) return; if (saveAttempt.current && confirmed(incoming, saveAttempt.current)) return; if (incoming.version > (base.current?.version ?? 0)) { setComparison(incoming); setChoices({}); setMessage( 'A preparação mudou. Compare antes de substituir os dados guardados.', ); } }, [view, state, memoryOnly]); async function ensure(forSubmission = true): Promise { if (!enabled) return true; const now = latest.current.getSnapshot(); if ( forSubmission && now.pending && base.current?.snapshot?.pending?.values.client_key === now.pending.values.client_key ) return true; if (busy.current || comparison || state === 'unavailable') return false; if (!saveAttempt.current && signature(now) === saved.current) return true; let attempt = saveAttempt.current; if (!attempt) { try { attempt = { version: base.current?.version ?? 0, operation_key: createOperationKey(), snapshot: now, }; } catch { setState('error'); setMessage( 'Não foi possível preparar a gravação. Reabra o portal no endereço HTTPS configurado.', ); return false; } } saveAttempt.current = attempt; busy.current = true; setState('saving'); setMessage('A guardar a preparação…'); try { const result: unknown = await profileJson( `/management/crm/leads/${leadId}/task-preparation`, attempt, ); if (!alive.current) return false; const next = response(result); if (!next || !confirmed(next, attempt)) throw new Error('Unconfirmed preparation'); // A previous automatic save may have preceded the creation attempt. const pending = latest.current.getSnapshot().pending; if ( forSubmission && pending && next.snapshot?.pending?.values.client_key !== pending.values.client_key ) { busy.current = false; return await ensure(true); } return true; } catch (error) { if (alive.current) { if ( error instanceof ProfileValidationError && Object.keys(error.fields).length ) saveAttempt.current = null; setState('error'); setMessage( error instanceof ProfileValidationError ? Object.keys(error.fields).length ? 'Reveja os campos da preparação antes de guardar. As alterações desta página foram conservadas.' : error.message : 'Não foi possível confirmar a preparação. Conserve esta página e volte a guardar ou actualize para comparar.', ); } return false; } finally { busy.current = false; if (alive.current) setTick((value) => value + 1); } } useEffect(() => { const hasWork = !!current.values.title || !!current.values.due_at || !!current.pending || fields.some((field) => current.generation[field] > 0); if ( !enabled || state !== 'ready' || comparison || busy.current || !hasWork || currentSignature === saved.current ) return; const timer = setTimeout(() => { void ensure(false); }, 800); return () => clearTimeout(timer); }, [currentSignature, enabled, state, comparison]); const source = (field: keyof TaskValues) => choices[field]?.source ?? (current.generation[field] > 0 ? 'page' : 'saved'); const unresolved = fields.filter( (field) => choices[field]?.source === 'saved' && choices[field]?.generation !== current.generation[field], ); function choose(field: keyof TaskValues, value: 'page' | 'saved') { setChoices((previous) => ({ ...previous, [field]: { source: value, generation: latest.current.getSnapshot().generation[field], }, })); } function recover() { if ( !comparison?.snapshot || comparison.unavailable || busy.current || unresolved.length ) return; const now = latest.current.getSnapshot(), stored = comparison.snapshot; const next: TaskPreparationSnapshot = { values: { ...stored.values }, generation: { ...stored.generation }, pending: stored.pending, }; for (const field of fields) { if (source(field) === 'page') { next.values[field] = now.values[field]; next.generation[field] = Math.max( now.generation[field], stored.generation[field], stored.pending?.generation[field] ?? 0, ) + 1; } } if (!latest.current.onRecover(next)) { setMessage( 'Existe outro envio sem confirmação nesta página. Recupere esse envio antes de adoptar a preparação guardada.', ); return; } base.current = comparison; saved.current = signature(stored); saveAttempt.current = null; setComparison(null); setChoices({}); setState('ready'); setMessage( 'Preparação recuperada. Compare os campos e confirme a acção pretendida.', ); } return { enabled, state, comparison, current, source, unresolved, choose, recover, ensure, message, blocked: enabled && (!!comparison || state === 'unavailable' || state === 'saving' || state === 'error'), durable: enabled && state === 'ready' && !saveAttempt.current && currentSignature === saved.current, manual: () => { setMemoryOnly(true); setComparison(null); setMessage( 'Continua apenas nesta página. A preparação guardada no servidor foi preservada.', ); }, }; }