369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
import { router } from '@inertiajs/react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { portalRequest, PortalApiError } from './portal-api';
|
|
import { createOperationKey } from './operation-key';
|
|
export type HandlingState = {
|
|
version: number;
|
|
status: 'open' | 'in_progress' | 'resolved';
|
|
assignee_user_id: number | null;
|
|
assignee_name: string | null;
|
|
updated_at: string | null;
|
|
resolved_at: string | null;
|
|
};
|
|
export type Incident = {
|
|
reference: string;
|
|
operation: string;
|
|
observed_version: number;
|
|
occurred_at: string;
|
|
error_code: string;
|
|
handling: HandlingState;
|
|
};
|
|
export type Command = 'claim' | 'release' | 'note' | 'resolve' | 'reopen';
|
|
export type HandlingProps = {
|
|
incident: Incident;
|
|
actorId: number;
|
|
commands: { key: string; version: number }[];
|
|
};
|
|
type Attempt = {
|
|
values: {
|
|
version: number;
|
|
action: Command;
|
|
note: string;
|
|
operation_key: string;
|
|
};
|
|
generation: number;
|
|
};
|
|
const object = (v: unknown): v is Record<string, unknown> =>
|
|
!!v && typeof v === 'object' && !Array.isArray(v);
|
|
const integer = (v: unknown, min = 0): v is number =>
|
|
typeof v === 'number' && Number.isSafeInteger(v) && v >= min;
|
|
const validState = (v: unknown): v is HandlingState =>
|
|
object(v) &&
|
|
integer(v.version) &&
|
|
['open', 'in_progress', 'resolved'].includes(String(v.status)) &&
|
|
(v.assignee_user_id === null || integer(v.assignee_user_id, 1)) &&
|
|
(v.assignee_name === null || typeof v.assignee_name === 'string') &&
|
|
(v.updated_at === null || typeof v.updated_at === 'string') &&
|
|
(v.resolved_at === null || typeof v.resolved_at === 'string');
|
|
export function useTechnicalIncidentHandling(props: HandlingProps) {
|
|
const { incident, actorId, commands } = props;
|
|
const [note, setNote] = useState(''),
|
|
[base, setBase] = useState(() => ({ ...incident.handling })),
|
|
[busy, setBusy] = useState(false),
|
|
[uncertain, setUncertain] = useState(false),
|
|
[refreshing, setRefreshing] = useState(false),
|
|
[blocked, setBlocked] = useState(false),
|
|
[needsRefresh, setNeedsRefresh] = useState(false),
|
|
[notice, setNotice] = useState(''),
|
|
[error, setError] = useState('');
|
|
const pending = useRef<Attempt | null>(null),
|
|
activeRequest = useRef<Attempt | null>(null),
|
|
liveNote = useRef(note),
|
|
generation = useRef(0),
|
|
confirmed = useRef<number | null>(null),
|
|
alive = useRef(true),
|
|
origin = useRef({ actorId, reference: incident.reference }),
|
|
scope = useRef({ actorId, reference: incident.reference });
|
|
if (
|
|
scope.current.actorId !== actorId ||
|
|
scope.current.reference !== incident.reference
|
|
)
|
|
scope.current = { actorId, reference: incident.reference };
|
|
liveNote.current = note;
|
|
const valid =
|
|
integer(actorId, 1) &&
|
|
actorId === origin.current.actorId &&
|
|
incident.reference === origin.current.reference &&
|
|
validState(incident.handling) &&
|
|
Array.isArray(commands) &&
|
|
commands.every(
|
|
(item) => typeof item.key === 'string' && integer(item.version, 1),
|
|
);
|
|
const stale = base.version !== incident.handling.version;
|
|
function change(value: string) {
|
|
generation.current++;
|
|
liveNote.current = value;
|
|
setNote(value);
|
|
setError('');
|
|
}
|
|
function acknowledge(attempt: Attempt, version: number) {
|
|
if (pending.current !== attempt) return;
|
|
pending.current = null;
|
|
activeRequest.current = null;
|
|
setBusy(false);
|
|
setUncertain(false);
|
|
setNeedsRefresh(true);
|
|
confirmed.current = version;
|
|
if (generation.current === attempt.generation) {
|
|
liveNote.current = '';
|
|
setNote('');
|
|
}
|
|
setNotice(
|
|
'Tratamento registado. As alterações posteriores da nota foram conservadas.',
|
|
);
|
|
setError('');
|
|
}
|
|
function read(value: unknown) {
|
|
if (
|
|
!object(value) ||
|
|
value.actorId !== actorId ||
|
|
!object(value.incident) ||
|
|
value.incident.reference !== incident.reference ||
|
|
!validState(value.incident.handling) ||
|
|
!Array.isArray(value.commands)
|
|
)
|
|
return false;
|
|
const attempt = pending.current;
|
|
if (
|
|
attempt &&
|
|
value.commands.some(
|
|
(c) =>
|
|
object(c) &&
|
|
c.key === attempt.values.operation_key &&
|
|
c.version === attempt.values.version + 1,
|
|
) &&
|
|
value.incident.handling.version >= attempt.values.version + 1
|
|
)
|
|
acknowledge(attempt, attempt.values.version + 1);
|
|
if (confirmed.current !== null) {
|
|
if (value.incident.handling.version === confirmed.current)
|
|
setBase({ ...value.incident.handling });
|
|
if (value.incident.handling.version >= confirmed.current) {
|
|
confirmed.current = null;
|
|
setNeedsRefresh(false);
|
|
}
|
|
}
|
|
setBlocked(false);
|
|
return true;
|
|
}
|
|
useEffect(() => {
|
|
alive.current = true;
|
|
return () => {
|
|
alive.current = false;
|
|
};
|
|
}, []);
|
|
useEffect(() => {
|
|
if (valid) read(props);
|
|
}, [incident, commands, actorId]);
|
|
function refresh() {
|
|
if (refreshing) return;
|
|
const epoch = scope.current;
|
|
setRefreshing(true);
|
|
router.reload({
|
|
only: ['incident', 'history', 'commands', 'actorId'],
|
|
onSuccess: (page) => {
|
|
if (
|
|
alive.current &&
|
|
scope.current === epoch &&
|
|
!read(page.props)
|
|
)
|
|
setError(
|
|
'Não foi possível confirmar esta consulta. A nota preparada foi conservada.',
|
|
);
|
|
},
|
|
onError: () => {
|
|
if (alive.current && scope.current === epoch)
|
|
setError(
|
|
'Não foi possível actualizar o incidente. A nota foi conservada.',
|
|
);
|
|
},
|
|
onFinish: () => {
|
|
if (alive.current && scope.current === epoch)
|
|
setRefreshing(false);
|
|
},
|
|
});
|
|
}
|
|
async function send(action: Command, retry = false) {
|
|
if (
|
|
!valid ||
|
|
blocked ||
|
|
refreshing ||
|
|
activeRequest.current ||
|
|
(!retry && (stale || needsRefresh || pending.current))
|
|
)
|
|
return;
|
|
let attempt = pending.current;
|
|
if (!attempt) {
|
|
if (
|
|
liveNote.current.trim().length < 10 ||
|
|
liveNote.current.length > 3000
|
|
) {
|
|
setError('Preencha uma nota com 10 a 3000 caracteres.');
|
|
return;
|
|
}
|
|
try {
|
|
attempt = {
|
|
values: {
|
|
version: base.version,
|
|
action,
|
|
note: liveNote.current,
|
|
operation_key: createOperationKey().toLowerCase(),
|
|
},
|
|
generation: generation.current,
|
|
};
|
|
} catch (e) {
|
|
setError(
|
|
e instanceof Error
|
|
? e.message
|
|
: 'Não foi possível preparar o registo.',
|
|
);
|
|
return;
|
|
}
|
|
pending.current = attempt;
|
|
}
|
|
const epoch = scope.current;
|
|
activeRequest.current = attempt;
|
|
setBusy(true);
|
|
setUncertain(true);
|
|
setError('');
|
|
try {
|
|
const result = await portalRequest<unknown>(
|
|
`/suporte-tecnico/incidentes/${incident.reference}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(attempt.values),
|
|
},
|
|
);
|
|
if (
|
|
!alive.current ||
|
|
scope.current !== epoch ||
|
|
pending.current !== attempt
|
|
)
|
|
return;
|
|
if (
|
|
!object(result) ||
|
|
result.reference !== incident.reference ||
|
|
result.actor_id !== actorId ||
|
|
result.operation_key !== attempt.values.operation_key ||
|
|
result.result_version !== attempt.values.version + 1 ||
|
|
!validState(result.handling) ||
|
|
result.handling.version < result.result_version
|
|
)
|
|
throw new Error(
|
|
'A confirmação recebida não corresponde a esta tentativa. Actualize o incidente.',
|
|
);
|
|
acknowledge(attempt, result.result_version);
|
|
refresh();
|
|
} catch (e) {
|
|
if (
|
|
!alive.current ||
|
|
scope.current !== epoch ||
|
|
pending.current !== attempt
|
|
)
|
|
return;
|
|
if (e instanceof PortalApiError && e.status === 422) {
|
|
pending.current = null;
|
|
setUncertain(false);
|
|
setError(
|
|
e.errors.note?.join(' ') ||
|
|
'Este tratamento não pode ser aplicado ao estado actual. Actualize os dados e reveja a nota.',
|
|
);
|
|
} else if (
|
|
e instanceof PortalApiError &&
|
|
(e.status === 403 || e.status === 401 || e.status === 419)
|
|
) {
|
|
setBlocked(true);
|
|
setError(
|
|
'O acesso ou a sessão precisam de confirmação. A nota e a tentativa original foram conservadas.',
|
|
);
|
|
} else
|
|
setError(
|
|
e instanceof PortalApiError && e.status === 409
|
|
? 'O incidente mudou. Actualize e compare o estado antes de preparar outra tentativa.'
|
|
: e instanceof Error
|
|
? e.message
|
|
: 'Resultado sem confirmação. Conserve a tentativa original.',
|
|
);
|
|
} finally {
|
|
if (
|
|
alive.current &&
|
|
scope.current === epoch &&
|
|
activeRequest.current === attempt
|
|
) {
|
|
activeRequest.current = null;
|
|
setBusy(false);
|
|
}
|
|
}
|
|
}
|
|
function adopt() {
|
|
if (!valid || !stale || busy || refreshing || needsRefresh || blocked)
|
|
return;
|
|
const attempt = pending.current;
|
|
if (attempt) {
|
|
if (
|
|
incident.handling.version <= attempt.values.version ||
|
|
commands.some((c) => c.key === attempt.values.operation_key)
|
|
)
|
|
return;
|
|
pending.current = null;
|
|
setUncertain(false);
|
|
}
|
|
setBase({ ...incident.handling });
|
|
setError('');
|
|
setNotice(
|
|
'Estado actual confirmado. Reveja a nota conservada antes de a enviar.',
|
|
);
|
|
}
|
|
const guard = useRef(false);
|
|
guard.current = !!note || uncertain;
|
|
useEffect(() => {
|
|
const remove = router.on('before', (event) => {
|
|
if (
|
|
guard.current &&
|
|
event.detail.visit.method === 'get' &&
|
|
!event.detail.visit.preserveState &&
|
|
!window.confirm(
|
|
'Existe uma nota 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 {
|
|
note,
|
|
change,
|
|
base,
|
|
stale,
|
|
busy,
|
|
uncertain,
|
|
refreshing,
|
|
notice,
|
|
error,
|
|
refresh,
|
|
send,
|
|
adopt,
|
|
blocked:
|
|
!valid ||
|
|
blocked ||
|
|
busy ||
|
|
refreshing ||
|
|
needsRefresh ||
|
|
stale ||
|
|
uncertain,
|
|
canRetry:
|
|
valid && !blocked && !busy && !refreshing && !!pending.current,
|
|
canAdopt:
|
|
valid &&
|
|
!blocked &&
|
|
!busy &&
|
|
!refreshing &&
|
|
!needsRefresh &&
|
|
stale &&
|
|
(!pending.current ||
|
|
incident.handling.version > pending.current.values.version),
|
|
pendingAction: pending.current?.values.action,
|
|
};
|
|
}
|