Files
Investidor-V2/resources/js/lib/crm-task-creation.ts
T

372 lines
14 KiB
TypeScript

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<string, number>;
};
const object = (value: unknown): value is Record<string, unknown> =>
!!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<Values>({
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<Record<string, number>>(
sameBlank ? { ...blank.generation } : {},
);
const pending = useRef<Attempt | null>(null);
const submitted = useRef<string | null>(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<string, unknown> & {
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,
};
}