389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
import { router, useForm } from '@inertiajs/react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { createOperationKey } from './operation-key';
|
|
|
|
export type EducationDeclaration = {
|
|
id: string;
|
|
qualification: string;
|
|
quantity: number;
|
|
status: string;
|
|
version_no: number;
|
|
period_start: string;
|
|
period_end: string;
|
|
reference_date?: string;
|
|
valid_until: string;
|
|
source: string;
|
|
submitted_by_name?: string | null;
|
|
reviewed_by_name?: string | null;
|
|
review_reason?: string | null;
|
|
employer?: string;
|
|
can_review?: boolean;
|
|
review_unavailable?: 'self' | 'expired' | null;
|
|
review_context?: {
|
|
actor_id: number;
|
|
commands: { key: string; version: number }[];
|
|
};
|
|
};
|
|
export type EducationReviewProps = {
|
|
declaration: EducationDeclaration;
|
|
kind: 'demands' | 'capacities';
|
|
actorId: number;
|
|
today: string;
|
|
references?: Record<string, string | null>;
|
|
};
|
|
type Values = { version_no: number; status: string; review_reason: string };
|
|
type Snapshot = Pick<
|
|
EducationDeclaration,
|
|
| 'version_no'
|
|
| 'qualification'
|
|
| 'quantity'
|
|
| 'status'
|
|
| 'period_start'
|
|
| 'period_end'
|
|
| 'valid_until'
|
|
| 'source'
|
|
> & {
|
|
reference_date: string;
|
|
employer: string;
|
|
references: Record<string, string | null>;
|
|
};
|
|
type Attempt = {
|
|
values: Values & { client_key: string };
|
|
reasonGeneration: number;
|
|
statusGeneration: number;
|
|
base: Snapshot;
|
|
};
|
|
const object = (value: unknown): value is Record<string, unknown> =>
|
|
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
const positive = (value: unknown): value is number =>
|
|
typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
|
|
const snapshot = (
|
|
row: EducationDeclaration,
|
|
references: Record<string, string | null> = {},
|
|
): Snapshot => ({
|
|
version_no: row.version_no,
|
|
qualification: row.qualification,
|
|
quantity: row.quantity,
|
|
status: row.status,
|
|
period_start: row.period_start,
|
|
period_end: row.period_end,
|
|
valid_until: row.valid_until,
|
|
source: row.source,
|
|
reference_date: row.reference_date ?? '',
|
|
employer: row.employer ?? '',
|
|
references: { ...references },
|
|
});
|
|
|
|
export function useEducationReview({
|
|
declaration: row,
|
|
kind,
|
|
actorId,
|
|
references = {},
|
|
}: EducationReviewProps) {
|
|
const form = useForm<Values>({
|
|
version_no: row.version_no,
|
|
status: 'reviewed',
|
|
review_reason: '',
|
|
});
|
|
const live = useRef(form.data);
|
|
live.current = form.data;
|
|
const base = useRef(snapshot(row, references)),
|
|
pending = useRef<Attempt | null>(null),
|
|
alive = useRef(true);
|
|
const generation = useRef({ status: 0, review_reason: 0 });
|
|
const [uncertain, setUncertain] = useState(false),
|
|
[conflict, setConflict] = useState(false),
|
|
[refreshing, setRefreshing] = useState(false),
|
|
[accessBlocked, setAccessBlocked] = useState(false),
|
|
[notice, setNotice] = useState('');
|
|
useEffect(() => {
|
|
alive.current = true;
|
|
return () => {
|
|
alive.current = false;
|
|
};
|
|
}, []);
|
|
const invalid =
|
|
!positive(actorId) ||
|
|
row.review_context?.actor_id !== actorId ||
|
|
!positive(row.version_no) ||
|
|
!Array.isArray(row.review_context?.commands);
|
|
const stale = form.data.version_no !== row.version_no;
|
|
function change(field: 'status' | 'review_reason', value: string) {
|
|
generation.current[field]++;
|
|
live.current = { ...live.current, [field]: value };
|
|
form.setData(live.current);
|
|
}
|
|
function read(props: unknown): EducationDeclaration | null {
|
|
if (
|
|
!object(props) ||
|
|
props.actorId !== actorId ||
|
|
props.kind !== kind ||
|
|
!object(props.declaration) ||
|
|
props.declaration.id !== row.id
|
|
)
|
|
return null;
|
|
const value = props.declaration;
|
|
if (
|
|
!positive(value.version_no) ||
|
|
!object(value.review_context) ||
|
|
value.review_context.actor_id !== actorId ||
|
|
!Array.isArray(value.review_context.commands)
|
|
)
|
|
return null;
|
|
return value as EducationDeclaration;
|
|
}
|
|
function returnedReferences(
|
|
props: Record<string, unknown>,
|
|
): Record<string, string | null> | null {
|
|
if (
|
|
!object(props.references) ||
|
|
Object.values(props.references).some(
|
|
(value) => value !== null && typeof value !== 'string',
|
|
)
|
|
)
|
|
return null;
|
|
return props.references as Record<string, string | null>;
|
|
}
|
|
function acknowledge(
|
|
record: EducationDeclaration,
|
|
currentReferences: Record<string, string | null> = references,
|
|
) {
|
|
const attempt = pending.current;
|
|
if (
|
|
!alive.current ||
|
|
!attempt ||
|
|
record.review_context?.actor_id !== actorId
|
|
)
|
|
return false;
|
|
const receipt = record.review_context.commands.find(
|
|
(item) => item.key === attempt.values.client_key,
|
|
);
|
|
if (
|
|
!receipt ||
|
|
receipt.version !== attempt.values.version_no + 1 ||
|
|
record.version_no < receipt.version
|
|
)
|
|
return false;
|
|
base.current =
|
|
record.version_no === receipt.version
|
|
? snapshot(record, currentReferences)
|
|
: {
|
|
...attempt.base,
|
|
version_no: receipt.version,
|
|
status: attempt.values.status,
|
|
};
|
|
live.current = {
|
|
version_no: receipt.version,
|
|
status:
|
|
generation.current.status === attempt.statusGeneration
|
|
? 'reviewed'
|
|
: live.current.status,
|
|
review_reason:
|
|
generation.current.review_reason === attempt.reasonGeneration
|
|
? ''
|
|
: live.current.review_reason,
|
|
};
|
|
form.setData(live.current);
|
|
form.clearErrors();
|
|
pending.current = null;
|
|
setUncertain(false);
|
|
setConflict(false);
|
|
setAccessBlocked(false);
|
|
setNotice(
|
|
'Revisão registada. As alterações posteriores ao envio foram conservadas.',
|
|
);
|
|
return true;
|
|
}
|
|
useEffect(() => {
|
|
if (!invalid && pending.current) acknowledge(row);
|
|
}, [row, actorId, invalid]);
|
|
function submit() {
|
|
if (
|
|
invalid ||
|
|
accessBlocked ||
|
|
refreshing ||
|
|
form.processing ||
|
|
(!pending.current && (row.can_review !== true || stale || conflict))
|
|
)
|
|
return;
|
|
if (!pending.current) {
|
|
let key: string;
|
|
try {
|
|
key = createOperationKey();
|
|
} catch {
|
|
setNotice(
|
|
'Não foi possível preparar a decisão. Reabra o portal no endereço HTTPS configurado.',
|
|
);
|
|
return;
|
|
}
|
|
pending.current = {
|
|
values: { ...live.current, client_key: key },
|
|
reasonGeneration: generation.current.review_reason,
|
|
statusGeneration: generation.current.status,
|
|
base: { ...base.current },
|
|
};
|
|
}
|
|
const attempt = pending.current;
|
|
setUncertain(true);
|
|
setNotice('A aguardar confirmação da revisão.');
|
|
form.transform(() => attempt.values);
|
|
form.post(`/management/education/${kind}/${row.id}/review`, {
|
|
preserveScroll: true,
|
|
onSuccess: (page) => {
|
|
if (!alive.current || pending.current !== attempt) return;
|
|
const record = read(page.props),
|
|
labels = returnedReferences(page.props);
|
|
if (!record || !labels || !acknowledge(record, labels))
|
|
setNotice(
|
|
'A resposta não permitiu confirmar a revisão. Actualize a declaração ou recupere o mesmo envio.',
|
|
);
|
|
},
|
|
onError: () => {
|
|
if (!alive.current || pending.current !== attempt) return;
|
|
pending.current = null;
|
|
setUncertain(false);
|
|
setNotice(
|
|
'Reveja os campos assinalados. A decisão preparada foi conservada.',
|
|
);
|
|
},
|
|
onNetworkError: () => {
|
|
if (alive.current && pending.current === attempt)
|
|
setNotice(
|
|
'Sem confirmação da revisão. Recupere o mesmo envio antes de preparar outra decisão.',
|
|
);
|
|
},
|
|
onCancel: () => {
|
|
if (alive.current && pending.current === attempt)
|
|
setNotice(
|
|
'Envio interrompido sem confirmação. Recupere o mesmo envio.',
|
|
);
|
|
},
|
|
onHttpException: (response) => {
|
|
if (!alive.current || pending.current !== attempt) return;
|
|
if ([401, 403, 419].includes(response.status))
|
|
setAccessBlocked(true);
|
|
if (response.status === 422) {
|
|
pending.current = null;
|
|
setUncertain(false);
|
|
}
|
|
if ([409, 422].includes(response.status)) setConflict(true);
|
|
setNotice(
|
|
'Não foi possível confirmar a revisão. Actualize a declaração e o acesso antes de continuar. A decisão preparada foi conservada.',
|
|
);
|
|
},
|
|
});
|
|
}
|
|
function refresh() {
|
|
if (refreshing || form.processing) return;
|
|
setRefreshing(true);
|
|
router.reload({
|
|
only: [
|
|
'declaration',
|
|
'references',
|
|
'today',
|
|
'actorId',
|
|
'kind',
|
|
'history',
|
|
],
|
|
onSuccess: (page) => {
|
|
if (!alive.current) return;
|
|
const record = read(page.props),
|
|
labels = returnedReferences(page.props);
|
|
if (!record || !labels) return;
|
|
setAccessBlocked(false);
|
|
if (pending.current) acknowledge(record, labels);
|
|
},
|
|
onFinish: () => {
|
|
if (alive.current) setRefreshing(false);
|
|
},
|
|
});
|
|
}
|
|
function adopt() {
|
|
if (
|
|
invalid ||
|
|
!stale ||
|
|
row.can_review !== true ||
|
|
refreshing ||
|
|
form.processing
|
|
)
|
|
return;
|
|
const attempt = pending.current;
|
|
if (attempt) {
|
|
if (acknowledge(row)) return;
|
|
if (
|
|
row.version_no <= attempt.values.version_no ||
|
|
row.review_context?.commands.some(
|
|
(item) => item.key === attempt.values.client_key,
|
|
)
|
|
)
|
|
return;
|
|
pending.current = null;
|
|
setUncertain(false);
|
|
}
|
|
base.current = snapshot(row, references);
|
|
live.current = { ...live.current, version_no: row.version_no };
|
|
form.setData(live.current);
|
|
setConflict(false);
|
|
form.clearErrors('version_no');
|
|
setNotice(
|
|
'Versão actual confirmada. A sua decisão preparada foi conservada para rever antes de enviar.',
|
|
);
|
|
}
|
|
const guard = useRef(false);
|
|
guard.current =
|
|
uncertain ||
|
|
!!form.data.review_reason ||
|
|
form.data.status !== 'reviewed';
|
|
useEffect(() => {
|
|
const remove = router.on('before', (event) => {
|
|
if (
|
|
guard.current &&
|
|
event.detail.visit.method === 'get' &&
|
|
!event.detail.visit.preserveState &&
|
|
!window.confirm(
|
|
'Existe uma revisão preparada ou um envio sem confirmação. Sair desta página?',
|
|
)
|
|
)
|
|
event.preventDefault();
|
|
});
|
|
const unload = (event: BeforeUnloadEvent) => {
|
|
if (guard.current) {
|
|
event.preventDefault();
|
|
event.returnValue = '';
|
|
}
|
|
};
|
|
window.addEventListener('beforeunload', unload);
|
|
return () => {
|
|
remove();
|
|
window.removeEventListener('beforeunload', unload);
|
|
};
|
|
}, []);
|
|
return {
|
|
form,
|
|
change,
|
|
submit,
|
|
refresh,
|
|
adopt,
|
|
base: base.current,
|
|
current: snapshot(row, references),
|
|
stale,
|
|
conflict,
|
|
refreshing,
|
|
uncertain,
|
|
notice,
|
|
canAdopt:
|
|
!invalid &&
|
|
stale &&
|
|
row.can_review === true &&
|
|
!refreshing &&
|
|
!form.processing,
|
|
blocked:
|
|
invalid ||
|
|
accessBlocked ||
|
|
refreshing ||
|
|
(!pending.current &&
|
|
(row.can_review !== true || stale || conflict)),
|
|
sent: pending.current?.values,
|
|
};
|
|
}
|