import { useCallback, useEffect, useRef, useState } from "react"; import { AlertCircle, FileStack, Loader2, Plus } from "lucide-react"; import { Alert, AlertDescription } from "../../../components/ui/alert.tsx"; import { Button } from "../../../components/ui/button.tsx"; import { EmptyState } from "../../../components/ui/empty-state.tsx"; import { ToggleGroupControl, ToggleGroupControlOption } from "../../../components/ui/toggle-group-control.tsx"; import { useAuthServiceContext, useStaffPermissionServiceContext } from "../../../services"; import { useRecordsService, type RecordsDirection, type RecordsRequest } from "../../../services/RecordsService.tsx"; import { formatDate, formatDateTime } from "../../../utils/dateUtils"; import { relayServerFailure } from "../../../utils/fetchFailureMessage"; import { isSaveRetired, startOwnedSave, useSaveScope, type SaveOperation } from "../../../utils/saveOperation"; import { useListOwner } from "../../../hooks/useListOwner"; import NewRecordsRequestDialog from "./NewRecordsRequestDialog.tsx"; import RecordsRequestDialog from "./RecordsRequestDialog.tsx"; import { QUEUES, claimedByOther, newer, stateLabel } from "./recordsWords.ts"; import "./RecordsPage.css"; const rowMeta = (request: RecordsRequest, me: string | undefined): string => { const parts = []; if (request.dueAt) parts.push("Due " + formatDate(request.dueAt)); parts.push("Updated " + formatDateTime(request.updatedAt)); if (claimedByOther(request, me)) parts.push("Someone is working this"); else if (request.claim?.by === me) parts.push("You are working this"); return parts.join(" - "); }; // The queue is eventually consistent, so a request this screen just wrote is held at the version the // server answered until the queue catches up; opening a request always rereads it by id. const RecordsPage = () => { const { canManage } = useStaffPermissionServiceContext(); const { currentUser, currentOrganization } = useAuthServiceContext(); const { listRequests } = useRecordsService(); const mayWork = canManage("records"); const me = currentUser?.fhir?.id ? "Practitioner/" + currentUser.fhir.id : undefined; // What this screen shows belongs to one viewer in one practice; anything read for another is dropped. const owner = (me ?? "") + "|" + (currentOrganization?.id ?? ""); // Each owner change starts a new lifetime, so returning to an earlier owner never revives what was open then. const lifetime = useRef({ owner, generation: 0 }); if (lifetime.current.owner !== owner) lifetime.current = { owner, generation: lifetime.current.generation + 1 }; const generation = lifetime.current.generation; const openings = useRef(0); const organizationId = currentOrganization?.id; // Each list read is admitted for this owner, sent under this practice, and cancelled when a newer read replaces it. const lives = useSaveScope(owner); const lastRead = useRef(); const [direction, setDirection] = useState("incoming"); const [queueKey, setQueueKey] = useState("open"); const [rows, setRows] = useState([]); const [held, setHeld] = useState>({}); const [truncated, setTruncated] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(); // The read on past the loaded rows: its exact continuation, kept through a failure so a retry asks for the same place. // Both are tagged with the filter they were read for, and show only under it. const [next, setNext] = useState<{ filter: string; cursor: string }>(); const [moreOf, setMoreOf] = useState<{ filter: string; busy?: boolean; error?: string; stalled?: boolean }>(); const moreInFlight = useRef(null); // Every continuation this read generation has already read from; one handed back again would only go round. const traversed = useRef<{ run: number; cursors: Set }>({ run: 0, cursors: new Set() }); // Viewer and sign-in session, read live: a read begun for one is never shown to the next, the same account included. const { owner: listOwner, live } = useListOwner(listRequests); // A dialog belongs to the lifetime it opened in, and each opening of the new-request dialog is its own. const [creating, setCreating] = useState<{ generation: number; opening: number }>(); const [opened, setOpened] = useState<{ generation: number; requestId: string }>(); const queue = QUEUES[direction].find((q) => q.key === queueKey) ?? QUEUES[direction][0]; // The filter as rendered now: a read for another one stops belonging in the render that changes it, not one effect later. const filter = direction + "|" + queue.states.join(","); const filterNow = useRef(filter); filterNow.current = filter; const nextCursor = next?.filter === filter ? next.cursor : undefined; const more: { busy?: boolean; error?: string; stalled?: boolean } = moreOf?.filter === filter ? moreOf : {}; const setMore = (state: { busy?: boolean; error?: string; stalled?: boolean }, of = filter) => setMoreOf({ filter: of, ...state }); // Only the latest list read may publish; a read for a queue, direction or owner since replaced is dropped. const run = useRef(0); useEffect(() => { setRows([]); setHeld({}); setOpened(undefined); setCreating(undefined); }, [owner]); const reload = useCallback(async () => { const mine = ++run.current; lastRead.current?.retire(); const life = lives.start(); lastRead.current = life; const of = listOwner; const ours = () => run.current === mine && filterNow.current === filter && live(of); traversed.current = { run: mine, cursors: new Set() }; setRows([]); setNext(undefined); setTruncated(false); setMoreOf(undefined); setLoading(true); setError(undefined); try { const admit = () => { if (!ours()) throw new DOMException("This list was replaced.", "AbortError"); life.admit(); }; const answer = await listRequests(direction, queue.states, { admit, signal: life.transport.signal, organizationId }); if (!ours()) return; setRows(answer.requests.filter((request) => request.direction === direction)); setTruncated(answer.truncated); setNext(answer.truncated && answer.nextCursor ? { filter, cursor: answer.nextCursor } : undefined); } catch (failure) { if (!ours()) return; console.error("Error loading records requests:", failure); setError(relayServerFailure(failure, "Records requests could not be loaded.")); } finally { if (ours()) setLoading(false); } }, [direction, filter, listOwner, listRequests, live, lives, organizationId, queue.states]); useEffect(() => { void reload(); return () => { run.current += 1; }; }, [reload, owner]); // One press, one request for the exact place the last page stopped; a newer read or owner retires it. const loadMore = async () => { const mine = run.current; const cursor = nextCursor; const parent = lastRead.current; if (!cursor || !parent || moreInFlight.current === mine) return; moreInFlight.current = mine; const life = startOwnedSave(parent); const of = listOwner; const forFilter = filter; const ours = () => run.current === mine && filterNow.current === forFilter && parent.belongs() && live(of); setMore({ busy: true }, forFilter); try { const admit = () => { if (!ours()) throw new DOMException("This list was replaced.", "AbortError"); life.admit(); }; const answer = await listRequests(direction, queue.states, { admit, signal: life.transport.signal, organizationId }, { cursor }); if (!ours()) return; setRows((current) => { const known = new Map(current.map((row) => [row.requestId, row])); for (const row of answer.requests) { if (row.direction !== direction) continue; const had = known.get(row.requestId); known.set(row.requestId, had ? newer(had, row) : row); } return [...known.values()]; }); const following = answer.truncated ? answer.nextCursor : undefined; setTruncated(answer.truncated); // A place read once already this generation would only go round again, however many steps away it was. const walked = traversed.current.run === mine ? traversed.current.cursors : new Set(); walked.add(cursor); traversed.current = { run: mine, cursors: walked }; if (following && walked.has(following)) { setNext(undefined); setMore({ stalled: true }, forFilter); } else { setNext(following ? { filter: forFilter, cursor: following } : undefined); setMore({}, forFilter); } } catch (failure) { if (!ours() || isSaveRetired(failure)) return; console.error("Error loading more records requests:", failure); setMore({ error: relayServerFailure(failure, "More requests could not be loaded.") }, forFilter); } finally { if (moreInFlight.current === mine) moreInFlight.current = null; if (ours()) setMoreOf((now) => (now?.busy ? { ...now, busy: false } : now)); } }; // A written request replaces its queue copy, and leaves the queue when its state moves elsewhere. const remember = useCallback((request: RecordsRequest) => setHeld((current) => ({ ...current, [request.requestId]: current[request.requestId] ? newer(current[request.requestId], request) : request, })), []); // A write answered after the lifetime it started in belongs to the old screen, not this one. const keep = useCallback((request: RecordsRequest) => { if (lifetime.current.generation === generation) remember(request); }, [generation, remember]); const merged = new Map(); for (const row of rows) merged.set(row.requestId, held[row.requestId] ? newer(row, held[row.requestId]) : row); for (const request of Object.values(held)) { if (request.direction === direction && !merged.has(request.requestId)) merged.set(request.requestId, request); } const visible = [...merged.values()] .filter((request) => request.direction === direction && queue.states.includes(request.state)) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return (

Records requests

Requests for records held here, and records this practice has asked for.

{mayWork && Connections} {mayWork && ( )}
{ setDirection(value === "outgoing" ? "outgoing" : "incoming"); setQueueKey("open"); }}> {QUEUES[direction].map((q) => )}
{error && !loading && ( {error} )} {loading &&

} {!loading && !error && visible.length === 0 && !truncated && !more.stalled && ( )} {!loading && visible.length > 0 && (
    {visible.map((request) => (
  • ))}
)} {!loading && !error && (
{nextCursor && ( )} {more.error &&

{more.error}

} {nextCursor && visible.length === 0 && !more.error &&

None on this page. There are more to load.

} {truncated && !nextCursor && !more.stalled &&

More requests were not loaded. Refresh to read the list again.

} {more.stalled &&

The list did not move on from here. Refresh to read it again.

}

This list can lag behind recent changes; opening a request always reads it fresh.

)}
{creating?.generation === generation && ( setCreating((now) => (now === creating ? undefined : now))} onCreated={(request) => { if (lifetime.current.generation !== generation) return; keep(request); setDirection(request.direction); setQueueKey("open"); setOpened({ generation, requestId: request.requestId }); }} /> )} {opened?.generation === generation && ( setOpened((now) => (now === opened ? undefined : now))} onChanged={keep} /> )}
); }; export default RecordsPage;