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}
+
+
+
+
+
+
+ );
+}
+
+function Section({
+ heading,
+ children,
+}: {
+ heading: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {heading}
+
+
+ {children}
+
+
+ );
+}
\ No newline at end of file