Files
Investidor-V2/resources/js/lib/investment-profile-editor.ts
T

434 lines
15 KiB
TypeScript

import { router } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import { createOperationKey } from './operation-key';
import { portalRequest, PortalApiError } from './portal-api';
export type ProfileRecord = {
id?: string;
version: number;
payload: Record<string, string>;
};
export type OrganizationRecord = {
id: string;
name: string;
version: number;
country: string | null;
canEdit: boolean;
provenance: { reference: string; kind: string } | null;
};
type Attempt = {
version: number;
payload: Record<string, string>;
operation_key: string;
};
const values = (record: ProfileRecord, fields: string[]) =>
Object.fromEntries(
[...fields, 'country'].map((key) => [key, record.payload[key] ?? '']),
);
const same = (left: Record<string, string>, right: Record<string, string>) =>
JSON.stringify(left) === JSON.stringify(right);
export function useInvestmentProfileEditor(
record: ProfileRecord,
fields: string[],
company: boolean,
actorId: number,
editorId: string,
reportDirty: (id: string, dirty: boolean) => void,
organization?: { id: string; canEdit: boolean },
) {
const [base, setBase] = useState(() => ({
...record,
payload: values(record, fields),
})),
[draft, setDraft] = useState(() => values(record, fields)),
[remote, setRemote] = useState<ProfileRecord | null>(null),
[busy, setBusy] = useState(false),
[reading, setReading] = useState(false),
[uncertain, setUncertain] = useState(false),
[blocked, setBlocked] = useState(false),
[errors, setErrors] = useState<Record<string, string>>({}),
[error, setError] = useState(''),
[notice, setNotice] = useState(''),
[externalBusy, setExternalBusy] = useState(false),
[permission, setPermission] = useState<{
editable: boolean;
propVersion: number;
propCanEdit: boolean;
} | null>(null);
const pending = useRef<Attempt | null>(null),
externalLock = useRef(false),
readInFlight = useRef(false),
active = useRef(false),
alive = useRef(true),
origin = useRef({ actorId, id: record.id }),
current = useRef({ actorId, id: record.id });
current.current = { actorId, id: record.id };
const valid = () =>
alive.current &&
Number.isSafeInteger(actorId) &&
actorId > 0 &&
origin.current.actorId === current.current.actorId &&
origin.current.id === current.current.id;
const dirty =
!same(draft, base.payload) || busy || uncertain || externalBusy;
const stale = (!company || !!record.id) && record.version > base.version;
const mayEdit =
!organization ||
(permission &&
permission.propVersion === record.version &&
permission.propCanEdit === organization.canEdit
? permission.editable
: organization.canEdit);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
useEffect(() => {
reportDirty(editorId, dirty);
}, [dirty, editorId, reportDirty]);
useEffect(
() => () => reportDirty(editorId, false),
[editorId, reportDirty],
);
function change(key: string, value: string) {
if (
active.current ||
externalLock.current ||
uncertain ||
blocked ||
!mayEdit ||
!valid()
)
return;
setDraft((prior) => ({ ...prior, [key]: value }));
setErrors((prior) => {
const next = { ...prior };
delete next['payload.' + key];
delete next[key];
return next;
});
setNotice('');
}
async function save() {
if (
active.current ||
externalLock.current ||
readInFlight.current ||
reading ||
blocked ||
!mayEdit ||
!valid() ||
(stale && !pending.current)
)
return;
try {
pending.current ??= {
version: base.version,
payload: { ...draft },
operation_key: createOperationKey(),
};
} catch (failure) {
setError(
failure instanceof Error
? failure.message
: 'Não foi possível preparar o pedido.',
);
return;
}
const attempt = pending.current;
active.current = true;
setBusy(true);
setError('');
setErrors({});
setNotice('');
try {
const result = await portalRequest<{
version: number;
id?: string;
country?: string;
}>(
organization
? '/api/v1/investment/organizations/' +
organization.id +
'/country'
: company
? '/api/v1/investment/companies' +
(record.id ? '/' + record.id : '')
: '/api/v1/investment/profile',
{
method: company ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
organization
? {
version: attempt.version,
operation_key: attempt.operation_key,
...attempt.payload,
}
: attempt,
),
},
);
if (!valid() || pending.current !== attempt) return;
if (
!Number.isSafeInteger(result.version) ||
result.version !== attempt.version + 1 ||
(organization && result.country !== attempt.payload.country) ||
(company &&
(typeof result.id !== 'string' ||
!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(
result.id,
) ||
(record.id && result.id !== record.id)))
)
throw new Error('Unconfirmed response');
pending.current = null;
setUncertain(false);
setRemote(null);
if (company && !record.id) {
const empty = values({ version: 0, payload: {} }, fields);
setDraft(empty);
setBase({ version: 0, payload: empty });
} else
setBase({
...record,
version: result.version,
payload: { ...attempt.payload },
});
setNotice('Gravação confirmada.');
router.reload({
only: organization
? ['organizations']
: ['account', 'companies'],
});
} catch (failure) {
if (!valid() || pending.current !== attempt) return;
if (failure instanceof PortalApiError && failure.status === 422) {
pending.current = null;
setUncertain(false);
setErrors(
Object.fromEntries(
Object.entries(failure.errors).map(
([key, messages]) => [
key,
messages[0] ?? 'Verifique este campo.',
],
),
),
);
setError('Verifique os campos assinalados.');
} else if (
failure instanceof PortalApiError &&
failure.status === 409
) {
pending.current = null;
setUncertain(false);
setBlocked(true);
setError(
'O registo mudou ou este pedido já tem outro conteúdo. Consulte e compare o registo guardado antes de voltar a gravar.',
);
} else if (
failure instanceof PortalApiError &&
[401, 403, 404, 419].includes(failure.status)
) {
setBlocked(true);
setUncertain(true);
setError(
'A sessão ou o acesso já não permite confirmar o pedido. A preparação permanece nesta página. Restabeleça o acesso e consulte o registo.',
);
} else {
setUncertain(true);
setError(
'Não foi possível confirmar a gravação. A preparação foi conservada. Repita o mesmo pedido para confirmar o resultado antes de editar.',
);
}
} finally {
if (valid()) {
active.current = false;
setBusy(false);
}
}
}
async function refresh() {
if (
active.current ||
externalLock.current ||
readInFlight.current ||
!valid()
)
return;
readInFlight.current = true;
setReading(true);
setError('');
try {
const response = await portalRequest<{
actorId: number;
account: ProfileRecord;
companies: ProfileRecord[];
organizations?: OrganizationRecord[];
}>('/api/v1/investment/profile');
if (!valid()) return;
if (
response.actorId !== actorId ||
!Array.isArray(response.companies)
)
throw new Error('Context changed');
const org = organization
? response.organizations?.find(
(item) => item.id === organization.id,
)
: undefined;
if (
organization &&
(!org ||
typeof org.canEdit !== 'boolean' ||
(org.country !== null && typeof org.country !== 'string') ||
(org.provenance !== null &&
typeof org.provenance?.reference !== 'string'))
) {
setBlocked(true);
throw new Error('Organisation unavailable');
}
if (org && organization)
setPermission({
editable: org.canEdit,
propVersion: record.version,
propCanEdit: organization.canEdit,
});
const next = org
? {
id: org.id,
version: org.version,
payload: {
country: org.country ?? '',
source_reference: org.provenance?.reference ?? '',
},
}
: company
? record.id
? response.companies.find((item) => item.id === record.id)
: { version: 0, payload: {} }
: response.account;
if (
!next ||
!Number.isSafeInteger(next.version) ||
next.version < 0 ||
!next.payload ||
typeof next.payload !== 'object' ||
Array.isArray(next.payload) ||
fields.some(
(key) =>
next.payload[key] != null &&
typeof next.payload[key] !== 'string',
)
)
throw new Error('Record unavailable');
setRemote({ ...next, payload: values(next, fields) });
setBlocked(pending.current === null);
setNotice(
uncertain
? 'Registo consultado. Confirme primeiro o pedido sem resposta; a preparação não foi alterada.'
: 'Registo consultado. Compare os valores antes de escolher como continuar.',
);
} catch {
if (valid())
setError(
'Não foi possível consultar o registo nesta sessão. A preparação foi conservada.',
);
} finally {
if (valid()) {
readInFlight.current = false;
setReading(false);
}
}
}
function adopt(keepDraft: boolean) {
if (
!remote ||
active.current ||
externalLock.current ||
readInFlight.current ||
busy ||
reading ||
uncertain ||
pending.current ||
!mayEdit ||
!valid()
)
return;
setBase(remote);
if (!keepDraft) setDraft({ ...remote.payload });
setRemote(null);
setBlocked(false);
setError('');
setErrors({});
setNotice(
keepDraft
? 'Versão actual adoptada; a sua preparação foi conservada. Reveja antes de guardar.'
: 'Valores guardados carregados.',
);
}
function beginCountryReuse(): boolean {
if (
!company ||
!record.id ||
dirty ||
blocked ||
stale ||
active.current ||
readInFlight.current ||
externalLock.current ||
pending.current ||
!valid()
)
return false;
externalLock.current = true;
setExternalBusy(true);
return true;
}
function endCountryReuse(result?: {
version: number;
country: string;
}): void {
if (!externalLock.current || !valid()) return;
if (result) {
const payload = {
...base.payload,
incorporation_country_code: result.country,
};
setBase({ ...base, version: result.version, payload });
setDraft(payload);
setRemote(null);
setErrors({});
setError('');
}
externalLock.current = false;
setExternalBusy(false);
}
return {
base,
draft,
busy,
reading,
uncertain,
blocked,
errors,
error,
notice,
stale,
remote,
dirty,
externalBusy,
beginCountryReuse,
endCountryReuse,
mayEdit,
change,
save,
refresh,
adopt,
};
}