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

385 lines
13 KiB
TypeScript

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<keyof TaskValues, number>;
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<string, unknown> =>
!!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<Save | null>(null);
const [comparison, setComparison] = useState<TaskPreparationView | null>(
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<boolean> {
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.',
);
},
};
}