Files
Investidor-V2/resources/js/lib/education-review.ts
T
2026-11-11 08:21:56 +02:00

655 lines
23 KiB
TypeScript

import { router, useForm } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import { createOperationKey } from './operation-key';
import { profileJson } from './profile-json';
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;
preparation?: ReviewPreparation;
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;
};
export type ReviewPreparationSnapshot = {
values: Values;
generation: { status: number; review_reason: number };
base: Snapshot;
pending: Attempt | null;
};
export type ReviewPreparation = {
actor_id: number;
kind: 'demands' | 'capacities';
record_id: string;
version: number;
operation_key: string | null;
snapshot: ReviewPreparationSnapshot | null;
unavailable: boolean;
};
type PreparationSave = {
actor_id: number;
version: number;
operation_key: string;
snapshot: ReviewPreparationSnapshot;
};
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 = {},
preparation,
}: 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 observed = useRef({ row, references });
observed.current = { row, references };
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 [savedPreparation, setSavedPreparation] = useState(
preparation ?? null,
);
const [persisting, setPersisting] = useState(false);
const preparationVersion = useRef(preparation?.version ?? 0);
const preparationSave = useRef<PreparationSave | null>(null);
const saving = useRef(false);
const preparationUrl = `/management/education/${kind}/${row.id}/review-preparation`;
function privateSnapshot(): ReviewPreparationSnapshot {
return structuredClone({
values: live.current,
generation: generation.current,
base: base.current,
pending: pending.current,
});
}
function validPreparation(value: unknown): value is ReviewPreparation {
const nonnegative = (n: unknown): n is number =>
typeof n === 'number' && Number.isSafeInteger(n) && n >= 0;
const values = (v: unknown): v is Values =>
object(v) &&
positive(v.version_no) &&
typeof v.review_reason === 'string' &&
['reviewed', 'rejected'].includes(String(v.status));
const evidence = (v: unknown): v is Snapshot =>
object(v) &&
positive(v.version_no) &&
nonnegative(v.quantity) &&
[
'qualification',
'status',
'period_start',
'period_end',
'valid_until',
'source',
'reference_date',
'employer',
].every((k) => typeof v[k] === 'string') &&
object(v.references) &&
Object.values(v.references).every(
(v) => v === null || typeof v === 'string',
);
if (
!object(value) ||
value.actor_id !== actorId ||
value.kind !== kind ||
value.record_id !== row.id ||
!nonnegative(value.version) ||
!(
value.operation_key === null ||
typeof value.operation_key === 'string'
) ||
typeof value.unavailable !== 'boolean'
)
return false;
if (value.snapshot === null) return true;
const state = value.snapshot;
if (
!object(state) ||
!values(state.values) ||
!evidence(state.base) ||
state.values.version_no !== state.base.version_no ||
!object(state.generation) ||
!nonnegative(state.generation.status) ||
!nonnegative(state.generation.review_reason)
)
return false;
return (
state.pending === null ||
(object(state.pending) &&
values(state.pending.values) &&
object(state.pending.values) &&
'client_key' in state.pending.values &&
typeof state.pending.values.client_key === 'string' &&
evidence(state.pending.base) &&
state.pending.values.version_no ===
state.pending.base.version_no &&
nonnegative(state.pending.reasonGeneration) &&
nonnegative(state.pending.statusGeneration))
);
}
async function persistPreparation(attempt?: Attempt): Promise<boolean> {
if (
invalid ||
preparation === undefined ||
saving.current ||
(attempt !== undefined && form.processing)
)
return false;
saving.current = true;
setPersisting(true);
try {
preparationSave.current ??= {
actor_id: actorId,
version: preparationVersion.current,
operation_key: createOperationKey(),
snapshot: privateSnapshot(),
};
const sent = preparationSave.current;
const value = await profileJson(preparationUrl, sent);
if (!alive.current || preparationSave.current !== sent)
return false;
if (!validPreparation(value))
throw new Error(
'A resposta da preparação não pôde ser conferida.',
);
if (value.unavailable || !value.snapshot)
throw new Error(
'A intenção guardada não pôde ser recuperada com segurança.',
);
setSavedPreparation(value);
if (value.operation_key !== sent.operation_key) {
setNotice(
'Há uma preparação mais recente noutra janela. Foi preservada; recupere e compare antes de continuar.',
);
return false;
}
preparationVersion.current = value.version;
preparationSave.current = null;
if (
attempt &&
sent.snapshot.pending?.values.client_key !==
attempt.values.client_key
) {
setNotice(
'A preparação anterior foi recuperada. Guarde a intenção mais recente antes de enviar a revisão.',
);
return false;
}
setNotice(
JSON.stringify(sent.snapshot) ===
JSON.stringify(privateSnapshot())
? 'Preparação privada guardada. Ainda não constitui uma decisão de revisão.'
: 'Preparação guardada; as edições feitas entretanto continuam por guardar.',
);
return true;
} catch (error) {
if (alive.current)
setNotice(
(error instanceof Error
? error.message
: 'Não foi possível guardar a preparação.') +
' A decisão não foi enviada. Repita a mesma gravação para recuperar a resposta incerta.',
);
return false;
} finally {
saving.current = false;
if (alive.current) setPersisting(false);
}
}
async function restorePreparation() {
if (
invalid ||
saving.current ||
form.processing ||
preparation === undefined
)
return;
saving.current = true;
setPersisting(true);
const before = JSON.stringify(privateSnapshot());
try {
const value = await profileJson(
`${preparationUrl}?actor_id=${actorId}`,
{},
'GET',
);
if (!alive.current) return;
if (!validPreparation(value))
throw new Error(
'A preparação guardada não pôde ser conferida.',
);
setSavedPreparation(value);
if (value.unavailable || !value.snapshot) {
setNotice(
value.unavailable
? 'A preparação guardada está indisponível e foi preservada.'
: 'Ainda não existe preparação guardada para esta conta e declaração.',
);
return;
}
if (before !== JSON.stringify(privateSnapshot())) {
setNotice(
'Conserve as novas edições: a preparação recuperada não as substituiu. Compare antes de recuperar novamente.',
);
return;
}
if (
(live.current.review_reason.trim() || pending.current) &&
!window.confirm(
'Substituir a preparação local pela versão privada guardada?',
)
)
return;
const state = structuredClone(value.snapshot);
preparationVersion.current = value.version;
preparationSave.current = null;
base.current = state.base;
generation.current = state.generation;
pending.current = state.pending;
live.current = state.values;
form.setData(state.values);
form.clearErrors();
setUncertain(state.pending !== null);
setConflict(false);
setAccessBlocked(false);
setNotice(
'Preparação recuperada. Confira a versão e a fonte actuais antes de enviar.',
);
if (state.pending)
acknowledge(observed.current.row, observed.current.references);
} catch (error) {
if (alive.current)
setNotice(
error instanceof Error
? error.message
: 'Não foi possível recuperar a preparação.',
);
} finally {
saving.current = false;
if (alive.current) setPersisting(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 ||
saving.current ||
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.');
const send = () => {
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.',
);
},
});
};
if (preparation !== undefined) {
setNotice('A conservar a intenção de revisão antes do envio.');
void persistPreparation(attempt).then((stored) => {
if (stored && alive.current && pending.current === attempt)
send();
});
} else send();
}
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,
persistPreparation: () => persistPreparation(),
restorePreparation,
preparationAvailable: preparation !== undefined,
savedPreparation,
persisting,
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:
persisting ||
invalid ||
accessBlocked ||
refreshing ||
(!pending.current &&
(row.can_review !== true || stale || conflict)),
sent: pending.current?.values,
};
}