Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion data/forms-catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,56 @@
"timings": ["within 48 hours", "72 hours"],
"sectionCue": "sections 26, 31, 36, 37, 41, 42",
"indexedAt": "Official form PDF indexed locally"
}
},
"priorityFacts": {
"clock": {
"title": "Usually 72 hours from referral",
"detail": "Within assessment window · 48h cue",
"body": "Usually 72 hours from referral. The referral is made within the required assessment window. Clock cues on the form pathway include within 48 hours and 72 hours — record the start time, expiry or review point, and next owner."
},
"authority": {
"title": "MP or AMHP (where Act permits)",
"detail": "Psychiatrist examination only",
"body": "Medical practitioner or authorised mental health practitioner where the Act permits. Authorises referral for examination by a psychiatrist. Does not by itself authorise treatment, detention, transport, restraint, seclusion or force."
},
"criteria": {
"title": "Reasonable suspicion ITO criteria may be met",
"detail": "Document illness · risk · capacity · least restrictive",
"body": "Reasonable suspicion that involuntary treatment order criteria may be met, with illness features, risk, capacity and least restrictive reasoning documented."
}
},
"actSections": [
{
"section": "26",
"title": "Referral for examination at authorised hospital or other place",
"summary": "A medical practitioner or authorised mental health practitioner may refer a person for examination by a psychiatrist if, having regard to the section 25 criteria, they reasonably suspect the person needs an involuntary treatment order — or, if already under a community treatment order, an inpatient treatment order. Examination may be at an authorised hospital or another appropriate place, with necessary arrangements made for that place."
},
{
"section": "31",
"title": "Revoking referral",
"summary": "An MP or AMHP may revoke a section 26 referral if satisfied the person no longer needs an involuntary treatment order. Revoking another practitioner’s referral requires consultation, or documented reasonable efforts to contact them. The person cannot continue to be detained once the referral is revoked."
},
{
"section": "36",
"title": "Referral for examination at authorised hospital",
"summary": "For a voluntary inpatient at an authorised hospital, an MP or AMHP may refer them for psychiatrist examination at that hospital if, having regard to the section 25 criteria, they reasonably suspect the inpatient needs an involuntary treatment order."
},
{
"section": "37",
"title": "Revoking referral",
"summary": "An MP or AMHP may revoke a section 36 referral if satisfied the voluntary inpatient no longer needs an involuntary treatment order. The same consultation rules apply when revoking another practitioner’s referral. Detention under that referral cannot continue once revoked."
},
{
"section": "41",
"title": "Form of referral",
"summary": "A referral must be in the approved form and include the particulars required by the Act. Use only the current approved Form 1A or PSOLIS pathway; do not invent wording or rely on an outdated PDF."
},
{
"section": "42",
"title": "Providing information contained in referral to person referred",
"summary": "The practitioner must give the referred person the required referral information (section 41(a) and (b), and usually section 41(c)) in the approved form, subject to the Act’s exception for information supplied by someone else on condition it not be disclosed to the person."
}
]
Comment thread
cursor[bot] marked this conversation as resolved.
},
{
"id": "form-1b",
Expand Down
53 changes: 28 additions & 25 deletions docs/branch-review-ledger.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/design-system/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -992,7 +992,7 @@ This generated snapshot is a local source-derived inventory. It does not assert
| `SearchField` | controls | yes | yes | no | yes | no | 0 |
| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 2 |
| `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 |
| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 22 |
| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 23 |
| `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 |
| `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 1 |
| `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 |
Expand Down
2 changes: 2 additions & 0 deletions docs/design-system/adoption-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1430,6 +1430,7 @@
"src/components/differentials/diagnosis-map-panel.tsx",
"src/components/document-viewer/document-clinical-summary.tsx",
"src/components/document-viewer/section-nav.tsx",
"src/components/forms/form-detail-page.tsx",
"src/components/forms/forms-search-results-page.tsx",
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/mode-nav/mode-nav.tsx",
Expand All @@ -1455,6 +1456,7 @@
"src/components/differentials/diagnosis-map-panel.tsx",
"src/components/document-viewer/document-clinical-summary.tsx",
"src/components/document-viewer/section-nav.tsx",
"src/components/forms/form-detail-page.tsx",
"src/components/forms/forms-search-results-page.tsx",
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/mode-nav/mode-nav.tsx",
Expand Down
238 changes: 215 additions & 23 deletions src/components/forms/form-detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import {
Bookmark,
BookmarkCheck,
BookOpenText,
CalendarDays,
ChevronRight,
CircleCheck,
Expand All @@ -25,7 +26,7 @@ import {
CircleX,
type LucideIcon,
} from "lucide-react";
import { useMemo, useState, type ReactNode } from "react";
import { useId, useMemo, useState, type ReactNode } from "react";

import {
cn,
Expand All @@ -41,11 +42,13 @@ import {
toneSuccess,
toneWarning,
} from "@/components/ui-primitives";
import { Sheet } from "@/components/ui/sheet";
import { InformationPageBreadcrumbs, InformationPageShell } from "@/components/information-page-shell";
import { FormCodeBadge, splitFormCode } from "@/components/forms/form-code-badge";
import { DisclosureGroup } from "@/components/ui/disclosure";
import { appModeHomeHref } from "@/lib/app-modes";
import { formCatalogDetails, formTitleForCode, type FormRecord } from "@/lib/form-catalog";
import type { FormActSection, FormPriorityFactCard } from "@/lib/form-ranker";
import type { ServiceChipTone, ServiceContact, ServiceCriterion, ServiceSummaryCard } from "@/lib/service-ranker";
import { useAccountData } from "@/components/account-data-provider";

Expand Down Expand Up @@ -121,18 +124,58 @@ function formShortTitle(form: FormRecord) {

function summaryIcon(card: ServiceSummaryCard) {
const label = `${card.id} ${card.label} ${card.title}`.toLowerCase();
const Icon = label.includes("clock")
? Clock3
: label.includes("destination") || label.includes("place") || label.includes("route")
? MapPin
: label.includes("authority") || label.includes("maker")
? UserRound
: label.includes("criteria") || label.includes("threshold")
? Scale
: ClipboardList;
const Icon =
label.includes("act-section") || label.includes("act section")
? BookOpenText
: label.includes("clock")
? Clock3
: label.includes("destination") || label.includes("place") || label.includes("route")
? MapPin
: label.includes("authority") || label.includes("maker")
? UserRound
: label.includes("criteria") || label.includes("threshold")
? Scale
: ClipboardList;
return <Icon className="h-5 w-5" aria-hidden />;
}

const PRIORITY_FACT_LABELS: Record<string, string> = {
clock: "Clock / review",
authority: "Made by / authority",
criteria: "Criteria",
};

function priorityFactBody(form: FormRecord, cardId: string): { title: string; body: string; detail?: string } | null {
const details = formCatalogDetails(form);
if (!details) return null;

const fromCard = (fact: FormPriorityFactCard | undefined, fallbackBody: string, fallbackDetail?: string) => {
if (!fact && !fallbackBody.trim()) return null;
const body = fact?.body?.trim() || fallbackBody.trim();
if (!body) return null;
return {
title: fact?.title?.trim() || PRIORITY_FACT_LABELS[cardId] || displayText(cardId),
detail: fact?.detail?.trim() || fallbackDetail,
body,
};
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (cardId === "clock") {
return fromCard(details.priorityFacts?.clock, details.clock, details.indexedClock);
}
if (cardId === "authority") {
return fromCard(
details.priorityFacts?.authority,
[details.maker, details.authorises, details.doesNotAuthorise].filter(Boolean).join(" "),
details.authorises,
);
}
if (cardId === "criteria") {
return fromCard(details.priorityFacts?.criteria, details.threshold, details.doesNotAuthorise);
}
return null;
}

function summaryCardsFor(form: FormRecord): ServiceSummaryCard[] {
if (form.summaryCards?.length) return form.summaryCards.slice(0, 4);

Expand Down Expand Up @@ -230,9 +273,17 @@ function confirmCheckParts(check: string): { cue?: string; body: string } {
return { cue: "Before use:", body: match[2] ?? "" };
}

function DetailCard({ card }: { card: ServiceSummaryCard }) {
function DetailCardShell({
card,
children,
footer,
}: {
card: ServiceSummaryCard;
children?: ReactNode;
footer?: ReactNode;
}) {
return (
<article className="min-h-[5.75rem] rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-lux)] p-1.5 shadow-[var(--shadow-inset)] sm:min-h-[7rem] sm:p-3">
<article className="flex min-h-[5.75rem] flex-col rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-lux)] p-1.5 shadow-[var(--shadow-inset)] sm:min-h-[7rem] sm:p-3">
<div className="mb-1 flex items-start gap-1.5 sm:mb-2 sm:gap-2">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] sm:h-9 sm:w-9">
{summaryIcon(card)}
Expand All @@ -241,13 +292,163 @@ function DetailCard({ card }: { card: ServiceSummaryCard }) {
{displayText(card.label, "Priority fact")}
</p>
</div>
{children}
{footer}
</article>
);
}

function DetailCard({
card,
onOpenDetail,
hasDetail,
}: {
card: ServiceSummaryCard;
onOpenDetail?: () => void;
hasDetail?: boolean;
}) {
const label = displayText(card.label, "Priority fact");
const content = (
<>
<h3 className="text-xs font-semibold leading-tight text-[color:var(--text-heading)] sm:text-sm sm:leading-5">
{displayText(card.title)}
</h3>
<p className={cn("mt-0.5 text-2xs font-medium leading-4 sm:mt-1 sm:text-xs sm:leading-5", textMuted)}>
{displayText(card.detail)}
</p>
</article>
</>
);

if (!hasDetail || !onOpenDetail) {
return <DetailCardShell card={card}>{content}</DetailCardShell>;
}

return (
<DetailCardShell
card={card}
footer={<p className={cn("mt-auto pt-1 text-2xs font-medium leading-4 sm:pt-1.5", textMuted)}>Tap for detail</p>}
>
<button
type="button"
onClick={onOpenDetail}
className="min-w-0 flex-1 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--surface-lux)]"
aria-label={`${label}: ${displayText(card.title)}. Open detail.`}
>
{content}
</button>
</DetailCardShell>
Comment thread
cursor[bot] marked this conversation as resolved.
);
}

function ActSectionsCard({
card,
sections,
onOpenSection,
}: {
card: ServiceSummaryCard;
sections: FormActSection[];
onOpenSection: (section: string) => void;
}) {
const groupLabelId = useId();
return (
<DetailCardShell card={card}>
<h3
id={groupLabelId}
className="text-xs font-semibold leading-tight text-[color:var(--text-heading)] sm:text-sm sm:leading-5"
>
{displayText(card.title, "MHA 2014 referral pathway")}
</h3>
<div className="mt-1 flex flex-wrap gap-1" role="group" aria-labelledby={groupLabelId}>
{sections.map((entry) => (
<button
key={entry.section}
type="button"
onClick={() => onOpenSection(entry.section)}
className={cn(
"inline-flex min-h-12 min-w-12 items-center justify-center rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-2 text-xs font-semibold text-[color:var(--text-heading)]",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]",
)}
aria-label={`Section ${entry.section}: ${entry.title}. Open detail.`}
>
{entry.section}
</button>
))}
</div>
<p className={cn("mt-auto pt-1 text-2xs font-medium leading-4 sm:pt-1.5", textMuted)}>
Tap a section for authority detail
</p>
</DetailCardShell>
);
}

function PriorityFactsSection({ form, cards }: { form: FormRecord; cards: ServiceSummaryCard[] }) {
const details = formCatalogDetails(form);
const actSections = details?.actSections ?? [];
const [activeFactId, setActiveFactId] = useState<string | null>(null);
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);

const activeFact = activeFactId ? priorityFactBody(form, activeFactId) : null;
const activeSection = activeSectionId
? (actSections.find((entry) => entry.section === activeSectionId) ?? null)
: null;

return (
<>
<section id="form-priority-facts" aria-label="Priority facts" className="space-y-2.5 sm:space-y-3">
<h2 className="text-base-minus font-semibold leading-5 text-[color:var(--text-heading)] sm:text-base">
Priority facts
</h2>
<div className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
{cards.map((card) => {
if (card.id === "act-sections" && actSections.length) {
return (
<ActSectionsCard key={card.id} card={card} sections={actSections} onOpenSection={setActiveSectionId} />
);
}
const detail = priorityFactBody(form, card.id);
const hasCondensedDetail = Boolean(details?.priorityFacts && detail);
return (
<DetailCard
key={card.id}
card={card}
hasDetail={hasCondensedDetail}
onOpenDetail={hasCondensedDetail ? () => setActiveFactId(card.id) : undefined}
/>
);
})}
</div>
</section>

<Sheet
open={Boolean(activeFact)}
onClose={() => setActiveFactId(null)}
title={activeFact ? displayText(activeFact.title) : "Priority fact"}
description={activeFact?.detail}
testId="form-priority-fact-sheet"
mobilePlacement="bottom"
>
{activeFact ? <p className="text-sm leading-6 text-[color:var(--text-body)]">{activeFact.body}</p> : null}
</Sheet>

<Sheet
open={Boolean(activeSection)}
onClose={() => setActiveSectionId(null)}
title={activeSection ? `Section ${activeSection.section}` : "Act section"}
description={activeSection?.title}
testId="form-act-section-sheet"
mobilePlacement="bottom"
>
{activeSection ? (
<div className="space-y-3">
<p className="text-sm leading-6 text-[color:var(--text-body)]">{activeSection.summary}</p>
<p className={cn("text-xs leading-5", textMuted)}>
Condensed reference from the Mental Health Act 2014 (WA). Confirm against the current Act and approved
form before clinical or legal use.
</p>
</div>
) : null}
</Sheet>
</>
);
}

Expand Down Expand Up @@ -855,16 +1056,7 @@ export function FormDetailPage({ form }: { form: FormRecord }) {
/>
</div>

<section id="form-priority-facts" aria-label="Priority facts" className="space-y-2.5 sm:space-y-3">
<h2 className="text-base-minus font-semibold leading-5 text-[color:var(--text-heading)] sm:text-base">
Priority facts
</h2>
<div className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
{summaryCards.map((card) => (
<DetailCard key={card.id} card={card} />
))}
</div>
</section>
<PriorityFactsSection form={form} cards={summaryCards} />

<section
id="form-legal-boundary"
Expand Down
Loading
Loading