Files
Investidor-V2/resources/js/lib/partnership-room-edit.ts
T

846 lines
30 KiB
TypeScript

import { router, useForm } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import { createOperationKey } from './operation-key';
type Values = Record<string, string>;
type Kind = 'agreement' | 'milestone';
type Snapshot = {
actorId: number | null;
partnershipVersion: number | null;
interestVersion: number | null;
commands: {
key: string;
action: string;
version: number;
status: string;
party: string | null;
}[];
confirmations: Record<string, { actorId: number; reason: string }>;
id: number | null;
version: number | null;
context: string;
roomVersion: number;
status: string;
canWrite: boolean;
values: Values;
};
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function positive(value: unknown): value is number {
return (
typeof value === 'number' && Number.isSafeInteger(value) && value > 0
);
}
function text(value: unknown): string | null {
return value == null ? '' : typeof value === 'string' ? value : null;
}
const milestoneKeys = [
'title',
'responsible_id',
'due_date',
'status',
'evidence',
];
function snapshot(
props: unknown,
roomId: number,
kind: Kind,
itemId: number | null,
creationKey?: string,
): Snapshot | null {
if (
!object(props) ||
!object(props.room) ||
props.room.id !== roomId ||
!positive(props.room.version) ||
typeof props.room.status !== 'string' ||
typeof props.canWrite !== 'boolean'
)
return null;
const room = props.room;
const actorId = positive(props.agreementActorId)
? props.agreementActorId
: null;
const partnershipVersion =
object(props.partnership) && positive(props.partnership.version)
? props.partnership.version
: null;
const interestVersion =
object(props.interest) && positive(props.interest.version)
? props.interest.version
: null;
if (
kind === 'agreement' &&
(!actorId ||
!partnershipVersion ||
!interestVersion ||
!Array.isArray(props.agreementCommands) ||
!props.agreementCommands.every(
(command) =>
object(command) &&
typeof command.key === 'string' &&
typeof command.action === 'string' &&
positive(command.version) &&
typeof command.status === 'string' &&
(command.party === null ||
command.party === 'proponent' ||
command.party === 'candidate'),
))
)
return null;
let values: Record<string, string | null>,
id: number | null = roomId,
version: number | null = room.version as number;
const confirmations: Snapshot['confirmations'] = {};
if (kind === 'agreement') {
if (room.agreement !== null && !object(room.agreement)) return null;
const agreement = object(room.agreement) ? room.agreement : {};
if (object(agreement.confirmations))
for (const party of ['proponent', 'candidate']) {
const confirmation = agreement.confirmations[party];
if (object(confirmation) && positive(confirmation.actor_id))
confirmations[party] = {
actorId: confirmation.actor_id,
reason:
typeof confirmation.reason === 'string'
? confirmation.reason
: '',
};
}
values = {
title: text(agreement.title),
terms: text(agreement.terms),
reason: '',
};
} else if (itemId === null && !creationKey) {
id = null;
version = null;
values = {
title: '',
responsible_id: '',
due_date: '',
status: 'pending',
evidence: '',
};
} else {
if (!Array.isArray(props.milestones)) return null;
const item = props.milestones.find(
(candidate) =>
object(candidate) &&
(creationKey
? candidate.client_key === creationKey
: candidate.id === itemId),
);
if (
!object(item) ||
!positive(item.id) ||
!positive(item.version) ||
!positive(item.responsible_id)
)
return null;
id = item.id;
version = item.version;
values = {
title: text(item.title),
responsible_id: String(item.responsible_id),
due_date: text(item.due_date),
status: text(item.status),
evidence: text(item.evidence),
};
}
if (!Object.values(values).every((value) => typeof value === 'string'))
return null;
return {
actorId,
partnershipVersion,
interestVersion,
confirmations,
commands:
kind === 'agreement' && Array.isArray(props.agreementCommands)
? props.agreementCommands.map((command) => ({
key: command.key,
action: command.action,
version: command.version,
status: command.status,
party: command.party,
}))
: [],
id,
version,
context: JSON.stringify(
kind === 'agreement'
? [
room.version,
room.status,
props.canWrite,
partnershipVersion,
interestVersion,
]
: [room.version, room.status, props.canWrite],
),
roomVersion: room.version as number,
status: room.status as string,
canWrite: props.canWrite,
values: values as Values,
};
}
const same = (a: Values, b: Values) =>
Object.keys(a).every((key) => a[key] === b[key]);
const normal = (value: string) => value.trim();
export function useRoomEdit({
props,
roomId,
kind,
itemId = null,
}: {
props: unknown;
roomId: number;
kind: Kind;
itemId?: number | null;
}) {
const alive = useRef(true);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
const current = snapshot(props, roomId, kind, itemId);
const initial = useRef(current);
const initialIdentity = useRef(JSON.stringify([roomId, kind, itemId]));
const observedIdentity = useRef(initialIdentity.current);
observedIdentity.current = JSON.stringify([roomId, kind, itemId]);
const empty: Values =
kind === 'agreement'
? { title: '', terms: '', reason: '' }
: {
title: '',
responsible_id: '',
due_date: '',
status: 'pending',
evidence: '',
};
const form = useForm<{
version: number | null;
client_key: string;
values: Values;
}>({
version: current?.version ?? null,
client_key: createOperationKey(),
values: current?.values ?? empty,
});
const live = useRef(form.data);
live.current = form.data;
const edited = useRef(new Set<string>());
const generation = useRef<Record<string, number>>({});
const pending = useRef<{
command: Record<string, string | number | null>;
generations: Record<string, number>;
ambiguous: boolean;
base: Snapshot;
values: Values;
} | null>(null);
const [choices, setChoices] = useState<Record<string, 'mine' | 'current'>>(
{},
);
const [refreshing, setRefreshing] = useState(false);
const [needsCompare, setNeedsCompare] = useState(false);
const [problem, setProblem] = useState<string | null>(null);
const [mustRefresh, setMustRefresh] = useState(false),
[accessDenied, setAccessDenied] = useState(false);
const [created, setCreated] = useState<number | null>(null);
const isNew = kind === 'milestone' && itemId === null;
const keys = Object.keys(current?.values ?? empty);
const invalid =
!current ||
!initial.current ||
(kind === 'agreement' && current.actorId !== initial.current.actorId) ||
initialIdentity.current !== JSON.stringify([roomId, kind, itemId]);
const stale =
!!current &&
!!initial.current &&
(current.version !== initial.current.version ||
current.context !== initial.current.context ||
!same(current.values, initial.current.values));
const changed =
current && initial.current
? keys.filter(
(key) => current.values[key] !== initial.current?.values[key],
)
: [];
const conflicts = current
? changed.filter(
(key) =>
edited.current.has(key) &&
live.current.values[key] !== current.values[key],
)
: [];
const choiceKey = (key: string) =>
JSON.stringify([
current?.version,
current?.context,
key,
current?.values[key],
]);
const unresolved = conflicts.filter(
(key) => choices[choiceKey(key)] !== 'mine',
);
const editable =
!!current?.canWrite &&
(kind === 'agreement'
? ['negotiation', 'agreement_recorded', 'agreed', 'execution']
: ['agreement_recorded', 'agreed', 'execution']
).includes(current.status);
const recovering =
(isNew || kind === 'agreement') &&
!!pending.current?.ambiguous &&
!!current?.canWrite &&
!accessDenied;
const blocked =
invalid ||
stale ||
refreshing ||
needsCompare ||
(kind === 'agreement' && (accessDenied || !!pending.current)) ||
(!editable && !recovering);
const dirty =
!!pending.current ||
(!!initial.current &&
(!same(live.current.values, initial.current.values) ||
keys.some(
(key) =>
edited.current.has(key) &&
current &&
live.current.values[key] !== current.values[key],
)));
// Untouched controls follow current data. Deliberate edits and uncertain commands require comparison.
useEffect(() => {
if (
!current ||
invalid ||
(!stale && !(kind === 'agreement' && pending.current?.ambiguous)) ||
dirty ||
edited.current.size > 0 ||
pending.current ||
form.processing ||
needsCompare
)
return;
if (kind === 'agreement' && mustRefresh) return;
initial.current = current;
live.current = {
...live.current,
version: current.version,
values: { ...current.values },
};
form.setDefaults(live.current);
form.setData(live.current);
}, [current, invalid, stale, dirty, form.processing, needsCompare]);
function change(key: string, value: string) {
if (!keys.includes(key)) return;
edited.current.add(key);
generation.current[key] = (generation.current[key] ?? 0) + 1;
live.current = {
...live.current,
values: { ...live.current.values, [key]: value },
};
form.setData(live.current);
setChoices((previous) => {
const next = { ...previous };
delete next[choiceKey(key)];
return next;
});
}
function choose(key: string, value: 'mine' | 'current') {
if (!current || !keys.includes(key)) return;
if (value === 'current') change(key, current.values[key]);
setChoices((previous) => ({ ...previous, [choiceKey(key)]: value }));
}
function adopt() {
if (
!current ||
invalid ||
(!stale && !(kind === 'agreement' && pending.current?.ambiguous)) ||
(kind === 'agreement' && (mustRefresh || accessDenied)) ||
unresolved.length ||
refreshing ||
form.processing
)
return;
const values = { ...current.values };
for (const key of keys)
if (edited.current.has(key)) values[key] = live.current.values[key];
initial.current = current;
live.current = { ...live.current, version: current.version, values };
form.setData(live.current);
form.setDefaults({ ...live.current, values: current.values });
form.clearErrors();
setChoices({});
setNeedsCompare(false);
setProblem(null);
if (kind === 'agreement') pending.current = null;
}
function httpFailure(response: { status: number }) {
if (!alive.current) return false;
if (kind === 'agreement') {
setMustRefresh(true);
setAccessDenied([401, 403, 404, 419].includes(response.status));
}
setProblem(
response.status === 409
? 'O registo ou o estado da sala mudou. Compare com os dados actuais; as suas edições foram conservadas.'
: 'Não foi possível confirmar a operação com o acesso actual. As suas edições foram conservadas.',
);
if (response.status === 409 || response.status >= 500)
setNeedsCompare(true);
if (pending.current) {
if (kind === 'agreement' && response.status === 409) {
pending.current = null;
return false;
}
if (
response.status >= 500 ||
response.status === 409 ||
pending.current.ambiguous
)
pending.current.ambiguous = true;
else pending.current = null;
}
return false;
}
function refresh() {
if (refreshing || form.processing) return;
setRefreshing(true);
router.reload({
only: [
'room',
'milestones',
'participants',
'canWrite',
'messages',
'documents',
'partnership',
'interest',
'agreementActorId',
'agreementCommands',
'agreementParty',
],
onSuccess: (page) => {
if (!alive.current) return;
if (
kind === 'agreement' &&
pending.current &&
acknowledgeAgreement(page.props, pending.current)
)
return;
const fresh = snapshot(page.props, roomId, kind, itemId);
if (
kind === 'agreement' &&
fresh &&
fresh.actorId === initial.current?.actorId
) {
setMustRefresh(false);
setAccessDenied(false);
}
if (
fresh &&
initial.current &&
fresh.version === initial.current.version &&
fresh.context === initial.current.context &&
same(fresh.values, initial.current.values)
) {
setNeedsCompare(false);
setProblem(null);
}
},
onHttpException: () => {
if (!alive.current) return false;
if (kind === 'agreement') {
setMustRefresh(true);
setAccessDenied(true);
}
setProblem(
'Não foi possível recuperar esta sala com o acesso actual. As suas edições foram conservadas.',
);
return false;
},
onNetworkError: () => {
if (!alive.current) return false;
setProblem(
'A recuperação não foi confirmada. As suas edições foram conservadas.',
);
return false;
},
onFinish: () => {
if (alive.current) setRefreshing(false);
},
});
}
function acknowledgeAgreement(
props: unknown,
sent: NonNullable<typeof pending.current>,
): boolean {
if (!alive.current || pending.current !== sent) return false;
const fresh = snapshot(props, roomId, 'agreement', null),
action = String(sent.command.action),
version = Number(sent.command.version) + 1;
const receipt = fresh?.commands.find(
(command) =>
command.key === sent.command.client_key &&
command.action === action &&
command.version === version,
);
if (
!fresh ||
!receipt ||
fresh.actorId !== sent.base.actorId ||
fresh.version === null ||
fresh.version < version
)
return false;
const expected: Values =
action === 'record'
? {
title: normal(sent.values.title),
terms: normal(sent.values.terms),
reason: '',
}
: { ...sent.base.values, reason: '' };
if (
fresh.version === version &&
(fresh.status !== receipt.status ||
fresh.values.title !== expected.title ||
fresh.values.terms !== expected.terms)
)
return false;
if (
fresh.version === version &&
action === 'confirm' &&
(!receipt.party ||
fresh.confirmations[receipt.party]?.actorId !==
sent.base.actorId ||
fresh.confirmations[receipt.party]?.reason !==
normal(String(sent.command.reason)) ||
(receipt.status === 'agreed' &&
(!fresh.confirmations.proponent ||
!fresh.confirmations.candidate)))
)
return false;
const later =
fresh.version > version ||
fresh.partnershipVersion !== sent.base.partnershipVersion ||
fresh.interestVersion !== sent.base.interestVersion;
const saved: Snapshot = later
? {
...sent.base,
version,
roomVersion: version,
status: receipt.status,
values: expected,
context: JSON.stringify([
version,
receipt.status,
sent.base.canWrite,
sent.base.partnershipVersion,
sent.base.interestVersion,
]),
}
: fresh;
const merged = { ...saved.values };
edited.current.clear();
for (const key of keys)
if (
(generation.current[key] ?? 0) !==
(sent.generations[key] ?? 0) ||
(action !== 'record' &&
key !== 'reason' &&
live.current.values[key] !== sent.base.values[key])
) {
merged[key] = live.current.values[key];
edited.current.add(key);
}
initial.current = saved;
pending.current = null;
live.current = {
...live.current,
version: saved.version,
values: merged,
};
form.setDefaults({ ...live.current, values: saved.values });
form.setData(live.current);
form.clearErrors();
setChoices({});
setNeedsCompare(later);
setMustRefresh(false);
setAccessDenied(false);
setProblem(
later
? 'A acção foi reconhecida, mas existem decisões posteriores. Compare antes de preparar outra acção.'
: null,
);
return true;
}
function submit(action = 'record', recover = false) {
if (
!alive.current ||
form.processing ||
!initial.current ||
(kind === 'agreement' &&
(invalid || accessDenied || !current?.canWrite || refreshing))
)
return;
if (recover && kind === 'agreement') {
if (!pending.current?.ambiguous) return;
action = String(pending.current.command.action);
} else if (blocked) return;
const base = initial.current;
if (kind === 'agreement' && !recover) {
const allowed: Record<string, string[]> = {
record: ['negotiation', 'agreement_recorded'],
confirm: ['agreement_recorded'],
start: ['agreed'],
complete: ['execution'],
renew: ['agreed', 'execution'],
cancel: [
'negotiation',
'agreement_recorded',
'agreed',
'execution',
],
};
if (!allowed[action]?.includes(base.status)) return;
}
const command: Record<string, string | number | null> =
pending.current?.command ??
(kind === 'agreement'
? action === 'record'
? {
...live.current.values,
version: live.current.version,
action,
client_key: createOperationKey(),
partnership_version: base.partnershipVersion,
interest_version: base.interestVersion,
}
: {
version: live.current.version,
action,
reason: live.current.values.reason,
client_key: createOperationKey(),
partnership_version: base.partnershipVersion,
interest_version: base.interestVersion,
}
: {
...live.current.values,
version: live.current.version,
...(isNew ? { client_key: live.current.client_key } : {}),
});
const sentGenerations = pending.current?.generations ?? {
...generation.current,
};
if ((isNew || kind === 'agreement') && !pending.current)
pending.current = {
command,
generations: sentGenerations,
ambiguous: false,
base,
values: { ...live.current.values },
};
const sentValues = { ...live.current.values };
const attempt = pending.current;
setProblem(null);
form.transform(() => command);
const unknown = () => {
if (!alive.current) return false;
if (kind === 'agreement' && pending.current !== attempt)
return false;
if (kind === 'agreement') setMustRefresh(true);
if (pending.current) pending.current.ambiguous = true;
else setNeedsCompare(true);
setProblem(
'A gravação não foi confirmada. As suas edições foram conservadas.',
);
return false;
};
const options = {
preserveScroll: true,
onSuccess: (page: { props: Record<string, unknown> }) => {
if (!alive.current) return;
if (kind === 'agreement') {
if (
attempt &&
pending.current === attempt &&
!acknowledgeAgreement(page.props, attempt)
) {
attempt.ambiguous = true;
setMustRefresh(true);
setNeedsCompare(true);
setProblem(
'A resposta não reconheceu esta tentativa. A preparação foi conservada.',
);
}
return;
}
if (
observedIdentity.current !==
JSON.stringify([roomId, kind, itemId])
)
return;
const saved = snapshot(
page.props,
roomId,
kind,
itemId,
isNew ? String(command.client_key) : undefined,
);
if (
!saved ||
(!isNew && saved.version !== Number(command.version) + 1)
) {
setProblem(
'A resposta não confirmou este registo. Recupere os dados antes de continuar; a edição foi conservada.',
);
setNeedsCompare(true);
if (pending.current) pending.current.ambiguous = true;
return;
}
if (!isNew) {
if (
milestoneKeys.some(
(key) =>
saved.values[key] !== normal(sentValues[key]),
) ||
saved.context !== base.context
) {
setProblem(
'Os dados devolvidos diferem da operação enviada. Compare antes de continuar; a sua edição foi conservada.',
);
setNeedsCompare(true);
return;
}
}
const defaults = isNew
? snapshot(page.props, roomId, kind, null)
: saved;
if (!defaults) return;
const merged = { ...defaults.values };
edited.current.clear();
for (const key of keys)
if (
(generation.current[key] ?? 0) !==
(sentGenerations[key] ?? 0)
) {
merged[key] = live.current.values[key];
edited.current.add(key);
}
initial.current = defaults;
if (isNew) {
setCreated(saved.id);
pending.current = null;
}
live.current = {
version: defaults.version,
client_key: isNew
? createOperationKey()
: live.current.client_key,
values: merged,
};
form.setDefaults({ ...live.current, values: defaults.values });
form.setData(live.current);
form.clearErrors();
setChoices({});
setNeedsCompare(false);
setProblem(null);
},
onError: () => {
if (
!alive.current ||
(kind === 'agreement' && pending.current !== attempt)
)
return;
if (pending.current && !pending.current.ambiguous)
pending.current = null;
},
onHttpException: (response: { status: number }) =>
kind === 'agreement' && pending.current !== attempt
? false
: httpFailure(response),
onNetworkError: unknown,
onCancel: unknown,
};
if (kind === 'agreement')
form.post(`/parcerias/salas/${roomId}/acordo`, options);
else if (isNew) form.post(`/parcerias/salas/${roomId}/marcos`, options);
else form.put(`/parcerias/salas/${roomId}/marcos/${itemId}`, options);
}
return {
form,
values: form.data.values,
current: current?.values ?? empty,
baseVersion: initial.current?.version,
version: current?.version,
status: current?.status,
stale,
invalid,
editable,
dirty,
blocked,
refreshing,
changed,
conflicts,
unresolved,
problem,
recovering,
pendingAction:
kind === 'agreement' && pending.current?.ambiguous
? String(pending.current.command.action)
: null,
canRecover:
kind === 'agreement' &&
recovering &&
!invalid &&
!refreshing &&
!form.processing,
recover: () => submit('record', true),
mustRefresh: kind === 'agreement' && mustRefresh,
created,
change,
choose,
adopt,
refresh,
submit,
};
}
export type RoomEditor = ReturnType<typeof useRoomEdit>;
/** One room-level leave guard. No draft content is persisted in browser storage. */
export function useRoomLeaveGuard(dirty: boolean) {
const live = useRef(dirty);
live.current = dirty;
useEffect(() => {
const leave = (event: BeforeUnloadEvent) => {
if (live.current) {
event.preventDefault();
event.returnValue = '';
}
};
const stop = router.on('before', (event) => {
if (
live.current &&
event.detail.visit.method === 'get' &&
event.detail.visit.preserveState !== true &&
!window.confirm(
'Há alterações por guardar nesta sala. Pretende sair sem as guardar?',
)
)
event.preventDefault();
});
window.addEventListener('beforeunload', leave);
return () => {
stop();
window.removeEventListener('beforeunload', leave);
};
}, []);
}