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 React, { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
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 { ValidationPanel } from "./ValidationPanel";
|
||||||
|
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
|
||||||
import type { ClaimDetailValidation, ClaimDetailValidationIssue } from "@/types";
|
import type { ClaimDetailValidation, ClaimDetailValidationIssue } from "@/types";
|
||||||
|
|
||||||
function renderIntoContainer(element: React.ReactElement): {
|
function renderIntoContainer(element: React.ReactElement): {
|
||||||
@@ -17,8 +18,11 @@ function renderIntoContainer(element: React.ReactElement): {
|
|||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
const root: Root = createRoot(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(() => {
|
act(() => {
|
||||||
root.render(element);
|
root.render(<DrillStackProvider>{element}</DrillStackProvider>);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
container,
|
container,
|
||||||
@@ -244,4 +248,129 @@ describe("ValidationPanel", () => {
|
|||||||
|
|
||||||
unmount();
|
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 { 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";
|
import type { ClaimDetail } from "@/types";
|
||||||
|
|
||||||
type ValidationPanelProps = {
|
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
|
* One rule-group block: header with rule code + count chip, followed by
|
||||||
* the list of messages underneath.
|
* 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({
|
function IssueGroup({
|
||||||
rule,
|
rule,
|
||||||
@@ -43,15 +53,20 @@ function IssueGroup({
|
|||||||
testId === "validation-errors"
|
testId === "validation-errors"
|
||||||
? "text-[color:var(--m-error)]"
|
? "text-[color:var(--m-error)]"
|
||||||
: "text-[color:var(--m-warning)]";
|
: "text-[color:var(--m-warning)]";
|
||||||
|
const { openPeek } = useDrillStack();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<button
|
||||||
className="mono text-[12px] font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
|
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}
|
{rule}
|
||||||
</span>
|
</button>
|
||||||
<span
|
<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"
|
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`}
|
data-testid={`${testId}-count`}
|
||||||
@@ -93,6 +108,11 @@ function IssueGroup({
|
|||||||
*/
|
*/
|
||||||
export function ValidationPanel({ validation }: ValidationPanelProps) {
|
export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||||
const allPassed = validation.passed && validation.warnings.length === 0;
|
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) {
|
if (allPassed) {
|
||||||
return (
|
return (
|
||||||
@@ -185,6 +205,22 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</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