434 lines
15 KiB
TypeScript
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,
|
|
};
|
|
}
|