import React from 'react'; import { ChevronDown, X } from 'lucide-react'; import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '../../../components/ui/dialog'; import { fieldsInBand, missingRequired, type ComposedField, type RecordFormBand, } from '../../../utils/composeRecordForm'; import './RecordItemOverlay.css'; // One proposed item, opened to be confirmed and filed. // // Spencer, 2026-09-03: "it brings up an overlay with the fields pre-filled showing first along with any // required fields under it. Under that we show an 'add more information' heading... Save button should be // sticky at the top of the overlay so it's not buried at the bottom off-screen." // // IT RENDERS STRUCTURE AND DELEGATES THE CONTROL. `renderField` is supplied by the caller, so the real // form's own control is what appears in each row rather than a lookalike built here. Two sets of controls // for one field is how a form starts disagreeing with the record it writes. interface Props { open: boolean; onClose: () => void; /** The kind being filed - "Prescription"; the item itself is in the body. */ title: string; /** Names what closing abandons, e.g. "Close prescription without saving". */ closeLabel?: string; fields: ComposedField[]; /** The real form's control for this field. */ renderField: (composed: ComposedField) => React.ReactNode; onSave: () => void; saving?: boolean; /** Save is not offered: a filing that may already be on record waits to be checked. */ held?: boolean; /** What Save does now, when it is not filing the item (e.g. linking one already stored). */ saveLabel?: string; /** Why the last attempt did not file. Rendered here rather than behind the overlay. */ error?: string; /** An earlier filing still to be settled, shown under the error and before the fields. */ notice?: React.ReactNode; /** Who and which visit this files to. */ subtitle?: string; /** What closing does and does not do. */ footer?: string; } /** * The three field labels the collapsed line names, and then how many are left. * * THREE, because the drawing shows three and the line has to stay one line. Naming every field would * wrap to four rows and stop being a summary; naming none gives no reason to open it. */ const summarise = (fields: ComposedField[]): string => { const named = fields.slice(0, 3).map((entry) => entry.field.label.toLowerCase()); const rest = fields.length - named.length; return rest > 0 ? `${named.join(', ')} and ${rest} more` : named.join(', '); }; const BANDS: { band: RecordFormBand; heading: string }[] = [ // The headings are the drawing's own, and the order is Spencer's. { band: 'from-the-note', heading: 'From your note' }, { band: 'still-needed', heading: 'Still needed' }, { band: 'more-information', heading: 'Add more information' }, ]; export const RecordItemOverlay: React.FC = ({ open, onClose, title, closeLabel = 'Close', fields, renderField, onSave, saving, held, saveLabel = 'Save to record', error, notice, subtitle, footer, }) => { const missing = missingRequired(fields); return ( { if (!next) onClose(); }}> {/* SAVE IS PINNED AT THE TOP, and Spencer named the reason rather than the position: "so it's not buried at the bottom off-screen." A form whose last band is the long tail of every remaining field puts its own Save an unknown distance down the page. */}
{title} {subtitle &&

{subtitle}

}
{/* THE CLOSE LIVES IN THE HEADER, which is both the drawing and the fix. The dialog's own X sits in the top-right corner of the CONTENT, and this header is sticky and lifted above it - so the X was on screen, took the hover, and every click on it landed on the header instead. Escape still closed the overlay, which is exactly why a code reading never saw it and no test could. One close, beside the Save, in the band that is pinned - so neither can be scrolled away from. */}
{/* INSIDE THE OVERLAY, AT THE TOP, because the overlay stays open on a failure - deliberately, so the clinician can correct and retry. Anything rendered behind it is under the modal scrim and cannot be read, so a save that failed looked like a save that did nothing. */} {error && (

{error}

)} {notice &&
{notice}
} {BANDS.map(({ band, heading }) => { const inBand = fieldsInBand(fields, band); // AN EMPTY BAND IS NOT DRAWN. A heading with nothing under it reads as something // that failed to load, and "Still needed" reading empty is the one that would be // most alarming - it would say something is owed and show nothing owed. if (inBand.length === 0) return null; /* THE LONG TAIL SITS BEHIND ONE LINE, as drawn: "everything else behind one line." Rendering it open would put the fields nobody has to fill between the clinician and the Save, which is the shape this overlay exists to escape. AND THE LINE NAMES WHAT IS INSIDE IT. The drawing spells three of them out - "substitution, days supply, pharmacy note" - because a bare "Add more information" gives no reason to open it, and a clinician who needs one of those has no way to know it is there. */ if (band === 'more-information') { return (
{heading} — {summarise(inBand)}
{inBand.map((composed) => (
{renderField(composed)}
))}
); } return (

{heading} {/* THE COUNT IS ON "STILL NEEDED" ONLY, as drawn. It answers the one question a clinician has at that heading - how much stands between them and Save - and putting one on the other bands would turn a useful number into decoration. */} {band === 'still-needed' && ( {inBand.length} )}

{inBand.map((composed) => (
{renderField(composed)}
))}
); })} {/* Said once, at the foot, rather than as a disabled-button tooltip nobody hovers. */} {missing.length > 0 && (

{missing.length === 1 ? `${missing[0]} is needed before this can be saved.` : `${missing.join(', ')} are needed before this can be saved.`}

)} {footer &&

{footer}

}
); }; export default RecordItemOverlay;