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

806 lines
29 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 Criterion = {
code: string;
label: string;
weight: number;
mandatory: boolean;
};
type Snapshot = {
id: number | null;
version: number | null;
parentVersion: number;
status: string;
context: string;
weightsHash: string;
criteria: Criterion[];
owner: boolean;
canSubmit: boolean;
canDecide: boolean;
canWithdraw: boolean;
values: Values;
commands: { key: string; version: number; action: string }[];
};
function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function dictionary(value: unknown): Record<string, unknown> | null {
return object(value)
? value
: Array.isArray(value) && value.length === 0
? {}
: null;
}
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;
}
export function criterionValue(value: string | undefined): {
state: string;
evidence: string;
} {
try {
const item: unknown = JSON.parse(value ?? '');
if (
object(item) &&
['unknown', 'met', 'unmet'].includes(String(item.state)) &&
typeof item.evidence === 'string'
)
return { state: String(item.state), evidence: item.evidence };
} catch {
/* An invalid preparation is not an assessment. */
}
return { state: 'unknown', evidence: '' };
}
const pack = (state: string, evidence: string) =>
JSON.stringify({ state, evidence });
function read(
props: unknown,
recordId: number,
target: number | null,
mode: 'submission' | 'decision',
actorId: number | null,
key?: string,
action?: string,
): Snapshot | null {
if (
!object(props) ||
!object(props.record) ||
props.record.id !== recordId ||
!positive(props.record.version) ||
typeof props.record.status !== 'string' ||
typeof props.owner !== 'boolean' ||
typeof props.canEdit !== 'boolean' ||
typeof props.acceptingInterests !== 'boolean' ||
!Array.isArray(props.interests)
)
return null;
const record = props.record;
const item = key
? props.interests.find(
(item) =>
object(item) &&
Array.isArray(item.commands) &&
item.commands.some(
(command) =>
object(command) &&
command.key === key &&
command.action === action,
),
)
: target === null
? null
: props.interests.find((item) => object(item) && item.id === target);
if ((target !== null && !object(item)) || (key && !object(item)))
return null;
const entry = object(item) ? item : null;
if (
entry &&
(!positive(entry.id) ||
!positive(entry.version) ||
typeof entry.status !== 'string' ||
typeof entry.can_edit !== 'boolean' ||
!dictionary(entry.payload) ||
!dictionary(entry.assessment) ||
!object(entry.match) ||
!Array.isArray(entry.match.criteria) ||
typeof entry.match.weights_hash !== 'string' ||
!/^[a-f0-9]{64}$/.test(entry.match.weights_hash))
)
return null;
const match = entry && object(entry.match) ? entry.match : null;
const criteria: Criterion[] = [];
for (const candidate of match && Array.isArray(match.criteria)
? match.criteria
: []) {
if (
!object(candidate) ||
typeof candidate.code !== 'string' ||
typeof candidate.label !== 'string' ||
typeof candidate.weight !== 'number' ||
typeof candidate.mandatory !== 'boolean'
)
return null;
criteria.push({
code: candidate.code,
label: candidate.label,
weight: candidate.weight,
mandatory: candidate.mandatory,
});
}
const values: Values = {};
if (mode === 'submission') {
const payload = entry ? (dictionary(entry.payload) ?? {}) : {};
for (const field of ['organization_id', 'proposal']) {
const value = text(entry?.[field]);
if (value === null) return null;
values[field] = value;
}
for (const field of [
'capacity',
'availability',
'contribution',
'conflicts',
]) {
const value = text(payload[field]);
if (value === null) return null;
values[field] = value;
}
values.consent = [true, 1, '1', 'yes', 'on'].includes(
payload.consent as string | number | boolean,
)
? 'yes'
: 'no';
} else {
values.reason = '';
const assessment = entry ? (dictionary(entry.assessment) ?? {}) : {};
for (const criterion of props.owner ? criteria : []) {
const data = assessment[criterion.code];
const assessed = object(data) ? data : {};
const state = assessed.state ?? 'unknown',
evidence = text(assessed.evidence);
if (
typeof state !== 'string' ||
!['met', 'unmet', 'unknown'].includes(state) ||
evidence === null
)
return null;
values['criterion:' + criterion.code] = pack(state, evidence);
}
}
const commands: Snapshot['commands'] = [];
for (const candidate of entry && Array.isArray(entry.commands)
? entry.commands
: [])
if (
object(candidate) &&
typeof candidate.key === 'string' &&
positive(candidate.version) &&
typeof candidate.action === 'string'
)
commands.push({
key: candidate.key,
version: candidate.version,
action: candidate.action,
});
const id = entry && positive(entry.id) ? entry.id : null,
version = entry && positive(entry.version) ? entry.version : null,
status =
entry && typeof entry.status === 'string' ? entry.status : 'new';
const effectiveRepresentation = entry
? entry.can_edit === true
: !!actorId && !props.owner;
const context =
mode === 'submission'
? [
record.version,
record.status,
record.deadline,
props.owner,
effectiveRepresentation,
props.acceptingInterests,
]
: props.owner
? [
record.version,
record.status,
record.deadline,
props.owner,
props.canEdit,
match?.weights_hash,
criteria,
]
: [recordId, props.owner, effectiveRepresentation];
return {
id,
version,
parentVersion: record.version as number,
status,
context: JSON.stringify(context),
weightsHash:
typeof match?.weights_hash === 'string' ? match.weights_hash : '',
criteria,
owner: props.owner,
canSubmit:
!!actorId &&
!props.owner &&
props.acceptingInterests &&
(entry
? entry.can_edit === true &&
['submitted', 'clarification', 'withdrawn'].includes(status)
: props.canStartInterest === true),
canDecide: props.owner && props.canEdit && !!entry,
canWithdraw: !!entry && entry.can_edit === true,
values,
commands,
};
}
const same = (a: Values, b: Values) =>
[...new Set([...Object.keys(a), ...Object.keys(b)])].every(
(key) => a[key] === b[key],
);
function normal(values: Values): Values {
return Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key,
key.startsWith('criterion:')
? pack(
criterionValue(value).state,
criterionValue(value).evidence.trim(),
)
: value.trim(),
]),
);
}
type Pending = {
command: Record<string, unknown>;
values: Values;
generations: Record<string, number>;
action: string;
base: Snapshot;
ambiguous: boolean;
};
export function useCandidacyEdit({
props,
recordId,
interestId = null,
mode,
actorId,
}: {
props: unknown;
recordId: number;
interestId?: number | null;
mode: 'submission' | 'decision';
actorId: number | null;
}) {
const [resolved, setResolved] = useState<number | null>(interestId);
const target = interestId ?? resolved;
const current = read(props, recordId, target, mode, actorId);
const initial = useRef(current);
const criterionLabels = useRef(
new Map(
(current?.criteria ?? []).map((criterion) => [
criterion.code,
criterion.label,
]),
),
);
for (const criterion of current?.criteria ?? [])
criterionLabels.current.set(criterion.code, criterion.label);
const identity = JSON.stringify([recordId, interestId, mode, actorId]);
const opened = useRef(identity);
const observed = useRef(identity);
observed.current = identity;
const empty: Values =
mode === 'submission'
? {
organization_id: '',
proposal: '',
capacity: '',
availability: '',
contribution: '',
conflicts: '',
consent: 'no',
}
: { reason: '' };
const form = useForm<{ values: Values }>({
values: current?.values ?? empty,
});
const live = useRef(form.data.values);
live.current = form.data.values;
const edited = useRef(new Set<string>()),
generations = useRef<Record<string, number>>({});
const pending = useRef<Pending | null>(null);
const [choices, setChoices] = useState<Record<string, 'mine' | 'current'>>(
{},
);
const [refreshing, setRefreshing] = useState(false),
[needsCompare, setNeedsCompare] = useState(false),
[problem, setProblem] = useState<string | null>(null);
const base = initial.current;
const keys = [
...new Set([
...Object.keys(base?.values ?? empty),
...Object.keys(current?.values ?? {}),
]),
];
const invalid =
!current ||
!base ||
opened.current !== observed.current ||
current.id !== base.id;
const stale =
!!current &&
!!base &&
(current.version !== base.version ||
current.context !== base.context ||
current.status !== base.status ||
!same(current.values, base.values));
const changed =
current && base
? keys.filter((key) => current.values[key] !== base.values[key])
: [];
const conflicts = current
? changed.filter(
(key) =>
Object.hasOwn(current.values, key) &&
edited.current.has(key) &&
live.current[key] !== current.values[key],
)
: [];
const choiceKey = (key: string) =>
JSON.stringify([
current?.id,
current?.version,
current?.context,
key,
current?.values[key],
]);
const unresolved = conflicts.filter(
(key) => choices[choiceKey(key)] !== 'mine',
);
const active =
!!current &&
['submitted', 'clarification', 'shortlisted'].includes(current.status);
const editable =
!!current &&
(mode === 'submission'
? current.canSubmit
: active &&
(current.owner ? current.canDecide : current.canWithdraw));
const canRecover = (action: string) =>
!!current &&
(action === 'submit'
? current.id === null
? !!actorId && !current.owner
: current.canWithdraw
: action === 'withdraw'
? current.canWithdraw
: current.canDecide);
const recovering = !!pending.current?.ambiguous;
const blocked =
invalid ||
refreshing ||
form.processing ||
(recovering && pending.current
? !canRecover(pending.current.action)
: stale || needsCompare || !editable);
const dirty =
!!pending.current ||
(!!base &&
(!same(live.current, base.values) ||
keys.some(
(key) =>
edited.current.has(key) &&
current &&
live.current[key] !== current.values[key],
)));
const retired = Object.keys(live.current).filter(
(key) =>
key.startsWith('criterion:') &&
!current?.criteria.some(
(criterion) => 'criterion:' + criterion.code === key,
),
);
useEffect(() => {
if (
!current ||
invalid ||
!stale ||
dirty ||
edited.current.size > 0 ||
pending.current ||
form.processing ||
needsCompare
)
return;
initial.current = current;
live.current = { ...current.values };
form.setDefaults({ values: live.current });
form.setData({ values: live.current });
}, [current, invalid, stale, dirty, form.processing, needsCompare]);
function change(key: string, value: string) {
if (!keys.includes(key)) return;
edited.current.add(key);
generations.current[key] = (generations.current[key] ?? 0) + 1;
live.current = { ...live.current, [key]: value };
form.setData({ values: live.current });
setChoices((previous) => {
const next = { ...previous };
delete next[choiceKey(key)];
return next;
});
}
function changeCriterion(
code: string,
field: 'state' | 'evidence',
value: string,
) {
const key = 'criterion:' + code,
data = criterionValue(live.current[key]);
change(key, JSON.stringify({ ...data, [field]: value }));
}
function discardRetired() {
if (form.processing || pending.current) return;
const values = { ...live.current };
for (const key of retired) {
delete values[key];
edited.current.delete(key);
}
live.current = values;
form.setData({ values });
}
function choose(key: string, value: 'mine' | 'current') {
if (!current) return;
if (value === 'current') change(key, current.values[key] ?? '');
setChoices((previous) => ({ ...previous, [choiceKey(key)]: value }));
}
function adopt() {
if (
!current ||
invalid ||
!stale ||
unresolved.length ||
refreshing ||
form.processing
)
return;
const values = { ...current.values };
for (const key of Object.keys(live.current))
if (edited.current.has(key)) values[key] = live.current[key];
initial.current = current;
live.current = values;
pending.current = null;
form.setDefaults({ values: current.values });
form.setData({ values });
form.clearErrors();
setChoices({});
setNeedsCompare(false);
setProblem(null);
}
function refresh() {
if (refreshing || form.processing) return;
setRefreshing(true);
router.reload({
only: [
'record',
'interests',
'owner',
'canEdit',
'moderator',
'publicationActions',
'publicationCommands',
'historySummary',
'acceptingInterests',
'organizations',
],
onSuccess: (page) => {
const fresh = read(page.props, recordId, target, mode, actorId);
if (
fresh &&
initial.current &&
fresh.id === initial.current.id &&
fresh.version === initial.current.version &&
fresh.context === initial.current.context &&
fresh.status === initial.current.status &&
same(fresh.values, initial.current.values)
) {
setNeedsCompare(false);
setProblem(null);
}
},
onHttpException: () => {
setProblem(
'Não foi possível recuperar esta candidatura com o acesso actual. A preparação foi conservada.',
);
return false;
},
onNetworkError: () => {
setProblem(
'A recuperação não foi confirmada. A preparação foi conservada.',
);
return false;
},
onFinish: () => setRefreshing(false),
});
}
function submit(
requestedAction = mode === 'submission' ? 'submit' : 'assess',
) {
if (blocked || !current || !base) return;
const action = pending.current?.action ?? requestedAction;
if (!recovering) {
if (
mode === 'decision' &&
(action === 'withdraw'
? !current.canWithdraw
: !current.canDecide)
)
return;
if (action === 'connect' && current.status !== 'shortlisted')
return;
}
let operation = pending.current;
if (!operation) {
const values = { ...live.current };
const command: Record<string, unknown> =
mode === 'submission'
? {
interest_id: target,
version: current.version,
partnership_version: current.parentVersion,
organization_id: values.organization_id,
proposal: values.proposal,
payload: {
capacity: values.capacity,
availability: values.availability,
contribution: values.contribution,
conflicts: values.conflicts,
consent: values.consent === 'yes',
},
}
: {
version: current.version,
action,
reason: values.reason,
assessment:
action === 'assess'
? Object.fromEntries(
current.criteria.map((criterion) => [
criterion.code,
criterionValue(
values[
'criterion:' +
criterion.code
],
),
]),
)
: {},
...(action === 'withdraw'
? {}
: {
partnership_version: current.parentVersion,
weights_hash: current.weightsHash,
}),
};
command.client_key = createOperationKey();
operation = {
command,
values,
generations: { ...generations.current },
base: current,
action,
ambiguous: false,
};
pending.current = operation;
}
const sent = operation;
setProblem(null);
form.transform(() => sent.command);
const unknown = () => {
if (pending.current) pending.current.ambiguous = true;
setProblem(
'A operação não foi confirmada. Recuperar repete o pedido original; as alterações posteriores foram conservadas.',
);
return false;
};
form.post(
mode === 'submission'
? `/parcerias/${recordId}/interesses`
: `/parcerias/candidaturas/${target}`,
{
preserveScroll: true,
onSuccess: (page) => {
if (observed.current !== opened.current) return;
const saved = read(
page.props,
recordId,
target,
mode,
actorId,
String(sent.command.client_key),
sent.action,
);
const acknowledgement = saved?.commands.find(
(command) =>
command.key === sent.command.client_key &&
command.action === sent.action,
);
if (!saved || saved.id === null || !acknowledgement) {
unknown();
return;
}
const acknowledgedKeys =
mode === 'decision' && sent.action === 'assess'
? [
'reason',
...sent.base.criteria.map(
(criterion) =>
'criterion:' + criterion.code,
),
]
: null;
const submittedValues = acknowledgedKeys
? Object.fromEntries(
acknowledgedKeys.map((key) => [
key,
sent.values[key],
]),
)
: mode === 'submission'
? sent.values
: sent.base.values;
const expected = normal(submittedValues);
if (mode === 'decision') expected.reason = '';
const actualExpectedVersion =
sent.base.version === null ? 1 : sent.base.version + 1;
if (
acknowledgement.version !== actualExpectedVersion ||
(target !== null && saved.id !== target)
) {
unknown();
return;
}
const exact =
saved.version === acknowledgement.version &&
saved.context === sent.base.context &&
same(saved.values, expected);
const merged = { ...expected };
const oldEdited = new Set(edited.current);
edited.current.clear();
for (const key of Object.keys(live.current)) {
const sentCriterion =
!key.startsWith('criterion:') ||
sent.base.criteria.some(
(criterion) =>
'criterion:' + criterion.code === key,
);
if (
(generations.current[key] ?? 0) !==
(sent.generations[key] ?? 0) ||
(mode === 'decision' &&
(sent.action !== 'assess' || !sentCriterion) &&
key !== 'reason' &&
oldEdited.has(key))
) {
merged[key] = live.current[key];
edited.current.add(key);
}
}
initial.current = exact
? saved
: {
...saved,
version: acknowledgement.version,
context: sent.base.context,
parentVersion: sent.base.parentVersion,
values: expected,
};
if (target === null) setResolved(saved.id);
pending.current = null;
live.current = merged;
form.setDefaults({ values: expected });
form.setData({ values: merged });
form.clearErrors();
setChoices({});
setNeedsCompare(!exact);
setProblem(
exact
? null
: 'A operação original foi identificada, mas a candidatura ou o contexto tem dados posteriores. Compare antes de continuar; a preparação foi conservada.',
);
},
onError: () => {
if (!pending.current?.ambiguous) pending.current = null;
},
onHttpException: (response) => {
if (
response.status >= 500 ||
response.status === 409 ||
pending.current?.ambiguous
) {
if (response.status === 409) setNeedsCompare(true);
return unknown();
}
pending.current = null;
setProblem(
'Não foi possível confirmar esta operação com o acesso actual. A preparação foi conservada.',
);
return false;
},
onNetworkError: unknown,
onCancel: unknown,
},
);
}
return {
form,
values: form.data.values,
current: current?.values ?? empty,
currentSnapshot: current,
criteria: current?.criteria ?? base?.criteria ?? [],
criterionLabels: Object.fromEntries(criterionLabels.current),
retired,
activeId: target,
editable,
ownerMode: current?.owner ?? base?.owner ?? false,
status: current?.status,
version: current?.version,
baseVersion: base?.version,
stale,
invalid,
changed,
conflicts,
unresolved,
refreshing,
blocked,
dirty,
problem,
recovering,
pendingAction: pending.current?.action,
change,
changeCriterion,
discardRetired,
choose,
adopt,
refresh,
submit,
};
}
export type CandidacyEditor = ReturnType<typeof useCandidacyEdit>;
export function useCandidacyLeaveGuard(dirty: boolean) {
const current = useRef(dirty);
current.current = dirty;
useEffect(() => {
const leave = (event: BeforeUnloadEvent) => {
if (current.current) {
event.preventDefault();
event.returnValue = '';
}
};
const stop = router.on('before', (event) => {
if (
current.current &&
event.detail.visit.method === 'get' &&
event.detail.visit.preserveState !== true &&
!window.confirm(
'Há preparação ou acções por confirmar nesta parceria. Pretende sair sem as conservar?',
)
)
event.preventDefault();
});
window.addEventListener('beforeunload', leave);
return () => {
stop();
window.removeEventListener('beforeunload', leave);
};
}, []);
}