diff --git a/frontend/features/studio/components/CqlTab.tsx b/frontend/features/studio/components/CqlTab.tsx index 60b552e..1b8760e 100644 --- a/frontend/features/studio/components/CqlTab.tsx +++ b/frontend/features/studio/components/CqlTab.tsx @@ -8,6 +8,7 @@ import { formatStatusLabel } from "@/lib/status"; import type { ApiClient } from "@/lib/api/client"; import type { MeasureDetail } from "../types"; import { compileStatusClass, formatIssue, parseCompileIssue } from "../utils"; +import { SqlPreviewPanel } from "./SqlPreviewPanel"; const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false, @@ -300,6 +301,8 @@ export function CqlTab({ ) : null} + + {showDraftCqlDialog && (
diff --git a/frontend/features/studio/components/SqlPreviewPanel.tsx b/frontend/features/studio/components/SqlPreviewPanel.tsx new file mode 100644 index 0000000..d937e1c --- /dev/null +++ b/frontend/features/studio/components/SqlPreviewPanel.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useState } from "react"; +import type { MeasureDetail } from "../types"; + +type Props = { + measure: MeasureDetail; +}; + +function buildSql(measure: MeasureDetail): string { + const { + policyRef, + eligibilityCriteria, + exclusions, + complianceWindow, + requiredDataElements, + } = measure; + + // Only parse as days when the string explicitly says "N days" / "N day". + // Strings like "Series of 3 doses over 6 months" must not be treated as a day count. + const windowMatch = complianceWindow?.match(/(\d+)\s*days?\b/i); + const windowDays = windowMatch ? parseInt(windowMatch[1], 10) : null; + const dueSoonDays = windowDays ? windowDays - 30 : null; + + const excl = exclusions?.[0]; + const exclSlug = excl?.criteriaText + ? excl.criteriaText.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, "") + : null; + const exclLine = exclSlug + ? ` WHEN ep.${exclSlug} = TRUE THEN 'EXCLUDED' -- ${excl!.label}` + : ` -- (no exclusion criteria defined in Spec)`; + + const dataComment = + (requiredDataElements ?? []).length > 0 + ? ` -- required: ${requiredDataElements.join(", ")}` + : ""; + + const enrollComment = eligibilityCriteria?.programEnrollmentText + ? `\n -- eligibility: ${eligibilityCriteria.programEnrollmentText}` + : ""; + + const roleClause = eligibilityCriteria?.roleFilter + ? ` AND e.role = '${eligibilityCriteria.roleFilter}'` + : ` -- role: unrestricted`; + + const siteClause = eligibilityCriteria?.siteFilter + ? ` AND e.site = '${eligibilityCriteria.siteFilter}'` + : ` -- site: all sites`; + + const overdueExpr = + windowDays !== null + ? `NOW() - MAX(exam.date) > INTERVAL '${windowDays} days'` + : `/* window: ${complianceWindow ?? "see CQL"} */`; + + const dueSoonExpr = + dueSoonDays !== null + ? `NOW() - MAX(exam.date) > INTERVAL '${dueSoonDays} days'` + : `/* DUE_SOON: see CQL */`; + + return `-- Illustrative analogy only — CQL is the compliance source of truth +-- Policy: ${policyRef ?? "see Spec tab"} +SELECT + e.id, + e.name, + e.role, + e.site, + MAX(exam.date) AS last_exam_date${dataComment ? ",\n" + dataComment : ""} + CASE +${exclLine} + WHEN MAX(exam.date) IS NULL THEN 'MISSING_DATA' + WHEN ${overdueExpr} THEN 'OVERDUE' + WHEN ${dueSoonExpr} THEN 'DUE_SOON' + -- DUE_SOON threshold approximate; see CQL for exact window + ELSE 'COMPLIANT' + END AS outcome_status +FROM employees e +JOIN employee_programs ep ON ep.employee_id = e.id${enrollComment} +LEFT JOIN exams exam ON exam.employee_id = e.id +WHERE e.active = TRUE +${roleClause} +${siteClause} +GROUP BY e.id, e.name, e.role, e.site, ep.exclusion_flag`; +} + +export function SqlPreviewPanel({ measure }: Props) { + const [open, setOpen] = useState(false); + const sql = buildSql(measure); + + return ( +
+ + + {open && ( +
+
+ Illustrative only + Not executed. CQL is the compliance source of truth. Column names and table structure are analogical. +
+
+            {sql}
+          
+
+ )} +
+ ); +} diff --git a/frontend/features/studio/components/__tests__/SqlPreviewPanel.test.tsx b/frontend/features/studio/components/__tests__/SqlPreviewPanel.test.tsx new file mode 100644 index 0000000..30045fe --- /dev/null +++ b/frontend/features/studio/components/__tests__/SqlPreviewPanel.test.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { SqlPreviewPanel } from "../SqlPreviewPanel"; +import type { MeasureDetail } from "../../types"; + +const sampleMeasure: MeasureDetail = { + id: "m1", + name: "Annual Audiogram Completed", + policyRef: "OSHA 29 CFR 1910.95", + oshaReferenceId: null, + version: "1.0", + status: "Active", + owner: "Safety", + description: "Annual audiogram for employees in hearing conservation.", + eligibilityCriteria: { + roleFilter: "Safety Technician", + siteFilter: "Plant A", + programEnrollmentText: "In Hearing Conservation Program", + }, + exclusions: [{ label: "Active Waiver", criteriaText: "Has Active Waiver" }], + complianceWindow: "365 days", + requiredDataElements: ["Audiogram Date", "Waiver Status"], + cqlText: "", + compileStatus: "COMPILED", + valueSets: [], + testFixtures: [], +}; + +describe("SqlPreviewPanel", () => { + it("renders a collapsed toggle button by default", () => { + render(); + expect(screen.getByRole("button", { name: /SQL Analogy/i })).toBeInTheDocument(); + expect(screen.queryByTestId("sql-preview-block")).toBeNull(); + }); + + it("expands and shows the illustrative-only banner on click", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + // The amber banner span uses exact text "Illustrative only" + expect(screen.getAllByText(/Illustrative only/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText(/Not executed\. CQL is the compliance source of truth/i)).toBeInTheDocument(); + }); + + it("shows policy ref and compliance window in the SQL block", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + const block = screen.getByTestId("sql-preview-block"); + expect(block.textContent).toContain("OSHA 29 CFR 1910.95"); + expect(block.textContent).toContain("365 days"); + expect(block.textContent).toContain("335 days"); + }); + + it("includes role and site filters from spec", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + const block = screen.getByTestId("sql-preview-block"); + expect(block.textContent).toContain("Safety Technician"); + expect(block.textContent).toContain("Plant A"); + }); + + it("includes the approximate DUE_SOON comment", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + const block = screen.getByTestId("sql-preview-block"); + expect(block.textContent).toContain("DUE_SOON threshold approximate; see CQL for exact window"); + }); + + it("collapses again on second click", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + expect(screen.queryByTestId("sql-preview-block")).toBeNull(); + }); + + it("renders fallback text when compliance window has no numeric value", () => { + const noWindow: MeasureDetail = { ...sampleMeasure, complianceWindow: "see policy" }; + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + const block = screen.getByTestId("sql-preview-block"); + expect(block.textContent).toContain("see policy"); + }); + + it("uses raw-string fallback for multi-number non-day windows like 'Series of 3 doses over 6 months'", () => { + const doseWindow: MeasureDetail = { ...sampleMeasure, complianceWindow: "Series of 3 doses over 6 months" }; + render(); + fireEvent.click(screen.getByRole("button", { name: /SQL Analogy/i })); + const block = screen.getByTestId("sql-preview-block"); + // Should pass the window through as a comment, not parse '3' as a day count + expect(block.textContent).toContain("Series of 3 doses over 6 months"); + expect(block.textContent).not.toContain("INTERVAL '3 days'"); + }); +});