import { useCallback, useEffect, useRef, useState } from 'react'; import type { Organization } from 'fhir/r4'; import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'; import { useAuthServiceContext } from '../../../services/AuthService.tsx'; import { useOrganizationServiceContext } from '../../../services/OrganizationService.tsx'; import { useOrgAuthorizationServiceContext, OrgAuthorizationAgreement, } from '../../../services/OrgAuthorizationService.tsx'; import { CONSENT_INSTRUMENTS, CONSENT_INSTRUMENT_DEFINITIONS, ConsentInstrumentId, consentPrompt, getOrgRequireSignedFormFor, } from '../../../config/consentInstruments.ts'; import { Button } from '../../../components/ui/button.tsx'; import { CheckboxControl } from '../../../components/ui/checkbox-control.tsx'; import { Label } from '../../../components/ui/label.tsx'; import { Textarea } from '../../../components/ui/textarea.tsx'; import { Alert, AlertDescription } from '../../../components/ui/alert.tsx'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import { FillFromPdf } from './FillFromPdf.tsx'; import { isSaveRetired, outcomeUnknown, useSaveScope, type SaveTransport } from '../../../utils/saveOperation'; import { HttpError } from '../../../services/HttpService.tsx'; import { olderThan } from '../../../utils/versionOrder'; import { noteAgreementSaveAnswer, recordUnconfirmedAgreementSave, settleUnconfirmedAgreementSave, unconfirmedAgreementSave, } from '../../../utils/unconfirmedAgreementSaves'; // The org admin's consent settings. Mounted on OrganizationPage behind canManageOrganization // (Admin-only, same as the other admin-only org settings cards there), in the org-settings-section // shape - Radix ui/* + Tailwind + lucide + the `btn btn-green` save convention. // // THREE AGREEMENTS, NOT ONE. This was a single editor called "Authorization to Treat" - // consent-to-treat and records-access welded into one name - and a practice now authors all three of the // documents it requires from a new patient, each with its own text, its own version and its own paper // requirement. Editing one must not move the other two, which is enforced server-side; what this screen // owes is never sending an edit that does not say which document it is for. // // PARTIAL UPDATE by server design (aws/sam/services/api/src/handlers/Organization/ConsentSettings/put.ts): // the toggle and the text editor each PUT ONLY their own field - never combined into one request. // One save at a time across all three editors. The server's write is read-modify-write on a single // Organization, so two overlapping saves would silently lose one - and with three editors on the screen // that is now a click apart rather than a race nobody could stage. Serializing at the UI is the honest // fix here: the alternative is a per-field patch endpoint, which is a larger change than the risk. const ConsentSettingsSection = () => { const { currentOrganization } = useAuthServiceContext(); const { getAgreements } = useOrgAuthorizationServiceContext(); const organizationId = currentOrganization?.id; // The agreements as read for one practice. While only another practice's are held the editors get none, so a // practice just opened never shows, or seeds a save from, the last one's text and version. const [loaded, setLoaded] = useState<{ organizationId: string; agreements: OrgAuthorizationAgreement[] }>(); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(); // The one save in flight across the three editors, by the claim that started it: only that claim releases it, so // a save from an earlier practice or opening finishing late never unlocks a current one. const [busy, setBusy] = useState(); const busyClaim = useRef(); const readScope = useSaveScope(organizationId, () => { busyClaim.current = undefined; setBusy(undefined); }); const claimBusy = useCallback((): (() => void) | undefined => { if (busyClaim.current) return undefined; const claim = Symbol('agreement save'); busyClaim.current = claim; setBusy(claim); return () => { if (busyClaim.current !== claim) return; busyClaim.current = undefined; setBusy(undefined); }; }, []); // Keyed on the org ID, not the object - a save calls setWpOrganization with a NEW object reference, // so keying on the whole object would re-seed every editor right after one of them saved and clobber // the in-progress edits in the other two. useEffect(() => { if (!organizationId) return; const operation = readScope.start(); setLoadError(undefined); setLoading(true); // Asked of this practice by id, so the answer cannot be for whichever practice is current when it is sent. operation.step(() => getAgreements(organizationId)) .then((agreements) => { if (operation.belongs()) setLoaded({ organizationId, agreements }); }) .catch((err) => { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Error loading the organization consent agreements:', err); setLoadError('Could not load the current agreements. Try again.'); }) .finally(() => { if (operation.belongs()) setLoading(false); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [organizationId]); const agreements = loaded && loaded.organizationId === organizationId ? loaded.agreements : undefined; return (

Patient agreements

Every new patient answers these three separately. Customize the wording of each, and choose which ones staff must have a signed form on file for before recording them in the office.

{loadError && ( {loadError} )} {CONSENT_INSTRUMENTS.map((instrument) => ( a.instrument === instrument)} loading={loading || (!agreements && !loadError)} loadFailed={!!loadError} savingAnywhere={busy !== undefined} claimBusy={claimBusy} /> ))}
); }; const AgreementEditor = ({ organizationId, instrument, agreement, loading, loadFailed, savingAnywhere, claimBusy, }: { organizationId?: string; instrument: ConsentInstrumentId; agreement?: OrgAuthorizationAgreement; loading: boolean; loadFailed: boolean; savingAnywhere: boolean; // The shared one-save-at-a-time lock: a release for this save, or nothing while another holds it. claimBusy: () => (() => void) | undefined; }) => { const definition = CONSENT_INSTRUMENT_DEFINITIONS[instrument]; const { currentOrganization, wpOrganization, setWpOrganization } = useAuthServiceContext(); const { updateConsentSettings } = useOrganizationServiceContext(); const { getAgreement } = useOrgAuthorizationServiceContext(); // Every save and read this editor starts belongs to this practice, the person signed in and this opening. const scope = useSaveScope(organizationId); const [requireSignedForm, setRequireSignedForm] = useState(() => getOrgRequireSignedFormFor(currentOrganization, instrument)); const [toggleSaving, setToggleSaving] = useState(false); const [toggleError, setToggleError] = useState(); // version 0 = this practice has never saved its own text for this document, so the platform text is // being served. LEAVE THE BOX EMPTY rather than prefilling that text - prefilling would let an // unmodified Save turn the platform's own wording into a "custom" v1 agreement, identical in content // but no longer flagged as the default, silently defeating the hint below. That trap existed once and // now exists three times. const [text, setText] = useState(''); const [version, setVersion] = useState(); const [saving, setSaving] = useState(false); const [error, setError] = useState(); const [saved, setSaved] = useState(false); // What a patient is served for this document now, as the server resolves it; never the box above. const [effective, setEffective] = useState(); const [effectiveFailed, setEffectiveFailed] = useState(false); // A wording save sent with no answer: it may have made a new version, so Save waits for a read of this document. const [unconfirmed, setUnconfirmed] = useState(() => unconfirmedAgreementSave(organizationId, instrument)); const [checking, setChecking] = useState(false); // What a check found, said without claiming more than it knows. const [notice, setNotice] = useState(); useEffect(() => { if (!agreement) return; setText(agreement.version === 0 ? '' : agreement.text); setVersion(agreement.version); setEffective(agreement); }, [agreement]); const usingPlatformDefault = version === 0; // Blocked while ANY editor is mid-save, since all three write the same Organization. const blocked = loading || saving || toggleSaving || checking || savingAnywhere; // The saved practice, unless the session already holds a newer one, such as a sibling editor's later save. const publishOrganization = (organization: Organization) => { const held = wpOrganization?.current; if (held && held.id === organization.id && olderThan(organization, held)) return; setWpOrganization(organization); }; const handleToggleChange = async (checked: boolean) => { const release = claimBusy(); if (!release) return; const operation = scope.start(); setToggleSaving(true); setToggleError(undefined); try { const { organization } = await operation.step( () => updateConsentSettings({ instrument, requireSignedAuthForm: checked }, operation.transport), true); setRequireSignedForm(checked); publishOrganization(organization); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Error saving the signed-form requirement:', err); setToggleError(outcomeUnknown(err) ? 'Whether this setting was saved could not be confirmed. Reload the page to check.' : relayServerFailure(err, 'Failed to save this setting. Please try again.')); } finally { release(); if (operation.belongs()) setToggleSaving(false); } }; const handleSave = async () => { const release = claimBusy(); if (!release) return; const operation = scope.start(); const fromVersion = version; const sentText = text; setSaving(true); setError(undefined); setNotice(undefined); setSaved(false); // Recorded as it is sent, so an editor opened while it is out waits for it too. Once sent it runs to its answer // even if this editor goes, and that answer is noted against this attempt, though never published. let attempt: string | undefined; const sending = new AbortController(); operation.transport.signal.addEventListener('abort', () => { if (!attempt) sending.abort(); }); const transport: SaveTransport = { signal: sending.signal, admit: (phase?: 'answer') => { if (phase === 'answer' && attempt) noteAgreementSaveAnswer(organizationId, instrument, attempt, 'saved'); operation.admit(); }, onSend: () => { attempt = recordUnconfirmedAgreementSave(organizationId, instrument, fromVersion, sentText); }, }; const request = () => updateConsentSettings({ instrument, agreementText: sentText }, transport).catch((err) => { if (attempt && err instanceof HttpError && !outcomeUnknown(err)) noteAgreementSaveAnswer(organizationId, instrument, attempt, 'refused'); throw err; }); let organization: Organization; try { ({ organization } = await operation.step(request, true)); settleUnconfirmedAgreementSave(organizationId, instrument); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; if (outcomeUnknown(err)) { setUnconfirmed(unconfirmedAgreementSave(organizationId, instrument)); return; } settleUnconfirmedAgreementSave(organizationId, instrument); console.error('Error saving the agreement:', err); setError(relayServerFailure(err, 'Failed to save the agreement. Please try again.')); return; } finally { release(); if (operation.belongs()) setSaving(false); } publishOrganization(organization); setSaved(true); // Re-read only this document, so the preview and version show what the server now serves, never a version // guessed from the box, and the other two editors are not reseeded. setVersion(undefined); setEffective(undefined); setEffectiveFailed(false); try { const current = await operation.step(() => getAgreement(organizationId, instrument, operation.transport)); setEffective(current); setVersion(current.version); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Error reloading the agreement:', err); setEffectiveFailed(true); } }; // Reads this document, then settles a save whose answer never reached its editor only on the answer to that very // request. A read says what is served, never which request served it, so the same wording from another writer // proves nothing: otherwise Save stays held and what is served is described, never claimed. The draft stays. const checkUnconfirmed = async () => { if (!unconfirmedAgreementSave(organizationId, instrument)) { setUnconfirmed(undefined); return; } const operation = scope.start(); setChecking(true); setError(undefined); setNotice(undefined); try { const current = await operation.step(() => getAgreement(organizationId, instrument, operation.transport)); setEffective(current); setEffectiveFailed(false); setVersion(current.version); const held = unconfirmedAgreementSave(organizationId, instrument); if (!held) { setUnconfirmed(undefined); return; } if (held.answer === 'saved') { settleUnconfirmedAgreementSave(organizationId, instrument); setUnconfirmed(undefined); setSaved(true); return; } if (held.answer === 'refused') { settleUnconfirmedAgreementSave(organizationId, instrument); setUnconfirmed(undefined); setError('That save was refused, so nothing new was saved. Your wording is still here.'); return; } setUnconfirmed(held); const sameWording = held.text.trim() ? current.text.trim() === held.text.trim() : current.version === 0; setNotice(current.version === held.fromVersion ? 'Your last save has had no answer and may still land, so Save stays off. Check again later.' : sameWording ? `Patients now read version ${current.version}, with the wording you sent. Whether your last save made it is not known, and it may still land, so Save stays off.` : `Patients now read version ${current.version}, saved elsewhere. Your last save has had no answer and may still land, so Save stays off.`); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return; setError(relayServerFailure(err, 'What patients read now could not be loaded. Try again.')); } finally { if (operation.belongs()) setChecking(false); } }; const previewFailed = effectiveFailed || (!effective && !loading && loadFailed); return (

{definition.name}

{definition.purpose}

{/* The exact sentence the patient sees, so an admin editing the text knows what it sits under - and can see that the notice is read rather than agreed to. */}

The patient is asked: “{consentPrompt(instrument)}”

{toggleError && ( {toggleError} )}
handleToggleChange(checked)} label="Require a signed form on file before staff record this in the office" /> {toggleSaving && }
{usingPlatformDefault && (

Using the platform default wording. Enter your own below to override it, or leave this blank to keep using the default.

)} {error && ( {error} )} {unconfirmed && ( Whether your last save of this wording landed could not be confirmed. Check what patients read now before saving again. )} {notice && ( {notice} )} {saved && ( {definition.name} saved. )}

What patients read now

{effective && !previewFailed && (

{effective.version === 0 ? 'Platform default wording' : "Your practice's wording"} {` · version ${effective.version}`}

)}
{previewFailed ?

This wording could not be loaded. Reload the page to try again.

: effective?.text ??

Loading…

}
{/* Fills the box below and nothing else. It has no access to the save, which is control ① of this card expressed as a prop rather than as a rule somebody has to follow. */} { setText(extracted); // The extracted text is UNSAVED, and the success banner from a previous save // must not sit above it saying otherwise. setSaved(false); setError(undefined); }} />