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; }; 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; operation_key: string; }; const values = (record: ProfileRecord, fields: string[]) => Object.fromEntries( [...fields, 'country'].map((key) => [key, record.payload[key] ?? '']), ); const same = (left: Record, right: Record) => 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(null), [busy, setBusy] = useState(false), [reading, setReading] = useState(false), [uncertain, setUncertain] = useState(false), [blocked, setBlocked] = useState(false), [errors, setErrors] = useState>({}), [error, setError] = useState(''), [notice, setNotice] = useState(''), [externalBusy, setExternalBusy] = useState(false), [permission, setPermission] = useState<{ editable: boolean; propVersion: number; propCanEdit: boolean; } | null>(null); const pending = useRef(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, }; }