import { router, useForm } from '@inertiajs/react'; import { useEffect, useRef, useState } from 'react'; import { createOperationKey } from './operation-key'; type Values = Record; type Kind = 'agreement' | 'milestone'; type Snapshot = { actorId: number | null; partnershipVersion: number | null; interestVersion: number | null; commands: { key: string; action: string; version: number; status: string; party: string | null; }[]; confirmations: Record; id: number | null; version: number | null; context: string; roomVersion: number; status: string; canWrite: boolean; values: Values; }; function object(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } 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; } const milestoneKeys = [ 'title', 'responsible_id', 'due_date', 'status', 'evidence', ]; function snapshot( props: unknown, roomId: number, kind: Kind, itemId: number | null, creationKey?: string, ): Snapshot | null { if ( !object(props) || !object(props.room) || props.room.id !== roomId || !positive(props.room.version) || typeof props.room.status !== 'string' || typeof props.canWrite !== 'boolean' ) return null; const room = props.room; const actorId = positive(props.agreementActorId) ? props.agreementActorId : null; const partnershipVersion = object(props.partnership) && positive(props.partnership.version) ? props.partnership.version : null; const interestVersion = object(props.interest) && positive(props.interest.version) ? props.interest.version : null; if ( kind === 'agreement' && (!actorId || !partnershipVersion || !interestVersion || !Array.isArray(props.agreementCommands) || !props.agreementCommands.every( (command) => object(command) && typeof command.key === 'string' && typeof command.action === 'string' && positive(command.version) && typeof command.status === 'string' && (command.party === null || command.party === 'proponent' || command.party === 'candidate'), )) ) return null; let values: Record, id: number | null = roomId, version: number | null = room.version as number; const confirmations: Snapshot['confirmations'] = {}; if (kind === 'agreement') { if (room.agreement !== null && !object(room.agreement)) return null; const agreement = object(room.agreement) ? room.agreement : {}; if (object(agreement.confirmations)) for (const party of ['proponent', 'candidate']) { const confirmation = agreement.confirmations[party]; if (object(confirmation) && positive(confirmation.actor_id)) confirmations[party] = { actorId: confirmation.actor_id, reason: typeof confirmation.reason === 'string' ? confirmation.reason : '', }; } values = { title: text(agreement.title), terms: text(agreement.terms), reason: '', }; } else if (itemId === null && !creationKey) { id = null; version = null; values = { title: '', responsible_id: '', due_date: '', status: 'pending', evidence: '', }; } else { if (!Array.isArray(props.milestones)) return null; const item = props.milestones.find( (candidate) => object(candidate) && (creationKey ? candidate.client_key === creationKey : candidate.id === itemId), ); if ( !object(item) || !positive(item.id) || !positive(item.version) || !positive(item.responsible_id) ) return null; id = item.id; version = item.version; values = { title: text(item.title), responsible_id: String(item.responsible_id), due_date: text(item.due_date), status: text(item.status), evidence: text(item.evidence), }; } if (!Object.values(values).every((value) => typeof value === 'string')) return null; return { actorId, partnershipVersion, interestVersion, confirmations, commands: kind === 'agreement' && Array.isArray(props.agreementCommands) ? props.agreementCommands.map((command) => ({ key: command.key, action: command.action, version: command.version, status: command.status, party: command.party, })) : [], id, version, context: JSON.stringify( kind === 'agreement' ? [ room.version, room.status, props.canWrite, partnershipVersion, interestVersion, ] : [room.version, room.status, props.canWrite], ), roomVersion: room.version as number, status: room.status as string, canWrite: props.canWrite, values: values as Values, }; } const same = (a: Values, b: Values) => Object.keys(a).every((key) => a[key] === b[key]); const normal = (value: string) => value.trim(); export function useRoomEdit({ props, roomId, kind, itemId = null, }: { props: unknown; roomId: number; kind: Kind; itemId?: number | null; }) { const alive = useRef(true); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); const current = snapshot(props, roomId, kind, itemId); const initial = useRef(current); const initialIdentity = useRef(JSON.stringify([roomId, kind, itemId])); const observedIdentity = useRef(initialIdentity.current); observedIdentity.current = JSON.stringify([roomId, kind, itemId]); const empty: Values = kind === 'agreement' ? { title: '', terms: '', reason: '' } : { title: '', responsible_id: '', due_date: '', status: 'pending', evidence: '', }; const form = useForm<{ version: number | null; client_key: string; values: Values; }>({ version: current?.version ?? null, client_key: createOperationKey(), values: current?.values ?? empty, }); const live = useRef(form.data); live.current = form.data; const edited = useRef(new Set()); const generation = useRef>({}); const pending = useRef<{ command: Record; generations: Record; ambiguous: boolean; base: Snapshot; values: Values; } | null>(null); const [choices, setChoices] = useState>( {}, ); const [refreshing, setRefreshing] = useState(false); const [needsCompare, setNeedsCompare] = useState(false); const [problem, setProblem] = useState(null); const [mustRefresh, setMustRefresh] = useState(false), [accessDenied, setAccessDenied] = useState(false); const [created, setCreated] = useState(null); const isNew = kind === 'milestone' && itemId === null; const keys = Object.keys(current?.values ?? empty); const invalid = !current || !initial.current || (kind === 'agreement' && current.actorId !== initial.current.actorId) || initialIdentity.current !== JSON.stringify([roomId, kind, itemId]); const stale = !!current && !!initial.current && (current.version !== initial.current.version || current.context !== initial.current.context || !same(current.values, initial.current.values)); const changed = current && initial.current ? keys.filter( (key) => current.values[key] !== initial.current?.values[key], ) : []; const conflicts = current ? changed.filter( (key) => edited.current.has(key) && live.current.values[key] !== current.values[key], ) : []; const choiceKey = (key: string) => JSON.stringify([ current?.version, current?.context, key, current?.values[key], ]); const unresolved = conflicts.filter( (key) => choices[choiceKey(key)] !== 'mine', ); const editable = !!current?.canWrite && (kind === 'agreement' ? ['negotiation', 'agreement_recorded', 'agreed', 'execution'] : ['agreement_recorded', 'agreed', 'execution'] ).includes(current.status); const recovering = (isNew || kind === 'agreement') && !!pending.current?.ambiguous && !!current?.canWrite && !accessDenied; const blocked = invalid || stale || refreshing || needsCompare || (kind === 'agreement' && (accessDenied || !!pending.current)) || (!editable && !recovering); const dirty = !!pending.current || (!!initial.current && (!same(live.current.values, initial.current.values) || keys.some( (key) => edited.current.has(key) && current && live.current.values[key] !== current.values[key], ))); // Untouched controls follow current data. Deliberate edits and uncertain commands require comparison. useEffect(() => { if ( !current || invalid || (!stale && !(kind === 'agreement' && pending.current?.ambiguous)) || dirty || edited.current.size > 0 || pending.current || form.processing || needsCompare ) return; if (kind === 'agreement' && mustRefresh) return; initial.current = current; live.current = { ...live.current, version: current.version, values: { ...current.values }, }; form.setDefaults(live.current); form.setData(live.current); }, [current, invalid, stale, dirty, form.processing, needsCompare]); function change(key: string, value: string) { if (!keys.includes(key)) return; edited.current.add(key); generation.current[key] = (generation.current[key] ?? 0) + 1; live.current = { ...live.current, values: { ...live.current.values, [key]: value }, }; form.setData(live.current); setChoices((previous) => { const next = { ...previous }; delete next[choiceKey(key)]; return next; }); } function choose(key: string, value: 'mine' | 'current') { if (!current || !keys.includes(key)) return; if (value === 'current') change(key, current.values[key]); setChoices((previous) => ({ ...previous, [choiceKey(key)]: value })); } function adopt() { if ( !current || invalid || (!stale && !(kind === 'agreement' && pending.current?.ambiguous)) || (kind === 'agreement' && (mustRefresh || accessDenied)) || unresolved.length || refreshing || form.processing ) return; const values = { ...current.values }; for (const key of keys) if (edited.current.has(key)) values[key] = live.current.values[key]; initial.current = current; live.current = { ...live.current, version: current.version, values }; form.setData(live.current); form.setDefaults({ ...live.current, values: current.values }); form.clearErrors(); setChoices({}); setNeedsCompare(false); setProblem(null); if (kind === 'agreement') pending.current = null; } function httpFailure(response: { status: number }) { if (!alive.current) return false; if (kind === 'agreement') { setMustRefresh(true); setAccessDenied([401, 403, 404, 419].includes(response.status)); } setProblem( response.status === 409 ? 'O registo ou o estado da sala mudou. Compare com os dados actuais; as suas edições foram conservadas.' : 'Não foi possível confirmar a operação com o acesso actual. As suas edições foram conservadas.', ); if (response.status === 409 || response.status >= 500) setNeedsCompare(true); if (pending.current) { if (kind === 'agreement' && response.status === 409) { pending.current = null; return false; } if ( response.status >= 500 || response.status === 409 || pending.current.ambiguous ) pending.current.ambiguous = true; else pending.current = null; } return false; } function refresh() { if (refreshing || form.processing) return; setRefreshing(true); router.reload({ only: [ 'room', 'milestones', 'participants', 'canWrite', 'messages', 'documents', 'partnership', 'interest', 'agreementActorId', 'agreementCommands', 'agreementParty', ], onSuccess: (page) => { if (!alive.current) return; if ( kind === 'agreement' && pending.current && acknowledgeAgreement(page.props, pending.current) ) return; const fresh = snapshot(page.props, roomId, kind, itemId); if ( kind === 'agreement' && fresh && fresh.actorId === initial.current?.actorId ) { setMustRefresh(false); setAccessDenied(false); } if ( fresh && initial.current && fresh.version === initial.current.version && fresh.context === initial.current.context && same(fresh.values, initial.current.values) ) { setNeedsCompare(false); setProblem(null); } }, onHttpException: () => { if (!alive.current) return false; if (kind === 'agreement') { setMustRefresh(true); setAccessDenied(true); } setProblem( 'Não foi possível recuperar esta sala com o acesso actual. As suas edições foram conservadas.', ); return false; }, onNetworkError: () => { if (!alive.current) return false; setProblem( 'A recuperação não foi confirmada. As suas edições foram conservadas.', ); return false; }, onFinish: () => { if (alive.current) setRefreshing(false); }, }); } function acknowledgeAgreement( props: unknown, sent: NonNullable, ): boolean { if (!alive.current || pending.current !== sent) return false; const fresh = snapshot(props, roomId, 'agreement', null), action = String(sent.command.action), version = Number(sent.command.version) + 1; const receipt = fresh?.commands.find( (command) => command.key === sent.command.client_key && command.action === action && command.version === version, ); if ( !fresh || !receipt || fresh.actorId !== sent.base.actorId || fresh.version === null || fresh.version < version ) return false; const expected: Values = action === 'record' ? { title: normal(sent.values.title), terms: normal(sent.values.terms), reason: '', } : { ...sent.base.values, reason: '' }; if ( fresh.version === version && (fresh.status !== receipt.status || fresh.values.title !== expected.title || fresh.values.terms !== expected.terms) ) return false; if ( fresh.version === version && action === 'confirm' && (!receipt.party || fresh.confirmations[receipt.party]?.actorId !== sent.base.actorId || fresh.confirmations[receipt.party]?.reason !== normal(String(sent.command.reason)) || (receipt.status === 'agreed' && (!fresh.confirmations.proponent || !fresh.confirmations.candidate))) ) return false; const later = fresh.version > version || fresh.partnershipVersion !== sent.base.partnershipVersion || fresh.interestVersion !== sent.base.interestVersion; const saved: Snapshot = later ? { ...sent.base, version, roomVersion: version, status: receipt.status, values: expected, context: JSON.stringify([ version, receipt.status, sent.base.canWrite, sent.base.partnershipVersion, sent.base.interestVersion, ]), } : fresh; const merged = { ...saved.values }; edited.current.clear(); for (const key of keys) if ( (generation.current[key] ?? 0) !== (sent.generations[key] ?? 0) || (action !== 'record' && key !== 'reason' && live.current.values[key] !== sent.base.values[key]) ) { merged[key] = live.current.values[key]; edited.current.add(key); } initial.current = saved; pending.current = null; live.current = { ...live.current, version: saved.version, values: merged, }; form.setDefaults({ ...live.current, values: saved.values }); form.setData(live.current); form.clearErrors(); setChoices({}); setNeedsCompare(later); setMustRefresh(false); setAccessDenied(false); setProblem( later ? 'A acção foi reconhecida, mas existem decisões posteriores. Compare antes de preparar outra acção.' : null, ); return true; } function submit(action = 'record', recover = false) { if ( !alive.current || form.processing || !initial.current || (kind === 'agreement' && (invalid || accessDenied || !current?.canWrite || refreshing)) ) return; if (recover && kind === 'agreement') { if (!pending.current?.ambiguous) return; action = String(pending.current.command.action); } else if (blocked) return; const base = initial.current; if (kind === 'agreement' && !recover) { const allowed: Record = { record: ['negotiation', 'agreement_recorded'], confirm: ['agreement_recorded'], start: ['agreed'], complete: ['execution'], renew: ['agreed', 'execution'], cancel: [ 'negotiation', 'agreement_recorded', 'agreed', 'execution', ], }; if (!allowed[action]?.includes(base.status)) return; } const command: Record = pending.current?.command ?? (kind === 'agreement' ? action === 'record' ? { ...live.current.values, version: live.current.version, action, client_key: createOperationKey(), partnership_version: base.partnershipVersion, interest_version: base.interestVersion, } : { version: live.current.version, action, reason: live.current.values.reason, client_key: createOperationKey(), partnership_version: base.partnershipVersion, interest_version: base.interestVersion, } : { ...live.current.values, version: live.current.version, ...(isNew ? { client_key: live.current.client_key } : {}), }); const sentGenerations = pending.current?.generations ?? { ...generation.current, }; if ((isNew || kind === 'agreement') && !pending.current) pending.current = { command, generations: sentGenerations, ambiguous: false, base, values: { ...live.current.values }, }; const sentValues = { ...live.current.values }; const attempt = pending.current; setProblem(null); form.transform(() => command); const unknown = () => { if (!alive.current) return false; if (kind === 'agreement' && pending.current !== attempt) return false; if (kind === 'agreement') setMustRefresh(true); if (pending.current) pending.current.ambiguous = true; else setNeedsCompare(true); setProblem( 'A gravação não foi confirmada. As suas edições foram conservadas.', ); return false; }; const options = { preserveScroll: true, onSuccess: (page: { props: Record }) => { if (!alive.current) return; if (kind === 'agreement') { if ( attempt && pending.current === attempt && !acknowledgeAgreement(page.props, attempt) ) { attempt.ambiguous = true; setMustRefresh(true); setNeedsCompare(true); setProblem( 'A resposta não reconheceu esta tentativa. A preparação foi conservada.', ); } return; } if ( observedIdentity.current !== JSON.stringify([roomId, kind, itemId]) ) return; const saved = snapshot( page.props, roomId, kind, itemId, isNew ? String(command.client_key) : undefined, ); if ( !saved || (!isNew && saved.version !== Number(command.version) + 1) ) { setProblem( 'A resposta não confirmou este registo. Recupere os dados antes de continuar; a edição foi conservada.', ); setNeedsCompare(true); if (pending.current) pending.current.ambiguous = true; return; } if (!isNew) { if ( milestoneKeys.some( (key) => saved.values[key] !== normal(sentValues[key]), ) || saved.context !== base.context ) { setProblem( 'Os dados devolvidos diferem da operação enviada. Compare antes de continuar; a sua edição foi conservada.', ); setNeedsCompare(true); return; } } const defaults = isNew ? snapshot(page.props, roomId, kind, null) : saved; if (!defaults) return; const merged = { ...defaults.values }; edited.current.clear(); for (const key of keys) if ( (generation.current[key] ?? 0) !== (sentGenerations[key] ?? 0) ) { merged[key] = live.current.values[key]; edited.current.add(key); } initial.current = defaults; if (isNew) { setCreated(saved.id); pending.current = null; } live.current = { version: defaults.version, client_key: isNew ? createOperationKey() : live.current.client_key, values: merged, }; form.setDefaults({ ...live.current, values: defaults.values }); form.setData(live.current); form.clearErrors(); setChoices({}); setNeedsCompare(false); setProblem(null); }, onError: () => { if ( !alive.current || (kind === 'agreement' && pending.current !== attempt) ) return; if (pending.current && !pending.current.ambiguous) pending.current = null; }, onHttpException: (response: { status: number }) => kind === 'agreement' && pending.current !== attempt ? false : httpFailure(response), onNetworkError: unknown, onCancel: unknown, }; if (kind === 'agreement') form.post(`/parcerias/salas/${roomId}/acordo`, options); else if (isNew) form.post(`/parcerias/salas/${roomId}/marcos`, options); else form.put(`/parcerias/salas/${roomId}/marcos/${itemId}`, options); } return { form, values: form.data.values, current: current?.values ?? empty, baseVersion: initial.current?.version, version: current?.version, status: current?.status, stale, invalid, editable, dirty, blocked, refreshing, changed, conflicts, unresolved, problem, recovering, pendingAction: kind === 'agreement' && pending.current?.ambiguous ? String(pending.current.command.action) : null, canRecover: kind === 'agreement' && recovering && !invalid && !refreshing && !form.processing, recover: () => submit('record', true), mustRefresh: kind === 'agreement' && mustRefresh, created, change, choose, adopt, refresh, submit, }; } export type RoomEditor = ReturnType; /** One room-level leave guard. No draft content is persisted in browser storage. */ export function useRoomLeaveGuard(dirty: boolean) { const live = useRef(dirty); live.current = dirty; useEffect(() => { const leave = (event: BeforeUnloadEvent) => { if (live.current) { event.preventDefault(); event.returnValue = ''; } }; const stop = router.on('before', (event) => { if ( live.current && event.detail.visit.method === 'get' && event.detail.visit.preserveState !== true && !window.confirm( 'Há alterações por guardar nesta sala. Pretende sair sem as guardar?', ) ) event.preventDefault(); }); window.addEventListener('beforeunload', leave); return () => { stop(); window.removeEventListener('beforeunload', leave); }; }, []); }