feat(claim-drawer): validation rule opens peek with rule catalog
This commit is contained in:
@@ -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(<DrillStackProvider>{element}</DrillStackProvider>);
|
||||
});
|
||||
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(
|
||||
<ValidationPanel
|
||||
validation={makeValidation({
|
||||
passed: false,
|
||||
errors: [
|
||||
makeIssue({ rule: "R050_diagnosis_present" }),
|
||||
],
|
||||
warnings: [
|
||||
makeIssue({
|
||||
rule: "R200_units_recommended",
|
||||
severity: "warning",
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ValidationPanel
|
||||
validation={makeValidation({
|
||||
passed: false,
|
||||
errors: [
|
||||
makeIssue({ rule: "R050_diagnosis_present" }),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<ValidationPanel
|
||||
validation={makeValidation({
|
||||
passed: false,
|
||||
errors: [
|
||||
makeIssue({ rule: "R999_totally_made_up" }),
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
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());
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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}
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
className="inline-flex items-center rounded-full bg-[color:var(--m-ink-tertiary)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[color:var(--m-ink-secondary)] tabular-nums"
|
||||
data-testid={`${testId}-count`}
|
||||
@@ -93,6 +108,11 @@ function IssueGroup({
|
||||
*/
|
||||
export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
const allPassed = validation.passed && validation.warnings.length === 0;
|
||||
// SP21 Phase 5 Task 5.9: peek stack for rule drill. The drill
|
||||
// provider is mounted at the App root; we only read the top entry
|
||||
// here so the peek renders regardless of which section pushed it.
|
||||
const { stack, closeTop } = useDrillStack();
|
||||
const topPeek = stack[stack.length - 1];
|
||||
|
||||
if (allPassed) {
|
||||
return (
|
||||
@@ -185,6 +205,22 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : 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" ? (
|
||||
<PeekModal
|
||||
open
|
||||
onClose={closeTop}
|
||||
eyebrow="Validation rule"
|
||||
title={topPeek.rule}
|
||||
>
|
||||
<ValidationRulePeekContent rule={topPeek.rule} />
|
||||
</PeekModal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, RuleDoc> = {
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="display text-[14px] text-foreground">
|
||||
{rule}
|
||||
</div>
|
||||
<div
|
||||
className="text-[12.5px]"
|
||||
style={{ color: "hsl(var(--muted-foreground))" }}
|
||||
>
|
||||
Unknown rule. The originating message is the authoritative
|
||||
description — this peek is a no-op for undocumented rule codes.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="display text-[15px] text-foreground">
|
||||
{doc.title}
|
||||
</div>
|
||||
<div
|
||||
className="mono text-[11px]"
|
||||
style={{ color: "hsl(var(--muted-foreground))" }}
|
||||
>
|
||||
{rule}
|
||||
</div>
|
||||
<div className="text-[13px] leading-relaxed text-[color:var(--m-ink-primary)]">
|
||||
{doc.description}
|
||||
</div>
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<Section heading="Why it matters">{doc.whyItMatters}</Section>
|
||||
<Section heading="How to fix">{doc.howToFix}</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
heading,
|
||||
children,
|
||||
}: {
|
||||
heading: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="mono text-[10px] uppercase tracking-[0.18em] font-semibold mb-0.5"
|
||||
style={{ color: "hsl(var(--muted-foreground))" }}
|
||||
>
|
||||
{heading}
|
||||
</div>
|
||||
<div className="text-[12.5px] leading-relaxed text-[color:var(--m-ink-secondary)]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user