655 lines
23 KiB
TypeScript
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,
|
|
};
|
|
}
|