214 lines
7.1 KiB
TypeScript
214 lines
7.1 KiB
TypeScript
import { router, useForm } from '@inertiajs/react';
|
|
import { useRef, useState } from 'react';
|
|
|
|
type Values = Record<string, string>;
|
|
type Kind = 'investor' | 'contact' | 'lead' | 'task';
|
|
function object(value: unknown): value is Record<string, unknown> {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
function savedRecord(
|
|
props: Record<string, unknown>,
|
|
kind: Kind,
|
|
id: string,
|
|
): Record<string, unknown> | null {
|
|
let candidate: unknown = kind === 'investor' ? props.investor : props.lead;
|
|
if (kind === 'contact')
|
|
candidate = Array.isArray(props.contacts)
|
|
? props.contacts.find((value) => object(value) && value.id === id)
|
|
: null;
|
|
if (kind === 'task')
|
|
candidate =
|
|
object(props.lead) && Array.isArray(props.lead.tasks)
|
|
? props.lead.tasks.find(
|
|
(value) => object(value) && value.id === id,
|
|
)
|
|
: null;
|
|
return object(candidate) && candidate.id === id ? candidate : null;
|
|
}
|
|
|
|
/** Keeps deliberate edits, including a deliberate return to the original value. */
|
|
export function useCrmRecordEdit({
|
|
id,
|
|
kind,
|
|
version,
|
|
values,
|
|
blankAfterSave = [],
|
|
}: {
|
|
id: string;
|
|
kind: Kind;
|
|
version: number;
|
|
values: Values;
|
|
blankAfterSave?: string[];
|
|
}) {
|
|
const form = useForm({ version, values });
|
|
const live = useRef(form.data);
|
|
live.current = form.data;
|
|
const identity = useRef(id);
|
|
identity.current = id;
|
|
const base = useRef({ id, version, values: { ...values } });
|
|
const edited = useRef(new Set<string>());
|
|
const generation = useRef<Record<string, number>>({});
|
|
const [choices, setChoices] = useState<Record<string, 'mine' | 'current'>>(
|
|
{},
|
|
);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const keys = Object.keys(values);
|
|
const stale = version !== form.data.version;
|
|
const changed = keys.filter(
|
|
(key) => values[key] !== base.current.values[key],
|
|
);
|
|
const conflicts = changed.filter(
|
|
(key) =>
|
|
edited.current.has(key) && form.data.values[key] !== values[key],
|
|
);
|
|
const unresolved = conflicts.filter(
|
|
(key) => choices[`${version}:${key}`] !== 'mine',
|
|
);
|
|
const invalid =
|
|
base.current.id !== id || !Number.isInteger(version) || version < 1;
|
|
|
|
function change(key: string, value: string) {
|
|
if (!keys.includes(key)) return;
|
|
edited.current.add(key);
|
|
generation.current[key] = (generation.current[key] ?? 0) + 1;
|
|
setChoices((previous) => {
|
|
const next = { ...previous };
|
|
delete next[`${version}:${key}`];
|
|
return next;
|
|
});
|
|
live.current = {
|
|
...live.current,
|
|
values: { ...live.current.values, [key]: value },
|
|
};
|
|
form.setData((previous) => ({
|
|
...previous,
|
|
values: { ...previous.values, [key]: value },
|
|
}));
|
|
}
|
|
function choose(key: string, choice: 'mine' | 'current') {
|
|
if (choice === 'current') change(key, values[key]);
|
|
setChoices((previous) => ({
|
|
...previous,
|
|
[`${version}:${key}`]: choice,
|
|
}));
|
|
}
|
|
function adopt() {
|
|
if (!stale || invalid || refreshing || unresolved.length) return;
|
|
const next = Object.fromEntries(
|
|
keys.map((key) => [
|
|
key,
|
|
edited.current.has(key)
|
|
? live.current.values[key]
|
|
: values[key],
|
|
]),
|
|
);
|
|
base.current = { id, version, values: { ...values } };
|
|
live.current = { version, values: next };
|
|
form.setData(live.current);
|
|
form.clearErrors('version');
|
|
setChoices({});
|
|
}
|
|
function refresh() {
|
|
if (refreshing || form.processing) return;
|
|
setRefreshing(true);
|
|
router.reload({
|
|
only:
|
|
kind === 'lead' || kind === 'task'
|
|
? ['lead', 'owners', 'taskCreation', 'nextAction']
|
|
: ['investor', 'contacts'],
|
|
onFinish: () => setRefreshing(false),
|
|
});
|
|
}
|
|
const blocked = invalid || stale || refreshing || !!form.errors.version;
|
|
function submit(url: string) {
|
|
if (blocked || form.processing) return;
|
|
const submitted = {
|
|
...live.current.values,
|
|
version: live.current.version,
|
|
};
|
|
const sentGeneration = { ...generation.current };
|
|
form.transform(() => submitted);
|
|
form.patch(url, {
|
|
preserveScroll: true,
|
|
onSuccess: (page) => {
|
|
if (identity.current !== id) return;
|
|
const record = savedRecord(page.props, kind, id);
|
|
const actualVersion = record?.version_no;
|
|
if (
|
|
!record ||
|
|
typeof actualVersion !== 'number' ||
|
|
!Number.isInteger(actualVersion) ||
|
|
actualVersion < 1
|
|
)
|
|
return;
|
|
const next: Values = {};
|
|
for (const key of keys) {
|
|
const value = record[key];
|
|
if (
|
|
blankAfterSave.includes(key) ||
|
|
value === null ||
|
|
value === undefined
|
|
)
|
|
next[key] = '';
|
|
else if (
|
|
typeof value === 'string' ||
|
|
typeof value === 'number'
|
|
)
|
|
next[key] = String(value);
|
|
else return;
|
|
}
|
|
base.current = {
|
|
id,
|
|
version: actualVersion,
|
|
values: { ...next },
|
|
};
|
|
edited.current.clear();
|
|
const merged = { ...next };
|
|
for (const key of keys) {
|
|
if (
|
|
(generation.current[key] ?? 0) !==
|
|
(sentGeneration[key] ?? 0)
|
|
) {
|
|
merged[key] = live.current.values[key];
|
|
edited.current.add(key);
|
|
}
|
|
}
|
|
setChoices({});
|
|
const saved = { version: actualVersion, values: next };
|
|
form.setDefaults(saved);
|
|
live.current = { version: actualVersion, values: merged };
|
|
form.setData(live.current);
|
|
form.clearErrors();
|
|
},
|
|
});
|
|
}
|
|
function error(key: string): string | undefined {
|
|
const value = Object.entries(form.errors).find(
|
|
([name]) => name === key,
|
|
)?.[1];
|
|
return typeof value === 'string' ? value : undefined;
|
|
}
|
|
return {
|
|
form,
|
|
values: form.data.values,
|
|
current: values,
|
|
version,
|
|
baseVersion: form.data.version,
|
|
changed,
|
|
conflicts,
|
|
unresolved,
|
|
stale,
|
|
refreshing,
|
|
invalid,
|
|
blocked,
|
|
change,
|
|
choose,
|
|
adopt,
|
|
refresh,
|
|
submit,
|
|
error,
|
|
};
|
|
}
|
|
|
|
export type CrmRecordEditor = ReturnType<typeof useCrmRecordEdit>;
|