Files
Investidor-V2/resources/js/lib/education-review.ts
T

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,
};
}