From 8db5db7610eeddaa03f329f108674f8f8dcd07b0 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 21 Jun 2026 17:58:36 -0600 Subject: [PATCH] feat(claim-drawer): validation rule opens peek with rule catalog --- .../ClaimDrawer/ValidationPanel.test.tsx | 133 ++++++++++++++++- .../ClaimDrawer/ValidationPanel.tsx | 42 +++++- .../drill/ValidationRulePeekContent.tsx | 134 ++++++++++++++++++ 3 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 src/components/drill/ValidationRulePeekContent.tsx diff --git a/src/components/ClaimDrawer/ValidationPanel.test.tsx b/src/components/ClaimDrawer/ValidationPanel.test.tsx index 67514b4..1dba3b0 100644 --- a/src/components/ClaimDrawer/ValidationPanel.test.tsx +++ b/src/components/ClaimDrawer/ValidationPanel.test.tsx @@ -6,8 +6,9 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, beforeEach } from "vitest"; import { ValidationPanel } from "./ValidationPanel"; +import { DrillStackProvider } from "@/components/drill/DrillStackProvider"; import type { ClaimDetailValidation, ClaimDetailValidationIssue } from "@/types"; function renderIntoContainer(element: React.ReactElement): { @@ -17,8 +18,11 @@ function renderIntoContainer(element: React.ReactElement): { const container = document.createElement("div"); document.body.appendChild(container); const root: Root = createRoot(container); + // SP21 Phase 5 Task 5.9: ValidationPanel now uses useDrillStack() + // to open validation-rule peeks. Wrap every render in a + // DrillStackProvider so the hook has a context to read from. act(() => { - root.render(element); + root.render({element}); }); return { container, @@ -244,4 +248,129 @@ describe("ValidationPanel", () => { unmount(); }); + + it("SP21 Task 5.9: rule code is drillable (renders as a button)", () => { + // The rule code in each IssueGroup now wraps in a button with + // data-testid="...-rule-drill". The other rule codes also drill + // the same way. + const { container, unmount } = renderIntoContainer( + + ); + + const errorRuleDrill = container.querySelector( + '[data-testid="validation-errors-rule-drill"]', + ); + const warningRuleDrill = container.querySelector( + '[data-testid="validation-warnings-rule-drill"]', + ); + + expect(errorRuleDrill).not.toBeNull(); + expect(errorRuleDrill?.tagName).toBe("BUTTON"); + expect(errorRuleDrill?.textContent).toContain("R050_diagnosis_present"); + + expect(warningRuleDrill).not.toBeNull(); + expect(warningRuleDrill?.tagName).toBe("BUTTON"); + expect(warningRuleDrill?.textContent).toContain("R200_units_recommended"); + + unmount(); + }); + + it("SP21 Task 5.9: clicking the rule code opens the PeekModal", async () => { + // Clicking the rule code in the errors sub-section pushes a + // `{ kind: "rule", rule }` peek onto the drill stack. The peek + // is a Radix Dialog portal with the eyebrow "Validation rule" + // and the rule code as the title. + const { container, unmount } = renderIntoContainer( + + ); + + const ruleBtn = container.querySelector( + '[data-testid="validation-errors-rule-drill"]', + ) as HTMLButtonElement | null; + expect(ruleBtn).not.toBeNull(); + + await act(async () => { + ruleBtn!.click(); + await Promise.resolve(); + }); + + // The PeekModal portals to document.body as a Radix dialog. Find + // the dialog with the "Validation rule" eyebrow. + const dialogs = document.body.querySelectorAll('[role="dialog"]'); + const peekDialog = Array.from(dialogs).find((d) => + d.textContent?.includes("Validation rule"), + ); + expect(peekDialog).not.toBeNull(); + // Title is the rule code. + expect(peekDialog?.textContent).toContain("R050_diagnosis_present"); + // Body includes the catalog description for R050. + expect(peekDialog?.textContent).toContain("Diagnosis pointer present"); + + unmount(); + }); + + it("SP21 Task 5.9: unknown rule still opens the peek (fallback note)", async () => { + // Rules not in the catalog still open the peek — operators + // should be able to correlate unknown rule codes to whatever + // they were just looking at. The peek shows an "Unknown rule" + // note instead of the catalog text. + const { container, unmount } = renderIntoContainer( + + ); + + const ruleBtn = container.querySelector( + '[data-testid="validation-errors-rule-drill"]', + ) as HTMLButtonElement | null; + expect(ruleBtn).not.toBeNull(); + + await act(async () => { + ruleBtn!.click(); + await Promise.resolve(); + }); + + const dialogs = document.body.querySelectorAll('[role="dialog"]'); + const peekDialog = Array.from(dialogs).find((d) => + d.textContent?.includes("Validation rule"), + ); + expect(peekDialog).not.toBeNull(); + expect(peekDialog?.textContent).toContain("R999_totally_made_up"); + expect(peekDialog?.textContent).toContain("Unknown rule"); + + unmount(); + }); +}); + +beforeEach(() => { + // The PeekModal portals to document.body as a Radix dialog. Wipe + // any leftover dialogs between tests so the "find dialog by + // eyebrow" assertions in the rule-drill tests don't see stale + // portals from a prior test. + document.body.querySelectorAll('[role="dialog"]').forEach((d) => d.remove()); }); diff --git a/src/components/ClaimDrawer/ValidationPanel.tsx b/src/components/ClaimDrawer/ValidationPanel.tsx index 0fa89b6..9a5c879 100644 --- a/src/components/ClaimDrawer/ValidationPanel.tsx +++ b/src/components/ClaimDrawer/ValidationPanel.tsx @@ -1,4 +1,7 @@ import { AlertCircle, AlertTriangle, CheckCircle2 } from "lucide-react"; +import { useDrillStack } from "@/components/drill/DrillStackProvider"; +import { PeekModal } from "@/components/drill/PeekModal"; +import { ValidationRulePeekContent } from "@/components/drill/ValidationRulePeekContent"; import type { ClaimDetail } from "@/types"; type ValidationPanelProps = { @@ -28,6 +31,13 @@ function groupByRule(issues: IssueList): Array<[string, IssueList]> { /** * One rule-group block: header with rule code + count chip, followed by * the list of messages underneath. + * + * SP21 Phase 5 Task 5.9: the rule code is now drillable — clicking it + * opens the validation-rule peek on top of the drawer (via the drill + * stack). The peek renders ValidationRulePeekContent for the rule + * code, falling back to a "unknown rule" note when the catalog has + * no entry. Unknown rules still render the peek so operators can + * correlate the code to whatever they were just looking at. */ function IssueGroup({ rule, @@ -43,15 +53,20 @@ function IssueGroup({ testId === "validation-errors" ? "text-[color:var(--m-error)]" : "text-[color:var(--m-warning)]"; + const { openPeek } = useDrillStack(); return (
- openPeek({ kind: "rule", rule })} + data-testid={`${testId}-rule-drill`} + aria-label={`Drill into rule ${rule}`} + className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)] cursor-pointer drillable rounded-sm px-0 -mx-0" > {rule} - +
) : null} + + {/* SP21 Phase 5 Task 5.9: validation-rule peek. Mounted at the + bottom of the panel so the peek (a Radix Dialog portal) sits + on top of the drawer. Only renders when the top of the + drill stack is a rule peek — payer peek (from PartiesGrid) + wins when it's on top because the stack caps at 1 entry. */} + {topPeek?.kind === "rule" ? ( + + + + ) : null} ); } diff --git a/src/components/drill/ValidationRulePeekContent.tsx b/src/components/drill/ValidationRulePeekContent.tsx new file mode 100644 index 0000000..6c22cd5 --- /dev/null +++ b/src/components/drill/ValidationRulePeekContent.tsx @@ -0,0 +1,134 @@ +interface Props { + /** The rule code (e.g. "R050_diagnosis_present" or just "R050"). */ + rule: string; +} + +interface RuleDoc { + /** Short human title (e.g. "Diagnosis pointer present"). */ + title: string; + /** Plain-English description of what the rule checks. */ + description: string; + /** Why the rule matters — operator-facing rationale. */ + whyItMatters: string; + /** How to fix — short, actionable. */ + howToFix: string; +} + +/** + * SP21 Phase 5 Task 5.9: rule catalog used by ValidationRulePeekContent. + * + * The catalog is intentionally small — it covers the rules we actually + * emit today (R050_diagnosis_present, R200_units_recommended). For + * anything not in the catalog the peek still renders (with an "Unknown + * rule" note) — operators should still be able to open the peek for + * any rule code so they can see the originating message verbatim. + * + * Adding a new entry here is the source-of-truth for the rule's + * documentation. The ValidationPanel wires the peek by rule code; if + * we add new rules later (Phase 6+), add a new entry here. + */ +const RULE_CATALOG: Record = { + R050_diagnosis_present: { + title: "Diagnosis pointer present", + description: + "Each service line must point to at least one diagnosis code in the claim header (the HL segment's HI element). A missing pointer makes the line unprocessable on the payer side.", + whyItMatters: + "Payers reject claims with missing diagnosis pointers at the 999 stage, which would otherwise re-trigger the 999 rejection loop. Catching it here gives the operator a chance to attach the dx before submission.", + howToFix: + "Open the claim's Service Lines table and attach the relevant diagnosis code (e.g. E11.9) to the line. The pointer is the line's diagnosis pointer list.", + }, + R200_units_recommended: { + title: "Service line units recommended", + description: + "Service lines that represent timed procedures (anesthesia, critical care, psychotherapy time-based codes) should carry an explicit units value. Defaulting to 1 is acceptable for most codes but flagged here for review.", + whyItMatters: + "Timed codes without units get under-reimbursed — payers default to 1 unit when the field is blank, even when the procedure took 45 minutes. The warning exists so an operator can verify the units are correct before submission.", + howToFix: + "Confirm the units value on the service line matches the documented encounter time. If the code is not time-based, no action is required.", + }, +}; + +/** + * Peek body for a validation rule — opens on top of the ClaimDrawer + * via PeekModal when the operator clicks a rule code in the + * ValidationPanel. The body shows the rule's title, description, why + * it matters, and how to fix it. + * + * Unknown rules (codes not in the catalog) render a small "Unknown + * rule — see originating message" note rather than blowing up. The + * peek still renders so the operator can correlate the rule code to + * whatever they were just looking at. + * + * No fetch — the catalog is static and bundled. (A future phase + * could swap this for a backend-served catalog if rules become + * user-extensible.) + */ +export function ValidationRulePeekContent({ rule }: Props) { + // Normalize: the rule code in the validation payload is the full + // form (`R050_diagnosis_present`), but a future backend response + // might use the short form (`R050`). Look up both. + const doc = + RULE_CATALOG[rule] ?? + RULE_CATALOG[rule.split("_")[0] ?? ""] ?? + null; + + if (!doc) { + return ( +
+
+ {rule} +
+
+ Unknown rule. The originating message is the authoritative + description — this peek is a no-op for undocumented rule codes. +
+
+ ); + } + + return ( +
+
+ {doc.title} +
+
+ {rule} +
+
+ {doc.description} +
+
+
{doc.whyItMatters}
+
{doc.howToFix}
+
+
+ ); +} + +function Section({ + heading, + children, +}: { + heading: string; + children: React.ReactNode; +}) { + return ( +
+
+ {heading} +
+
+ {children} +
+
+ ); +} \ No newline at end of file