372 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|