import { router, useForm } from '@inertiajs/react'; import { useEffect, useRef, useState } from 'react'; import { createOperationKey } from './operation-key'; import type { Lead } from '@/pages/crm/types'; import { useCrmTaskPreparation, taskDeadlineLocal, taskDeadlineUtc, } from './crm-task-preparation'; import type { TaskPreparationView, TaskPreparationSnapshot, } from './crm-task-preparation'; export type TaskCreationContext = { lead_id: string; actor_id: number; commands: { key: string; task_id: string; version: number }[]; preparation?: TaskPreparationView; }; type Values = { title: string; due_at: string; assignee_user_id: string }; type Attempt = { values: Values & { client_key: string }; generation: Record; }; const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); const positive = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0; export function useCrmTaskCreation(lead: Lead, context: TaskCreationContext) { const form = useForm({ title: '', due_at: '', assignee_user_id: String(lead.owner_user_id ?? ''), }); const live = useRef(form.data); live.current = form.data; const blank = context.preparation?.snapshot; const sameBlank = blank?.pending === null && blank.values?.title === '' && blank.values?.due_at === '' && blank.values?.assignee_user_id === String(lead.owner_user_id ?? '') && Object.values(blank.generation ?? {}).length === 3 && Object.values(blank.generation ?? {}).every( (value) => Number.isSafeInteger(value) && value >= 0, ); const generation = useRef>( sameBlank ? { ...blank.generation } : {}, ); const pending = useRef(null); const submitted = useRef(null); const recoveredDeadline = useRef<{ local: string; utc: string } | null>( null, ); const alive = useRef(true); const [waiting, setWaiting] = useState(false); const [blocked, setBlocked] = useState(false); const [refreshing, setRefreshing] = useState(false); const [notice, setNotice] = useState(''); const valid = context.lead_id === lead.id && positive(context.actor_id) && Array.isArray(context.commands); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); useEffect(() => { if (generation.current.assignee_user_id) return; const assignee_user_id = String(lead.owner_user_id ?? ''); live.current = { ...live.current, assignee_user_id }; form.setData(live.current); }, [lead.owner_user_id]); function change(key: keyof Values, value: string) { if (key === 'due_at') recoveredDeadline.current = null; generation.current[key] = (generation.current[key] ?? 0) + 1; live.current = { ...live.current, [key]: value }; form.setData(live.current); } function scoped(props: unknown): props is Record & { taskCreation: TaskCreationContext; } { return ( object(props) && object(props.lead) && props.lead.id === lead.id && object(props.taskCreation) && props.taskCreation.lead_id === lead.id && props.taskCreation.actor_id === context.actor_id && Array.isArray(props.taskCreation.commands) ); } function acknowledge(props: unknown) { const attempt = pending.current; if (!alive.current || !attempt || !scoped(props)) return false; const receipt = props.taskCreation.commands.find( (item: unknown) => object(item) && item.key === attempt.values.client_key, ); if ( !receipt || typeof receipt.task_id !== 'string' || !positive(receipt.version) ) return false; const task = object(props.lead) && Array.isArray(props.lead.tasks) ? props.lead.tasks.find( (item: unknown) => object(item) && item.id === receipt.task_id, ) : null; if ( task && (!positive(task.version_no) || task.version_no < receipt.version || (task.version_no === receipt.version && (task.title !== attempt.values.title.trim() || task.assignee_user_id !== Number(attempt.values.assignee_user_id) || new Date(task.due_at).getTime() !== new Date(attempt.values.due_at).getTime()))) ) return false; const next = { ...live.current }; for (const key of ['title', 'due_at'] as const) { if ( (generation.current[key] ?? 0) === (attempt.generation[key] ?? 0) ) next[key] = ''; } live.current = next; form.setData(next); form.clearErrors(); pending.current = null; setWaiting(false); setBlocked(false); setNotice( task ? 'Tarefa registada. As alterações posteriores ao envio foram conservadas.' : 'A criação já foi registada. A tarefa já não está disponível neste lead; consulte o histórico antes de criar outra.', ); return true; } function capture(): TaskPreparationSnapshot { return { values: { ...live.current, due_at: recoveredDeadline.current?.local === live.current.due_at ? recoveredDeadline.current.utc : taskDeadlineUtc(live.current.due_at), }, generation: { title: generation.current.title ?? 0, due_at: generation.current.due_at ?? 0, assignee_user_id: generation.current.assignee_user_id ?? 0, }, pending: pending.current ? { values: { ...pending.current.values }, generation: { title: pending.current.generation.title ?? 0, due_at: pending.current.generation.due_at ?? 0, assignee_user_id: pending.current.generation.assignee_user_id ?? 0, }, } : null, }; } const preparation = useCrmTaskPreparation({ leadId: lead.id, actorId: context.actor_id, view: context.preparation, getSnapshot: capture, onRecover: (snapshot) => { if ( pending.current && submitted.current === pending.current.values.client_key && pending.current.values.client_key !== snapshot.pending?.values.client_key ) return false; generation.current = { ...snapshot.generation }; generation.current.assignee_user_id = Math.max( 1, snapshot.generation.assignee_user_id, ); live.current = { ...snapshot.values, due_at: taskDeadlineLocal(snapshot.values.due_at), }; recoveredDeadline.current = { local: live.current.due_at, utc: snapshot.values.due_at, }; form.setData(live.current); pending.current = snapshot.pending; submitted.current = snapshot.pending?.values.client_key ?? null; setWaiting(!!snapshot.pending); setBlocked(false); if (snapshot.pending) { if (!acknowledge({ lead, taskCreation: context })) setNotice( 'Foi recuperado um envio sem confirmação. Recupere a mesma criação antes de iniciar outra.', ); } return true; }, }); const canReplay = !!pending.current && submitted.current === pending.current.values.client_key; function submit() { if ( !valid || blocked || refreshing || form.processing || (preparation.blocked && !canReplay) ) return; if (!pending.current) { let client_key: string; try { client_key = createOperationKey(); } catch { setNotice( 'Não foi possível preparar o envio. Reabra o portal no endereço HTTPS configurado.', ); return; } pending.current = { values: { ...live.current, due_at: capture().values.due_at, client_key, }, generation: { ...generation.current }, }; } const attempt = pending.current; setWaiting(true); if (canReplay || !preparation.enabled) send(attempt); else { setNotice('A guardar a preparação antes de criar a tarefa…'); void preparation.ensure().then((saved) => { if (!alive.current || pending.current !== attempt) return; if (saved) send(attempt); else setNotice( 'O envio ficou por confirmar. Volte a guardar a preparação antes de continuar.', ); }); } } function send(attempt: Attempt) { submitted.current = attempt.values.client_key; setNotice('A aguardar confirmação da criação.'); form.transform(() => attempt.values); form.post(`/management/crm/leads/${lead.id}/tasks`, { preserveScroll: true, onSuccess: (page) => { if (!alive.current || pending.current !== attempt) return; if (!acknowledge(page.props)) setNotice( 'A resposta não permitiu confirmar a criação. Actualize as tarefas ou recupere o mesmo envio.', ); }, onError: () => { if (!alive.current || pending.current !== attempt) return; pending.current = null; setWaiting(false); setNotice( 'Reveja os campos assinalados. As suas alterações foram conservadas.', ); }, onNetworkError: () => { if (alive.current && pending.current === attempt) setNotice( 'Sem confirmação do envio. Recupere a mesma criação para evitar duplicar a tarefa.', ); }, onHttpException: (response) => { if (!alive.current || pending.current !== attempt) return; if ([401, 403, 404, 409, 419].includes(response.status)) setBlocked(true); setNotice( 'Não foi possível confirmar a criação. Actualize as tarefas e o acesso antes de continuar. As alterações foram conservadas.', ); }, onCancel: () => { if (alive.current && pending.current === attempt) setNotice( 'Envio interrompido sem confirmação. Recupere a mesma criação.', ); }, }); } function refresh() { if (refreshing || form.processing) return; setRefreshing(true); router.reload({ only: ['lead', 'owners', 'taskCreation', 'nextAction'], onSuccess: (page) => { if (!alive.current || !scoped(page.props)) return; setBlocked(false); if (pending.current && !acknowledge(page.props)) setNotice( 'A criação ainda não foi confirmada. Recupere o mesmo envio; as edições posteriores ficam para a próxima tarefa.', ); }, onFinish: () => { if (alive.current) setRefreshing(false); }, }); } const guard = useRef(false); guard.current = !preparation.durable && (waiting || !!form.data.title || !!form.data.due_at || (!!generation.current.assignee_user_id && form.data.assignee_user_id !== String(lead.owner_user_id ?? ''))); useEffect(() => { const remove = router.on('before', (event) => { const visit = event.detail.visit; if ( guard.current && visit.method === 'get' && !visit.preserveState && !window.confirm( 'Existem alterações ou um envio por confirmar. 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 { form, change, submit, refresh, waiting, blocked: blocked || !valid || (preparation.blocked && !canReplay), refreshing, notice, sent: pending.current?.values, preparation, }; }