feature: Simulation Control Center (Dashboard)
This commit is contained in:
@@ -142,6 +142,7 @@ public class EncountersListTests : IAsyncLifetime
|
|||||||
icu.GetProperty("gcsClassification").GetString().Should().Be("MODERATE");
|
icu.GetProperty("gcsClassification").GetString().Should().Be("MODERATE");
|
||||||
icu.GetProperty("attendingPhysician").GetString().Should().Be("Dr. Ward");
|
icu.GetProperty("attendingPhysician").GetString().Should().Be("Dr. Ward");
|
||||||
icu.GetProperty("lastObservationAt").GetDateTimeOffset().Should().NotBe(default);
|
icu.GetProperty("lastObservationAt").GetDateTimeOffset().Should().NotBe(default);
|
||||||
|
icu.GetProperty("isSimulated").GetBoolean().Should().BeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ public record WardEncounterSummary(
|
|||||||
string? GcsClassification,
|
string? GcsClassification,
|
||||||
DateTimeOffset? LastObservationAt,
|
DateTimeOffset? LastObservationAt,
|
||||||
string AttendingPhysician,
|
string AttendingPhysician,
|
||||||
DateTimeOffset AdmittedAt);
|
DateTimeOffset AdmittedAt,
|
||||||
|
bool IsSimulated = false);
|
||||||
|
|||||||
@@ -153,7 +153,8 @@ public class EncounterService : IEncounterService
|
|||||||
gcs?.Classification,
|
gcs?.Classification,
|
||||||
lastObservationAt,
|
lastObservationAt,
|
||||||
encounter.AttendingPhysician,
|
encounter.AttendingPhysician,
|
||||||
encounter.AdmittedAt));
|
encounter.AdmittedAt,
|
||||||
|
encounter.Patient.IsSimulated));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PagedResult<WardEncounterSummary>(summaries, page, pageSize, total);
|
return new PagedResult<WardEncounterSummary>(summaries, page, pageSize, total);
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
2dcd301eaabc7f81c1959c1adbb82b85204e1298 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz","integrity":"sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==","time":1785953912200,"size":571304,"metadata":{"time":1785953910664,"url":"https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"ed80e4dd0876eb8251989b454a19ded6\"","last-modified":"Sat, 13 Jun 2026 08:32:26 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
a685b54110c7e12febc5aa1baf5334b7fbb9bd5d {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz","integrity":"sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==","time":1785953911384,"size":32601,"metadata":{"time":1785953910992,"url":"https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"09853320d153c1e10f72563196274767\"","last-modified":"Thu, 02 Apr 2026 16:43:47 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
4e2fb49cf08db34c1a637a9f636d1d27ce4dd609 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz","integrity":"sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==","time":1785953912063,"size":1277372,"metadata":{"time":1785953910791,"url":"https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"6ffe96a217c6bef180cec697051b79d6\"","last-modified":"Fri, 12 Jun 2026 17:59:12 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
dc3f116e2fc0e24497fb31ef9dcf885e7783c990 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz","integrity":"sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==","time":1785953911295,"size":46389,"metadata":{"time":1785953911062,"url":"https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"62617ff0f132aa342fc74aa08d2d0d2e\"","last-modified":"Mon, 13 Apr 2026 20:41:46 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
93797821fbaaac28556311c548104081b86f3518 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz","integrity":"sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==","time":1785953911772,"size":427320,"metadata":{"time":1785953910956,"url":"https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"758a7d062fc209b0090ab072f4eee573\"","last-modified":"Tue, 05 May 2026 21:02:49 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
e67aed607cd01e02c6ebedcf28d21279491314cb {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/semver/-/semver-7.8.4.tgz","integrity":"sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==","time":1785953911977,"size":29325,"metadata":{"time":1785953910571,"url":"https://registry.npmjs.org/semver/-/semver-7.8.4.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"715397a31bd5c36494a3ed2a26bb88cb\"","last-modified":"Tue, 09 Jun 2026 23:50:05 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
6f1e2f039374ccb6ac28b858ec5c5618f121e92c {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz","integrity":"sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==","time":1785953911816,"size":67009,"metadata":{"time":1785953910583,"url":"https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"0b30e6ab9d64834c6bb441d62e617709\"","last-modified":"Tue, 17 Jun 2025 17:54:01 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
673d2f7f8226db25658360b0bb849a5ee603316c {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/glob/-/glob-10.5.0.tgz","integrity":"sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==","time":1785953912026,"size":74227,"metadata":{"time":1785953910963,"url":"https://registry.npmjs.org/glob/-/glob-10.5.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"35a8b606f227a3c84a8af5f3ebc03679\"","last-modified":"Tue, 18 Nov 2025 01:34:52 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
d1d5fe102df1c35571fbb714c7ab02a01cec6ba5 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz","integrity":"sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==","time":1785953912465,"size":3699964,"metadata":{"time":1785953910818,"url":"https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"cd82608915af34d4260109fc1ad6f7b8\"","last-modified":"Mon, 09 Mar 2026 04:23:34 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
c011da6f5a5f0bfe35f00a5177c4326ee481cfaa {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz","integrity":"sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==","time":1785953911307,"size":17360,"metadata":{"time":1785953911069,"url":"https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"8926ea0bf6fa92f4c76f5a9f8c801a3f\"","last-modified":"Tue, 31 Mar 2026 10:24:54 GMT"},"options":{"compress":true}}}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
|
||||||
|
497b3f086f894cbc8d2c76bb974449be9e3c2ff4 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/vite/-/vite-7.3.5.tgz","integrity":"sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==","time":1785953912118,"size":543193,"metadata":{"time":1785953911134,"url":"https://registry.npmjs.org/vite/-/vite-7.3.5.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"366ac74512ac7cdc06673bd7362a157f\"","last-modified":"Mon, 01 Jun 2026 10:19:33 GMT"},"options":{"compress":true}}}
|
||||||
|
ee9f9593cef8c446efbf7c8aa2c1b6c271b08575 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/vite/-/vite-7.3.5.tgz","integrity":"sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==","time":1785953912122,"size":543193,"metadata":{"time":1785953911109,"url":"https://registry.npmjs.org/vite/-/vite-7.3.5.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"366ac74512ac7cdc06673bd7362a157f\"","last-modified":"Mon, 01 Jun 2026 10:19:33 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
ea25a12abc7092163c99cc73e4168e53f741e14b {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz","integrity":"sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==","time":1785953911429,"size":9355,"metadata":{"time":1785953910761,"url":"https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"e0aae139db7a2b9ce9182e755fb7e054\"","last-modified":"Sat, 04 May 2024 01:10:39 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
2c5baf041c1d5cb99a633042b26ba55b2d984ab3 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz","integrity":"sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==","time":1785953912409,"size":335129,"metadata":{"time":1785953910626,"url":"https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"d56e8d61c0afbadb89f9ab8f60870573\"","last-modified":"Mon, 01 Jun 2026 09:39:05 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
28e162e7303833ccf27101cccde30bdb0bffdf10 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz","integrity":"sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==","time":1785953911896,"size":438527,"metadata":{"time":1785953910674,"url":"https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"3581d99c820bf686b8c59131895ac7cf\"","last-modified":"Mon, 25 May 2026 11:16:21 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
19838e61dfa5067eb59d05b305614d96bbd1cf89 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz","integrity":"sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==","time":1785953912512,"size":4746453,"metadata":{"time":1785953910925,"url":"https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"0cb4b5c1243dce10cc2efa4dd4c5bc59\"","last-modified":"Thu, 02 Apr 2026 16:43:43 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
be1ff74de135f502f26d7a49bda384aa28ff8776 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz","integrity":"sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==","time":1785953911322,"size":3205,"metadata":{"time":1785953910747,"url":"https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"966c194bda253ce0a4f6a8301b6828ee\"","last-modified":"Mon, 08 Jul 2024 12:57:25 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
05fe4f0bf572f7d20fdc303b6b064bccabbc6c3c {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz","integrity":"sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==","time":1785953912687,"size":8873207,"metadata":{"time":1785953910656,"url":"https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"76d28d6bf8f31554d587cd494cd8045e\"","last-modified":"Wed, 27 May 2026 11:47:02 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
7c3a3473b73bd8f3dfb90d58b6bd3e27c8d1ec32 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz","integrity":"sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==","time":1785953911912,"size":881262,"metadata":{"time":1785953910876,"url":"https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"a806648ad3d705c9f54816b2f4771223\"","last-modified":"Sat, 13 Jun 2026 08:31:58 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
94da227b56946879a36cc2227fd492f384ea94d0 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz","integrity":"sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==","time":1785953912291,"size":728769,"metadata":{"time":1785953910555,"url":"https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"2b85be9f8c2af7046d1181f3d873eda4\"","last-modified":"Sat, 12 Apr 2025 07:09:23 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
06f0683b26e0225edaae1d86d37467b03088b647 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz","integrity":"sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==","time":1785953912001,"size":174936,"metadata":{"time":1785953910523,"url":"https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"dd35902285a469bef4b6341d2d899174\"","last-modified":"Wed, 27 May 2026 11:49:17 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
f2c512257fcfeae452af0e1a76c161ac93881296 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/vite/-/vite-8.0.16.tgz","integrity":"sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==","time":1785953912135,"size":538260,"metadata":{"time":1785953910603,"url":"https://registry.npmjs.org/vite/-/vite-8.0.16.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"1c1ff0e0d13d58d1692d08a5ecc097e2\"","last-modified":"Mon, 01 Jun 2026 09:50:47 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
95545cc9f8501d418b345c7683775f3ff8c1a1c1 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz","integrity":"sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==","time":1785953911310,"size":9681,"metadata":{"time":1785953910996,"url":"https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"ab7e2817e97f21770839622bfc33fc36\"","last-modified":"Fri, 27 Feb 2026 23:16:49 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
309290abb0f4672b228fcc24f5000f83b4b09379 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz","integrity":"sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==","time":1785953911966,"size":982610,"metadata":{"time":1785953910836,"url":"https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"ef2a6aa27e252652e8e20b9df1f5c864\"","last-modified":"Sat, 13 Jun 2026 08:32:02 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
86545026ef44375b1a05070f481eb2f0e28927ea {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz","integrity":"sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==","time":1785953912158,"size":196338,"metadata":{"time":1785953910951,"url":"https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"25ec268915df89e2c9147cc847cfa0ae\"","last-modified":"Thu, 27 Feb 2025 17:54:02 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
fcdf5dec3ff20265222963c02402cccf93cb436e {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz","integrity":"sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==","time":1785953911336,"size":5240,"metadata":{"time":1785953910578,"url":"https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"525ca79ade2ccfef596412f4ffa432c3\"","last-modified":"Tue, 16 Dec 2025 15:17:59 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
a631269bc2d1a106a8dccf568adece63694153ef {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/which/-/which-2.0.2.tgz","integrity":"sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==","time":1785953911289,"size":4496,"metadata":{"time":1785953910542,"url":"https://registry.npmjs.org/which/-/which-2.0.2.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"9556a736013ff5223cc9731d9f32b55f\"","last-modified":"Mon, 18 Nov 2019 22:26:18 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
24be7f0adf6dd77562f46dea0028063dd47e1c3e {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz","integrity":"sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==","time":1785953911308,"size":53370,"metadata":{"time":1785953911059,"url":"https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"49edc750f1e546f945131df311d72e52\"","last-modified":"Wed, 17 Jun 2026 16:56:26 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
b1906c92556ec267f05fac0dbe5dfc0eced263de {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz","integrity":"sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==","time":1785953911308,"size":39414,"metadata":{"time":1785953911103,"url":"https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"31c7953660299e731ee70e98354f398f\"","last-modified":"Mon, 01 Jun 2026 09:38:48 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
e88d176a08778649375e12b00ae8e4c22c0a8f0c {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz","integrity":"sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==","time":1785953911305,"size":7167,"metadata":{"time":1785953910846,"url":"https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"cac9bb6bc6055fc1fcf99c3135dfa5f3\"","last-modified":"Thu, 18 Jun 2026 17:29:54 GMT"},"options":{"compress":true}}}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
bb6ac3a8e022d5572d54b2f5d3932c2d21af0188 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz","integrity":"sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==","time":1785953911308,"size":3965,"metadata":{"time":1785953911137,"url":"https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"5d5a51f9670467a8edaef4269b413647\"","last-modified":"Fri, 20 Jan 2023 02:27:13 GMT"},"options":{"compress":true}}}
|
||||||
Generated
+1
@@ -7,6 +7,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "vigilcare-dashboard",
|
"name": "vigilcare-dashboard",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:unit": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -40,17 +40,15 @@ describe('PatientBanner', () => {
|
|||||||
expect(strip.classes().join(' ')).toMatch(/bg-red-600/)
|
expect(strip.classes().join(' ')).toMatch(/bg-red-600/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('showsNkdaWhenNoAllergies', () => {
|
it('showsSimChipWhenPatientIsSimulated', () => {
|
||||||
const wrapper = mount(PatientBanner, {
|
const wrapper = mount(PatientBanner, {
|
||||||
props: {
|
props: {
|
||||||
encounter: {
|
encounter: {
|
||||||
...encounter,
|
...encounter,
|
||||||
patient: { ...encounter.patient, allergies: null },
|
patient: { ...encounter.patient, isSimulated: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(wrapper.text()).toContain('NKDA')
|
expect(wrapper.text()).toContain('SIM')
|
||||||
const strip = wrapper.find('[role="status"]')
|
|
||||||
expect(strip.classes().join(' ')).not.toMatch(/bg-red-600/)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ScenarioCard from '@/components/simulation/ScenarioCard.vue'
|
||||||
|
|
||||||
|
const baseScenario = {
|
||||||
|
id: 'stable-baseline-01',
|
||||||
|
name: 'Stable Baseline — Routine Inpatient Monitoring',
|
||||||
|
description: '52-year-old female admitted for elective cholecystectomy. Completely uneventful 8-hour post-op monitoring period with all vitals remaining normal throughout. This is a CONTROL scenario that validates the system does NOT generate false alerts on a stable patient.',
|
||||||
|
durationMinutes: 480,
|
||||||
|
tags: ['stable', 'baseline', 'control', 'surgery'],
|
||||||
|
department: 'Surgery',
|
||||||
|
eventCount: 312,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScenarioCard', () => {
|
||||||
|
it('rendersMetadata', () => {
|
||||||
|
const wrapper = mount(ScenarioCard, {
|
||||||
|
props: { scenario: baseScenario },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain(baseScenario.name)
|
||||||
|
expect(wrapper.text()).toContain('8 hours of monitoring')
|
||||||
|
expect(wrapper.text()).toContain('Surgery')
|
||||||
|
expect(wrapper.text()).toContain('312 events')
|
||||||
|
expect(wrapper.text()).toContain('control')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labelsControlScenario', () => {
|
||||||
|
const wrapper = mount(ScenarioCard, {
|
||||||
|
props: { scenario: baseScenario },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Control scenario — should stay quiet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labelsRealDataScenario', () => {
|
||||||
|
const wrapper = mount(ScenarioCard, {
|
||||||
|
props: {
|
||||||
|
scenario: {
|
||||||
|
...baseScenario,
|
||||||
|
id: 'mimic-case-01',
|
||||||
|
tags: ['mimic-iv', 'real-data', 'sepsis'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Real de-identified ICU record')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disablesStartAtConcurrencyLimit', async () => {
|
||||||
|
const wrapper = mount(ScenarioCard, {
|
||||||
|
props: {
|
||||||
|
scenario: baseScenario,
|
||||||
|
atConcurrencyLimit: true,
|
||||||
|
maxConcurrentRuns: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start')
|
||||||
|
expect(startBtn.attributes('disabled')).toBeDefined()
|
||||||
|
expect(startBtn.attributes('title')).toMatch(/Concurrency limit reached/)
|
||||||
|
|
||||||
|
await startBtn.trigger('click')
|
||||||
|
expect(wrapper.emitted('start')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emitsStartWithScenarioId', async () => {
|
||||||
|
const wrapper = mount(ScenarioCard, {
|
||||||
|
props: { scenario: baseScenario },
|
||||||
|
})
|
||||||
|
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start')
|
||||||
|
await startBtn.trigger('click')
|
||||||
|
expect(wrapper.emitted('start')).toEqual([['stable-baseline-01']])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import SimulationControlView from '@/views/SimulationControlView.vue'
|
||||||
|
|
||||||
|
const {
|
||||||
|
fetchScenarios,
|
||||||
|
fetchRuns,
|
||||||
|
fetchSimulationConfig,
|
||||||
|
startRun,
|
||||||
|
stopRun,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
fetchScenarios: vi.fn(),
|
||||||
|
fetchRuns: vi.fn(),
|
||||||
|
fetchSimulationConfig: vi.fn(),
|
||||||
|
startRun: vi.fn(),
|
||||||
|
stopRun: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/simulation', () => ({
|
||||||
|
fetchScenarios,
|
||||||
|
fetchRuns,
|
||||||
|
fetchSimulationConfig,
|
||||||
|
startRun,
|
||||||
|
stopRun,
|
||||||
|
fetchRun: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/ward', () => ({
|
||||||
|
useWardStore: () => ({ loadEncounters: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
{
|
||||||
|
id: 'uti-sepsis-elderly-01',
|
||||||
|
name: 'UTI Sepsis Elderly',
|
||||||
|
description: 'Elderly patient with UTI progressing to sepsis.',
|
||||||
|
durationMinutes: 480,
|
||||||
|
tags: ['sepsis', 'uti'],
|
||||||
|
department: 'GeneralMedicine',
|
||||||
|
eventCount: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stable-baseline-01',
|
||||||
|
name: 'Stable Baseline',
|
||||||
|
description: 'Control scenario.',
|
||||||
|
durationMinutes: 480,
|
||||||
|
tags: ['control', 'stable'],
|
||||||
|
department: 'Surgery',
|
||||||
|
eventCount: 312,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('SimulationControlView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
fetchScenarios.mockResolvedValue(scenarios)
|
||||||
|
fetchRuns.mockResolvedValue([])
|
||||||
|
fetchSimulationConfig.mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
maxSpeed: 600,
|
||||||
|
maxConcurrentRuns: 2,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('showsWallClockDurationForSelectedScenarioSpeed', async () => {
|
||||||
|
const wrapper = mount(SimulationControlView)
|
||||||
|
await vi.waitFor(() => expect(wrapper.text()).toContain('UTI Sepsis Elderly'))
|
||||||
|
|
||||||
|
// Default Fast (60×): 480 sim minutes → 8 wall-clock minutes
|
||||||
|
expect(wrapper.text()).toMatch(/8 minutes for selected scenario/)
|
||||||
|
|
||||||
|
const realTime = wrapper.findAll('button').find(b => b.text().includes('Real time'))
|
||||||
|
expect(realTime.text()).toMatch(/8 hours for selected scenario/)
|
||||||
|
|
||||||
|
const instant = wrapper.findAll('button').find(b => b.text().includes('Instant'))
|
||||||
|
expect(instant.text()).toMatch(/1 minute for selected scenario/)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
it('filtersCatalogueByTag', async () => {
|
||||||
|
const wrapper = mount(SimulationControlView)
|
||||||
|
await vi.waitFor(() => expect(wrapper.text()).toContain('Stable Baseline'))
|
||||||
|
|
||||||
|
const sepsisChip = wrapper.findAll('button').find(b => b.text() === 'sepsis')
|
||||||
|
await sepsisChip.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('UTI Sepsis Elderly')
|
||||||
|
expect(wrapper.text()).not.toContain('Stable Baseline')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('showsEmptyStateWhenCatalogueMissing', async () => {
|
||||||
|
fetchScenarios.mockResolvedValue([])
|
||||||
|
const wrapper = mount(SimulationControlView)
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(wrapper.text()).toContain('Simulation:ScenarioDirectory'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import SimulationModeBanner from '@/components/simulation/SimulationModeBanner.vue'
|
||||||
|
import { useSimulationStore } from '@/stores/simulation'
|
||||||
|
|
||||||
|
describe('SimulationModeBanner', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rendersWhenEnabled', () => {
|
||||||
|
useSimulationStore().enabled = true
|
||||||
|
const wrapper = mount(SimulationModeBanner)
|
||||||
|
expect(wrapper.text()).toContain('SIMULATION MODE')
|
||||||
|
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isAbsentWhenDisabled', () => {
|
||||||
|
useSimulationStore().enabled = false
|
||||||
|
const wrapper = mount(SimulationModeBanner)
|
||||||
|
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.text()).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import SimulationRunPanel from '@/components/simulation/SimulationRunPanel.vue'
|
||||||
|
|
||||||
|
const RouterLinkStub = {
|
||||||
|
props: ['to'],
|
||||||
|
template: '<a :href="typeof to === \'string\' ? to : \'\'"><slot /></a>',
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeRun = {
|
||||||
|
runId: 'run-1',
|
||||||
|
scenarioId: 'uti-sepsis-elderly-01',
|
||||||
|
scenarioName: 'UTI Sepsis Elderly',
|
||||||
|
status: 'RUNNING',
|
||||||
|
speed: 60,
|
||||||
|
encounterId: 'enc-99',
|
||||||
|
patientDisplayName: 'Mary Chen',
|
||||||
|
startedAt: '2026-08-05T10:00:00Z',
|
||||||
|
elapsedRealSeconds: 125,
|
||||||
|
lastOffsetMinutes: 200,
|
||||||
|
totalOffsetMinutes: 480,
|
||||||
|
progressPercent: 41.6,
|
||||||
|
observationsSent: 40,
|
||||||
|
medicationsSent: 2,
|
||||||
|
ordersPlaced: 1,
|
||||||
|
failureReason: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountPanel(props) {
|
||||||
|
return mount(SimulationRunPanel, {
|
||||||
|
props,
|
||||||
|
global: { stubs: { RouterLink: RouterLinkStub } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SimulationRunPanel', () => {
|
||||||
|
it('showsProgressAndSimClock', () => {
|
||||||
|
const wrapper = mountPanel({ activeRuns: [activeRun], recentRuns: [] })
|
||||||
|
expect(wrapper.text()).toContain('03:20 of 08:00 elapsed')
|
||||||
|
expect(wrapper.text()).toContain('42%')
|
||||||
|
expect(wrapper.text()).toContain('60×')
|
||||||
|
expect(wrapper.find('[role="progressbar"]').attributes('aria-valuenow')).toBe('42')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('linksToPatientEncounter', () => {
|
||||||
|
const wrapper = mountPanel({ activeRuns: [activeRun], recentRuns: [] })
|
||||||
|
const link = wrapper.find('a[href="/patients/enc-99"]')
|
||||||
|
expect(link.exists()).toBe(true)
|
||||||
|
expect(link.text()).toContain('Mary Chen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requiresStopConfirmBeforeEmitting', async () => {
|
||||||
|
const wrapper = mountPanel({ activeRuns: [activeRun], recentRuns: [] })
|
||||||
|
|
||||||
|
await wrapper.findAll('button').find(b => b.text() === 'Stop').trigger('click')
|
||||||
|
expect(wrapper.emitted('stop')).toBeUndefined()
|
||||||
|
expect(wrapper.text()).toContain('Stopping leaves the patient on the ward')
|
||||||
|
|
||||||
|
await wrapper.findAll('button').find(b => b.text() === 'Confirm stop').trigger('click')
|
||||||
|
expect(wrapper.emitted('stop')).toEqual([['run-1']])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('showsFailureReasonForFailedRecentRun', () => {
|
||||||
|
const wrapper = mountPanel({
|
||||||
|
activeRuns: [],
|
||||||
|
recentRuns: [{
|
||||||
|
...activeRun,
|
||||||
|
status: 'FAILED',
|
||||||
|
failureReason: 'Loopback authentication failed',
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Failed')
|
||||||
|
expect(wrapper.text()).toContain('Loopback authentication failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { canAccessOps, filterNavLinks, isDashboardRole, roleCanAccessRoute, MAIN_NAV_LINKS } from '@/composables/roleAccess'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import {
|
||||||
|
canAccessOps,
|
||||||
|
filterNavLinks,
|
||||||
|
isDashboardRole,
|
||||||
|
roleCanAccessRoute,
|
||||||
|
MAIN_NAV_LINKS,
|
||||||
|
useRoleAccess,
|
||||||
|
} from '@/composables/roleAccess'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useSimulationStore } from '@/stores/simulation'
|
||||||
|
|
||||||
describe('roleAccess', () => {
|
describe('roleAccess', () => {
|
||||||
it('filtersNavLinksByRole', () => {
|
it('filtersNavLinksByRole', () => {
|
||||||
@@ -29,3 +39,50 @@ describe('roleAccess', () => {
|
|||||||
expect(canAccessOps('INTEGRATION')).toBe(false)
|
expect(canAccessOps('INTEGRATION')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('useRoleAccess simulation nav', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.token = 'tok'
|
||||||
|
auth.user = {
|
||||||
|
userId: '1',
|
||||||
|
username: 'nurse',
|
||||||
|
displayName: 'Nurse',
|
||||||
|
role: 'NURSE',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includesSimulationLinkOnlyWhenEnabled', () => {
|
||||||
|
const sim = useSimulationStore()
|
||||||
|
const { mainNavLinks } = useRoleAccess()
|
||||||
|
|
||||||
|
sim.enabled = false
|
||||||
|
expect(mainNavLinks.value.some(l => l.to === '/simulation')).toBe(false)
|
||||||
|
|
||||||
|
sim.enabled = true
|
||||||
|
expect(mainNavLinks.value.some(l => l.to === '/simulation')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omitsSimulationLinkWhenDisabledRegardlessOfRole', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const sim = useSimulationStore()
|
||||||
|
sim.enabled = false
|
||||||
|
|
||||||
|
for (const role of ['NURSE', 'PHYSICIAN', 'ADMIN']) {
|
||||||
|
auth.user = { ...auth.user, role }
|
||||||
|
const { mainNavLinks } = useRoleAccess()
|
||||||
|
expect(mainNavLinks.value.some(l => l.to === '/simulation')).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('movesQualityToOverflowWhenSimulationEnabledOnMobile', () => {
|
||||||
|
const sim = useSimulationStore()
|
||||||
|
const { mobileNavLinks, mobileOverflowLinks } = useRoleAccess()
|
||||||
|
|
||||||
|
sim.enabled = true
|
||||||
|
expect(mobileNavLinks.value.some(l => l.to === '/simulation')).toBe(true)
|
||||||
|
expect(mobileNavLinks.value.some(l => l.to === '/admin/reconciliation')).toBe(false)
|
||||||
|
expect(mobileOverflowLinks.value.some(l => l.to === '/admin/reconciliation')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useSimulationStore } from '@/stores/simulation'
|
||||||
|
|
||||||
|
const { fetchSimulationConfig } = vi.hoisted(() => ({
|
||||||
|
fetchSimulationConfig: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/simulation', () => ({
|
||||||
|
fetchSimulationConfig,
|
||||||
|
fetchScenarios: vi.fn(),
|
||||||
|
fetchRuns: vi.fn(),
|
||||||
|
startRun: vi.fn(),
|
||||||
|
stopRun: vi.fn(),
|
||||||
|
fetchRun: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('simulation router guard', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.token = 'test-token'
|
||||||
|
auth.user = {
|
||||||
|
userId: 'u1',
|
||||||
|
username: 'nurse',
|
||||||
|
displayName: 'Test Nurse',
|
||||||
|
role: 'NURSE',
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSimulationConfig.mockResolvedValue({ enabled: false })
|
||||||
|
|
||||||
|
const { default: router } = await import('@/router')
|
||||||
|
// Reset to a known route; ignore failures from first load
|
||||||
|
await router.push('/ward').catch(() => {})
|
||||||
|
await router.isReady()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirectsDirectSimulationNavigationWhenDisabled', async () => {
|
||||||
|
const sim = useSimulationStore()
|
||||||
|
sim.enabled = false
|
||||||
|
|
||||||
|
const { default: router } = await import('@/router')
|
||||||
|
await router.push('/simulation')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.path).toBe('/ward')
|
||||||
|
expect(fetchSimulationConfig).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allowsSimulationWhenEnabled', async () => {
|
||||||
|
const sim = useSimulationStore()
|
||||||
|
sim.enabled = true
|
||||||
|
|
||||||
|
const { default: router } = await import('@/router')
|
||||||
|
await router.push('/simulation')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.path).toBe('/simulation')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { useSimulationStore } from '@/stores/simulation'
|
||||||
|
|
||||||
|
const {
|
||||||
|
fetchSimulationConfig,
|
||||||
|
fetchScenarios,
|
||||||
|
fetchRuns,
|
||||||
|
startRun,
|
||||||
|
stopRun,
|
||||||
|
loadEncounters,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
fetchSimulationConfig: vi.fn(),
|
||||||
|
fetchScenarios: vi.fn(),
|
||||||
|
fetchRuns: vi.fn(),
|
||||||
|
startRun: vi.fn(),
|
||||||
|
stopRun: vi.fn(),
|
||||||
|
loadEncounters: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/simulation', () => ({
|
||||||
|
fetchSimulationConfig,
|
||||||
|
fetchScenarios,
|
||||||
|
fetchRuns,
|
||||||
|
startRun,
|
||||||
|
stopRun,
|
||||||
|
fetchRun: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/ward', () => ({
|
||||||
|
useWardStore: () => ({ loadEncounters }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function runningRun(overrides = {}) {
|
||||||
|
return {
|
||||||
|
runId: 'run-1',
|
||||||
|
scenarioId: 'uti-sepsis-elderly-01',
|
||||||
|
scenarioName: 'UTI Sepsis',
|
||||||
|
status: 'RUNNING',
|
||||||
|
speed: 60,
|
||||||
|
patientId: 'p1',
|
||||||
|
encounterId: 'e1',
|
||||||
|
patientDisplayName: 'Test Patient',
|
||||||
|
startedAt: '2026-08-05T10:00:00Z',
|
||||||
|
elapsedRealSeconds: 30,
|
||||||
|
lastOffsetMinutes: 20,
|
||||||
|
totalOffsetMinutes: 480,
|
||||||
|
progressPercent: 4,
|
||||||
|
observationsSent: 3,
|
||||||
|
medicationsSent: 0,
|
||||||
|
ordersPlaced: 0,
|
||||||
|
failureReason: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useSimulationStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
fetchRuns.mockResolvedValue([])
|
||||||
|
fetchScenarios.mockResolvedValue([])
|
||||||
|
fetchSimulationConfig.mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
maxSpeed: 600,
|
||||||
|
maxConcurrentRuns: 2,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
const store = useSimulationStore()
|
||||||
|
store.stopPolling()
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activeRuns_and_atConcurrencyLimit', () => {
|
||||||
|
const store = useSimulationStore()
|
||||||
|
store.maxConcurrentRuns = 2
|
||||||
|
store.runs = [
|
||||||
|
runningRun({ runId: 'a', status: 'RUNNING' }),
|
||||||
|
runningRun({ runId: 'b', status: 'PENDING' }),
|
||||||
|
runningRun({ runId: 'c', status: 'COMPLETED' }),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(store.activeRuns.map(r => r.runId)).toEqual(['a', 'b'])
|
||||||
|
expect(store.hasActiveRun).toBe(true)
|
||||||
|
expect(store.atConcurrencyLimit).toBe(true)
|
||||||
|
expect(store.recentRuns.map(r => r.runId)).toEqual(['c'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('start_pushesOptimisticPlaceholderThenReconciles', async () => {
|
||||||
|
const store = useSimulationStore()
|
||||||
|
store.scenarios = [{ id: 's1', name: 'Scenario One', durationMinutes: 480 }]
|
||||||
|
|
||||||
|
let resolveStart
|
||||||
|
startRun.mockReturnValue(new Promise((resolve) => {
|
||||||
|
resolveStart = resolve
|
||||||
|
}))
|
||||||
|
|
||||||
|
const startPromise = store.start('s1', 60)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.activeRuns).toHaveLength(1)
|
||||||
|
expect(store.activeRuns[0].status).toBe('PENDING')
|
||||||
|
expect(store.activeRuns[0]._optimistic).toBe(true)
|
||||||
|
expect(store.starting).toBe(true)
|
||||||
|
|
||||||
|
resolveStart(runningRun({ runId: 'real-1', scenarioId: 's1', scenarioName: 'Scenario One' }))
|
||||||
|
await startPromise
|
||||||
|
|
||||||
|
expect(store.runs).toHaveLength(1)
|
||||||
|
expect(store.runs[0].runId).toBe('real-1')
|
||||||
|
expect(store.runs[0]._optimistic).toBeUndefined()
|
||||||
|
expect(store.starting).toBe(false)
|
||||||
|
expect(loadEncounters).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('start_rollsBackPlaceholderOnFailure', async () => {
|
||||||
|
const store = useSimulationStore()
|
||||||
|
startRun.mockRejectedValue(new Error('Maximum concurrent simulation runs (2) reached.'))
|
||||||
|
|
||||||
|
await expect(store.start('s1', 60)).rejects.toThrow(/concurrent/)
|
||||||
|
expect(store.runs).toEqual([])
|
||||||
|
expect(store.error).toMatch(/concurrent/)
|
||||||
|
expect(store.starting).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stop_updatesRunFromApi', async () => {
|
||||||
|
const store = useSimulationStore()
|
||||||
|
store.runs = [runningRun()]
|
||||||
|
stopRun.mockResolvedValue(runningRun({ status: 'CANCELLED' }))
|
||||||
|
|
||||||
|
await store.stop('run-1')
|
||||||
|
expect(stopRun).toHaveBeenCalledWith('run-1')
|
||||||
|
expect(store.runs[0].status).toBe('CANCELLED')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('polling_runsOnlyWhileActiveRunExists', async () => {
|
||||||
|
const store = useSimulationStore()
|
||||||
|
store.startPolling()
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000)
|
||||||
|
expect(fetchRuns).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
store.runs = [runningRun()]
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000)
|
||||||
|
expect(fetchRuns).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
fetchRuns.mockResolvedValue([runningRun({ status: 'COMPLETED', progressPercent: 100 })])
|
||||||
|
await store.refreshRuns()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
fetchRuns.mockClear()
|
||||||
|
await vi.advanceTimersByTimeAsync(4_000)
|
||||||
|
expect(fetchRuns).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
store.stopPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loadConfig_failsClosedOnError', async () => {
|
||||||
|
const store = useSimulationStore()
|
||||||
|
fetchSimulationConfig.mockRejectedValue(new Error('network'))
|
||||||
|
await store.loadConfig()
|
||||||
|
expect(store.enabled).toBe(false)
|
||||||
|
expect(store.maxConcurrentRuns).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -89,3 +89,14 @@ const BUNDLE_ELEMENT_LABELS = {
|
|||||||
export function bundleElementLabel(elementType) {
|
export function bundleElementLabel(elementType) {
|
||||||
return BUNDLE_ELEMENT_LABELS[elementType] ?? elementType
|
return BUNDLE_ELEMENT_LABELS[elementType] ?? elementType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the ward summary or encounter detail marks the patient as simulated.
|
||||||
|
* Ward list: top-level `isSimulated`. Patient detail: `patient.isSimulated`.
|
||||||
|
*/
|
||||||
|
export function isSimulatedPatient(source) {
|
||||||
|
if (!source) return false
|
||||||
|
if (source.isSimulated === true) return true
|
||||||
|
if (source.patient?.isSimulated === true) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export const fetchSimulationConfig = () => api.get('/api/v1/simulation/config')
|
||||||
|
export const fetchScenarios = () => api.get('/api/v1/simulation/scenarios')
|
||||||
|
export const fetchRuns = () => api.get('/api/v1/simulation/runs')
|
||||||
|
export const fetchRun = (runId) => api.get(`/api/v1/simulation/runs/${runId}`)
|
||||||
|
export const startRun = (body) => api.post('/api/v1/simulation/runs', body)
|
||||||
|
export const stopRun = (runId) => api.post(`/api/v1/simulation/runs/${runId}/stop`)
|
||||||
@@ -1,13 +1,31 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { onMounted, watch } from 'vue'
|
||||||
import AppHeader from './AppHeader.vue'
|
import AppHeader from './AppHeader.vue'
|
||||||
import AppSidebar from './AppSidebar.vue'
|
import AppSidebar from './AppSidebar.vue'
|
||||||
import MobileNav from './MobileNav.vue'
|
import MobileNav from './MobileNav.vue'
|
||||||
import CriticalAlertBanner from '@/components/alerts/CriticalAlertBanner.vue'
|
import CriticalAlertBanner from '@/components/alerts/CriticalAlertBanner.vue'
|
||||||
|
import SimulationModeBanner from '@/components/simulation/SimulationModeBanner.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import { useSimulationStore } from '@/stores/simulation'
|
||||||
import { useCriticalAlertPolling } from '@/composables/useCriticalAlertPolling'
|
import { useCriticalAlertPolling } from '@/composables/useCriticalAlertPolling'
|
||||||
|
import { useSimulationAvailable } from '@/composables/useSimulationAvailable'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
const simulationStore = useSimulationStore()
|
||||||
|
const { detect: detectSimulation } = useSimulationAvailable()
|
||||||
|
|
||||||
useCriticalAlertPolling(settingsStore.pollInterval)
|
useCriticalAlertPolling(settingsStore.pollInterval)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (authStore.isAuthenticated) detectSimulation()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => authStore.isAuthenticated, (ok) => {
|
||||||
|
if (ok) detectSimulation()
|
||||||
|
else simulationStore.enabled = false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -21,6 +39,7 @@ useCriticalAlertPolling(settingsStore.pollInterval)
|
|||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
<SimulationModeBanner />
|
||||||
<CriticalAlertBanner />
|
<CriticalAlertBanner />
|
||||||
<main id="main-content" class="flex-1 overflow-y-auto p-4 pb-24 lg:p-8 lg:pb-8" tabindex="-1">
|
<main id="main-content" class="flex-1 overflow-y-auto p-4 pb-24 lg:p-8 lg:pb-8" tabindex="-1">
|
||||||
<div class="mx-auto w-full max-w-7xl">
|
<div class="mx-auto w-full max-w-7xl">
|
||||||
|
|||||||
@@ -133,6 +133,27 @@ function linkClasses(path) {
|
|||||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="link.icon === 'simulation'"
|
||||||
|
class="h-6 w-6 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
v-else
|
v-else
|
||||||
class="h-6 w-6 shrink-0"
|
class="h-6 w-6 shrink-0"
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useRoleAccess } from '@/composables/roleAccess'
|
import { useRoleAccess } from '@/composables/roleAccess'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const { mobileNavLinks } = useRoleAccess()
|
const { mobileNavLinks, mobileOverflowLinks } = useRoleAccess()
|
||||||
|
|
||||||
|
const overflowOpen = ref(false)
|
||||||
|
|
||||||
const activePath = computed(() => route.path)
|
const activePath = computed(() => route.path)
|
||||||
|
|
||||||
|
function linkActive(to) {
|
||||||
|
return activePath.value.startsWith(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOverflow() {
|
||||||
|
overflowOpen.value = !overflowOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOverflow() {
|
||||||
|
overflowOpen.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -16,14 +30,32 @@ const activePath = computed(() => route.path)
|
|||||||
class="fixed inset-x-0 bottom-0 z-40 border-t border-gray-200 bg-white lg:hidden dark:border-gray-800 dark:bg-gray-900"
|
class="fixed inset-x-0 bottom-0 z-40 border-t border-gray-200 bg-white lg:hidden dark:border-gray-800 dark:bg-gray-900"
|
||||||
aria-label="Mobile navigation"
|
aria-label="Mobile navigation"
|
||||||
>
|
>
|
||||||
|
<div v-if="overflowOpen && mobileOverflowLinks.length" class="border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<ul class="flex flex-col py-2">
|
||||||
|
<li v-for="link in mobileOverflowLinks" :key="link.to">
|
||||||
|
<RouterLink
|
||||||
|
:to="link.to"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
|
||||||
|
:class="linkActive(link.to)
|
||||||
|
? 'text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-gray-700 dark:text-gray-300'"
|
||||||
|
@click="closeOverflow"
|
||||||
|
>
|
||||||
|
{{ link.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="flex h-16 items-stretch">
|
<ul class="flex h-16 items-stretch">
|
||||||
<li v-for="link in mobileNavLinks" :key="link.to" class="flex-1">
|
<li v-for="link in mobileNavLinks" :key="link.to" class="min-w-0 flex-1">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="link.to"
|
:to="link.to"
|
||||||
class="flex h-full flex-col items-center justify-center gap-2 px-4 py-2 text-xs font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
|
class="flex h-full flex-col items-center justify-center gap-1 px-1 py-2 text-[11px] font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:gap-2 sm:px-4 sm:text-xs"
|
||||||
:class="activePath.startsWith(link.to)
|
:class="linkActive(link.to)
|
||||||
? 'text-blue-600 dark:text-blue-400'
|
? 'text-blue-600 dark:text-blue-400'
|
||||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||||
|
@click="closeOverflow"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
v-if="link.icon === 'ward'"
|
v-if="link.icon === 'ward'"
|
||||||
@@ -70,6 +102,27 @@ const activePath = computed(() => route.path)
|
|||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="link.icon === 'simulation'"
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
v-else
|
v-else
|
||||||
class="h-6 w-6"
|
class="h-6 w-6"
|
||||||
@@ -85,19 +138,38 @@ const activePath = computed(() => route.path)
|
|||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ link.label }}
|
<span class="truncate">{{ link.label }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex-1">
|
|
||||||
|
<li v-if="mobileOverflowLinks.length" class="min-w-0 flex-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-full w-full flex-col items-center justify-center gap-2 px-4 py-2 text-xs font-medium text-gray-500 transition duration-200 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500 dark:text-gray-400 dark:hover:text-red-400"
|
class="flex h-full w-full flex-col items-center justify-center gap-1 px-1 py-2 text-[11px] font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:gap-2 sm:px-4 sm:text-xs"
|
||||||
|
:class="overflowOpen || mobileOverflowLinks.some(l => linkActive(l.to))
|
||||||
|
? 'text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||||
|
:aria-expanded="overflowOpen"
|
||||||
|
aria-label="More navigation"
|
||||||
|
@click="toggleOverflow"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="truncate">More</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="min-w-0 flex-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-full w-full flex-col items-center justify-center gap-1 px-1 py-2 text-[11px] font-medium text-gray-500 transition duration-200 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500 dark:text-gray-400 dark:hover:text-red-400 sm:gap-2 sm:px-4 sm:text-xs"
|
||||||
@click="authStore.logout()"
|
@click="authStore.logout()"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
</svg>
|
</svg>
|
||||||
Sign out
|
<span class="truncate">Sign out</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
formatGender,
|
formatGender,
|
||||||
hasKnownAllergies,
|
hasKnownAllergies,
|
||||||
} from '@/composables/patientFormat'
|
} from '@/composables/patientFormat'
|
||||||
|
import { isSimulatedPatient } from '@/api/normalize'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
encounter: { type: Object, required: true },
|
encounter: { type: Object, required: true },
|
||||||
@@ -15,6 +16,8 @@ const props = defineProps({
|
|||||||
|
|
||||||
const patient = computed(() => props.encounter.patient ?? {})
|
const patient = computed(() => props.encounter.patient ?? {})
|
||||||
|
|
||||||
|
const simulated = computed(() => isSimulatedPatient(props.encounter))
|
||||||
|
|
||||||
const fullName = computed(() =>
|
const fullName = computed(() =>
|
||||||
[patient.value.firstName, patient.value.lastName].filter(Boolean).join(' ') || 'Unknown patient',
|
[patient.value.firstName, patient.value.lastName].filter(Boolean).join(' ') || 'Unknown patient',
|
||||||
)
|
)
|
||||||
@@ -49,6 +52,13 @@ const emergencyContact = computed(() => {
|
|||||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">
|
<h1 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
{{ fullName }}
|
{{ fullName }}
|
||||||
</h1>
|
</h1>
|
||||||
|
<span
|
||||||
|
v-if="simulated"
|
||||||
|
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
|
||||||
|
title="Simulated patient"
|
||||||
|
>
|
||||||
|
SIM
|
||||||
|
</span>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
MRN {{ patient.mrn ?? '—' }}
|
MRN {{ patient.mrn ?? '—' }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import Card from '@/components/ui/Card.vue'
|
||||||
|
import Button from '@/components/ui/Button.vue'
|
||||||
|
import { formatDepartment } from '@/composables/sepsisFormat'
|
||||||
|
import {
|
||||||
|
formatMonitoringDuration,
|
||||||
|
tagChipClass,
|
||||||
|
isControlTag,
|
||||||
|
isRealDataTag,
|
||||||
|
} from '@/composables/simulationFormat'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
scenario: { type: Object, required: true },
|
||||||
|
atConcurrencyLimit: { type: Boolean, default: false },
|
||||||
|
maxConcurrentRuns: { type: Number, default: 0 },
|
||||||
|
selected: { type: Boolean, default: false },
|
||||||
|
starting: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['start', 'select'])
|
||||||
|
|
||||||
|
const expanded = ref(false)
|
||||||
|
|
||||||
|
const description = computed(() => props.scenario.description?.trim() || '')
|
||||||
|
const canExpand = computed(() => description.value.length > 140)
|
||||||
|
|
||||||
|
const durationLabel = computed(() =>
|
||||||
|
formatMonitoringDuration(props.scenario.durationMinutes),
|
||||||
|
)
|
||||||
|
|
||||||
|
const departmentLabel = computed(() =>
|
||||||
|
formatDepartment(props.scenario.department),
|
||||||
|
)
|
||||||
|
|
||||||
|
const eventLabel = computed(() => {
|
||||||
|
const n = props.scenario.eventCount ?? 0
|
||||||
|
return `${n} event${n === 1 ? '' : 's'}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const tags = computed(() => props.scenario.tags ?? [])
|
||||||
|
|
||||||
|
const showControlNote = computed(() => tags.value.some(isControlTag))
|
||||||
|
const showRealDataNote = computed(() => tags.value.some(isRealDataTag))
|
||||||
|
|
||||||
|
const startDisabled = computed(() =>
|
||||||
|
props.atConcurrencyLimit || props.starting,
|
||||||
|
)
|
||||||
|
|
||||||
|
const disabledReason = computed(() => {
|
||||||
|
if (props.atConcurrencyLimit) {
|
||||||
|
const n = props.maxConcurrentRuns
|
||||||
|
return n > 0
|
||||||
|
? `Concurrency limit reached (${n} run${n === 1 ? '' : 's'} max). Stop an active run first.`
|
||||||
|
: 'Concurrency limit reached. Stop an active run first.'
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
function onStart() {
|
||||||
|
if (startDisabled.value) return
|
||||||
|
emit('start', props.scenario.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect() {
|
||||||
|
emit('select', props.scenario.id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card
|
||||||
|
padding="md"
|
||||||
|
class="flex h-full flex-col transition"
|
||||||
|
:class="selected
|
||||||
|
? 'ring-2 ring-blue-500 dark:ring-blue-400'
|
||||||
|
: 'hover:border-gray-300 dark:hover:border-gray-600'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex-1 cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-pressed="selected"
|
||||||
|
@click="onSelect"
|
||||||
|
@keydown.enter.prevent="onSelect"
|
||||||
|
@keydown.space.prevent="onSelect"
|
||||||
|
>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ scenario.name }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="description"
|
||||||
|
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
|
||||||
|
:class="expanded ? '' : 'line-clamp-3'"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="canExpand"
|
||||||
|
type="button"
|
||||||
|
class="mt-1 self-start text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
@click="expanded = !expanded"
|
||||||
|
>
|
||||||
|
{{ expanded ? 'Show less' : 'Show more' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ durationLabel }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ departmentLabel }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{{ eventLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tags.length" class="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="tag in tags"
|
||||||
|
:key="tag"
|
||||||
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="tagChipClass(tag)"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="showControlNote"
|
||||||
|
class="mt-3 text-xs font-medium text-emerald-700 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
Control scenario — should stay quiet
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="showRealDataNote"
|
||||||
|
class="mt-1 text-xs font-medium text-violet-700 dark:text-violet-300"
|
||||||
|
>
|
||||||
|
Real de-identified ICU record
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-800">
|
||||||
|
<Button
|
||||||
|
class="w-full"
|
||||||
|
size="sm"
|
||||||
|
:disabled="startDisabled"
|
||||||
|
:title="disabledReason"
|
||||||
|
@click="onStart"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup>
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useSimulationStore } from '@/stores/simulation'
|
||||||
|
|
||||||
|
const { enabled } = storeToRefs(useSimulationStore())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="enabled"
|
||||||
|
role="status"
|
||||||
|
class="border-b border-amber-400 bg-amber-100 px-4 py-2.5 text-center text-sm text-amber-950 dark:border-amber-600 dark:bg-amber-950/60 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
<span class="font-bold tracking-wide">SIMULATION MODE</span>
|
||||||
|
<span class="mx-1.5">—</span>
|
||||||
|
patients shown here are simulated. Do not use for clinical decisions.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import Card from '@/components/ui/Card.vue'
|
||||||
|
import Badge from '@/components/ui/Badge.vue'
|
||||||
|
import Button from '@/components/ui/Button.vue'
|
||||||
|
import {
|
||||||
|
formatSimClock,
|
||||||
|
formatElapsedReal,
|
||||||
|
formatSpeedBadge,
|
||||||
|
runStatusLabel,
|
||||||
|
runStatusVariant,
|
||||||
|
} from '@/composables/simulationFormat'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
activeRuns: { type: Array, default: () => [] },
|
||||||
|
recentRuns: { type: Array, default: () => [] },
|
||||||
|
stoppingId: { type: [String, null], default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['stop'])
|
||||||
|
|
||||||
|
const confirmStopId = ref(null)
|
||||||
|
|
||||||
|
function requestStop(runId) {
|
||||||
|
confirmStopId.value = runId
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelStop() {
|
||||||
|
confirmStopId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmStop(runId) {
|
||||||
|
confirmStopId.value = null
|
||||||
|
emit('stop', runId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function patientLink(run) {
|
||||||
|
return run.encounterId ? `/patients/${run.encounterId}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressWidth(run) {
|
||||||
|
const pct = Math.min(100, Math.max(0, Number(run.progressPercent) || 0))
|
||||||
|
return `${pct}%`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div v-if="activeRuns.length" class="space-y-4">
|
||||||
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Active runs
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
v-for="run in activeRuns"
|
||||||
|
:key="run.runId"
|
||||||
|
padding="md"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ run.scenarioName }}
|
||||||
|
</h3>
|
||||||
|
<Badge :variant="runStatusVariant(run.status)" size="xs">
|
||||||
|
{{ runStatusLabel(run.status) }}
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||||
|
>
|
||||||
|
{{ formatSpeedBadge(run.speed) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<template v-if="patientLink(run)">
|
||||||
|
<RouterLink
|
||||||
|
:to="patientLink(run)"
|
||||||
|
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{{ run.patientDisplayName || 'Open patient' }}
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ run.patientDisplayName || 'Patient pending…' }}
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shrink-0">
|
||||||
|
<template v-if="confirmStopId === run.runId">
|
||||||
|
<div class="flex flex-col items-end gap-2">
|
||||||
|
<p class="max-w-xs text-right text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Stopping leaves the patient on the ward.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button size="sm" variant="secondary" @click="cancelStop">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
:disabled="stoppingId === run.runId"
|
||||||
|
@click="confirmStop(run.runId)"
|
||||||
|
>
|
||||||
|
Confirm stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
:disabled="run._optimistic || stoppingId === run.runId"
|
||||||
|
@click="requestStop(run.runId)"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{{ formatSimClock(run.lastOffsetMinutes, run.totalOffsetMinutes) }}</span>
|
||||||
|
<span>{{ Math.round(run.progressPercent ?? 0) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800"
|
||||||
|
role="progressbar"
|
||||||
|
:aria-valuenow="Math.round(run.progressPercent ?? 0)"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-blue-600 transition-all dark:bg-blue-500"
|
||||||
|
:style="{ width: progressWidth(run) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Wall clock {{ formatElapsedReal(run.elapsedRealSeconds) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mt-4 grid grid-cols-3 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Observations</dt>
|
||||||
|
<dd class="mt-0.5 font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ run.observationsSent ?? 0 }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Medications</dt>
|
||||||
|
<dd class="mt-0.5 font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ run.medicationsSent ?? 0 }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Orders</dt>
|
||||||
|
<dd class="mt-0.5 font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ run.ordersPlaced ?? 0 }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="run.status === 'FAILED' && run.failureReason"
|
||||||
|
class="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{{ run.failureReason }}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="recentRuns.length">
|
||||||
|
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Recent runs
|
||||||
|
</h2>
|
||||||
|
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Scenario</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Status</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Duration</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Counters</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Patient</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||||
|
<tr
|
||||||
|
v-for="run in recentRuns"
|
||||||
|
:key="run.runId"
|
||||||
|
:class="run.status === 'FAILED' ? 'bg-red-50/50 dark:bg-red-950/20' : ''"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3 text-gray-900 dark:text-white">
|
||||||
|
{{ run.scenarioName }}
|
||||||
|
<p
|
||||||
|
v-if="run.status === 'FAILED' && run.failureReason"
|
||||||
|
class="mt-1 text-xs text-red-600 dark:text-red-400"
|
||||||
|
>
|
||||||
|
{{ run.failureReason }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<Badge :variant="runStatusVariant(run.status)" size="xs">
|
||||||
|
{{ runStatusLabel(run.status) }}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||||
|
{{ formatElapsedReal(run.elapsedRealSeconds) }}
|
||||||
|
<span class="text-gray-400">·</span>
|
||||||
|
{{ formatSpeedBadge(run.speed) }}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||||
|
{{ run.observationsSent ?? 0 }} obs ·
|
||||||
|
{{ run.medicationsSent ?? 0 }} med ·
|
||||||
|
{{ run.ordersPlaced ?? 0 }} ord
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<RouterLink
|
||||||
|
v-if="patientLink(run)"
|
||||||
|
:to="patientLink(run)"
|
||||||
|
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
>
|
||||||
|
{{ run.patientDisplayName || 'Open' }}
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else class="text-gray-400">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
patientRoom,
|
patientRoom,
|
||||||
stalenessClass,
|
stalenessClass,
|
||||||
} from '@/composables/wardFormat'
|
} from '@/composables/wardFormat'
|
||||||
|
import { isSimulatedPatient } from '@/api/normalize'
|
||||||
|
|
||||||
const props = defineProps({ patient: { type: Object, required: true } })
|
const props = defineProps({ patient: { type: Object, required: true } })
|
||||||
|
|
||||||
@@ -23,8 +24,10 @@ const vitalsStaleness = computed(() =>
|
|||||||
observationStaleness(props.patient.lastObservationAt, props.patient.status),
|
observationStaleness(props.patient.lastObservationAt, props.patient.status),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const simulated = computed(() => isSimulatedPatient(props.patient))
|
||||||
|
|
||||||
const cardLabel = computed(() =>
|
const cardLabel = computed(() =>
|
||||||
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}`,
|
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}${simulated.value ? ', simulated' : ''}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits(['activate'])
|
const emit = defineEmits(['activate'])
|
||||||
@@ -53,6 +56,13 @@ function onKeydown(event) {
|
|||||||
Room {{ patientRoom(patient) }}
|
Room {{ patientRoom(patient) }}
|
||||||
</span>
|
</span>
|
||||||
<Badge v-if="patient.sepsisActive" variant="critical" size="xs">Sepsis</Badge>
|
<Badge v-if="patient.sepsisActive" variant="critical" size="xs">Sepsis</Badge>
|
||||||
|
<span
|
||||||
|
v-if="simulated"
|
||||||
|
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
|
||||||
|
title="Simulated patient"
|
||||||
|
>
|
||||||
|
SIM
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="mt-2 truncate text-base font-semibold text-gray-900 dark:text-white">
|
<h3 class="mt-2 truncate text-base font-semibold text-gray-900 dark:text-white">
|
||||||
{{ patient.firstName }} {{ patient.lastName }}
|
{{ patient.firstName }} {{ patient.lastName }}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
stalenessClass,
|
stalenessClass,
|
||||||
} from '@/composables/wardFormat'
|
} from '@/composables/wardFormat'
|
||||||
import { formatDepartment } from '@/composables/sepsisFormat'
|
import { formatDepartment } from '@/composables/sepsisFormat'
|
||||||
|
import { isSimulatedPatient } from '@/api/normalize'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
patient: { type: Object, required: true },
|
patient: { type: Object, required: true },
|
||||||
@@ -18,6 +19,8 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['activate'])
|
const emit = defineEmits(['activate'])
|
||||||
|
|
||||||
|
const simulated = computed(() => isSimulatedPatient(props.patient))
|
||||||
|
|
||||||
const riskVariant = computed(() => {
|
const riskVariant = computed(() => {
|
||||||
const score = props.patient.news2Score ?? 0
|
const score = props.patient.news2Score ?? 0
|
||||||
if (score >= 7) return 'critical'
|
if (score >= 7) return 'critical'
|
||||||
@@ -45,7 +48,7 @@ const vitalsStaleness = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const rowLabel = computed(() =>
|
const rowLabel = computed(() =>
|
||||||
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}`,
|
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}${simulated.value ? ', simulated' : ''}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
function onKeydown(event) {
|
function onKeydown(event) {
|
||||||
@@ -68,8 +71,17 @@ function onKeydown(event) {
|
|||||||
{{ patientRoom(patient) }}
|
{{ patientRoom(patient) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-4">
|
<td class="px-4 py-4">
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{{ patient.firstName }} {{ patient.lastName }}
|
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ patient.firstName }} {{ patient.lastName }}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="simulated"
|
||||||
|
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
|
||||||
|
title="Simulated patient"
|
||||||
|
>
|
||||||
|
SIM
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ patient.mrn }}</div>
|
<div class="text-xs text-gray-500 dark:text-gray-400">{{ patient.mrn }}</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useSimulationStore } from '@/stores/simulation'
|
||||||
|
|
||||||
const CLINICAL_ROLES = ['NURSE', 'PHYSICIAN', 'ADMIN']
|
const CLINICAL_ROLES = ['NURSE', 'PHYSICIAN', 'ADMIN']
|
||||||
|
|
||||||
@@ -10,6 +11,19 @@ export const OPS_NAV_LINK = {
|
|||||||
icon: 'ops',
|
icon: 'ops',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SIMULATION_NAV_LINK = {
|
||||||
|
to: '/simulation',
|
||||||
|
label: 'Simulation',
|
||||||
|
icon: 'simulation',
|
||||||
|
roles: CLINICAL_ROLES,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorter label for the bottom bar at ~360 px. */
|
||||||
|
export const SIMULATION_MOBILE_NAV_LINK = {
|
||||||
|
...SIMULATION_NAV_LINK,
|
||||||
|
label: 'Sim',
|
||||||
|
}
|
||||||
|
|
||||||
export const MAIN_NAV_LINKS = [
|
export const MAIN_NAV_LINKS = [
|
||||||
{ to: '/ward', label: 'Virtual Ward', icon: 'ward', roles: CLINICAL_ROLES },
|
{ to: '/ward', label: 'Virtual Ward', icon: 'ward', roles: CLINICAL_ROLES },
|
||||||
{ to: '/departments', label: 'Departments', icon: 'departments', roles: CLINICAL_ROLES },
|
{ to: '/departments', label: 'Departments', icon: 'departments', roles: CLINICAL_ROLES },
|
||||||
@@ -37,6 +51,8 @@ export const MOBILE_NAV_LINKS = [
|
|||||||
{ to: '/admin/reconciliation', label: 'Quality', icon: 'reconciliation', roles: CLINICAL_ROLES },
|
{ to: '/admin/reconciliation', label: 'Quality', icon: 'reconciliation', roles: CLINICAL_ROLES },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const QUALITY_MOBILE_PATH = '/admin/reconciliation'
|
||||||
|
|
||||||
export function isDashboardRole(role) {
|
export function isDashboardRole(role) {
|
||||||
return CLINICAL_ROLES.includes(role)
|
return CLINICAL_ROLES.includes(role)
|
||||||
}
|
}
|
||||||
@@ -60,6 +76,7 @@ export function filterNavLinks(links, role) {
|
|||||||
|
|
||||||
export function useRoleAccess() {
|
export function useRoleAccess() {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const simulation = useSimulationStore()
|
||||||
const { role } = storeToRefs(auth)
|
const { role } = storeToRefs(auth)
|
||||||
|
|
||||||
const isNurse = computed(() => role.value === 'NURSE')
|
const isNurse = computed(() => role.value === 'NURSE')
|
||||||
@@ -69,11 +86,30 @@ export function useRoleAccess() {
|
|||||||
|
|
||||||
const mainNavLinks = computed(() => {
|
const mainNavLinks = computed(() => {
|
||||||
const links = filterNavLinks(MAIN_NAV_LINKS, role.value)
|
const links = filterNavLinks(MAIN_NAV_LINKS, role.value)
|
||||||
|
if (simulation.enabled && CLINICAL_ROLES.includes(role.value)) {
|
||||||
|
links.push(SIMULATION_NAV_LINK)
|
||||||
|
}
|
||||||
if (canAccessOps(role.value)) links.push(OPS_NAV_LINK)
|
if (canAccessOps(role.value)) links.push(OPS_NAV_LINK)
|
||||||
return links
|
return links
|
||||||
})
|
})
|
||||||
const adminNavLinks = computed(() => filterNavLinks(ADMIN_NAV_LINKS, role.value))
|
const adminNavLinks = computed(() => filterNavLinks(ADMIN_NAV_LINKS, role.value))
|
||||||
const mobileNavLinks = computed(() => filterNavLinks(MOBILE_NAV_LINKS, role.value))
|
|
||||||
|
// Keep the bottom bar usable at ~360 px: when simulation is on, Quality moves to overflow.
|
||||||
|
const mobileNavLinks = computed(() => {
|
||||||
|
const links = filterNavLinks(MOBILE_NAV_LINKS, role.value)
|
||||||
|
if (!simulation.enabled || !CLINICAL_ROLES.includes(role.value)) return links
|
||||||
|
return [
|
||||||
|
...links.filter(l => l.to !== QUALITY_MOBILE_PATH),
|
||||||
|
SIMULATION_MOBILE_NAV_LINK,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const mobileOverflowLinks = computed(() => {
|
||||||
|
if (!simulation.enabled || !CLINICAL_ROLES.includes(role.value)) return []
|
||||||
|
return filterNavLinks(MOBILE_NAV_LINKS, role.value)
|
||||||
|
.filter(l => l.to === QUALITY_MOBILE_PATH)
|
||||||
|
})
|
||||||
|
|
||||||
const showAdminSection = computed(() => adminNavLinks.value.length > 0)
|
const showAdminSection = computed(() => adminNavLinks.value.length > 0)
|
||||||
|
|
||||||
const nursePatientLayout = computed(() => isNurse.value)
|
const nursePatientLayout = computed(() => isNurse.value)
|
||||||
@@ -88,6 +124,7 @@ export function useRoleAccess() {
|
|||||||
mainNavLinks,
|
mainNavLinks,
|
||||||
adminNavLinks,
|
adminNavLinks,
|
||||||
mobileNavLinks,
|
mobileNavLinks,
|
||||||
|
mobileOverflowLinks,
|
||||||
showAdminSection,
|
showAdminSection,
|
||||||
nursePatientLayout,
|
nursePatientLayout,
|
||||||
physicianPatientLayout,
|
physicianPatientLayout,
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/** Formats a minute count as a short clinician-facing duration. */
|
||||||
|
export function formatDurationMinutes(minutes) {
|
||||||
|
if (minutes == null || Number.isNaN(Number(minutes))) return '—'
|
||||||
|
const total = Math.max(0, Number(minutes))
|
||||||
|
if (total < 60) {
|
||||||
|
const m = Math.round(total)
|
||||||
|
return `${m} minute${m === 1 ? '' : 's'}`
|
||||||
|
}
|
||||||
|
const hoursExact = total / 60
|
||||||
|
if (Math.abs(hoursExact - Math.round(hoursExact)) < 0.05) {
|
||||||
|
const h = Math.round(hoursExact)
|
||||||
|
return `${h} hour${h === 1 ? '' : 's'}`
|
||||||
|
}
|
||||||
|
const h = Math.floor(total / 60)
|
||||||
|
const m = Math.round(total % 60)
|
||||||
|
if (m === 0) return `${h} hour${h === 1 ? '' : 's'}`
|
||||||
|
return `${h}h ${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scenario duration pill: "8 hours of monitoring". */
|
||||||
|
export function formatMonitoringDuration(durationMinutes) {
|
||||||
|
return `${formatDurationMinutes(durationMinutes)} of monitoring`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wall-clock time for a scenario at a given speed multiplier. */
|
||||||
|
export function formatWallClockForSpeed(durationMinutes, speed) {
|
||||||
|
if (durationMinutes == null || !speed) return '—'
|
||||||
|
return formatDurationMinutes(Number(durationMinutes) / Number(speed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulated clock: "03:20 of 08:00 elapsed". */
|
||||||
|
export function formatSimClock(lastOffsetMinutes, totalOffsetMinutes) {
|
||||||
|
const fmt = (mins) => {
|
||||||
|
const total = Math.max(0, Math.floor(Number(mins) || 0))
|
||||||
|
const h = Math.floor(total / 60)
|
||||||
|
const m = total % 60
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
return `${fmt(lastOffsetMinutes)} of ${fmt(totalOffsetMinutes)} elapsed`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wall-clock elapsed from elapsedRealSeconds. */
|
||||||
|
export function formatElapsedReal(seconds) {
|
||||||
|
const s = Math.max(0, Math.floor(Number(seconds) || 0))
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const rem = s % 60
|
||||||
|
if (m >= 60) {
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
return `${h}h ${m % 60}m`
|
||||||
|
}
|
||||||
|
return `${m}:${String(rem).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSpeedBadge(speed) {
|
||||||
|
if (speed == null) return '—'
|
||||||
|
return `${speed}×`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runStatusLabel(status) {
|
||||||
|
const map = {
|
||||||
|
PENDING: 'Pending',
|
||||||
|
RUNNING: 'Running',
|
||||||
|
COMPLETED: 'Completed',
|
||||||
|
CANCELLED: 'Cancelled',
|
||||||
|
FAILED: 'Failed',
|
||||||
|
}
|
||||||
|
return map[status] ?? status ?? '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runStatusVariant(status) {
|
||||||
|
if (status === 'FAILED') return 'critical'
|
||||||
|
if (status === 'CANCELLED') return 'warning'
|
||||||
|
if (status === 'RUNNING') return 'success'
|
||||||
|
if (status === 'COMPLETED') return 'success'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_CHIP_CLASS = {
|
||||||
|
sepsis: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
||||||
|
control: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||||
|
'mimic-iv': 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
|
||||||
|
'real-data': 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
|
||||||
|
stable: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-300',
|
||||||
|
baseline: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagChipClass(tag) {
|
||||||
|
return TAG_CHIP_CLASS[tag]
|
||||||
|
?? 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isControlTag(tag) {
|
||||||
|
return tag === 'control'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRealDataTag(tag) {
|
||||||
|
return tag === 'real-data' || tag === 'mimic-iv'
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useSimulationStore } from '@/stores/simulation'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature-detects in-app simulation via GET /api/v1/simulation/config.
|
||||||
|
* Fail-closed: any error leaves simulationStore.enabled = false.
|
||||||
|
*/
|
||||||
|
export function useSimulationAvailable() {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const simulation = useSimulationStore()
|
||||||
|
|
||||||
|
const available = computed(() => simulation.enabled)
|
||||||
|
|
||||||
|
async function detect() {
|
||||||
|
if (!auth.isAuthenticated) {
|
||||||
|
simulation.enabled = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await simulation.loadConfig()
|
||||||
|
return simulation.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
return { available, detect }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useSimulationStore } from '@/stores/simulation'
|
||||||
import { canAccessOps, defaultRouteForRole, isDashboardRole, roleCanAccessRoute } from '@/composables/roleAccess'
|
import { canAccessOps, defaultRouteForRole, isDashboardRole, roleCanAccessRoute } from '@/composables/roleAccess'
|
||||||
|
|
||||||
const CLINICAL = ['NURSE', 'PHYSICIAN', 'ADMIN']
|
const CLINICAL = ['NURSE', 'PHYSICIAN', 'ADMIN']
|
||||||
@@ -45,6 +46,12 @@ const routes = [
|
|||||||
component: () => import('@/views/SepsisBoardView.vue'),
|
component: () => import('@/views/SepsisBoardView.vue'),
|
||||||
meta: { title: 'Sepsis Bundle Board', layout: 'default', allowedRoles: CLINICAL },
|
meta: { title: 'Sepsis Bundle Board', layout: 'default', allowedRoles: CLINICAL },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/simulation',
|
||||||
|
name: 'SimulationControl',
|
||||||
|
component: () => import('@/views/SimulationControlView.vue'),
|
||||||
|
meta: { title: 'Simulation', layout: 'default', allowedRoles: CLINICAL, requiresSimulation: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/thresholds',
|
path: '/admin/thresholds',
|
||||||
name: 'ThresholdManagement',
|
name: 'ThresholdManagement',
|
||||||
@@ -92,7 +99,7 @@ const router = createRouter({
|
|||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
router.beforeEach(async (to) => {
|
||||||
document.title = `${to.meta.title ?? 'VigilCare'} — VigilCare`
|
document.title = `${to.meta.title ?? 'VigilCare'} — VigilCare`
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -108,6 +115,15 @@ router.beforeEach((to) => {
|
|||||||
if (!to.meta.public && auth.isAuthenticated && !roleCanAccessRoute(auth.role, to.meta)) {
|
if (!to.meta.public && auth.isAuthenticated && !roleCanAccessRoute(auth.role, to.meta)) {
|
||||||
return { path: defaultRouteForRole(auth.role) }
|
return { path: defaultRouteForRole(auth.role) }
|
||||||
}
|
}
|
||||||
|
if (to.meta.requiresSimulation && auth.isAuthenticated) {
|
||||||
|
const simulation = useSimulationStore()
|
||||||
|
if (!simulation.enabled) {
|
||||||
|
await simulation.loadConfig()
|
||||||
|
}
|
||||||
|
if (!simulation.enabled) {
|
||||||
|
return { path: '/ward' }
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
fetchSimulationConfig,
|
||||||
|
fetchScenarios,
|
||||||
|
fetchRuns,
|
||||||
|
startRun,
|
||||||
|
stopRun,
|
||||||
|
} from '@/api/simulation'
|
||||||
|
import { useWardStore } from '@/stores/ward'
|
||||||
|
|
||||||
|
const ACTIVE_STATUSES = new Set(['PENDING', 'RUNNING'])
|
||||||
|
const POLL_INTERVAL_MS = 2_000
|
||||||
|
|
||||||
|
function isActiveStatus(status) {
|
||||||
|
return ACTIVE_STATUSES.has(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSimulationStore = defineStore('simulation', () => {
|
||||||
|
const enabled = ref(false)
|
||||||
|
const maxSpeed = ref(null)
|
||||||
|
const maxConcurrentRuns = ref(0)
|
||||||
|
const scenarios = ref([])
|
||||||
|
const runs = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const starting = ref(false)
|
||||||
|
|
||||||
|
let pollTimer = null
|
||||||
|
let stopActiveWatch = null
|
||||||
|
|
||||||
|
const activeRuns = computed(() =>
|
||||||
|
runs.value.filter(r => isActiveStatus(r.status)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const recentRuns = computed(() =>
|
||||||
|
runs.value
|
||||||
|
.filter(r => !isActiveStatus(r.status))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt))
|
||||||
|
.slice(0, 10),
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasActiveRun = computed(() => activeRuns.value.length > 0)
|
||||||
|
|
||||||
|
const atConcurrencyLimit = computed(() =>
|
||||||
|
activeRuns.value.length >= maxConcurrentRuns.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const scenariosByTag = computed(() => {
|
||||||
|
const groups = {}
|
||||||
|
for (const scenario of scenarios.value) {
|
||||||
|
for (const tag of scenario.tags ?? []) {
|
||||||
|
if (!groups[tag]) groups[tag] = []
|
||||||
|
groups[tag].push(scenario)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const data = await fetchSimulationConfig()
|
||||||
|
enabled.value = !!data?.enabled
|
||||||
|
maxSpeed.value = data?.maxSpeed ?? null
|
||||||
|
maxConcurrentRuns.value = data?.maxConcurrentRuns ?? 0
|
||||||
|
} catch {
|
||||||
|
// Fail closed — never surface simulation controls on a broken probe.
|
||||||
|
enabled.value = false
|
||||||
|
maxSpeed.value = null
|
||||||
|
maxConcurrentRuns.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScenarios() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const data = await fetchScenarios()
|
||||||
|
scenarios.value = Array.isArray(data) ? data : []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
scenarios.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectNewlyRunning(previousById, nextRuns) {
|
||||||
|
return nextRuns.some((run) => {
|
||||||
|
if (run.status !== 'RUNNING') return false
|
||||||
|
const prev = previousById.get(run.runId)
|
||||||
|
return prev !== 'RUNNING'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRuns() {
|
||||||
|
try {
|
||||||
|
const previousById = new Map(runs.value.map(r => [r.runId, r.status]))
|
||||||
|
const data = await fetchRuns()
|
||||||
|
const next = Array.isArray(data) ? data : []
|
||||||
|
|
||||||
|
// Keep optimistic placeholders until the server echoes a real run.
|
||||||
|
const optimistic = runs.value.filter(r => r._optimistic)
|
||||||
|
const serverIds = new Set(next.map(r => r.runId))
|
||||||
|
const stillPending = optimistic.filter(r => !serverIds.has(r.runId))
|
||||||
|
runs.value = [...stillPending, ...next]
|
||||||
|
|
||||||
|
if (detectNewlyRunning(previousById, next)) {
|
||||||
|
useWardStore().loadEncounters()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(scenarioId, speed) {
|
||||||
|
starting.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
const scenario = scenarios.value.find(s => s.id === scenarioId)
|
||||||
|
const placeholderId = `optimistic-${Date.now()}`
|
||||||
|
const placeholder = {
|
||||||
|
runId: placeholderId,
|
||||||
|
scenarioId,
|
||||||
|
scenarioName: scenario?.name ?? scenarioId,
|
||||||
|
status: 'PENDING',
|
||||||
|
speed,
|
||||||
|
patientId: null,
|
||||||
|
encounterId: null,
|
||||||
|
patientDisplayName: '',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
elapsedRealSeconds: 0,
|
||||||
|
lastOffsetMinutes: 0,
|
||||||
|
totalOffsetMinutes: scenario?.durationMinutes ?? 0,
|
||||||
|
progressPercent: 0,
|
||||||
|
observationsSent: 0,
|
||||||
|
medicationsSent: 0,
|
||||||
|
ordersPlaced: 0,
|
||||||
|
failureReason: null,
|
||||||
|
_optimistic: true,
|
||||||
|
}
|
||||||
|
runs.value = [placeholder, ...runs.value]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const run = await startRun({ scenarioId, speed })
|
||||||
|
runs.value = [
|
||||||
|
run,
|
||||||
|
...runs.value.filter(r => r.runId !== placeholderId && r.runId !== run.runId),
|
||||||
|
]
|
||||||
|
if (run.status === 'RUNNING') {
|
||||||
|
useWardStore().loadEncounters()
|
||||||
|
}
|
||||||
|
return run
|
||||||
|
} catch (e) {
|
||||||
|
runs.value = runs.value.filter(r => r.runId !== placeholderId)
|
||||||
|
error.value = e.message
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
starting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(runId) {
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const run = await stopRun(runId)
|
||||||
|
const idx = runs.value.findIndex(r => r.runId === runId)
|
||||||
|
if (idx >= 0) {
|
||||||
|
runs.value[idx] = run
|
||||||
|
} else {
|
||||||
|
runs.value = [run, ...runs.value]
|
||||||
|
}
|
||||||
|
return run
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPollTimer(active) {
|
||||||
|
if (active) {
|
||||||
|
if (!pollTimer) {
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
refreshRuns()
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
} else if (pollTimer) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** View-owned lifecycle: start watching active runs and poll while any are in flight. */
|
||||||
|
function startPolling() {
|
||||||
|
if (stopActiveWatch) return
|
||||||
|
stopActiveWatch = watch(hasActiveRun, (active) => {
|
||||||
|
syncPollTimer(active)
|
||||||
|
}, { immediate: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (stopActiveWatch) {
|
||||||
|
stopActiveWatch()
|
||||||
|
stopActiveWatch = null
|
||||||
|
}
|
||||||
|
syncPollTimer(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
maxSpeed,
|
||||||
|
maxConcurrentRuns,
|
||||||
|
scenarios,
|
||||||
|
runs,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
starting,
|
||||||
|
activeRuns,
|
||||||
|
recentRuns,
|
||||||
|
hasActiveRun,
|
||||||
|
atConcurrencyLimit,
|
||||||
|
scenariosByTag,
|
||||||
|
clearError,
|
||||||
|
loadConfig,
|
||||||
|
loadScenarios,
|
||||||
|
refreshRuns,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
startPolling,
|
||||||
|
stopPolling,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useSimulationStore } from '@/stores/simulation'
|
||||||
|
import ScenarioCard from '@/components/simulation/ScenarioCard.vue'
|
||||||
|
import SimulationRunPanel from '@/components/simulation/SimulationRunPanel.vue'
|
||||||
|
import Button from '@/components/ui/Button.vue'
|
||||||
|
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
|
import { formatWallClockForSpeed } from '@/composables/simulationFormat'
|
||||||
|
|
||||||
|
const SPEED_OPTIONS = [
|
||||||
|
{ label: 'Real time', multiplier: 1 },
|
||||||
|
{ label: 'Fast', multiplier: 60 },
|
||||||
|
{ label: 'Very fast', multiplier: 240 },
|
||||||
|
{ label: 'Instant', multiplier: 600 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const simulationStore = useSimulationStore()
|
||||||
|
const {
|
||||||
|
scenarios,
|
||||||
|
activeRuns,
|
||||||
|
recentRuns,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
starting,
|
||||||
|
atConcurrencyLimit,
|
||||||
|
maxConcurrentRuns,
|
||||||
|
maxSpeed,
|
||||||
|
} = storeToRefs(simulationStore)
|
||||||
|
|
||||||
|
const selectedScenarioId = ref(null)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const activeTag = ref(null)
|
||||||
|
const speed = ref(60)
|
||||||
|
const stoppingId = ref(null)
|
||||||
|
|
||||||
|
const availableSpeeds = computed(() => {
|
||||||
|
const cap = maxSpeed.value
|
||||||
|
return SPEED_OPTIONS.map((opt) => {
|
||||||
|
const multiplier = cap != null ? Math.min(opt.multiplier, cap) : opt.multiplier
|
||||||
|
return { ...opt, multiplier }
|
||||||
|
}).filter((opt, index, arr) =>
|
||||||
|
// Drop Instant (or any option) if a lower option already uses the same capped value
|
||||||
|
index === arr.findIndex(o => o.multiplier === opt.multiplier),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedScenario = computed(() =>
|
||||||
|
scenarios.value.find(s => s.id === selectedScenarioId.value)
|
||||||
|
?? filteredScenarios.value[0]
|
||||||
|
?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const allTags = computed(() => {
|
||||||
|
const set = new Set()
|
||||||
|
for (const s of scenarios.value) {
|
||||||
|
for (const tag of s.tags ?? []) set.add(tag)
|
||||||
|
}
|
||||||
|
return [...set].sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredScenarios = computed(() => {
|
||||||
|
const q = searchQuery.value.trim().toLowerCase()
|
||||||
|
return scenarios.value.filter((s) => {
|
||||||
|
if (activeTag.value && !(s.tags ?? []).includes(activeTag.value)) return false
|
||||||
|
if (!q) return true
|
||||||
|
const haystack = [
|
||||||
|
s.name,
|
||||||
|
s.description,
|
||||||
|
s.id,
|
||||||
|
s.department,
|
||||||
|
...(s.tags ?? []),
|
||||||
|
].filter(Boolean).join(' ').toLowerCase()
|
||||||
|
return haystack.includes(q)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function wallClockFor(multiplier) {
|
||||||
|
if (!selectedScenario.value?.durationMinutes) return null
|
||||||
|
return formatWallClockForSpeed(selectedScenario.value.durationMinutes, multiplier)
|
||||||
|
}
|
||||||
|
|
||||||
|
function concurrencyErrorMessage(raw) {
|
||||||
|
const n = maxConcurrentRuns.value
|
||||||
|
if (/concurrent|concurrency|409/i.test(raw ?? '')) {
|
||||||
|
return n > 0
|
||||||
|
? `Concurrency limit reached — at most ${n} simulation run${n === 1 ? '' : 's'} can run at once. Stop an active run first.`
|
||||||
|
: 'Concurrency limit reached. Stop an active run first.'
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onStart(scenarioId) {
|
||||||
|
selectedScenarioId.value = scenarioId
|
||||||
|
try {
|
||||||
|
await simulationStore.start(scenarioId, speed.value)
|
||||||
|
} catch (e) {
|
||||||
|
simulationStore.error = concurrencyErrorMessage(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onStop(runId) {
|
||||||
|
stoppingId.value = runId
|
||||||
|
try {
|
||||||
|
await simulationStore.stop(runId)
|
||||||
|
} catch {
|
||||||
|
// error already on store
|
||||||
|
} finally {
|
||||||
|
stoppingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectScenario(id) {
|
||||||
|
selectedScenarioId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTag(tag) {
|
||||||
|
activeTag.value = activeTag.value === tag ? null : tag
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
simulationStore.loadScenarios(),
|
||||||
|
simulationStore.refreshRuns(),
|
||||||
|
])
|
||||||
|
simulationStore.startPolling()
|
||||||
|
if (!selectedScenarioId.value && scenarios.value[0]) {
|
||||||
|
selectedScenarioId.value = scenarios.value[0].id
|
||||||
|
}
|
||||||
|
if (!availableSpeeds.value.some(o => o.multiplier === speed.value)) {
|
||||||
|
const preferred = availableSpeeds.value.find(o => o.multiplier === 60)
|
||||||
|
?? availableSpeeds.value[1]
|
||||||
|
?? availableSpeeds.value[0]
|
||||||
|
if (preferred) speed.value = preferred.multiplier
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
simulationStore.stopPolling()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-bold dark:text-white">Simulation</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Replay a recorded clinical scenario into this ward. All patients created here are simulated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
role="alert"
|
||||||
|
class="flex items-start justify-between gap-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
|
||||||
|
>
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 font-medium underline"
|
||||||
|
@click="simulationStore.clearError()"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SimulationRunPanel
|
||||||
|
v-if="activeRuns.length || recentRuns.length"
|
||||||
|
:active-runs="activeRuns"
|
||||||
|
:recent-runs="recentRuns"
|
||||||
|
:stopping-id="stoppingId"
|
||||||
|
@stop="onStop"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section aria-labelledby="sim-speed-heading">
|
||||||
|
<h2
|
||||||
|
id="sim-speed-heading"
|
||||||
|
class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
Replay speed
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
class="inline-flex max-w-full flex-wrap rounded-lg border border-gray-200 bg-white p-1 dark:border-gray-700 dark:bg-gray-900"
|
||||||
|
role="group"
|
||||||
|
aria-label="Replay speed"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="opt in availableSpeeds"
|
||||||
|
:key="opt.label"
|
||||||
|
type="button"
|
||||||
|
class="min-h-11 rounded-md px-4 py-2 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||||
|
:class="speed === opt.multiplier
|
||||||
|
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800'"
|
||||||
|
:aria-pressed="speed === opt.multiplier"
|
||||||
|
@click="speed = opt.multiplier"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ opt.label }}</span>
|
||||||
|
<span
|
||||||
|
class="mt-0.5 block text-xs"
|
||||||
|
:class="speed === opt.multiplier ? 'text-blue-100' : 'text-gray-500 dark:text-gray-400'"
|
||||||
|
>
|
||||||
|
<template v-if="wallClockFor(opt.multiplier)">
|
||||||
|
{{ wallClockFor(opt.multiplier) }} for selected scenario
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ opt.multiplier }}×
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="selectedScenario" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Durations above use
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-300">{{ selectedScenario.name }}</span>
|
||||||
|
({{ selectedScenario.durationMinutes }} simulated minutes).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="sim-catalogue-heading">
|
||||||
|
<div class="mb-4 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<h2
|
||||||
|
id="sim-catalogue-heading"
|
||||||
|
class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
Scenario catalogue
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 space-y-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<label class="block">
|
||||||
|
<span class="sr-only">Search scenarios</span>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search scenarios by name, tag, or department…"
|
||||||
|
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="allTags.length" class="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
v-for="tag in allTags"
|
||||||
|
:key="tag"
|
||||||
|
size="sm"
|
||||||
|
:variant="activeTag === tag ? 'primary' : 'secondary'"
|
||||||
|
@click="toggleTag(tag)"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="activeTag"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
@click="activeTag = null"
|
||||||
|
>
|
||||||
|
Clear filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton v-if="loading && scenarios.length === 0" :rows="3" />
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
v-else-if="scenarios.length === 0"
|
||||||
|
message="No scenarios found. Check that Simulation:ScenarioDirectory is configured and contains scenario JSON files."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
v-else-if="filteredScenarios.length === 0"
|
||||||
|
message="No scenarios match the current search or tag filter."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="scenario in filteredScenarios"
|
||||||
|
:key="scenario.id"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<ScenarioCard
|
||||||
|
:scenario="scenario"
|
||||||
|
:selected="selectedScenarioId === scenario.id"
|
||||||
|
:at-concurrency-limit="atConcurrencyLimit"
|
||||||
|
:max-concurrent-runs="maxConcurrentRuns"
|
||||||
|
:starting="starting"
|
||||||
|
@select="selectScenario"
|
||||||
|
@start="onStart"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user