import { router, useForm } from '@inertiajs/react'; import { useEffect, useRef, useState } from 'react'; import { createOperationKey } from './operation-key'; type Values = Record; type Criterion = { code: string; label: string; weight: number; mandatory: boolean; }; type Snapshot = { id: number | null; version: number | null; parentVersion: number; status: string; context: string; weightsHash: string; criteria: Criterion[]; owner: boolean; canSubmit: boolean; canDecide: boolean; canWithdraw: boolean; values: Values; commands: { key: string; version: number; action: string }[]; }; function object(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } function dictionary(value: unknown): Record | null { return object(value) ? value : Array.isArray(value) && value.length === 0 ? {} : null; } function positive(value: unknown): value is number { return ( typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ); } function text(value: unknown): string | null { return value == null ? '' : typeof value === 'string' ? value : null; } export function criterionValue(value: string | undefined): { state: string; evidence: string; } { try { const item: unknown = JSON.parse(value ?? ''); if ( object(item) && ['unknown', 'met', 'unmet'].includes(String(item.state)) && typeof item.evidence === 'string' ) return { state: String(item.state), evidence: item.evidence }; } catch { /* An invalid preparation is not an assessment. */ } return { state: 'unknown', evidence: '' }; } const pack = (state: string, evidence: string) => JSON.stringify({ state, evidence }); function read( props: unknown, recordId: number, target: number | null, mode: 'submission' | 'decision', actorId: number | null, key?: string, action?: string, ): Snapshot | null { if ( !object(props) || !object(props.record) || props.record.id !== recordId || !positive(props.record.version) || typeof props.record.status !== 'string' || typeof props.owner !== 'boolean' || typeof props.canEdit !== 'boolean' || typeof props.acceptingInterests !== 'boolean' || !Array.isArray(props.interests) ) return null; const record = props.record; const item = key ? props.interests.find( (item) => object(item) && Array.isArray(item.commands) && item.commands.some( (command) => object(command) && command.key === key && command.action === action, ), ) : target === null ? null : props.interests.find((item) => object(item) && item.id === target); if ((target !== null && !object(item)) || (key && !object(item))) return null; const entry = object(item) ? item : null; if ( entry && (!positive(entry.id) || !positive(entry.version) || typeof entry.status !== 'string' || typeof entry.can_edit !== 'boolean' || !dictionary(entry.payload) || !dictionary(entry.assessment) || !object(entry.match) || !Array.isArray(entry.match.criteria) || typeof entry.match.weights_hash !== 'string' || !/^[a-f0-9]{64}$/.test(entry.match.weights_hash)) ) return null; const match = entry && object(entry.match) ? entry.match : null; const criteria: Criterion[] = []; for (const candidate of match && Array.isArray(match.criteria) ? match.criteria : []) { if ( !object(candidate) || typeof candidate.code !== 'string' || typeof candidate.label !== 'string' || typeof candidate.weight !== 'number' || typeof candidate.mandatory !== 'boolean' ) return null; criteria.push({ code: candidate.code, label: candidate.label, weight: candidate.weight, mandatory: candidate.mandatory, }); } const values: Values = {}; if (mode === 'submission') { const payload = entry ? (dictionary(entry.payload) ?? {}) : {}; for (const field of ['organization_id', 'proposal']) { const value = text(entry?.[field]); if (value === null) return null; values[field] = value; } for (const field of [ 'capacity', 'availability', 'contribution', 'conflicts', ]) { const value = text(payload[field]); if (value === null) return null; values[field] = value; } values.consent = [true, 1, '1', 'yes', 'on'].includes( payload.consent as string | number | boolean, ) ? 'yes' : 'no'; } else { values.reason = ''; const assessment = entry ? (dictionary(entry.assessment) ?? {}) : {}; for (const criterion of props.owner ? criteria : []) { const data = assessment[criterion.code]; const assessed = object(data) ? data : {}; const state = assessed.state ?? 'unknown', evidence = text(assessed.evidence); if ( typeof state !== 'string' || !['met', 'unmet', 'unknown'].includes(state) || evidence === null ) return null; values['criterion:' + criterion.code] = pack(state, evidence); } } const commands: Snapshot['commands'] = []; for (const candidate of entry && Array.isArray(entry.commands) ? entry.commands : []) if ( object(candidate) && typeof candidate.key === 'string' && positive(candidate.version) && typeof candidate.action === 'string' ) commands.push({ key: candidate.key, version: candidate.version, action: candidate.action, }); const id = entry && positive(entry.id) ? entry.id : null, version = entry && positive(entry.version) ? entry.version : null, status = entry && typeof entry.status === 'string' ? entry.status : 'new'; const effectiveRepresentation = entry ? entry.can_edit === true : !!actorId && !props.owner; const context = mode === 'submission' ? [ record.version, record.status, record.deadline, props.owner, effectiveRepresentation, props.acceptingInterests, ] : props.owner ? [ record.version, record.status, record.deadline, props.owner, props.canEdit, match?.weights_hash, criteria, ] : [recordId, props.owner, effectiveRepresentation]; return { id, version, parentVersion: record.version as number, status, context: JSON.stringify(context), weightsHash: typeof match?.weights_hash === 'string' ? match.weights_hash : '', criteria, owner: props.owner, canSubmit: !!actorId && !props.owner && props.acceptingInterests && (entry ? entry.can_edit === true && ['submitted', 'clarification', 'withdrawn'].includes(status) : props.canStartInterest === true), canDecide: props.owner && props.canEdit && !!entry, canWithdraw: !!entry && entry.can_edit === true, values, commands, }; } const same = (a: Values, b: Values) => [...new Set([...Object.keys(a), ...Object.keys(b)])].every( (key) => a[key] === b[key], ); function normal(values: Values): Values { return Object.fromEntries( Object.entries(values).map(([key, value]) => [ key, key.startsWith('criterion:') ? pack( criterionValue(value).state, criterionValue(value).evidence.trim(), ) : value.trim(), ]), ); } type Pending = { command: Record; values: Values; generations: Record; action: string; base: Snapshot; ambiguous: boolean; }; export function useCandidacyEdit({ props, recordId, interestId = null, mode, actorId, }: { props: unknown; recordId: number; interestId?: number | null; mode: 'submission' | 'decision'; actorId: number | null; }) { const [resolved, setResolved] = useState(interestId); const target = interestId ?? resolved; const current = read(props, recordId, target, mode, actorId); const initial = useRef(current); const criterionLabels = useRef( new Map( (current?.criteria ?? []).map((criterion) => [ criterion.code, criterion.label, ]), ), ); for (const criterion of current?.criteria ?? []) criterionLabels.current.set(criterion.code, criterion.label); const identity = JSON.stringify([recordId, interestId, mode, actorId]); const opened = useRef(identity); const observed = useRef(identity); observed.current = identity; const empty: Values = mode === 'submission' ? { organization_id: '', proposal: '', capacity: '', availability: '', contribution: '', conflicts: '', consent: 'no', } : { reason: '' }; const form = useForm<{ values: Values }>({ values: current?.values ?? empty, }); const live = useRef(form.data.values); live.current = form.data.values; const edited = useRef(new Set()), generations = useRef>({}); const pending = useRef(null); const [choices, setChoices] = useState>( {}, ); const [refreshing, setRefreshing] = useState(false), [needsCompare, setNeedsCompare] = useState(false), [problem, setProblem] = useState(null); const base = initial.current; const keys = [ ...new Set([ ...Object.keys(base?.values ?? empty), ...Object.keys(current?.values ?? {}), ]), ]; const invalid = !current || !base || opened.current !== observed.current || current.id !== base.id; const stale = !!current && !!base && (current.version !== base.version || current.context !== base.context || current.status !== base.status || !same(current.values, base.values)); const changed = current && base ? keys.filter((key) => current.values[key] !== base.values[key]) : []; const conflicts = current ? changed.filter( (key) => Object.hasOwn(current.values, key) && edited.current.has(key) && live.current[key] !== current.values[key], ) : []; const choiceKey = (key: string) => JSON.stringify([ current?.id, current?.version, current?.context, key, current?.values[key], ]); const unresolved = conflicts.filter( (key) => choices[choiceKey(key)] !== 'mine', ); const active = !!current && ['submitted', 'clarification', 'shortlisted'].includes(current.status); const editable = !!current && (mode === 'submission' ? current.canSubmit : active && (current.owner ? current.canDecide : current.canWithdraw)); const canRecover = (action: string) => !!current && (action === 'submit' ? current.id === null ? !!actorId && !current.owner : current.canWithdraw : action === 'withdraw' ? current.canWithdraw : current.canDecide); const recovering = !!pending.current?.ambiguous; const blocked = invalid || refreshing || form.processing || (recovering && pending.current ? !canRecover(pending.current.action) : stale || needsCompare || !editable); const dirty = !!pending.current || (!!base && (!same(live.current, base.values) || keys.some( (key) => edited.current.has(key) && current && live.current[key] !== current.values[key], ))); const retired = Object.keys(live.current).filter( (key) => key.startsWith('criterion:') && !current?.criteria.some( (criterion) => 'criterion:' + criterion.code === key, ), ); useEffect(() => { if ( !current || invalid || !stale || dirty || edited.current.size > 0 || pending.current || form.processing || needsCompare ) return; initial.current = current; live.current = { ...current.values }; form.setDefaults({ values: live.current }); form.setData({ values: live.current }); }, [current, invalid, stale, dirty, form.processing, needsCompare]); function change(key: string, value: string) { if (!keys.includes(key)) return; edited.current.add(key); generations.current[key] = (generations.current[key] ?? 0) + 1; live.current = { ...live.current, [key]: value }; form.setData({ values: live.current }); setChoices((previous) => { const next = { ...previous }; delete next[choiceKey(key)]; return next; }); } function changeCriterion( code: string, field: 'state' | 'evidence', value: string, ) { const key = 'criterion:' + code, data = criterionValue(live.current[key]); change(key, JSON.stringify({ ...data, [field]: value })); } function discardRetired() { if (form.processing || pending.current) return; const values = { ...live.current }; for (const key of retired) { delete values[key]; edited.current.delete(key); } live.current = values; form.setData({ values }); } function choose(key: string, value: 'mine' | 'current') { if (!current) return; if (value === 'current') change(key, current.values[key] ?? ''); setChoices((previous) => ({ ...previous, [choiceKey(key)]: value })); } function adopt() { if ( !current || invalid || !stale || unresolved.length || refreshing || form.processing ) return; const values = { ...current.values }; for (const key of Object.keys(live.current)) if (edited.current.has(key)) values[key] = live.current[key]; initial.current = current; live.current = values; pending.current = null; form.setDefaults({ values: current.values }); form.setData({ values }); form.clearErrors(); setChoices({}); setNeedsCompare(false); setProblem(null); } function refresh() { if (refreshing || form.processing) return; setRefreshing(true); router.reload({ only: [ 'record', 'interests', 'owner', 'canEdit', 'moderator', 'publicationActions', 'publicationCommands', 'historySummary', 'acceptingInterests', 'organizations', ], onSuccess: (page) => { const fresh = read(page.props, recordId, target, mode, actorId); if ( fresh && initial.current && fresh.id === initial.current.id && fresh.version === initial.current.version && fresh.context === initial.current.context && fresh.status === initial.current.status && same(fresh.values, initial.current.values) ) { setNeedsCompare(false); setProblem(null); } }, onHttpException: () => { setProblem( 'Não foi possível recuperar esta candidatura com o acesso actual. A preparação foi conservada.', ); return false; }, onNetworkError: () => { setProblem( 'A recuperação não foi confirmada. A preparação foi conservada.', ); return false; }, onFinish: () => setRefreshing(false), }); } function submit( requestedAction = mode === 'submission' ? 'submit' : 'assess', ) { if (blocked || !current || !base) return; const action = pending.current?.action ?? requestedAction; if (!recovering) { if ( mode === 'decision' && (action === 'withdraw' ? !current.canWithdraw : !current.canDecide) ) return; if (action === 'connect' && current.status !== 'shortlisted') return; } let operation = pending.current; if (!operation) { const values = { ...live.current }; const command: Record = mode === 'submission' ? { interest_id: target, version: current.version, partnership_version: current.parentVersion, organization_id: values.organization_id, proposal: values.proposal, payload: { capacity: values.capacity, availability: values.availability, contribution: values.contribution, conflicts: values.conflicts, consent: values.consent === 'yes', }, } : { version: current.version, action, reason: values.reason, assessment: action === 'assess' ? Object.fromEntries( current.criteria.map((criterion) => [ criterion.code, criterionValue( values[ 'criterion:' + criterion.code ], ), ]), ) : {}, ...(action === 'withdraw' ? {} : { partnership_version: current.parentVersion, weights_hash: current.weightsHash, }), }; command.client_key = createOperationKey(); operation = { command, values, generations: { ...generations.current }, base: current, action, ambiguous: false, }; pending.current = operation; } const sent = operation; setProblem(null); form.transform(() => sent.command); const unknown = () => { if (pending.current) pending.current.ambiguous = true; setProblem( 'A operação não foi confirmada. Recuperar repete o pedido original; as alterações posteriores foram conservadas.', ); return false; }; form.post( mode === 'submission' ? `/parcerias/${recordId}/interesses` : `/parcerias/candidaturas/${target}`, { preserveScroll: true, onSuccess: (page) => { if (observed.current !== opened.current) return; const saved = read( page.props, recordId, target, mode, actorId, String(sent.command.client_key), sent.action, ); const acknowledgement = saved?.commands.find( (command) => command.key === sent.command.client_key && command.action === sent.action, ); if (!saved || saved.id === null || !acknowledgement) { unknown(); return; } const acknowledgedKeys = mode === 'decision' && sent.action === 'assess' ? [ 'reason', ...sent.base.criteria.map( (criterion) => 'criterion:' + criterion.code, ), ] : null; const submittedValues = acknowledgedKeys ? Object.fromEntries( acknowledgedKeys.map((key) => [ key, sent.values[key], ]), ) : mode === 'submission' ? sent.values : sent.base.values; const expected = normal(submittedValues); if (mode === 'decision') expected.reason = ''; const actualExpectedVersion = sent.base.version === null ? 1 : sent.base.version + 1; if ( acknowledgement.version !== actualExpectedVersion || (target !== null && saved.id !== target) ) { unknown(); return; } const exact = saved.version === acknowledgement.version && saved.context === sent.base.context && same(saved.values, expected); const merged = { ...expected }; const oldEdited = new Set(edited.current); edited.current.clear(); for (const key of Object.keys(live.current)) { const sentCriterion = !key.startsWith('criterion:') || sent.base.criteria.some( (criterion) => 'criterion:' + criterion.code === key, ); if ( (generations.current[key] ?? 0) !== (sent.generations[key] ?? 0) || (mode === 'decision' && (sent.action !== 'assess' || !sentCriterion) && key !== 'reason' && oldEdited.has(key)) ) { merged[key] = live.current[key]; edited.current.add(key); } } initial.current = exact ? saved : { ...saved, version: acknowledgement.version, context: sent.base.context, parentVersion: sent.base.parentVersion, values: expected, }; if (target === null) setResolved(saved.id); pending.current = null; live.current = merged; form.setDefaults({ values: expected }); form.setData({ values: merged }); form.clearErrors(); setChoices({}); setNeedsCompare(!exact); setProblem( exact ? null : 'A operação original foi identificada, mas a candidatura ou o contexto tem dados posteriores. Compare antes de continuar; a preparação foi conservada.', ); }, onError: () => { if (!pending.current?.ambiguous) pending.current = null; }, onHttpException: (response) => { if ( response.status >= 500 || response.status === 409 || pending.current?.ambiguous ) { if (response.status === 409) setNeedsCompare(true); return unknown(); } pending.current = null; setProblem( 'Não foi possível confirmar esta operação com o acesso actual. A preparação foi conservada.', ); return false; }, onNetworkError: unknown, onCancel: unknown, }, ); } return { form, values: form.data.values, current: current?.values ?? empty, currentSnapshot: current, criteria: current?.criteria ?? base?.criteria ?? [], criterionLabels: Object.fromEntries(criterionLabels.current), retired, activeId: target, editable, ownerMode: current?.owner ?? base?.owner ?? false, status: current?.status, version: current?.version, baseVersion: base?.version, stale, invalid, changed, conflicts, unresolved, refreshing, blocked, dirty, problem, recovering, pendingAction: pending.current?.action, change, changeCriterion, discardRetired, choose, adopt, refresh, submit, }; } export type CandidacyEditor = ReturnType; export function useCandidacyLeaveGuard(dirty: boolean) { const current = useRef(dirty); current.current = dirty; useEffect(() => { const leave = (event: BeforeUnloadEvent) => { if (current.current) { event.preventDefault(); event.returnValue = ''; } }; const stop = router.on('before', (event) => { if ( current.current && event.detail.visit.method === 'get' && event.detail.visit.preserveState !== true && !window.confirm( 'Há preparação ou acções por confirmar nesta parceria. Pretende sair sem as conservar?', ) ) event.preventDefault(); }); window.addEventListener('beforeunload', leave); return () => { stop(); window.removeEventListener('beforeunload', leave); }; }, []); }