385 lines
13 KiB
TypeScript
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.',
|
|
);
|
|
},
|
|
};
|
|
}
|