diff --git a/backend/src/main/java/com/workwell/BackendApplication.java b/backend/src/main/java/com/workwell/BackendApplication.java index f5b0c10..e7c7fd9 100644 --- a/backend/src/main/java/com/workwell/BackendApplication.java +++ b/backend/src/main/java/com/workwell/BackendApplication.java @@ -12,6 +12,10 @@ @EnableScheduling public class BackendApplication { + static { + java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("UTC")); + } + public static void main(String[] args) { SpringApplication.run(BackendApplication.class, args); } diff --git a/backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java b/backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java index 9f8ee59..23b329b 100644 --- a/backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java +++ b/backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java @@ -35,7 +35,7 @@ public ValueSetGovernanceService(JdbcTemplate jdbcTemplate, ObjectMapper objectM } public ResolveCheckResult resolveCheck(UUID measureId) { - ensureDemoValueSetLinks(); + ensureDemoValueSets(); UUID measureVersionId = latestMeasureVersionId(measureId); String cqlText = getCqlText(measureVersionId); @@ -230,11 +230,118 @@ INSERT INTO terminology_mappings (id, local_code, local_display, local_system, // Private helpers - private void ensureDemoValueSetLinks() { + private void ensureDemoValueSets() { + // 1. Update/Ensure the main 4 procedure value sets with their correct CQL-matching names & OIDs + ensureValueSet( + DEMO_VS_AUDIOGRAM, + "urn:workwell:vs:audiogram-procedures", + "Audiogram Procedures", + "2025-demo", + "[{\"code\":\"LOCAL-AUD-001\",\"display\":\"Baseline audiogram\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"LOCAL-AUD-002\",\"display\":\"Annual audiogram evaluation\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"LOCAL-AUD-003\",\"display\":\"Audiometric test pure tone\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"audiogram-procedure\",\"display\":\"Audiogram procedure\",\"system\":\"urn:workwell:vs:audiogram-procedures\"}," + + "{\"code\":\"92557\",\"display\":\"Comprehensive audiometry evaluation\",\"system\":\"http://www.ama-assn.org/go/cpt\"}]" + ); + + ensureValueSet( + DEMO_VS_TB, + "urn:workwell:vs:tb-screening", + "TB Screening Procedures", + "2025-demo", + "[{\"code\":\"LOCAL-TB-001\",\"display\":\"PPD skin test placement\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"LOCAL-TB-002\",\"display\":\"TB IGRA blood test\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"tb-screen\",\"display\":\"TB Screening Procedures\",\"system\":\"urn:workwell:vs:tb-screening\"}," + + "{\"code\":\"86580\",\"display\":\"Intradermal skin test\",\"system\":\"http://www.ama-assn.org/go/cpt\"}]" + ); + + ensureValueSet( + DEMO_VS_HAZWOPER, + "urn:workwell:vs:hazwoper-exams", + "HAZWOPER Surveillance Exams", + "2025-demo", + "[{\"code\":\"LOCAL-HAZ-001\",\"display\":\"HAZWOPER medical surveillance exam\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"LOCAL-HAZ-002\",\"display\":\"Annual fitness-for-duty evaluation\",\"system\":\"urn:workwell:demo\"}," + + "{\"code\":\"hazwoper-exam\",\"display\":\"HAZWOPER Surveillance Exams\",\"system\":\"urn:workwell:vs:hazwoper-exams\"}]" + ); + + ensureValueSet( + DEMO_VS_FLU, + "urn:workwell:vs:flu-vaccines", + "Influenza Vaccines", + "2025-demo", + "[{\"code\":\"88\",\"display\":\"Influenza virus vaccine unspecified\",\"system\":\"http://hl7.org/fhir/sid/cvx\"}," + + "{\"code\":\"141\",\"display\":\"Influenza seasonal injectable\",\"system\":\"http://hl7.org/fhir/sid/cvx\"}," + + "{\"code\":\"flu-vaccine\",\"display\":\"Influenza Vaccines\",\"system\":\"urn:workwell:vs:flu-vaccines\"}," + + "{\"code\":\"LOCAL-FLU-001\",\"display\":\"Flu vaccine administered\",\"system\":\"urn:workwell:demo\"}]" + ); + + // 2. Seed remaining 8 value sets with stable UUIDs + UUID VS_AUDIOGRAM_ENROLL = UUID.fromString("a0000001-0000-0000-0000-000000000005"); + UUID VS_AUDIOGRAM_WAIVER = UUID.fromString("a0000001-0000-0000-0000-000000000006"); + + UUID VS_TB_ENROLL = UUID.fromString("a0000001-0000-0000-0000-000000000007"); + UUID VS_TB_WAIVER = UUID.fromString("a0000001-0000-0000-0000-000000000008"); + + UUID VS_HAZWOPER_ENROLL = UUID.fromString("a0000001-0000-0000-0000-000000000009"); + UUID VS_HAZWOPER_WAIVER = UUID.fromString("a0000001-0000-0000-0000-000000000010"); + + UUID VS_FLU_ENROLL = UUID.fromString("a0000001-0000-0000-0000-000000000011"); + UUID VS_FLU_WAIVER = UUID.fromString("a0000001-0000-0000-0000-000000000012"); + + ensureValueSet(VS_AUDIOGRAM_ENROLL, "urn:workwell:vs:hearing-enrollment", "Hearing Conservation Enrollment", "2025-demo", + "[{\"code\":\"hearing-enrollment\",\"display\":\"Hearing Conservation Enrollment\",\"system\":\"urn:workwell:vs:hearing-enrollment\"}]"); + ensureValueSet(VS_AUDIOGRAM_WAIVER, "urn:workwell:vs:audiogram-waiver", "Audiogram Medical Waiver", "2025-demo", + "[{\"code\":\"audiogram-waiver\",\"display\":\"Audiogram Medical Waiver\",\"system\":\"urn:workwell:vs:audiogram-waiver\"}]"); + + ensureValueSet(VS_TB_ENROLL, "urn:workwell:vs:tb-eligible-roles", "TB Eligible Roles", "2025-demo", + "[{\"code\":\"tb-program\",\"display\":\"TB Eligible Roles\",\"system\":\"urn:workwell:vs:tb-eligible-roles\"}]"); + ensureValueSet(VS_TB_WAIVER, "urn:workwell:vs:tb-exemption", "TB Medical Exemption", "2025-demo", + "[{\"code\":\"tb-exemption\",\"display\":\"TB Medical Exemption\",\"system\":\"urn:workwell:vs:tb-exemption\"}]"); + + ensureValueSet(VS_HAZWOPER_ENROLL, "urn:workwell:vs:hazwoper-enrollment", "HAZWOPER Program Enrollment", "2025-demo", + "[{\"code\":\"hazwoper-program\",\"display\":\"HAZWOPER Program Enrollment\",\"system\":\"urn:workwell:vs:hazwoper-enrollment\"}]"); + ensureValueSet(VS_HAZWOPER_WAIVER, "urn:workwell:vs:hazwoper-exemption", "HAZWOPER Medical Exemption", "2025-demo", + "[{\"code\":\"hazwoper-exemption\",\"display\":\"HAZWOPER Medical Exemption\",\"system\":\"urn:workwell:vs:hazwoper-exemption\"}]"); + + ensureValueSet(VS_FLU_ENROLL, "urn:workwell:vs:clinical-roles", "Clinical Facing Roles", "2025-demo", + "[{\"code\":\"clinical-role\",\"display\":\"Clinical Facing Roles\",\"system\":\"urn:workwell:vs:clinical-roles\"}]"); + ensureValueSet(VS_FLU_WAIVER, "urn:workwell:vs:flu-exemption", "Flu Vaccine Exemption", "2025-demo", + "[{\"code\":\"flu-exemption\",\"display\":\"Flu Vaccine Exemption\",\"system\":\"urn:workwell:vs:flu-exemption\"}]"); + + // 3. Link them all ensureLink("Audiogram", DEMO_VS_AUDIOGRAM); + ensureLink("Audiogram", VS_AUDIOGRAM_ENROLL); + ensureLink("Audiogram", VS_AUDIOGRAM_WAIVER); + ensureLink("TB Surveillance", DEMO_VS_TB); + ensureLink("TB Surveillance", VS_TB_ENROLL); + ensureLink("TB Surveillance", VS_TB_WAIVER); + ensureLink("HAZWOPER Surveillance", DEMO_VS_HAZWOPER); + ensureLink("HAZWOPER Surveillance", VS_HAZWOPER_ENROLL); + ensureLink("HAZWOPER Surveillance", VS_HAZWOPER_WAIVER); + ensureLink("Flu Vaccine", DEMO_VS_FLU); + ensureLink("Flu Vaccine", VS_FLU_ENROLL); + ensureLink("Flu Vaccine", VS_FLU_WAIVER); + } + + private void ensureValueSet(UUID id, String oid, String name, String version, String codesJson) { + jdbcTemplate.update("DELETE FROM value_sets WHERE oid = ? AND id <> ?", oid, id); + + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM value_sets WHERE id = ?", Integer.class, id); + if (count != null && count > 0) { + jdbcTemplate.update( + "UPDATE value_sets SET oid = ?, name = ?, version = ?, codes_json = ?::jsonb, resolution_status = 'RESOLVED', last_resolved_at = NOW() WHERE id = ?", + oid, name, version, codesJson, id + ); + } else { + jdbcTemplate.update( + "INSERT INTO value_sets (id, oid, name, version, codes_json, resolution_status, status, source, last_resolved_at) VALUES (?, ?, ?, ?, ?::jsonb, 'RESOLVED', 'ACTIVE', 'WorkWell Demo', NOW())", + id, oid, name, version, codesJson + ); + } } private void ensureLink(String measureName, UUID valueSetId) { diff --git a/backend/src/main/java/com/workwell/run/RunPersistenceService.java b/backend/src/main/java/com/workwell/run/RunPersistenceService.java index fa4152b..c031a73 100644 --- a/backend/src/main/java/com/workwell/run/RunPersistenceService.java +++ b/backend/src/main/java/com/workwell/run/RunPersistenceService.java @@ -319,9 +319,21 @@ public void finalizeAsyncRun(UUID runId, String scopeLabel, List String stage = "start"; try { seedSyntheticEmployees(); - Instant startedAt = LocalDate.parse(measureRuns.get(0).evaluationDate()).atStartOfDay().toInstant(ZoneOffset.UTC); + + Timestamp dbStartedAtTs = null; + try { + dbStartedAtTs = jdbcTemplate.queryForObject("SELECT started_at FROM runs WHERE id = ?", Timestamp.class, runId); + } catch (Exception ex) { + log.warn("Could not query started_at for run {}: {}", runId, ex.getMessage()); + } + Instant actualStart = dbStartedAtTs != null ? dbStartedAtTs.toInstant() : Instant.now(); Instant completedAt = Instant.now(); + long durationMs = Math.max(0, completedAt.toEpochMilli() - actualStart.toEpochMilli()); + String evaluationPeriod = measureRuns.get(0).evaluationDate(); + LocalDate evalDate = LocalDate.parse(evaluationPeriod); + Instant periodStart = evalDate.minusYears(1).atStartOfDay().toInstant(ZoneOffset.UTC); + Instant periodEnd = evalDate.atStartOfDay().toInstant(ZoneOffset.UTC); long totalEvaluated = measureRuns.stream().mapToLong(payload -> payload.outcomes().size()).sum(); long compliant = measureRuns.stream() @@ -427,14 +439,14 @@ public void finalizeAsyncRun(UUID runId, String scopeLabel, List WHERE id = ? """, finalStatus, - Timestamp.from(startedAt), + Timestamp.from(actualStart), Timestamp.from(completedAt), totalEvaluated, compliant, nonCompliant, - Timestamp.from(startedAt), - Timestamp.from(completedAt), - completedAt.toEpochMilli() - startedAt.toEpochMilli(), + Timestamp.from(periodStart), + Timestamp.from(periodEnd), + durationMs, failureSummary, partialFailureCount, runId diff --git a/backend/src/test/java/com/workwell/measure/ValueSetGovernanceIntegrationTest.java b/backend/src/test/java/com/workwell/measure/ValueSetGovernanceIntegrationTest.java index 685c6d5..a9a289a 100644 --- a/backend/src/test/java/com/workwell/measure/ValueSetGovernanceIntegrationTest.java +++ b/backend/src/test/java/com/workwell/measure/ValueSetGovernanceIntegrationTest.java @@ -67,7 +67,7 @@ void resolveCheckWithSeededDemoValueSetsLinked() { assertThat(result.measureId()).isEqualTo(audiogramId); assertThat(result.valueSets()).isNotEmpty(); - assertThat(result.valueSets()).anyMatch(vs -> "Audiogram Procedure Codes".equals(vs.name())); + assertThat(result.valueSets()).anyMatch(vs -> "Audiogram Procedures".equals(vs.name())); assertThat(result.valueSets()).anyMatch(vs -> vs.codeCount() > 0); } diff --git a/backend/src/test/java/com/workwell/web/MeasureControllerTest.java b/backend/src/test/java/com/workwell/web/MeasureControllerTest.java index b374d2d..5259d6a 100644 --- a/backend/src/test/java/com/workwell/web/MeasureControllerTest.java +++ b/backend/src/test/java/com/workwell/web/MeasureControllerTest.java @@ -138,8 +138,8 @@ void resolveCheckEndpointReturnsOk() throws Exception { measureId, versionId, true, List.of(new ValueSetGovernanceService.ValueSetCheckItem( UUID.fromString("a0000001-0000-0000-0000-000000000001"), - "Audiogram Procedure Codes", - "urn:workwell:vs:audiogram-procedure-codes", + "Audiogram Procedures", + "urn:workwell:vs:audiogram-procedures", "2025-demo", "RESOLVED", 4, List.of(), false )), List.of(), List.of() @@ -149,7 +149,7 @@ void resolveCheckEndpointReturnsOk() throws Exception { mockMvc.perform(post("/api/measures/{id}/value-sets/resolve-check", measureId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.allResolved").value(true)) - .andExpect(jsonPath("$.valueSets[0].name").value("Audiogram Procedure Codes")) + .andExpect(jsonPath("$.valueSets[0].name").value("Audiogram Procedures")) .andExpect(jsonPath("$.valueSets[0].codeCount").value(4)) .andExpect(jsonPath("$.blockers").isArray()) .andExpect(jsonPath("$.warnings").isArray()); @@ -161,8 +161,8 @@ void valueSetDiffEndpointReturnsOk() throws Exception { UUID toId = UUID.fromString("a0000001-0000-0000-0000-000000000002"); when(valueSetGovernanceService.diff(fromId, toId)).thenReturn( new ValueSetGovernanceService.ValueSetDiffResponse( - fromId.toString(), "Audiogram Procedure Codes", "2025-demo", - toId.toString(), "TB Screening Procedure Codes", "2025-demo", + fromId.toString(), "Audiogram Procedures", "2025-demo", + toId.toString(), "TB Screening Procedures", "2025-demo", List.of(new ValueSetGovernanceService.CodeEntry("LOCAL-TB-001", "PPD skin test", "urn:workwell:demo")), List.of(new ValueSetGovernanceService.CodeEntry("LOCAL-AUD-001", "Baseline audiogram", "urn:workwell:demo")), List.of(), List.of("1 code(s) added.", "1 code(s) removed.") @@ -171,8 +171,8 @@ void valueSetDiffEndpointReturnsOk() throws Exception { mockMvc.perform(get("/api/value-sets/{id}/diff", fromId).param("to", toId.toString())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.fromName").value("Audiogram Procedure Codes")) - .andExpect(jsonPath("$.toName").value("TB Screening Procedure Codes")) + .andExpect(jsonPath("$.fromName").value("Audiogram Procedures")) + .andExpect(jsonPath("$.toName").value("TB Screening Procedures")) .andExpect(jsonPath("$.addedCodes[0].code").value("LOCAL-TB-001")) .andExpect(jsonPath("$.removedCodes[0].code").value("LOCAL-AUD-001")); } diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index ca84de3..8ca5187 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -1,5 +1,27 @@ # Journal +## 2026-05-20 — UAT Sections 6-8: Run history, Studio, Admin fixes (issue #29) + +**Goal:** Fix all reported Section 6 (Run History), Section 7 (Studio/CQL), and Section 8 (Admin panel) UAT bugs from GitHub issue #29. + +**Branch:** `fix/sprint-1-uat-sections-6-8` + +**What changed:** + +- `backend/src/main/java/com/workwell/run/RunPersistenceService.java` — Fixed `finalizeAsyncRun()` duration computation: was incorrectly using the evaluationDate (a historical date) to compute `duration_ms`, yielding absurd values like `69068s`. Now fetches the actual `started_at` from the DB and computes real wall-clock duration. Also fixed `measurement_period_start`/`measurement_period_end` to correctly reflect the 1-year evaluation window (evalDate-1yr → evalDate) instead of repeating `startedAt` twice. +- `backend/src/main/java/com/workwell/BackendApplication.java` — Set JVM default timezone to UTC on startup for consistent timestamp handling. +- `backend/src/main/java/com/workwell/measure/ValueSetGovernanceService.java` — Renamed `ensureDemoValueSetLinks()` to `ensureDemoValueSets()` and expanded it to seed all 4 demo value sets (audiogram, TB, HAZWOPER, flu vaccine) with their correct CQL-matching canonical OIDs and local codes so `resolveCheck` finds matching codes. +- `frontend/app/(dashboard)/admin/page.tsx` — Added confirmation dialog before disabling the scheduler to prevent accidental disables during a demo. +- `frontend/features/studio/components/CqlTab.tsx` — Added "New Version" button with a modal dialog for entering a change summary and cloning the current CQL into a new draft measure version. +- PR review follow-up: wired the CQL-tab modal summary directly into the version-clone request so it no longer depends on asynchronous React state, added a Runs/Run Detail display guard that renders anomalous `durationMs` values over 1 hour as `-` or `Stalled`, and restored Cases search state synchronization when browser history changes the `search` URL parameter. + +**Verification:** +- `backend/gradlew.bat test --tests com.workwell.export.* --tests com.workwell.web.RunControllerTest` — BUILD SUCCESSFUL (21s). +- Backend compiles cleanly: `gradlew compileJava` — BUILD SUCCESSFUL (16s). +- Playwright end-to-end: triggered async All Programs run — completed with `179s` real duration (vs old seeded `60s` constant). Duration correctly reflects actual CQL evaluation time. + +--- + ## 2026-05-20 — UAT Section 5: Case detail fixes (issue #28) **Goal:** Fix Section 5 case-detail bugs from UAT #23: escalation confirmation, outreach delivery badge refresh, audit packet format selectors, and walkthrough-guide inaccuracies. diff --git a/frontend/app/(dashboard)/admin/page.tsx b/frontend/app/(dashboard)/admin/page.tsx index 3c5a9db..38a3c47 100644 --- a/frontend/app/(dashboard)/admin/page.tsx +++ b/frontend/app/(dashboard)/admin/page.tsx @@ -149,6 +149,7 @@ export default function AdminPage() { const [templatePreview, setTemplatePreview] = useState(null); const [deliveryLog, setDeliveryLog] = useState([]); const [showResetConfirm, setShowResetConfirm] = useState(false); + const [showDisableSchedulerConfirm, setShowDisableSchedulerConfirm] = useState(false); const [resetting, setResetting] = useState(false); const [resetMessage, setResetMessage] = useState(null); const { siteId } = useGlobalFilters(); @@ -464,23 +465,54 @@ export default function AdminPage() {

Last scheduled run: {scheduler?.lastRunAt ? new Date(scheduler.lastRunAt).toLocaleString() : "Never"} ({formatStatusLabel(scheduler?.lastRunStatus ?? "never")})

-
- - +
+
+ + {!showDisableSchedulerConfirm && ( + + )} +
+ {showDisableSchedulerConfirm && ( +
+

Are you sure you want to disable the scheduler?

+
+ + +
+
+ )}
diff --git a/frontend/app/(dashboard)/runs/page.tsx b/frontend/app/(dashboard)/runs/page.tsx index e478fff..b79dc59 100644 --- a/frontend/app/(dashboard)/runs/page.tsx +++ b/frontend/app/(dashboard)/runs/page.tsx @@ -97,6 +97,7 @@ type RunInsightResponse = { }; const RUN_PAGE_SIZE = 20; +const MAX_DISPLAY_DURATION_MS = 60 * 60 * 1000; function formatAbsoluteTimestamp(dateString: string | null): string { if (!dateString) return "-"; @@ -133,6 +134,14 @@ function formatRelativeTimestamp(dateString: string | null): string { return date.toLocaleDateString(); } +function formatRunDuration(durationMs: number, status?: string): string { + if (!Number.isFinite(durationMs) || durationMs < 0) return "-"; + if (durationMs > MAX_DISPLAY_DURATION_MS) { + return normalizeEnumValue(status ?? "") === "RUNNING" ? "Stalled" : "-"; + } + return `${Math.round(durationMs / 1000)}s`; +} + export default function RunsPage() { const api = useApi(); const router = useRouter(); @@ -495,7 +504,7 @@ export default function RunsPage() { {labelFor(RUN_STATUS_LABELS, run.status)} {labelFor(SCOPE_LABELS, run.scopeType)} - {Math.round(run.durationMs / 1000)}s + {formatRunDuration(run.durationMs, run.status)} {formatRelativeTimestamp(run.startedAt)} @@ -541,7 +550,7 @@ export default function RunsPage() {

Trigger: {labelFor(TRIGGER_LABELS, selectedRun.triggerType)}

Started: {selectedRun.startedAt ? new Date(selectedRun.startedAt).toLocaleString() : "-"}

Completed: {selectedRun.completedAt ? new Date(selectedRun.completedAt).toLocaleString() : "-"}

-

Duration: {Math.round(selectedRun.durationMs / 1000)}s

+

Duration: {formatRunDuration(selectedRun.durationMs, selectedRun.status)}

Evaluated: {selectedRun.totalEvaluated}

Cases: {selectedRun.totalCases}

Pass Rate: {selectedRun.passRate.toFixed(1)}%

diff --git a/frontend/app/(dashboard)/studio/[id]/page.tsx b/frontend/app/(dashboard)/studio/[id]/page.tsx index 12ab8d1..f6cccb1 100644 --- a/frontend/app/(dashboard)/studio/[id]/page.tsx +++ b/frontend/app/(dashboard)/studio/[id]/page.tsx @@ -54,16 +54,22 @@ export default function StudioMeasurePage() { return () => window.clearTimeout(timer); }, [measureId]); - async function createNewVersion() { + async function createNewVersion(summaryOverride?: string): Promise { + const summary = (summaryOverride ?? changeSummary).trim(); setError(null); - if (!changeSummary.trim()) { setError("Change summary is required to create a new version."); return; } + if (!summary) { + setError("Change summary is required to create a new version."); + return false; + } try { - await api.post(`/api/measures/${measureId}/versions`, { changeSummary: changeSummary.trim() }); + await api.post(`/api/measures/${measureId}/versions`, { changeSummary: summary }); setChangeSummary(""); emitToast("New draft version created"); await load(); + return true; } catch (err) { setError(err instanceof Error ? err.message : "Version clone failed"); + return false; } } @@ -110,7 +116,7 @@ export default function StudioMeasurePage() { value={changeSummary} onChange={(e) => setChangeSummary(e.target.value)} /> - @@ -170,6 +176,8 @@ export default function StudioMeasurePage() { onCompileWarnings={setCompileWarnings} onCompiled={load} onError={(msg) => setError(msg || null)} + canClone={canClone} + onCreateNewVersion={createNewVersion} /> ) : null} diff --git a/frontend/features/studio/components/CqlTab.tsx b/frontend/features/studio/components/CqlTab.tsx index 603c639..627234c 100644 --- a/frontend/features/studio/components/CqlTab.tsx +++ b/frontend/features/studio/components/CqlTab.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import type { Monaco, OnChange, OnMount } from "@monaco-editor/react"; import { emitToast } from "@/lib/toast"; @@ -32,6 +32,8 @@ type Props = { onCompileWarnings: (warnings: string[]) => void; onCompiled: () => void; onError: (msg: string) => void; + canClone: boolean; + onCreateNewVersion: (summary: string) => Promise; }; export function CqlTab({ @@ -45,10 +47,35 @@ export function CqlTab({ onCompileErrors, onCompileWarnings, onCompiled, - onError + onError, + canClone, + onCreateNewVersion }: Props) { const editorRef = useRef[0] | null>(null); const monacoRef = useRef(null); + const [showNewVersionDialog, setShowNewVersionDialog] = useState(false); + const [newVersionSummary, setNewVersionSummary] = useState(""); + const [creatingVersion, setCreatingVersion] = useState(false); + + async function handleSubmitNewVersion() { + if (!newVersionSummary.trim()) { + onError("Change summary is required to create a new version."); + return; + } + setCreatingVersion(true); + onError(""); + try { + const created = await onCreateNewVersion(newVersionSummary.trim()); + if (created) { + setNewVersionSummary(""); + setShowNewVersionDialog(false); + } + } catch { + onError("Version clone failed"); + } finally { + setCreatingVersion(false); + } + } useEffect(() => { const editor = editorRef.current; @@ -144,7 +171,60 @@ export function CqlTab({ {formatStatusLabel(measure.compileStatus ?? "UNKNOWN")} + {canClone && ( + + )}
+ + {showNewVersionDialog && ( +
+
+

Create New Measure Version

+

+ This will clone the current CQL logic into a new draft version. +

+
+ +