806 lines
29 KiB
TypeScript
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);
|
|
};
|
|
}, []);
|
|
}
|