feature: Degraded Operations Visibility

This commit is contained in:
voltsrage
2026-06-23 23:18:31 +08:00
parent 940e27c0ac
commit 4399996448
81 changed files with 3949 additions and 323 deletions
@@ -0,0 +1,72 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
[Collection("Integration")]
public class OperationsApiTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private readonly HttpClient _client;
public OperationsApiTests(ApiFixture fixture)
{
_fixture = fixture;
_client = fixture.CreateClient();
}
public async Task InitializeAsync()
{
_client.AsAdmin();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await DbResetHelper.ResetAsync(db);
await GatewayRegistrySeeder.SeedAsync(db);
var gateway = await db.WardGateways.FindAsync(GatewayRegistrySeeder.DemoGatewayId);
gateway!.RecordHeartbeat(GatewayStatus.Degraded, 847, DateTimeOffset.UtcNow.AddMinutes(-5));
await db.SaveChangesAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task GetFleet_ReturnsAllGateways()
{
var resp = await _client.GetAsync("/api/v1/operations/gateways");
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<ApiResponse<List<GatewayFleetItem>>>();
body!.Data.Should().NotBeEmpty();
}
[Fact]
public async Task GetFleet_FilterDegraded()
{
var resp = await _client.GetAsync("/api/v1/operations/gateways?status=DEGRADED");
var body = await resp.Content.ReadFromJsonAsync<ApiResponse<List<GatewayFleetItem>>>();
body!.Data.Should().OnlyContain(g => g.Status == "DEGRADED");
}
[Fact]
public async Task GetSiteSummary_CountsByStatus()
{
var resp = await _client.GetAsync(
$"/api/v1/operations/sites/{GatewayRegistrySeeder.DemoSiteId}/summary");
var body = await resp.Content.ReadFromJsonAsync<ApiResponse<SiteGatewaySummaryResponse>>();
body!.Data!.TotalGateways.Should().BeGreaterThan(0);
body.Data.Degraded.Should().BeGreaterThanOrEqualTo(1);
}
[Fact]
public async Task GetGatewayDetail_IncludesSyncHistory()
{
var resp = await _client.GetAsync(
$"/api/v1/operations/gateways/{GatewayRegistrySeeder.DemoGatewayId}");
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<ApiResponse<GatewayDetailResponse>>();
body!.Data!.Gateway.GatewayCode.Should().Be("GW-ICU-3B");
body.Data.RecentBatches.Should().NotBeNull();
}
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
public sealed class GatewayStaleDetectorService : BackgroundService
{
private readonly IServiceScopeFactory _scopes;
private readonly GatewayMonitoringOptions _opts;
private readonly ClinicalMetrics _metrics;
private readonly ILogger<GatewayStaleDetectorService> _logger;
public GatewayStaleDetectorService(
IServiceScopeFactory scopes,
IOptions<GatewayMonitoringOptions> opts,
ClinicalMetrics metrics,
ILogger<GatewayStaleDetectorService> logger)
{
_scopes = scopes;
_opts = opts.Value;
_metrics = metrics;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(_opts.PollIntervalMinutes));
while (await timer.WaitForNextTickAsync(ct))
await DetectStaleAsync(ct);
}
private async Task DetectStaleAsync(CancellationToken ct)
{
await using var scope = _scopes.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cutoff = DateTimeOffset.UtcNow.AddMinutes(-_opts.StaleThresholdMinutes);
var stale = await db.WardGateways
.Include(g => g.Site)
.Where(g => g.Status != GatewayStatus.Offline
&& (g.LastHeartbeatAt == null || g.LastHeartbeatAt < cutoff))
.ToListAsync(ct);
foreach (var gateway in stale)
{
gateway.MarkOffline();
_logger.LogWarning(
"Gateway {Code} ({Department}) marked OFFLINE — last heartbeat {LastHeartbeat}",
gateway.GatewayCode, gateway.Department, gateway.LastHeartbeatAt);
_metrics.WardGatewaysOffline
.WithLabels(gateway.Site.SiteCode)
.Inc();
}
if (stale.Count > 0)
await db.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,6 @@
public sealed class GatewayMonitoringOptions
{
public const string Section = "GatewayMonitoring";
public int StaleThresholdMinutes { get; init; } = 10;
public int PollIntervalMinutes { get; init; } = 5;
}
@@ -12,8 +12,15 @@ using Microsoft.AspNetCore.Mvc;
public class EncountersController : ControllerBase
{
private readonly IEncounterService _encounters;
private readonly IDischargeSummaryService _dischargeSummary;
public EncountersController(IEncounterService encounters) => _encounters = encounters;
public EncountersController(
IEncounterService encounters,
IDischargeSummaryService dischargeSummary)
{
_encounters = encounters;
_dischargeSummary = dischargeSummary;
}
/// <summary>
/// Lists encounters for ward dashboards with denormalized clinical summary fields.
@@ -115,4 +122,31 @@ public class EncountersController : ControllerBase
var timeline = await _encounters.GetTimelineAsync(id);
return Ok(ApiResponse<object>.Ok(timeline));
}
/// <summary>
/// Returns discharge summary availability for a discharged encounter.
/// </summary>
[HttpGet("{id:guid}/discharge-summary")]
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
[ProducesResponseType(typeof(ApiResponse<DischargeSummaryInfo>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetDischargeSummary(Guid id)
{
var info = await _dischargeSummary.GetInfoAsync(id);
return Ok(ApiResponse<DischargeSummaryInfo>.Ok(info));
}
/// <summary>
/// Downloads the generated discharge summary document.
/// </summary>
[HttpGet("{id:guid}/discharge-summary/content")]
[AuthorizePermission(ClinicalPermissions.EncountersRead)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
public async Task<IActionResult> DownloadDischargeSummary(Guid id)
{
var content = await _dischargeSummary.GetContentAsync(id);
return File(content.Stream, content.ContentType, content.FileName);
}
}
@@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Gateway fleet operations: fleet listing, gateway detail, and site summaries.
/// </summary>
[ApiController]
[Route("api/v1/operations")]
[Authorize]
[Produces("application/json")]
public class OperationsController : ControllerBase
{
private readonly IOperationsService _operations;
public OperationsController(IOperationsService operations) => _operations = operations;
/// <summary>
/// Lists registered gateways with optional status and site filters.
/// </summary>
/// <param name="status">Optional gateway status filter (DB literal, e.g. DEGRADED).</param>
/// <param name="siteId">Optional site id filter.</param>
/// <returns>Gateway fleet items.</returns>
[HttpGet("gateways")]
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<GatewayFleetItem>>), StatusCodes.Status200OK)]
public async Task<IActionResult> ListGateways(
[FromQuery] string? status,
[FromQuery] Guid? siteId,
CancellationToken ct)
{
var items = await _operations.GetGatewayFleetAsync(
new GatewayFleetFilter(status, siteId), ct);
return Ok(ApiResponse<IReadOnlyList<GatewayFleetItem>>.Ok(items));
}
/// <summary>
/// Gets a single gateway by id with buffer depth, heartbeat metadata, and sync history.
/// </summary>
/// <param name="gatewayId">Gateway id.</param>
/// <returns>Gateway detail.</returns>
[HttpGet("gateways/{gatewayId:guid}")]
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
[ProducesResponseType(typeof(ApiResponse<GatewayDetailResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetGateway(Guid gatewayId, CancellationToken ct)
{
var detail = await _operations.GetGatewayDetailAsync(gatewayId, ct);
return Ok(ApiResponse<GatewayDetailResponse>.Ok(detail));
}
/// <summary>
/// Returns an aggregate gateway summary for a site.
/// </summary>
/// <param name="siteId">Site id.</param>
/// <returns>Site gateway summary.</returns>
[HttpGet("sites/{siteId:guid}/summary")]
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
[ProducesResponseType(typeof(ApiResponse<SiteGatewaySummaryResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSiteSummary(Guid siteId, CancellationToken ct)
{
var summary = await _operations.GetSiteSummaryAsync(siteId, ct);
return Ok(ApiResponse<SiteGatewaySummaryResponse>.Ok(summary));
}
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Clinical user account management for hospital IT administrators.
/// </summary>
[ApiController]
[Route("api/v1/users")]
[Produces("application/json")]
[Authorize]
public class UsersController : ControllerBase
{
private readonly IUserService _users;
public UsersController(IUserService users) => _users = users;
/// <summary>Lists all clinical user accounts.</summary>
[HttpGet]
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
[ProducesResponseType(typeof(ApiResponse<List<ClinicalUserResponse>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List()
{
var users = await _users.ListAsync();
return Ok(ApiResponse<List<ClinicalUserResponse>>.Ok(users));
}
/// <summary>Creates a new clinical user account.</summary>
[HttpPost]
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
[ProducesResponseType(typeof(ApiResponse<ClinicalUserResponse>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Create([FromBody] CreateUserRequest req)
{
var user = await _users.CreateAsync(req);
return StatusCode(201, ApiResponse<ClinicalUserResponse>.Created(user));
}
/// <summary>Updates role, display name, or active status for a user.</summary>
[HttpPatch("{id:guid}")]
[AuthorizePermission(ClinicalPermissions.UsersAdmin)]
[ProducesResponseType(typeof(ApiResponse<ClinicalUserResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateUserRequest req)
{
var user = await _users.UpdateAsync(id, req);
return Ok(ApiResponse<ClinicalUserResponse>.Ok(user));
}
}
@@ -0,0 +1,7 @@
public record DischargeSummaryInfo(
string Status,
DateTimeOffset? DischargedAt,
string? ContentType,
string? FileName);
public record DischargeSummaryContent(Stream Stream, string ContentType, string FileName);
@@ -0,0 +1,4 @@
public record GatewayDetailResponse(
GatewayFleetItem Gateway,
IReadOnlyList<ClinicalSyncHistoryItem> RecentBatches,
int PendingConflictCount);
@@ -0,0 +1 @@
public record GatewayFleetFilter(string? Status, Guid? SiteId);
@@ -0,0 +1,11 @@
public record GatewayFleetItem(
Guid Id,
string GatewayCode,
string Department,
string SiteCode,
string SiteName,
string Status,
int ReportedBufferDepth,
DateTimeOffset? LastHeartbeatAt,
DateTimeOffset? LastSyncAt,
double? MinutesSinceHeartbeat);
@@ -0,0 +1,7 @@
public record SiteGatewaySummaryResponse(
Guid SiteId,
int TotalGateways,
int Online,
int Degraded,
int Offline,
int TotalBufferedEvents);
@@ -0,0 +1,8 @@
public record ClinicalUserResponse(
Guid Id,
string Username,
string DisplayName,
string Role,
bool IsActive,
DateTimeOffset CreatedAt,
DateTimeOffset? LastLoginAt);
@@ -0,0 +1,5 @@
public record CreateUserRequest(
string Username,
string Password,
string DisplayName,
string Role);
@@ -0,0 +1,4 @@
public record UpdateUserRequest(
string? DisplayName,
string? Role,
bool? IsActive);
+7
View File
@@ -148,6 +148,9 @@ try
builder.Services.Configure<ClinicalSyncOptions>(builder.Configuration.GetSection(ClinicalSyncOptions.Section));
builder.Services.Configure<GatewayMonitoringOptions>(
builder.Configuration.GetSection(GatewayMonitoringOptions.Section));
builder.Services.AddCors(options =>
{
options.AddPolicy("Dashboard", policy =>
@@ -160,6 +163,7 @@ try
builder.Services.AddScoped<IPatientService, PatientService>();
builder.Services.AddScoped<IEncounterService, EncounterService>();
builder.Services.AddScoped<IDischargeSummaryService, DischargeSummaryService>();
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
builder.Services.AddScoped<IObservationService, ObservationService>();
builder.Services.AddScoped<IObservationQueryService, ObservationQueryService>();
@@ -198,6 +202,7 @@ try
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IAuditService, AuditService>();
builder.Services.AddScoped<IClinicalSyncService, ClinicalSyncService>();
builder.Services.AddScoped<ClinicalSyncBatchProcessor>();
@@ -205,6 +210,8 @@ try
builder.Services.AddScoped<IPhiAccessLogService, PhiAccessLogService>();
builder.Services.AddScoped<ISiteService, SiteService>();
builder.Services.AddScoped<IGatewayRegistryService, GatewayRegistryService>();
builder.Services.AddScoped<IOperationsService, OperationsService>();
builder.Services.AddHostedService<GatewayStaleDetectorService>();
builder.Services.AddHostedService<ThresholdCacheLoader>();
builder.Services.AddHostedService<KafkaTopicProvisioner>();
@@ -0,0 +1,106 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Minio;
using Minio.DataModel.Args;
using Minio.Exceptions;
public sealed class DischargeSummaryService : IDischargeSummaryService
{
private readonly AppDbContext _db;
private readonly MinioOptions _minioOpts;
public DischargeSummaryService(AppDbContext db, IOptions<MinioOptions> minioOpts)
{
_db = db;
_minioOpts = minioOpts.Value;
}
public async Task<DischargeSummaryInfo> GetInfoAsync(Guid encounterId, CancellationToken ct = default)
{
var encounter = await _db.Encounters
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == encounterId, ct);
if (encounter is null)
throw new NotFoundException("Encounter not found.");
if (encounter.Status != EncounterStatus.Discharged)
{
return new DischargeSummaryInfo(
"NotDischarged",
encounter.DischargedAt,
null,
null);
}
var exists = await ObjectExistsAsync(encounterId, ct);
if (!exists)
{
return new DischargeSummaryInfo(
"Pending",
encounter.DischargedAt,
null,
null);
}
return new DischargeSummaryInfo(
"Ready",
encounter.DischargedAt,
"text/plain",
"discharge-summary.pdf");
}
public async Task<DischargeSummaryContent> GetContentAsync(Guid encounterId, CancellationToken ct = default)
{
var info = await GetInfoAsync(encounterId, ct);
if (info.Status == "NotDischarged")
{
throw new ConflictException(
"Discharge summary is only available for discharged encounters.",
"ENCOUNTER_NOT_DISCHARGED");
}
if (info.Status == "Pending")
{
throw new NotFoundException(
"Discharge summary is still being generated.",
"DISCHARGE_SUMMARY_PENDING");
}
var client = MinioClientFactory.Build(_minioOpts);
var bucket = _minioOpts.BucketName;
var objectKey = ObjectKey(encounterId);
var ms = new MemoryStream();
await client.GetObjectAsync(new GetObjectArgs()
.WithBucket(bucket)
.WithObject(objectKey)
.WithCallbackStream(stream => stream.CopyTo(ms)), ct);
ms.Position = 0;
return new DischargeSummaryContent(ms, info.ContentType!, info.FileName!);
}
private async Task<bool> ObjectExistsAsync(Guid encounterId, CancellationToken ct)
{
var client = MinioClientFactory.Build(_minioOpts);
var bucket = _minioOpts.BucketName;
var objectKey = ObjectKey(encounterId);
try
{
await client.StatObjectAsync(new StatObjectArgs()
.WithBucket(bucket)
.WithObject(objectKey), ct);
return true;
}
catch (ObjectNotFoundException)
{
return false;
}
}
private static string ObjectKey(Guid encounterId) =>
$"discharge-summaries/{encounterId}/summary.pdf";
}
@@ -0,0 +1,5 @@
public interface IDischargeSummaryService
{
Task<DischargeSummaryInfo> GetInfoAsync(Guid encounterId, CancellationToken ct = default);
Task<DischargeSummaryContent> GetContentAsync(Guid encounterId, CancellationToken ct = default);
}
@@ -0,0 +1,6 @@
public interface IOperationsService
{
Task<IReadOnlyList<GatewayFleetItem>> GetGatewayFleetAsync(GatewayFleetFilter filter, CancellationToken ct);
Task<GatewayDetailResponse> GetGatewayDetailAsync(Guid gatewayId, CancellationToken ct);
Task<SiteGatewaySummaryResponse> GetSiteSummaryAsync(Guid siteId, CancellationToken ct);
}
@@ -0,0 +1,6 @@
public interface IUserService
{
Task<List<ClinicalUserResponse>> ListAsync();
Task<ClinicalUserResponse> CreateAsync(CreateUserRequest req);
Task<ClinicalUserResponse> UpdateAsync(Guid id, UpdateUserRequest req);
}
@@ -0,0 +1,104 @@
using Dapper;
using Microsoft.EntityFrameworkCore;
using Npgsql;
public class OperationsService : IOperationsService
{
private readonly AppDbContext _db;
private readonly IClinicalSyncService _sync;
public OperationsService(AppDbContext db, IClinicalSyncService sync)
{
_db = db;
_sync = sync;
}
public async Task<IReadOnlyList<GatewayFleetItem>> GetGatewayFleetAsync(
GatewayFleetFilter filter, CancellationToken ct)
{
await using var conn = new NpgsqlConnection(_db.Database.GetConnectionString());
await conn.OpenAsync(ct);
const string sql = """
SELECT
g.id,
g.gateway_code AS GatewayCode,
g.department AS Department,
s.site_code AS SiteCode,
s.name AS SiteName,
g.status AS Status,
g.reported_buffer_depth AS ReportedBufferDepth,
g.last_heartbeat_at AS LastHeartbeatAt,
g.last_sync_at AS LastSyncAt,
EXTRACT(EPOCH FROM (NOW() - g.last_heartbeat_at)) / 60 AS MinutesSinceHeartbeat
FROM ward_gateways g
JOIN clinical_sites s ON s.id = g.site_id
WHERE (@Status IS NULL OR g.status = @Status)
AND (@SiteId IS NULL OR g.site_id = @SiteId)
ORDER BY g.status DESC, MinutesSinceHeartbeat DESC NULLS LAST
""";
var rows = await conn.QueryAsync<GatewayFleetItem>(sql, new
{
Status = filter.Status,
SiteId = filter.SiteId
});
return rows.ToList();
}
public async Task<GatewayDetailResponse> GetGatewayDetailAsync(Guid gatewayId, CancellationToken ct)
{
var fleet = await GetGatewayFleetAsync(new GatewayFleetFilter(null, null), ct);
var gateway = fleet.FirstOrDefault(g => g.Id == gatewayId)
?? throw new NotFoundException("Gateway not found.", "GATEWAY_NOT_FOUND");
var siteId = await _db.WardGateways
.Where(g => g.Id == gatewayId)
.Select(g => g.SiteId)
.FirstAsync(ct);
var history = await _sync.GetSyncHistoryAsync(siteId, gatewayId, 10, ct);
var lastConflictBatch = await _db.ClinicalSyncBatches
.AsNoTracking()
.Where(b => b.GatewayId == gatewayId && b.Status == ClinicalSyncBatchStatus.Conflict)
.OrderByDescending(b => b.SubmittedAt)
.Include(b => b.Conflicts)
.FirstOrDefaultAsync(ct);
var pendingConflicts = lastConflictBatch?.Conflicts.Count ?? 0;
return new GatewayDetailResponse(gateway, history, pendingConflicts);
}
public async Task<SiteGatewaySummaryResponse> GetSiteSummaryAsync(Guid siteId, CancellationToken ct)
{
var siteExists = await _db.ClinicalSites.AnyAsync(s => s.Id == siteId, ct);
if (!siteExists)
throw new NotFoundException("Site not found.", "SITE_NOT_FOUND");
await using var conn = new NpgsqlConnection(_db.Database.GetConnectionString());
await conn.OpenAsync(ct);
const string sql = """
SELECT
COUNT(*) AS TotalGateways,
COUNT(*) FILTER (WHERE status = 'ONLINE') AS Online,
COUNT(*) FILTER (WHERE status = 'DEGRADED') AS Degraded,
COUNT(*) FILTER (WHERE status = 'OFFLINE') AS Offline,
COALESCE(SUM(reported_buffer_depth), 0) AS TotalBufferedEvents
FROM ward_gateways
WHERE site_id = @SiteId
""";
var row = await conn.QuerySingleAsync(sql, new { SiteId = siteId });
return new SiteGatewaySummaryResponse(
siteId,
(int)row.totalgateways,
(int)row.online,
(int)row.degraded,
(int)row.offline,
(int)row.totalbufferedevents);
}
}
@@ -0,0 +1,121 @@
using Microsoft.EntityFrameworkCore;
public class UserService : IUserService
{
private readonly AppDbContext _db;
private readonly ICurrentUserService _currentUser;
public UserService(AppDbContext db, ICurrentUserService currentUser)
{
_db = db;
_currentUser = currentUser;
}
public async Task<List<ClinicalUserResponse>> ListAsync()
{
var users = await _db.ClinicalUsers
.AsNoTracking()
.OrderBy(u => u.Username)
.ToListAsync();
return users.Select(Map).ToList();
}
public async Task<ClinicalUserResponse> CreateAsync(CreateUserRequest req)
{
ValidateUsername(req.Username);
ValidatePassword(req.Password);
ValidateDisplayName(req.DisplayName);
var role = ParseRole(req.Role);
var exists = await _db.ClinicalUsers.AnyAsync(u => u.Username == req.Username);
if (exists)
throw new ConflictException(
$"Username '{req.Username}' is already taken.",
"USERNAME_CONFLICT");
var user = new ClinicalUser
{
Id = Guid.NewGuid(),
Username = req.Username.Trim(),
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password),
DisplayName = req.DisplayName.Trim(),
Role = role,
IsActive = true,
CreatedAt = DateTimeOffset.UtcNow,
};
_db.ClinicalUsers.Add(user);
await _db.SaveChangesAsync();
return Map(user);
}
public async Task<ClinicalUserResponse> UpdateAsync(Guid id, UpdateUserRequest req)
{
var user = await _db.ClinicalUsers.FindAsync(id);
if (user is null)
throw new NotFoundException("User not found.", "USER_NOT_FOUND");
if (req.DisplayName is not null)
{
ValidateDisplayName(req.DisplayName);
user.DisplayName = req.DisplayName.Trim();
}
if (req.Role is not null)
user.Role = ParseRole(req.Role);
if (req.IsActive is not null)
{
if (!req.IsActive.Value && _currentUser.UserId == id)
throw new ValidationException(
"You cannot deactivate your own account.",
"SELF_DEACTIVATION_DENIED");
user.IsActive = req.IsActive.Value;
}
await _db.SaveChangesAsync();
return Map(user);
}
private static ClinicalUserResponse Map(ClinicalUser u) =>
new(u.Id, u.Username, u.DisplayName, u.Role.ToDbString(), u.IsActive, u.CreatedAt, u.LastLoginAt);
private static void ValidateUsername(string username)
{
if (string.IsNullOrWhiteSpace(username))
throw new ValidationException("Username is required.", "USERNAME_REQUIRED");
if (username.Trim().Length > 100)
throw new ValidationException("Username must be 100 characters or fewer.", "USERNAME_TOO_LONG");
}
private static void ValidatePassword(string password)
{
if (string.IsNullOrWhiteSpace(password))
throw new ValidationException("Password is required.", "PASSWORD_REQUIRED");
if (password.Length < 8)
throw new ValidationException("Password must be at least 8 characters.", "PASSWORD_TOO_SHORT");
}
private static void ValidateDisplayName(string displayName)
{
if (string.IsNullOrWhiteSpace(displayName))
throw new ValidationException("Display name is required.", "DISPLAY_NAME_REQUIRED");
if (displayName.Trim().Length > 200)
throw new ValidationException("Display name must be 200 characters or fewer.", "DISPLAY_NAME_TOO_LONG");
}
private static ClinicalRole ParseRole(string role)
{
if (string.IsNullOrWhiteSpace(role))
throw new ValidationException("Role is required.", "ROLE_REQUIRED");
try
{
return ClinicalRoleExtensions.FromDbString(role.Trim().ToUpperInvariant());
}
catch (ArgumentOutOfRangeException)
{
throw new ValidationException(
"Role must be one of: NURSE, PHYSICIAN, ADMIN, INTEGRATION.",
"INVALID_ROLE");
}
}
}
@@ -15,6 +15,7 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Confluent.Kafka" Version="2.14.0" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.13.12" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Hl7.Fhir.R4" Version="5.11.1" />
+4
View File
@@ -210,5 +210,9 @@
},
"ClinicalSync": {
"SuppressPagingForSyncedAlerts": true
},
"GatewayMonitoring": {
"StaleThresholdMinutes": 10,
"PollIntervalMinutes": 5
}
}
+60 -43
View File
@@ -19,6 +19,22 @@ Each item includes **who needs it** (clinical role), **why** it matters for pati
---
## Implementation progress
**Last updated:** June 2026
| Status | Count | Items |
|--------|------:|-------|
| **Done** | 22 | #118, #2023, #25 |
| **Partial** | 1 | #26 (core WCAG improvements shipped; full audit not completed) |
| **Open** | 5 | #19, #24, #27, #28 |
**Recently completed (dashboard phases 2223):** scoring history charts (SOFA, GCS, qSOFA), patient banner, encounter timeline, medication chart markers, ward table clinical columns with sort/search, sepsis board, department overview, critical alert banner, handoff report, discharge summary panel, vitals/GCS entry, alert role context on acknowledge, threshold management, user management (`UsersController` + UI), audit log viewer, reconciliation dashboard, role-based navigation, dark-mode chart theming (`useChartTheme.js`), mobile patient-detail layout with collapsible sections, and baseline WCAG improvements (severity icons, chart `aria-label`s, modal focus trap, skip link, ward table keyboard nav).
**Still open:** gateway status dashboard (#19), alert-triage keyboard shortcuts (#24), Grafana expansion (#27), real-time push (#28).
---
# Part A — Patient Safety & Bedside Decision Support
---
@@ -902,32 +918,32 @@ Real-time push reduces alert notification latency from 5-10 seconds to sub-secon
| # | Issue | Priority | Part | Backend exists? | Status |
|---|-------|----------|------|----------------|--------|
| 1 | No SOFA trend chart | P0 | A | Yes | Open |
| 2 | No GCS trend chart / history endpoint | P0 | A | Partial (no history endpoint) | Open |
| 3 | No qSOFA history view | P0 | A | No (Redis-only, no persistence) | Open |
| 4 | No medication overlay on vital charts | P0 | A | Yes | Open |
| 5 | No encounter timeline view | P1 | A | Yes | Open |
| 6 | No patient demographics / allergy banner | P1 | A | Yes | Open |
| 7 | Ward table missing SOFA, GCS, staleness | P1 | B | Partial (DTO needs extension) | Open |
| 8 | No sepsis bundle compliance board | P2 | B | Yes | Open |
| 9 | No department overview with aggregates | P2 | B | Partial | Open |
| 10 | No sort controls on ward table | P2 | B | N/A (client-side) | Open |
| 11 | No search/filter on ward table | P2 | B | Yes (analytics search) | Open |
| 12 | No critical alert notification/sound | P1 | C | No (needs push or client detect) | Open |
| 13 | No handoff/shift summary report | P1 | C | Yes (data available) | Open |
| 14 | No discharge summary view | P2 | C | Partial (needs download endpoint) | Open |
| 15 | Alert ack lacks role context | P3 | C | Partial | Open |
| 16 | No vitals entry form | P3 | C | Yes | Open |
| 17 | No threshold management UI | P2 | D | Yes | Open |
| 18 | No user management UI | P2 | D | No (needs backend endpoints) | Open |
| 1 | No SOFA trend chart | P0 | A | Yes | **Done**`SofaHistory.vue` |
| 2 | No GCS trend chart / history endpoint | P0 | A | Yes | **Done**`GcsHistory.vue`, `GET /gcs/history` |
| 3 | No qSOFA history view | P0 | A | Yes | **Done**`QsofaHistory.vue`, `qsofa_evaluations` + `GET /qsofa/history` |
| 4 | No medication overlay on vital charts | P0 | A | Yes | **Done**`medicationMarkerPlugin.js` |
| 5 | No encounter timeline view | P1 | A | Yes | **Done**`EncounterTimeline.vue` |
| 6 | No patient demographics / allergy banner | P1 | A | Yes | **Done**`PatientBanner.vue` |
| 7 | Ward table missing SOFA, GCS, staleness | P1 | B | Yes | **Done** — extended `WardEncounterSummary` + `PatientRow` |
| 8 | No sepsis bundle compliance board | P2 | B | Yes | **Done**`SepsisBoardView.vue` |
| 9 | No department overview with aggregates | P2 | B | Yes | **Done**`DepartmentOverviewView.vue` |
| 10 | No sort controls on ward table | P2 | B | N/A (client-side) | **Done**`wardSort.js`, `SortableHeader` |
| 11 | No search/filter on ward table | P2 | B | Yes (analytics search) | **Done**`WardToolbar.vue` |
| 12 | No critical alert notification/sound | P1 | C | Partial (client detect) | **Done**`CriticalAlertBanner.vue`, sound mute toggle |
| 13 | No handoff/shift summary report | P1 | C | Yes (data available) | **Done**`HandoffReport.vue` |
| 14 | No discharge summary view | P2 | C | Yes | **Done**`DischargeSummaryPanel.vue`, `GET /discharge-summary` |
| 15 | Alert ack lacks role context | P3 | C | Yes | **Done**`alertAcknowledge.js`, `AcknowledgeModal.vue` |
| 16 | No vitals entry form | P3 | C | Yes | **Done**`VitalsEntryForm.vue`, `GcsEntryForm.vue` |
| 17 | No threshold management UI | P2 | D | Yes | **Done**`ThresholdManagementView.vue` |
| 18 | No user management UI | P2 | D | Yes | **Done**`UsersController`, `UserManagementView.vue` |
| 19 | No gateway status dashboard | P2 | D | Yes | Open |
| 20 | No audit log viewer | P4 | D | Yes | Open |
| 21 | No reconciliation dashboard | P4 | D | Yes | Open |
| 22 | No role-based navigation | P3 | E | N/A (frontend-only) | Open |
| 23 | Dark mode chart inconsistency | P3 | E | N/A | Open |
| 20 | No audit log viewer | P4 | D | Yes | **Done**`AuditLogView.vue` |
| 21 | No reconciliation dashboard | P4 | D | Yes | **Done**`ReconciliationView.vue` |
| 22 | No role-based navigation | P3 | E | N/A (frontend-only) | **Done**`roleAccess.js`, route guards |
| 23 | Dark mode chart inconsistency | P3 | E | N/A | **Done**`useChartTheme.js` |
| 24 | No keyboard shortcuts | P3 | E | N/A | Open |
| 25 | No mobile optimization for patient detail | P3 | E | N/A | Open |
| 26 | No accessibility (WCAG) compliance | P5 | E | N/A | Open |
| 25 | No mobile optimization for patient detail | P3 | E | N/A | **Done** — collapsible sections, single-column mobile layout |
| 26 | No accessibility (WCAG) compliance | P5 | E | N/A | **Partial** — severity icons, chart labels, modal focus trap, skip link, ward keyboard nav |
| 27 | Grafana dashboard incomplete | P4 | F | Partial (needs more metrics) | Open |
| 28 | No real-time push (polling only) | P4 | F | No (needs SSE/SignalR) | Open |
@@ -988,32 +1004,33 @@ flowchart TD
### Sprint-sized batches
| Batch | Items | Outcome |
|-------|-------|---------|
| **1 — Bedside Decision Support** | P0 SOFA chart, P0 GCS history + chart, P0 medication overlay, P1 patient banner, P1 timeline | Physicians and nurses see full scoring history and medication context for clinical decisions |
| **2 — Ward Awareness** | P0 qSOFA history, P1 ward table columns, P2 sort/search, P1 critical alert notification, P2 sepsis board | Charge nurses have complete situational awareness; critical alerts demand attention |
| **3 — Clinical Workflows** | P1 handoff report, P2 discharge summary, P3 vitals entry form, P3 alert role context | Nurses can chart vitals in-app; shift handoff is printable; discharge documents accessible |
| **4 — Admin Tools** | P3 role-based nav, P2 threshold management, P2 gateway status, P2 user management, P2 department overview | Admins manage thresholds without API calls; IT monitors gateway health; navigation is role-scoped |
| **5 — Polish & Infrastructure** | P3 dark mode charts, P3 keyboard shortcuts, P3 mobile optimization, P4 audit/reconciliation views, P4 Grafana, P5 WCAG | Night-shift readability, power-user efficiency, compliance audit trail, monitoring depth |
| **6 — Real-Time** | P4 SSE/SignalR push | Sub-second alert notification, reduced polling load |
| Batch | Items | Outcome | Status |
|-------|-------|---------|--------|
| **1 — Bedside Decision Support** | P0 SOFA chart, P0 GCS history + chart, P0 medication overlay, P1 patient banner, P1 timeline | Physicians and nurses see full scoring history and medication context for clinical decisions | **Done** |
| **2 — Ward Awareness** | P0 qSOFA history, P1 ward table columns, P2 sort/search, P1 critical alert notification, P2 sepsis board | Charge nurses have complete situational awareness; critical alerts demand attention | **Done** (department overview also shipped) |
| **3 — Clinical Workflows** | P1 handoff report, P2 discharge summary, P3 vitals entry form, P3 alert role context | Nurses can chart vitals in-app; shift handoff is printable; discharge documents accessible | **Done** |
| **4 — Admin Tools** | P3 role-based nav, P2 threshold management, P2 gateway status, P2 user management, P2 department overview | Admins manage thresholds without API calls; IT monitors gateway health; navigation is role-scoped | **Partial** — gateway status UI still open |
| **5 — Polish & Infrastructure** | P3 dark mode charts, P3 keyboard shortcuts, P3 mobile optimization, P4 audit/reconciliation views, P4 Grafana, P5 WCAG | Night-shift readability, power-user efficiency, compliance audit trail, monitoring depth | **Partial** — shortcuts, Grafana, full WCAG audit open |
| **6 — Real-Time** | P4 SSE/SignalR push | Sub-second alert notification, reduced polling load | Open |
---
## Backend work required
Most dashboard gaps are **frontend-only** — the API endpoints already exist but the dashboard does not consume them. The following items require backend changes:
Most dashboard gaps are **frontend-only** — the API endpoints already exist but the dashboard does not consume them. The following items required or still require backend changes:
| Item | Backend work needed |
|------|-------------------|
| GCS history chart | New `GET /encounters/{id}/gcs/history` endpoint |
| qSOFA history | Decide persistence strategy (Redis → DB) + new history endpoint |
| Ward table columns | Extend `WardEncounterSummary` DTO with SOFA/GCS/staleness |
| Discharge summary view | New `GET /encounters/{id}/discharge-summary` download endpoint |
| User management | New `UsersController` with CRUD endpoints |
| Real-time push | New SSE endpoint or SignalR hub |
| Grafana expansion | Depends on platform gap P5 metrics being implemented first |
| Item | Backend work needed | Status |
|------|---------------------|--------|
| GCS history chart | `GET /encounters/{id}/gcs/history` endpoint | **Done** |
| qSOFA history | `qsofa_evaluations` table + `GET /qsofa/history` | **Done** |
| Ward table columns | Extend `WardEncounterSummary` DTO with SOFA/GCS/staleness | **Done** |
| Discharge summary view | `GET /encounters/{id}/discharge-summary` + content download | **Done** |
| User management | `UsersController` with list/create/patch | **Done** |
| Gateway status dashboard | Fleet/summary APIs exist (Phase 23); dashboard UI not built | Open (frontend) |
| Real-time push | New SSE endpoint or SignalR hub | Open |
| Grafana expansion | Depends on platform gap P5 metrics being implemented first | Open |
All other items can be built against existing API endpoints.
All other closed items were built against existing API endpoints.
---
@@ -0,0 +1,10 @@
## Climate resilience demo
Run the network partition walkthrough:
export ADMIN_JWT=<admin-token>
export GATEWAY_JWT=<gateway-dashboard-token>
./scripts/demo-network-partition.sh
Expected flow: gateway ONLINE → partition → observation buffered locally →
central shows DEGRADED/OFFLINE + buffer depth > 0 → heal → ONLINE + buffer 0.
+108
View File
@@ -192,6 +192,114 @@
"fields": ""
}
}
},
{
"id": 9,
"type": "stat",
"title": "Ward Gateways Offline",
"description": "Gateways in DEGRADED or OFFLINE status",
"gridPos": { "x": 0, "y": 16, "w": 6, "h": 4 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "sum(ward_gateways_offline_gauge)",
"legendFormat": "offline/degraded",
"refId": "A"
}
],
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "auto",
"reduceOptions": {
"values": false,
"calcs": ["lastNotNull"],
"fields": ""
}
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 2 }
]
}
},
"overrides": []
}
},
{
"id": 10,
"type": "bargauge",
"title": "Gateway Buffer Depth",
"gridPos": { "x": 6, "y": 16, "w": 12, "h": 6 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "ward_gateway_buffer_depth",
"legendFormat": "{{gateway_code}} / {{department}}",
"refId": "A"
}
]
},
{
"id": 11,
"type": "timeseries",
"title": "Clinical Sync Batches (5m rate)",
"gridPos": { "x": 18, "y": 16, "w": 6, "h": 6 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "rate(clinical_sync_batches_total[5m])",
"legendFormat": "{{status}}",
"refId": "A"
},
{
"expr": "histogram_quantile(0.95, rate(clinical_sync_batch_duration_seconds_bucket[5m]))",
"legendFormat": "p95 duration",
"refId": "B"
}
]
},
{
"id": 12,
"type": "stat",
"title": "Total Sync Backlog (buffer depth sum)",
"gridPos": { "x": 0, "y": 20, "w": 6, "h": 4 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "sum(ward_gateway_buffer_depth)",
"legendFormat": "pending events",
"refId": "A"
}
],
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "auto",
"reduceOptions": {
"values": false,
"calcs": ["lastNotNull"],
"fields": ""
}
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 100 },
{ "color": "red", "value": 500 }
]
}
},
"overrides": []
}
}
]
}
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
CENTRAL="${CENTRAL_URL:-http://localhost:5080}"
GATEWAY="${GATEWAY_URL:-http://localhost:5081}"
JWT="${ADMIN_JWT:?Set ADMIN_JWT to a valid admin bearer token}"
GATEWAY_JWT="${GATEWAY_JWT:?Set GATEWAY_JWT to a valid gateway dashboard JWT}"
echo "=== VigilCare climate resilience demo ==="
echo "==> 1. Full stack up"
docker compose --profile full --profile ward-gateway up -d
sleep 30
echo "==> 2. Baseline — gateway ONLINE"
curl -sf "$CENTRAL/api/v1/operations/gateways" \
-H "Authorization: Bearer $JWT" \
| jq '.data[] | {code: .gatewayCode, status, buffer: .reportedBufferDepth}'
echo "==> 3. Partition — disconnect gateway from central network"
GATEWAY_CONTAINER=$(docker ps -qf name=ward-gateway-api)
NETWORK=$(docker network ls --format '{{.Name}}' | grep vigilcare | head -1)
docker network disconnect "$NETWORK" "$GATEWAY_CONTAINER" 2>/dev/null || true
echo "Partition active — posting observation to gateway..."
ENCOUNTER_ID=$(curl -sf "$GATEWAY/api/v1/encounters?status=ACTIVE&department=ICU" \
-H "Authorization: Bearer $GATEWAY_JWT" | jq -r '.data.items[0].id')
curl -sf -X POST "$GATEWAY/api/v1/encounters/$ENCOUNTER_ID/observations" \
-H "Authorization: Bearer $GATEWAY_JWT" \
-H "Content-Type: application/json" \
-d "{\"observationCode\":\"HEART_RATE\",\"value\":165,\"unit\":\"bpm\",\"source\":\"DEVICE\",\"recordedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"idempotencyKey\":\"partition-demo-$(date +%s)\"}"
sleep 15
echo "==> 4. Fleet status during partition"
curl -sf "$CENTRAL/api/v1/operations/gateways" \
-H "Authorization: Bearer $JWT" \
| jq '.data[] | {code: .gatewayCode, status, buffer: .reportedBufferDepth, minutes: .minutesSinceHeartbeat}'
echo "==> 5. Heal partition"
docker network connect "$NETWORK" "$GATEWAY_CONTAINER"
echo "Waiting for sync (60s)..."
sleep 60
echo "==> 6. Recovery — ONLINE + buffer 0"
curl -sf "$CENTRAL/api/v1/operations/gateways" \
-H "Authorization: Bearer $JWT" \
| jq '.data[] | {code: .gatewayCode, status, buffer: .reportedBufferDepth}'
echo "=== Demo complete ==="
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
echo "==> API integration tests"
dotnet test VigilCareClinicalAPI.Tests --filter "FullyQualifiedName~OperationsApi"
echo "==> Vitest ops view"
cd vigilcare-dashboard && npm run test -- GatewayOperations.spec.js && cd ..
echo "==> Optional partition demo (skip with SKIP_PARTITION=1)"
if [[ "${SKIP_PARTITION:-0}" != "1" ]]; then
./scripts/demo-network-partition.sh
fi
echo "==> Grafana checklist"
echo " - Open http://localhost:3101 (admin/admin)"
echo " - Dashboard: VigilCare Clinical"
echo " - Verify panels: Ward Gateways Offline, Gateway Buffer Depth,"
echo " Clinical Sync Batches, Total Sync Backlog"
echo " - Export snapshot via Share → Snapshot"
echo "Phase 23 verification passed."
+2
View File
@@ -2,12 +2,14 @@
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppShell from '@/components/layout/AppShell.vue'
import DegradedModeBanner from '@/components/DegradedModeBanner.vue'
const route = useRoute()
const useShell = computed(() => !route.meta.public)
</script>
<template>
<DegradedModeBanner />
<AppShell v-if="useShell">
<RouterView v-slot="{ Component }">
<KeepAlive include="WardDashboard">
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import DischargeSummaryPanel from '@/components/patient/DischargeSummaryPanel.vue'
vi.mock('@/api/encounters', () => ({
fetchDischargeSummaryStatus: vi.fn(() => Promise.resolve({
status: 'Ready',
dischargedAt: '2026-06-23T10:00:00Z',
})),
fetchDischargeSummaryContent: vi.fn(() => Promise.resolve('DISCHARGE SUMMARY\nPatient: Jane Doe')),
}))
describe('DischargeSummaryPanel', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('rendersSummaryWhenDischarged', async () => {
const wrapper = mount(DischargeSummaryPanel, {
props: {
encounterId: 'enc-1',
discharged: true,
},
})
await vi.waitFor(() => expect(wrapper.text()).toContain('DISCHARGE SUMMARY'))
expect(wrapper.text()).toContain('Download')
})
it('hidesWhenNotDischarged', () => {
const wrapper = mount(DischargeSummaryPanel, {
props: {
encounterId: 'enc-1',
discharged: false,
},
})
expect(wrapper.text()).toBe('')
})
})
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import GatewayOperations from '@/views/GatewayOperations.vue'
vi.mock('@/api/operations', () => ({
fetchGatewayFleet: vi.fn(),
fetchGatewayDetail: vi.fn(),
}))
vi.mock('@/composables/usePolling', () => ({
usePolling: (fn) => { fn(); return {} },
}))
import { fetchGatewayFleet } from '@/api/operations'
describe('GatewayOperations', () => {
beforeEach(() => {
setActivePinia(createPinia())
fetchGatewayFleet.mockResolvedValue([
{
id: 'gw-1',
gatewayCode: 'GW-ICU-3B',
department: 'ICU',
siteName: 'Demo Hospital',
status: 'DEGRADED',
reportedBufferDepth: 847,
minutesSinceHeartbeat: 12,
lastSyncAt: null,
},
])
})
it('rendersFleetTableWithDegradedBadge', async () => {
const wrapper = mount(GatewayOperations)
await flushPromises()
expect(wrapper.text()).toContain('GW-ICU-3B')
expect(wrapper.text()).toContain('DEGRADED')
expect(wrapper.text()).toContain('847')
})
})
@@ -0,0 +1,32 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ThresholdManagementView from '@/views/ThresholdManagementView.vue'
vi.mock('@/api/thresholds', () => ({
fetchThresholds: vi.fn(() => Promise.resolve([
{
id: 'thr-1',
observationCode: 'HEART_RATE',
displayName: 'Heart Rate',
unit: 'bpm',
criticalLow: 30,
warningLow: 50,
warningHigh: 100,
criticalHigh: 150,
},
])),
createThreshold: vi.fn(),
updateThreshold: vi.fn(),
deleteThreshold: vi.fn(),
}))
describe('ThresholdManagementView', () => {
it('rendersThresholdTable', async () => {
setActivePinia(createPinia())
const wrapper = mount(ThresholdManagementView)
await vi.waitFor(() => expect(wrapper.text()).toContain('HEART_RATE'))
expect(wrapper.text()).toContain('Alert Thresholds')
expect(wrapper.text()).toContain('Create Threshold')
})
})
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { canAccessOps, filterNavLinks, isDashboardRole, roleCanAccessRoute, MAIN_NAV_LINKS } from '@/composables/roleAccess'
describe('roleAccess', () => {
it('filtersNavLinksByRole', () => {
const nurseLinks = filterNavLinks(MAIN_NAV_LINKS, 'NURSE')
expect(nurseLinks.some((l) => l.to === '/feedback')).toBe(false)
expect(nurseLinks.some((l) => l.to === '/alerts')).toBe(true)
const physicianLinks = filterNavLinks(MAIN_NAV_LINKS, 'PHYSICIAN')
expect(physicianLinks.some((l) => l.to === '/feedback')).toBe(true)
})
it('rejectsIntegrationDashboardRole', () => {
expect(isDashboardRole('INTEGRATION')).toBe(false)
expect(isDashboardRole('NURSE')).toBe(true)
})
it('guardsRoutesByAllowedRoles', () => {
expect(roleCanAccessRoute('NURSE', { allowedRoles: ['PHYSICIAN', 'ADMIN'] })).toBe(false)
expect(roleCanAccessRoute('ADMIN', { allowedRoles: ['ADMIN'] })).toBe(true)
expect(roleCanAccessRoute('INTEGRATION', { allowedRoles: ['NURSE'] })).toBe(false)
})
it('restrictsOpsAccessToAdminByDefault', () => {
expect(canAccessOps('ADMIN')).toBe(true)
expect(canAccessOps('NURSE')).toBe(false)
expect(canAccessOps('PHYSICIAN')).toBe(false)
expect(canAccessOps('INTEGRATION')).toBe(false)
})
})
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import {
emptyThresholdForm,
thresholdFormToPayload,
validateThresholdForm,
} from '@/composables/thresholdForm'
describe('thresholdForm', () => {
it('validatesRequiredFields', () => {
const result = validateThresholdForm(emptyThresholdForm())
expect(result.valid).toBe(false)
expect(result.errors.observationCode).toBeTruthy()
})
it('validatesThresholdOrdering', () => {
const result = validateThresholdForm({
observationCode: 'HEART_RATE',
displayName: 'Heart Rate',
unit: 'bpm',
criticalLow: '60',
warningLow: '50',
warningHigh: '100',
criticalHigh: '130',
})
expect(result.valid).toBe(false)
expect(result.errors.warningLow).toBeTruthy()
})
it('buildsPayloadWithNullBounds', () => {
const payload = thresholdFormToPayload({
observationCode: 'SPO2',
displayName: 'SpO₂',
unit: '%',
criticalLow: '',
warningLow: '92',
warningHigh: '',
criticalHigh: '88',
})
expect(payload).toEqual({
observationCode: 'SPO2',
displayName: 'SpO₂',
unit: '%',
criticalLow: null,
warningLow: 92,
warningHigh: null,
criticalHigh: 88,
})
})
})
+54
View File
@@ -0,0 +1,54 @@
import { api } from './client'
function toQuery(params) {
const q = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined && value !== '') {
q.set(key, String(value))
}
}
const s = q.toString()
return s ? `?${s}` : ''
}
export function fetchAuditLogs({
entityType,
entityId,
userId,
action,
from,
to,
page = 1,
pageSize = 50,
} = {}) {
return api.get(`/api/v1/audit-logs${toQuery({
entityType,
entityId,
userId,
action,
from,
to,
page,
pageSize,
})}`)
}
export function fetchPhiAccessLogs({
patientId,
userId,
accessType,
from,
to,
page = 1,
pageSize = 50,
} = {}) {
return api.get(`/api/v1/phi-access-logs${toQuery({
patientId,
userId,
accessType,
from,
to,
page,
pageSize,
})}`)
}
+26
View File
@@ -52,4 +52,30 @@ export const api = {
method: 'POST',
body: body !== undefined ? JSON.stringify(body) : undefined,
}),
put: (path, body) => request(path, {
method: 'PUT',
body: body !== undefined ? JSON.stringify(body) : undefined,
}),
patch: (path, body) => request(path, {
method: 'PATCH',
body: body !== undefined ? JSON.stringify(body) : undefined,
}),
delete: async (path) => {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'DELETE',
headers: authHeaders(),
})
if (res.status === 401) {
throw new Error('Session expired — please log in again.')
}
if (res.status === 204) return null
const envelope = await res.json()
if (!res.ok || !envelope.success) {
const msg = envelope.error?.message ?? `API ${res.status}: ${path}`
throw new Error(msg)
}
return envelope.data ?? null
},
}
export { BASE_URL, authHeaders }
+26 -1
View File
@@ -1,4 +1,4 @@
import { api } from './client'
import { api, BASE_URL, authHeaders } from './client'
export function fetchActiveEncounters(department, { page = 1, pageSize = 20 } = {}) {
const params = new URLSearchParams({
@@ -51,3 +51,28 @@ export function submitVitalsObservations(encounterId, observations) {
observations,
})
}
export function fetchDischargeSummaryStatus(encounterId) {
return api.get(`/api/v1/encounters/${encounterId}/discharge-summary`)
}
export async function fetchDischargeSummaryContent(encounterId) {
const res = await fetch(
`${BASE_URL}/api/v1/encounters/${encounterId}/discharge-summary/content`,
{ headers: authHeaders() },
)
if (res.status === 404) {
const envelope = await res.json().catch(() => null)
const code = envelope?.error?.code
if (code === 'DISCHARGE_SUMMARY_PENDING') return null
throw new Error(envelope?.error?.message ?? 'Discharge summary not found.')
}
if (res.status === 401) {
throw new Error('Session expired — please log in again.')
}
if (!res.ok) {
const envelope = await res.json().catch(() => null)
throw new Error(envelope?.error?.message ?? `Failed to download discharge summary (${res.status})`)
}
return res.text()
}
+10
View File
@@ -0,0 +1,10 @@
import { api } from './client'
export function fetchGatewayFleet(status) {
const qs = status ? `?status=${encodeURIComponent(status)}` : ''
return api.get(`/api/v1/operations/gateways${qs}`)
}
export function fetchGatewayDetail(gatewayId) {
return api.get(`/api/v1/operations/gateways/${gatewayId}`)
}
@@ -0,0 +1,26 @@
import { api } from './client'
function toQuery(params) {
const q = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined && value !== '') {
q.set(key, String(value))
}
}
const s = q.toString()
return s ? `?${s}` : ''
}
export function fetchReconciliationAlerts({
checkType,
resolved,
page = 1,
pageSize = 50,
} = {}) {
return api.get(`/api/v1/reconciliation-alerts${toQuery({
checkType,
resolved,
page,
pageSize,
})}`)
}
+17
View File
@@ -0,0 +1,17 @@
import { api } from './client'
export function fetchThresholds() {
return api.get('/api/v1/alert-thresholds')
}
export function createThreshold(payload) {
return api.post('/api/v1/alert-thresholds', payload)
}
export function updateThreshold(id, payload) {
return api.put(`/api/v1/alert-thresholds/${id}`, payload)
}
export function deleteThreshold(id) {
return api.delete(`/api/v1/alert-thresholds/${id}`)
}
+13
View File
@@ -0,0 +1,13 @@
import { api } from './client'
export function fetchUsers() {
return api.get('/api/v1/users')
}
export function createUser(payload) {
return api.post('/api/v1/users', payload)
}
export function updateUser(id, payload) {
return api.patch(`/api/v1/users/${id}`, payload)
}
@@ -0,0 +1,15 @@
<script setup>
import { useApiMode } from '@/composables/useApiMode'
const { isGatewayProxy } = useApiMode()
</script>
<template>
<div
v-if="isGatewayProxy"
role="alert"
class="border-b border-amber-300 bg-amber-50 px-4 py-2 text-center text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/50 dark:text-amber-200"
>
Central sync paused ward operating in local mode. Alerts and documentation on this ward remain active.
</div>
</template>
@@ -0,0 +1,92 @@
<script setup>
import { reactive, computed, watch } from 'vue'
import Modal from '@/components/ui/Modal.vue'
import Button from '@/components/ui/Button.vue'
import {
emptyThresholdForm,
validateThresholdForm,
} from '@/composables/thresholdForm'
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: 'Edit Threshold' },
initialValues: { type: Object, default: () => emptyThresholdForm() },
submitting: { type: Boolean, default: false },
error: { type: String, default: '' },
readOnlyCode: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'submit'])
const values = reactive(emptyThresholdForm())
const touched = reactive(emptyThresholdForm())
const validation = computed(() => validateThresholdForm(values))
const fieldErrors = computed(() => validation.value.errors)
const fields = [
{ key: 'observationCode', label: 'Observation code', type: 'text' },
{ key: 'displayName', label: 'Display name', type: 'text' },
{ key: 'unit', label: 'Unit', type: 'text' },
{ key: 'criticalLow', label: 'Critical low', type: 'number', step: '0.001' },
{ key: 'warningLow', label: 'Warning low', type: 'number', step: '0.001' },
{ key: 'warningHigh', label: 'Warning high', type: 'number', step: '0.001' },
{ key: 'criticalHigh', label: 'Critical high', type: 'number', step: '0.001' },
]
watch(() => props.open, (isOpen) => {
if (!isOpen) return
Object.assign(values, props.initialValues)
for (const field of fields) touched[field.key] = false
}, { immediate: true })
function onBlur(key) {
touched[key] = true
}
function showError(key) {
return Boolean(touched[key] && fieldErrors.value[key])
}
function submit() {
for (const field of fields) touched[field.key] = true
if (!validation.value.valid) return
emit('submit', validation.value.payload)
}
</script>
<template>
<Modal :open="open" :title="title" @close="emit('close')">
<form class="space-y-4" @submit.prevent="submit">
<div v-for="field in fields" :key="field.key">
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ field.label }}
</label>
<input
v-model="values[field.key]"
:type="field.type"
:step="field.step"
:readonly="readOnlyCode && field.key === 'observationCode'"
class="w-full rounded border px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
:class="[
showError(field.key) ? 'border-red-500' : 'border-gray-300',
readOnlyCode && field.key === 'observationCode' ? 'bg-gray-100 dark:bg-gray-700' : '',
]"
@blur="onBlur(field.key)"
>
<p v-if="showError(field.key)" class="mt-1 text-xs text-red-600 dark:text-red-400">
{{ fieldErrors[field.key] }}
</p>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" @click="emit('close')">Cancel</Button>
<Button type="submit" :disabled="submitting">
{{ submitting ? 'Saving…' : 'Save' }}
</Button>
</div>
</form>
</Modal>
</template>
@@ -0,0 +1,148 @@
<script setup>
import { reactive, computed, watch } from 'vue'
import Modal from '@/components/ui/Modal.vue'
import Button from '@/components/ui/Button.vue'
import {
emptyUserForm,
ROLE_OPTIONS,
validateUserForm,
} from '@/composables/userForm'
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: 'Edit User' },
mode: { type: String, default: 'edit', validator: (v) => ['create', 'edit'].includes(v) },
initialValues: { type: Object, default: () => emptyUserForm() },
submitting: { type: Boolean, default: false },
error: { type: String, default: '' },
})
const emit = defineEmits(['close', 'submit'])
const values = reactive(emptyUserForm())
const touched = reactive({
username: false,
password: false,
displayName: false,
role: false,
})
const validation = computed(() => validateUserForm(values, props.mode))
const fieldErrors = computed(() => validation.value.errors)
watch(() => props.open, (isOpen) => {
if (!isOpen) return
Object.assign(values, props.initialValues)
for (const key of Object.keys(touched)) touched[key] = false
}, { immediate: true })
function onBlur(key) {
touched[key] = true
}
function showError(key) {
return Boolean(touched[key] && fieldErrors.value[key])
}
function submit() {
for (const key of Object.keys(touched)) touched[key] = true
if (!validation.value.valid) return
emit('submit', validation.value.payload)
}
</script>
<template>
<Modal :open="open" :title="title" @close="emit('close')">
<form class="space-y-4" @submit.prevent="submit">
<div v-if="mode === 'create'">
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Username
</label>
<input
v-model="values.username"
type="text"
autocomplete="off"
class="w-full rounded border px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
:class="showError('username') ? 'border-red-500' : 'border-gray-300'"
@blur="onBlur('username')"
>
<p v-if="showError('username')" class="mt-1 text-xs text-red-600 dark:text-red-400">
{{ fieldErrors.username }}
</p>
</div>
<div v-if="mode === 'create'">
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Password
</label>
<input
v-model="values.password"
type="password"
autocomplete="new-password"
class="w-full rounded border px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
:class="showError('password') ? 'border-red-500' : 'border-gray-300'"
@blur="onBlur('password')"
>
<p v-if="showError('password')" class="mt-1 text-xs text-red-600 dark:text-red-400">
{{ fieldErrors.password }}
</p>
</div>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Display name
</label>
<input
v-model="values.displayName"
type="text"
class="w-full rounded border px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
:class="showError('displayName') ? 'border-red-500' : 'border-gray-300'"
@blur="onBlur('displayName')"
>
<p v-if="showError('displayName')" class="mt-1 text-xs text-red-600 dark:text-red-400">
{{ fieldErrors.displayName }}
</p>
</div>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Role
</label>
<select
v-model="values.role"
class="w-full rounded border px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
:class="showError('role') ? 'border-red-500' : 'border-gray-300'"
@blur="onBlur('role')"
>
<option v-for="opt in ROLE_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<p v-if="showError('role')" class="mt-1 text-xs text-red-600 dark:text-red-400">
{{ fieldErrors.role }}
</p>
</div>
<div v-if="mode === 'edit'" class="flex items-center gap-2">
<input
id="user-active"
v-model="values.isActive"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
>
<label for="user-active" class="text-sm text-gray-700 dark:text-gray-300">
Account active
</label>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" @click="emit('close')">Cancel</Button>
<Button type="submit" :disabled="submitting">
{{ submitting ? 'Saving…' : 'Save' }}
</Button>
</div>
</form>
</Modal>
</template>
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import SeverityBadge from '@/components/ui/SeverityBadge.vue'
import Button from '@/components/ui/Button.vue'
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
import { alertTypeLabel } from '@/api/normalize'
@@ -23,10 +24,6 @@ const actionHint = computed(() => {
return hints[props.alert.alertType] ?? null
})
function severityVariant(severity) {
return severity === 'Critical' ? 'critical' : 'warning'
}
function showActions(status) {
return status !== 'Resolved'
}
@@ -50,7 +47,7 @@ function formatTime(iso) {
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="severityVariant(alert.severity)">{{ alert.severity }}</Badge>
<SeverityBadge :severity="alert.severity" />
<Badge variant="info" size="xs">{{ alert.status }}</Badge>
<Badge v-if="actionHint" variant="info" size="xs">{{ actionHint }}</Badge>
</div>
@@ -77,6 +74,7 @@ function formatTime(iso) {
v-if="canAcknowledge(alert.status)"
size="sm"
variant="secondary"
:aria-label="`Acknowledge ${alertTypeLabel(alert.alertType)} alert`"
@click="emit('acknowledge')"
>
Acknowledge
@@ -85,6 +83,7 @@ function formatTime(iso) {
v-if="canResolve(alert.status)"
size="sm"
variant="primary"
:aria-label="`Resolve ${alertTypeLabel(alert.alertType)} alert`"
@click="emit('resolve')"
>
Resolve
@@ -1,12 +1,14 @@
<script setup>
import { computed, shallowRef, markRaw } from 'vue'
import { computed } from 'vue'
import { Line } from 'vue-chartjs'
import { Chart as ChartJS, registerables } from 'chart.js'
import { formatTime } from '@/composables/chartFormat'
import { useChartTheme } from '@/composables/useChartTheme'
ChartJS.register(...registerables)
const props = defineProps({ history: { type: Array, required: true } })
const { buildOptions, darkMode, accentLine } = useChartTheme()
const COMPONENT_DATASETS = [
{ label: 'Eye', key: 'eyeScore', color: '#3b82f6' },
@@ -33,6 +35,7 @@ const sortedHistory = computed(() =>
const chartData = computed(() => {
const sorted = sortedHistory.value
const labels = sorted.map(h => formatTime(h.calculatedAt))
const totalLineColor = accentLine.value
const componentDatasets = COMPONENT_DATASETS.map(({ label, key, color }) => ({
label,
@@ -52,7 +55,7 @@ const chartData = computed(() => {
{
label: 'GCS Total',
data: sorted.map(h => h.totalScore),
borderColor: '#111827',
borderColor: totalLineColor,
backgroundColor: sorted.map(h => {
if (h.totalScore <= 8) return 'rgba(220, 38, 38, 0.15)'
if (h.totalScore <= 12) return 'rgba(245, 158, 11, 0.15)'
@@ -71,12 +74,9 @@ const chartData = computed(() => {
}
})
const chartOptions = shallowRef(markRaw({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
const chartOptions = computed(() => {
darkMode.value
return buildOptions({
interaction: { mode: 'index', intersect: false },
scales: {
y: {
@@ -89,7 +89,6 @@ const chartOptions = shallowRef(markRaw({
legend: {
display: true,
position: 'bottom',
labels: { boxWidth: 12, font: { size: 11 } },
},
tooltip: {
callbacks: {
@@ -101,7 +100,8 @@ const chartOptions = shallowRef(markRaw({
},
},
},
}))
}).value
})
</script>
<template>
@@ -113,7 +113,11 @@ const chartOptions = shallowRef(markRaw({
<span class="text-amber-600 dark:text-amber-400">Moderate 912</span>,
<span class="text-red-600 dark:text-red-400">Severe 38</span>.
</p>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<div
class="aspect-video w-full min-h-[220px] min-w-0 overflow-hidden sm:min-h-0"
role="img"
aria-label="Glasgow Coma Scale over time chart"
>
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
@@ -1,12 +1,14 @@
<script setup>
import { computed, shallowRef, markRaw } from 'vue'
import { computed } from 'vue'
import { Line } from 'vue-chartjs'
import { Chart, registerables } from 'chart.js'
import { formatTime } from '@/composables/chartFormat'
import { useChartTheme } from '@/composables/useChartTheme'
Chart.register(...registerables)
const props = defineProps({ history: { type: Array, required: true } })
const { buildOptions, darkMode } = useChartTheme()
const chartData = computed(() => ({
labels: props.history.map(h => formatTime(h.calculatedAt)),
@@ -25,25 +27,24 @@ const chartData = computed(() => ({
}],
}))
const chartOptions = shallowRef(markRaw({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
const chartOptions = computed(() => {
darkMode.value
return buildOptions({
scales: {
y: { min: 0, max: 20, title: { display: true, text: 'NEWS2 Score' } },
},
plugins: {
legend: { display: false },
},
}))
}).value
})
</script>
<template>
<div class="w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<h3 class="mb-4 text-sm font-medium text-gray-700 dark:text-gray-300">NEWS2 Score Over Time</h3>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<div
class="aspect-video w-full min-h-[220px] min-w-0 overflow-hidden sm:min-h-0"
role="img"
aria-label="NEWS2 score over time line chart"
>
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
@@ -1,12 +1,14 @@
<script setup>
import { computed, shallowRef, markRaw } from 'vue'
import { computed } from 'vue'
import { Line } from 'vue-chartjs'
import { Chart as ChartJS, registerables } from 'chart.js'
import { formatTime } from '@/composables/chartFormat'
import { useChartTheme } from '@/composables/useChartTheme'
ChartJS.register(...registerables)
const props = defineProps({ history: { type: Array, required: true } })
const { buildOptions, darkMode, accentLine } = useChartTheme()
const CRITERION_LABELS = [
{ label: 'Resp rate', key: 'respRate', color: '#3b82f6' },
@@ -37,6 +39,7 @@ const sortedHistory = computed(() =>
const chartData = computed(() => {
const sorted = sortedHistory.value
const labels = sorted.map(h => formatTime(h.evaluatedAt))
const totalLineColor = accentLine.value
const criterionDatasets = CRITERION_LABELS.map(({ label, key, color }) => ({
label,
@@ -57,7 +60,7 @@ const chartData = computed(() => {
{
label: 'Active criteria',
data: sorted.map(h => h.activeCriteria),
borderColor: '#111827',
borderColor: totalLineColor,
backgroundColor: sorted.map(h => {
if (h.activeCriteria >= 2) return 'rgba(220, 38, 38, 0.2)'
if (h.activeCriteria === 1) return 'rgba(245, 158, 11, 0.2)'
@@ -78,12 +81,9 @@ const chartData = computed(() => {
}
})
const chartOptions = shallowRef(markRaw({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
const chartOptions = computed(() => {
darkMode.value
return buildOptions({
interaction: { mode: 'index', intersect: false },
scales: {
count: {
@@ -106,7 +106,6 @@ const chartOptions = shallowRef(markRaw({
legend: {
display: true,
position: 'bottom',
labels: { boxWidth: 12, font: { size: 11 } },
},
tooltip: {
callbacks: {
@@ -122,7 +121,8 @@ const chartOptions = shallowRef(markRaw({
},
},
},
}))
}).value
})
</script>
<template>
@@ -134,7 +134,11 @@ const chartOptions = shallowRef(markRaw({
<span class="text-amber-600 dark:text-amber-400">1</span>,
<span class="text-red-600 dark:text-red-400">2</span>.
</p>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<div
class="aspect-video w-full min-h-[220px] min-w-0 overflow-hidden sm:min-h-0"
role="img"
aria-label="qSOFA screening criteria over time chart"
>
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
@@ -1,12 +1,14 @@
<script setup>
import { computed, shallowRef, markRaw } from 'vue'
import { computed } from 'vue'
import { Chart } from 'vue-chartjs'
import { Chart as ChartJS, registerables } from 'chart.js'
import { formatTime } from '@/composables/chartFormat'
import { useChartTheme } from '@/composables/useChartTheme'
ChartJS.register(...registerables)
const props = defineProps({ history: { type: Array, required: true } })
const { buildOptions, darkMode, accentLine } = useChartTheme()
const ORGAN_DATASETS = [
{ label: 'Respiratory', key: 'respiratoryScore', color: '#3b82f6' },
@@ -30,6 +32,7 @@ const sortedHistory = computed(() =>
const chartData = computed(() => {
const sorted = sortedHistory.value
const labels = sorted.map(h => formatTime(h.calculatedAt))
const totalLineColor = accentLine.value
const organDatasets = ORGAN_DATASETS.map(({ label, key, color }) => ({
type: 'line',
@@ -53,7 +56,7 @@ const chartData = computed(() => {
type: 'line',
label: 'SOFA Total',
data: sorted.map(h => h.totalScore),
borderColor: '#111827',
borderColor: totalLineColor,
backgroundColor: 'transparent',
pointBackgroundColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
pointBorderColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
@@ -67,12 +70,9 @@ const chartData = computed(() => {
}
})
const chartOptions = shallowRef(markRaw({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
const chartOptions = computed(() => {
darkMode.value
return buildOptions({
interaction: { mode: 'index', intersect: false },
scales: {
x: { stacked: true },
@@ -87,7 +87,6 @@ const chartOptions = shallowRef(markRaw({
legend: {
display: true,
position: 'bottom',
labels: { boxWidth: 12, font: { size: 11 } },
},
tooltip: {
callbacks: {
@@ -102,7 +101,8 @@ const chartOptions = shallowRef(markRaw({
},
},
},
}))
}).value
})
</script>
<template>
@@ -114,7 +114,11 @@ const chartOptions = shallowRef(markRaw({
<span class="text-amber-600 dark:text-amber-400">69</span>,
<span class="text-red-600 dark:text-red-400">10+</span>.
</p>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<div
class="aspect-video w-full min-h-[220px] min-w-0 overflow-hidden sm:min-h-0"
role="img"
aria-label="SOFA score over time chart with per-organ contributions"
>
<Chart type="line" :data="chartData" :options="chartOptions" />
</div>
</div>
@@ -7,6 +7,7 @@ import {
formatMedicationTooltip,
getMedicationsNearTimestamp,
} from '@/composables/chartMedications'
import { useChartTheme } from '@/composables/useChartTheme'
import { medicationMarkerPlugin } from '@/plugins/medicationMarkerPlugin'
Chart.register(...registerables, medicationMarkerPlugin)
@@ -21,6 +22,8 @@ const props = defineProps({
medications: { type: Array, default: () => [] },
})
const { buildOptions, darkMode } = useChartTheme()
const lineData = computed(() => {
const data = toValue(props.chartData)
return data?.datasets ? data : { labels: [], timestamps: [], datasets: [] }
@@ -30,18 +33,14 @@ const chartMedications = computed(() =>
filterMedicationsForWindow(props.observations, props.observationCode, props.medications),
)
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
const chartOptions = computed(() => {
darkMode.value
const options = buildOptions({
scales: {
y: { min: props.yMin, max: props.yMax },
x: { ticks: { maxTicksAuto: true, maxRotation: 45 } },
},
plugins: {
legend: { display: false },
medicationMarkers: {
medications: chartMedications.value,
timestamps: lineData.value.timestamps ?? [],
@@ -60,13 +59,20 @@ const chartOptions = computed(() => ({
},
},
},
}))
}).value
return options
})
</script>
<template>
<div class="w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<h3 class="mb-4 text-sm font-medium text-gray-700 dark:text-gray-300">{{ title }}</h3>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<div
class="aspect-video w-full min-h-[220px] min-w-0 overflow-hidden sm:min-h-0"
role="img"
:aria-label="`${title} trend chart`"
>
<Line :data="lineData" :options="chartOptions" />
</div>
</div>
@@ -11,12 +11,18 @@ useCriticalAlertPolling(settingsStore.pollInterval)
</script>
<template>
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-lg focus:bg-blue-600 focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:text-white focus:outline-none focus:ring-2 focus:ring-blue-400"
>
Skip to main content
</a>
<div class="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<AppSidebar />
<div class="flex min-w-0 flex-1 flex-col">
<AppHeader />
<CriticalAlertBanner />
<main class="flex-1 overflow-y-auto p-4 pb-24 lg:p-8 lg:pb-8">
<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">
<slot />
</div>
@@ -1,15 +1,9 @@
<script setup>
import { useRoute } from 'vue-router'
import { useRoleAccess } from '@/composables/roleAccess'
const route = useRoute()
const links = [
{ to: '/ward', label: 'Virtual Ward', icon: 'ward' },
{ to: '/departments', label: 'Departments', icon: 'departments' },
{ to: '/sepsis', label: 'Sepsis Board', icon: 'sepsis' },
{ to: '/alerts', label: 'Alert Center', icon: 'alerts' },
{ to: '/feedback', label: 'Feedback Summary', icon: 'feedback' },
]
const { mainNavLinks, adminNavLinks, showAdminSection } = useRoleAccess()
function linkClasses(path) {
const active = route.path.startsWith(path)
@@ -26,7 +20,7 @@ function linkClasses(path) {
</div>
<nav class="flex-1 px-4 py-4" aria-label="Main navigation">
<ul class="space-y-2">
<li v-for="link in links" :key="link.to">
<li v-for="link in mainNavLinks" :key="link.to">
<RouterLink
:to="link.to"
class="flex items-center gap-4 rounded-lg px-4 py-2 text-sm font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
@@ -47,21 +41,6 @@ function linkClasses(path) {
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
<svg
v-else-if="link.icon === 'alerts'"
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="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
v-else-if="link.icon === 'sepsis'"
class="h-6 w-6 shrink-0"
@@ -77,6 +56,21 @@ function linkClasses(path) {
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg
v-else-if="link.icon === 'alerts'"
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="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
v-else-if="link.icon === 'departments'"
class="h-6 w-6 shrink-0"
@@ -92,6 +86,36 @@ function linkClasses(path) {
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<svg
v-else-if="link.icon === 'reconciliation'"
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="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
v-else-if="link.icon === 'ops'"
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="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
<svg
v-else
class="h-6 w-6 shrink-0"
@@ -111,6 +135,74 @@ function linkClasses(path) {
</RouterLink>
</li>
</ul>
<div v-if="showAdminSection" class="mt-8">
<p class="mb-2 px-4 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Admin
</p>
<ul class="space-y-2">
<li v-for="link in adminNavLinks" :key="link.to">
<RouterLink
:to="link.to"
class="flex items-center gap-4 rounded-lg px-4 py-2 text-sm font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
:class="linkClasses(link.to)"
>
<svg
v-if="link.icon === 'users'"
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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<svg
v-else-if="link.icon === 'audit'"
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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<svg
v-else
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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{{ link.label }}
</RouterLink>
</li>
</ul>
</div>
</nav>
</aside>
</template>
@@ -1,13 +1,10 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useRoleAccess } from '@/composables/roleAccess'
const route = useRoute()
const links = [
{ to: '/ward', label: 'Ward', icon: 'ward' },
{ to: '/alerts', label: 'Alerts', icon: 'alerts' },
]
const { mobileNavLinks } = useRoleAccess()
const activePath = computed(() => route.path)
</script>
@@ -18,7 +15,7 @@ const activePath = computed(() => route.path)
aria-label="Mobile navigation"
>
<ul class="flex h-16 items-stretch">
<li v-for="link in links" :key="link.to" class="flex-1">
<li v-for="link in mobileNavLinks" :key="link.to" class="flex-1">
<RouterLink
: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"
@@ -41,6 +38,36 @@ const activePath = computed(() => route.path)
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
<svg
v-else-if="link.icon === 'sepsis'"
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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg
v-else-if="link.icon === 'reconciliation'"
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="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
v-else
class="h-6 w-6"
@@ -4,7 +4,7 @@ import { storeToRefs } from 'pinia'
import { useAlertStore } from '@/stores/alerts'
import { usePolling } from '@/composables/usePolling'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import SeverityBadge from '@/components/ui/SeverityBadge.vue'
import Button from '@/components/ui/Button.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import AcknowledgeModal from '@/components/alerts/AcknowledgeModal.vue'
@@ -32,10 +32,6 @@ const visibleAlerts = computed(() =>
alerts.value.filter(a => a.status !== 'Resolved'),
)
function severityVariant(severity) {
return severity === 'Critical' ? 'critical' : 'warning'
}
async function handleAcknowledge(note) {
if (!confirmingAlert.value) return
await alertStore.acknowledge(confirmingAlert.value.id, note)
@@ -58,18 +54,24 @@ async function resolve(alertId) {
</template>
<EmptyState v-if="!loading && visibleAlerts.length === 0" message="No open alerts" />
<ul v-else class="divide-y divide-gray-200 dark:divide-gray-700">
<ul v-else class="divide-y divide-gray-200 dark:divide-gray-700" role="list" aria-label="Active alerts">
<li
v-for="alert in visibleAlerts"
:id="`alert-row-${alert.id}`"
:key="alert.id"
class="flex cursor-pointer flex-col gap-4 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between"
:class="selectedId === alert.id ? 'bg-blue-50/50 dark:bg-blue-950/20' : ''"
class="py-4 first:pt-0 last:pb-0"
>
<button
type="button"
class="flex w-full min-h-11 cursor-pointer flex-col gap-4 rounded-lg px-2 py-2 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 sm:flex-row sm:items-start sm:justify-between"
:class="selectedId === alert.id ? 'bg-blue-50/50 dark:bg-blue-950/20' : 'hover:bg-gray-50 dark:hover:bg-gray-800/40'"
:aria-pressed="selectedId === alert.id"
:aria-label="`Select ${alertTypeLabel(alert.alertType)} alert, ${alert.severity} severity`"
@click="emit('select', alert)"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="severityVariant(alert.severity)">{{ alert.severity }}</Badge>
<SeverityBadge :severity="alert.severity" />
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ alertTypeLabel(alert.alertType) }}
</span>
@@ -89,6 +91,7 @@ async function resolve(alertId) {
v-if="alert.status === 'Open' || alert.status === 'Escalated'"
size="sm"
variant="secondary"
:aria-label="`Acknowledge ${alertTypeLabel(alert.alertType)} alert`"
@click.stop="confirmingAlert = alert"
>
Ack
@@ -97,11 +100,13 @@ async function resolve(alertId) {
v-if="alert.status === 'Acknowledged'"
size="sm"
variant="primary"
:aria-label="`Resolve ${alertTypeLabel(alert.alertType)} alert`"
@click.stop="resolve(alert.id)"
>
Resolve
</Button>
</div>
</button>
</li>
</ul>
@@ -0,0 +1,122 @@
<script setup>
import { ref, watch, onBeforeUnmount } from 'vue'
import Card from '@/components/ui/Card.vue'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import {
fetchDischargeSummaryStatus,
fetchDischargeSummaryContent,
} from '@/api/encounters'
const props = defineProps({
encounterId: { type: String, required: true },
discharged: { type: Boolean, default: false },
})
const loading = ref(true)
const error = ref('')
const status = ref(null)
const content = ref('')
let pollTimer = null
async function loadStatus() {
if (!props.discharged) {
loading.value = false
return
}
error.value = ''
try {
status.value = await fetchDischargeSummaryStatus(props.encounterId)
if (status.value?.status === 'Ready') {
content.value = await fetchDischargeSummaryContent(props.encounterId)
stopPolling()
} else if (status.value?.status === 'Pending') {
content.value = ''
startPolling()
}
} catch (err) {
error.value = err.message
stopPolling()
} finally {
loading.value = false
}
}
function startPolling() {
stopPolling()
pollTimer = setInterval(loadStatus, 5000)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function downloadSummary() {
if (!content.value) return
const blob = new Blob([content.value], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'discharge-summary.pdf'
link.click()
URL.revokeObjectURL(url)
}
function formatDischargedAt(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString()
}
watch(() => [props.encounterId, props.discharged], () => {
loading.value = true
loadStatus()
}, { immediate: true })
onBeforeUnmount(stopPolling)
</script>
<template>
<Card v-if="discharged">
<template #header>
<div class="mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Discharge Summary
</h2>
<Button
v-if="content"
size="sm"
variant="secondary"
@click="downloadSummary"
>
Download
</Button>
</div>
</template>
<Skeleton v-if="loading" :rows="4" />
<p v-else-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div v-else-if="status?.status === 'Pending'" class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<p>Discharge summary is being generated</p>
<p v-if="status.dischargedAt">
Discharged {{ formatDischargedAt(status.dischargedAt) }}
</p>
</div>
<div v-else-if="status?.status === 'Ready'" class="space-y-3">
<p class="text-xs text-gray-500 dark:text-gray-400">
Generated document · Discharged {{ formatDischargedAt(status.dischargedAt) }}
</p>
<pre class="max-h-96 overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-4 text-xs whitespace-pre-wrap text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100">{{ content }}</pre>
</div>
<p v-else class="text-sm text-gray-500 dark:text-gray-400">
Discharge summary is not available for this encounter.
</p>
</Card>
</template>
@@ -21,12 +21,17 @@ const speedPresets = [
<template>
<div class="flex w-full min-w-0 flex-col gap-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900 sm:flex-row sm:items-center">
<Button variant="ghost" size="sm" @click="isPaused ? emit('resume') : emit('pause')">
<Button
variant="ghost"
size="sm"
:aria-label="isPaused ? 'Resume replay' : 'Pause replay'"
@click="isPaused ? emit('resume') : emit('pause')"
>
<span class="sr-only">{{ isPaused ? 'Resume' : 'Pause' }}</span>
{{ isPaused ? '▶' : '⏸' }}
</Button>
<div class="min-w-0 flex-1">
<div class="min-w-0 flex-1" role="progressbar" :aria-valuenow="progress" aria-valuemin="0" aria-valuemax="100" :aria-label="`Replay progress ${progress}%`">
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full rounded-full bg-blue-500 transition-all duration-300"
@@ -43,10 +48,13 @@ const speedPresets = [
<button
v-for="preset in speedPresets"
:key="preset.value"
class="rounded px-2 py-2 text-xs transition"
type="button"
class="min-h-11 min-w-11 rounded px-3 py-2 text-xs transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
:class="speed === preset.value
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400'"
:aria-pressed="speed === preset.value"
:aria-label="`Set replay speed to ${preset.label}`"
@click="emit('set-speed', preset.value)"
>
{{ preset.label }}
@@ -54,7 +62,7 @@ const speedPresets = [
</div>
<div v-if="alerts.length" class="border-t border-gray-200 pt-4 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0 dark:border-gray-700">
<Button variant="ghost" size="sm" @click="emit('jump-to-alert')">
<Button variant="ghost" size="sm" aria-label="Jump to next alert" @click="emit('jump-to-alert')">
Next Alert &rarr;
</Button>
</div>
@@ -25,9 +25,9 @@ const classes = computed(() => {
const base =
'inline-flex items-center justify-center font-medium rounded-lg transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none'
const sizes = {
sm: 'px-4 py-2 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-2 text-base',
sm: 'min-h-11 px-4 py-2 text-xs',
md: 'min-h-11 px-4 py-2 text-sm',
lg: 'min-h-12 px-6 py-3 text-base',
}
const variants = {
primary:
@@ -0,0 +1,50 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({
title: { type: String, required: true },
defaultOpen: { type: Boolean, default: false },
sectionId: { type: String, default: undefined },
})
const open = ref(props.defaultOpen)
const panelId = props.sectionId ? `${props.sectionId}-panel` : undefined
const headerId = props.sectionId ? `${props.sectionId}-header` : undefined
function toggle() {
open.value = !open.value
}
</script>
<template>
<section :aria-labelledby="headerId">
<button
:id="headerId"
type="button"
class="flex min-h-11 w-full items-center justify-between gap-4 rounded-lg border border-gray-200 bg-white px-4 py-3 text-left text-sm font-semibold text-gray-900 transition hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:hover:bg-gray-800"
:aria-expanded="open"
:aria-controls="panelId"
@click="toggle"
>
<span>{{ title }}</span>
<svg
class="h-5 w-5 shrink-0 text-gray-500 transition-transform dark:text-gray-400"
:class="open ? 'rotate-180' : ''"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div
v-show="open"
:id="panelId"
:aria-labelledby="headerId"
class="mt-4 space-y-4"
>
<slot />
</div>
</section>
</template>
@@ -1,9 +1,18 @@
<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'
import { useFocusTrap } from '@/composables/useFocusTrap'
const props = defineProps({
open: Boolean,
title: String,
titleId: { type: String, default: 'modal-title' },
})
defineProps({ open: Boolean, title: String })
const emit = defineEmits(['close'])
const modalRef = ref(null)
const isActive = computed(() => props.open)
useFocusTrap(modalRef, isActive)
function onKeydown(e) {
if (e.key === 'Escape') emit('close')
@@ -11,15 +20,33 @@ function onKeydown(e) {
onMounted(() => document.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
watch(toRef(props, 'open'), (open) => {
document.body.style.overflow = open ? 'hidden' : ''
}, { immediate: true })
onBeforeUnmount(() => {
document.body.style.overflow = ''
})
</script>
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" @click="$emit('close')" />
<div ref="modalRef" role="dialog" aria-modal="true"
class="relative z-10 max-h-[calc(100svh-2rem)] w-full max-w-md overflow-y-auto rounded-lg bg-white p-8 shadow-lg dark:bg-gray-800">
<h2 v-if="title" class="mb-4 text-lg font-semibold dark:text-white">{{ title }}</h2>
<div
class="absolute inset-0 bg-black/50 backdrop-blur-sm"
aria-hidden="true"
@click="$emit('close')"
/>
<div
ref="modalRef"
role="dialog"
aria-modal="true"
:aria-labelledby="title ? titleId : undefined"
tabindex="-1"
class="relative z-10 max-h-[calc(100svh-2rem)] w-full max-w-md overflow-y-auto rounded-lg bg-white p-8 shadow-lg dark:bg-gray-800"
>
<h2 v-if="title" :id="titleId" class="mb-4 text-lg font-semibold dark:text-white">{{ title }}</h2>
<slot />
</div>
</div>
@@ -0,0 +1,58 @@
<script setup>
import { computed } from 'vue'
import Badge from '@/components/ui/Badge.vue'
const props = defineProps({
severity: { type: String, required: true },
size: { type: String, default: 'sm' },
})
const variant = computed(() => {
const normalized = props.severity?.toLowerCase()
if (normalized === 'critical') return 'critical'
if (normalized === 'warning') return 'warning'
if (normalized === 'info') return 'info'
return 'success'
})
const isCritical = computed(() => variant.value === 'critical')
const isWarning = computed(() => variant.value === 'warning')
</script>
<template>
<Badge :variant="variant" :size="size">
<span class="inline-flex items-center gap-1">
<svg
v-if="isCritical"
class="h-3.5 w-3.5 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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<svg
v-else-if="isWarning"
class="h-3.5 w-3.5 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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{{ severity }}</span>
</span>
</Badge>
</template>
@@ -22,11 +22,29 @@ const riskVariant = computed(() => {
const vitalsStaleness = computed(() =>
observationStaleness(props.patient.lastObservationAt, props.patient.status),
)
const cardLabel = computed(() =>
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}`,
)
const emit = defineEmits(['activate'])
function onKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
emit('activate')
}
}
</script>
<template>
<article
class="cursor-pointer rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition duration-200 hover:border-gray-300 hover:shadow-md active:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:active:bg-gray-800"
role="button"
tabindex="0"
:aria-label="cardLabel"
class="cursor-pointer rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition duration-200 hover:border-gray-300 hover:shadow-md active:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:active:bg-gray-800"
@click="emit('activate')"
@keydown="onKeydown"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
@@ -11,7 +11,12 @@ import {
} from '@/composables/wardFormat'
import { formatDepartment } from '@/composables/sepsisFormat'
const props = defineProps({ patient: { type: Object, required: true } })
const props = defineProps({
patient: { type: Object, required: true },
tabindex: { type: [Number, String], default: 0 },
})
const emit = defineEmits(['activate'])
const riskVariant = computed(() => {
const score = props.patient.news2Score ?? 0
@@ -38,10 +43,27 @@ const gcsVariant = computed(() => {
const vitalsStaleness = computed(() =>
observationStaleness(props.patient.lastObservationAt, props.patient.status),
)
const rowLabel = computed(() =>
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}`,
)
function onKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
emit('activate')
}
}
</script>
<template>
<tr>
<tr
data-patient-row
:tabindex="tabindex"
:aria-label="rowLabel"
@keydown="onKeydown"
@click="emit('activate')"
>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
{{ patientRoom(patient) }}
</td>
@@ -1,4 +1,5 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import PatientRow from './PatientRow.vue'
import PatientCard from './PatientCard.vue'
@@ -12,26 +13,50 @@ defineProps({
const emit = defineEmits(['sort'])
const router = useRouter()
const focusedRowIndex = ref(-1)
function goToPatient(encounterId) {
router.push({ name: 'PatientDetail', params: { encounterId } })
}
function onTableKeydown(event) {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const rows = [...event.currentTarget.querySelectorAll('tr[data-patient-row]')]
if (!rows.length) return
let nextIndex = focusedRowIndex.value
if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = rows.length - 1
else if (event.key === 'ArrowDown') nextIndex = Math.min(rows.length - 1, nextIndex + 1)
else if (event.key === 'ArrowUp') nextIndex = Math.max(0, nextIndex - 1)
if (nextIndex < 0) nextIndex = 0
focusedRowIndex.value = nextIndex
rows[nextIndex]?.focus()
}
function onRowFocus(index) {
focusedRowIndex.value = index
}
</script>
<template>
<!-- Mobile: card list -->
<div class="space-y-4 md:hidden">
<div class="space-y-4 md:hidden" role="list" aria-label="Ward patients">
<PatientCard
v-for="patient in patients"
:key="patient.encounterId"
:patient="patient"
@click="goToPatient(patient.encounterId)"
@activate="goToPatient(patient.encounterId)"
/>
</div>
<!-- Desktop: scrollable table -->
<div class="hidden overflow-x-auto rounded-lg border border-gray-200 md:block dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700" aria-label="Ward patients">
<thead class="sticky top-0 bg-gray-50 dark:bg-gray-800">
<tr>
<SortableHeader
@@ -63,10 +88,10 @@ function goToPatient(encounterId) {
align="right"
@sort="emit('sort', $event)"
/>
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th scope="col" class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
SOFA
</th>
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th scope="col" class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
GCS
</th>
<SortableHeader
@@ -77,13 +102,13 @@ function goToPatient(encounterId) {
align="right"
@sort="emit('sort', $event)"
/>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th scope="col" class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Attending
</th>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th scope="col" class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
LOS
</th>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th scope="col" class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Last vitals
</th>
<SortableHeader
@@ -102,13 +127,18 @@ function goToPatient(encounterId) {
/>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
<tbody
class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900"
@keydown="onTableKeydown"
>
<PatientRow
v-for="patient in patients"
v-for="(patient, index) in patients"
:key="patient.encounterId"
:patient="patient"
class="cursor-pointer transition duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
@click="goToPatient(patient.encounterId)"
:tabindex="focusedRowIndex === index || (focusedRowIndex < 0 && index === 0) ? 0 : -1"
class="cursor-pointer transition duration-200 hover:bg-gray-50 focus-visible:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800"
@focus="onRowFocus(index)"
@activate="goToPatient(patient.encounterId)"
/>
</tbody>
</table>
@@ -0,0 +1,65 @@
export const AUDIT_ACTIONS = [
{ value: 'THRESHOLD_CREATED', label: 'Threshold created' },
{ value: 'THRESHOLD_UPDATED', label: 'Threshold updated' },
{ value: 'THRESHOLD_DELETED', label: 'Threshold deleted' },
{ value: 'ALERT_ACKNOWLEDGED', label: 'Alert acknowledged' },
{ value: 'ALERT_RESOLVED', label: 'Alert resolved' },
{ value: 'ENCOUNTER_STATUS_CHANGED', label: 'Encounter status changed' },
{ value: 'PATIENT_REGISTERED', label: 'Patient registered' },
{ value: 'PATIENT_UPDATED', label: 'Patient updated' },
{ value: 'SUPPRESSION_WINDOW_SET', label: 'Suppression window set' },
{ value: 'USER_LOGIN', label: 'User login' },
{ value: 'AUTHORIZATION_DENIED', label: 'Authorization denied' },
]
export const PHI_ACCESS_TYPES = [
{ value: 'VIEW', label: 'View' },
{ value: 'LIST', label: 'List' },
{ value: 'SEARCH', label: 'Search' },
{ value: 'CREATE', label: 'Create' },
{ value: 'UPDATE', label: 'Update' },
]
export function actionLabel(action) {
return AUDIT_ACTIONS.find((a) => a.value === action)?.label ?? action
}
export function accessTypeLabel(accessType) {
return PHI_ACCESS_TYPES.find((a) => a.value === accessType)?.label ?? accessType
}
export function formatAuditTimestamp(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString([], {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
export function formatJsonBlock(raw) {
if (!raw) return null
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
export function defaultFromDate() {
const d = new Date()
d.setDate(d.getDate() - 30)
return d.toISOString().slice(0, 16)
}
export function defaultToDate() {
return new Date().toISOString().slice(0, 16)
}
export function toIsoOffset(localDatetime) {
if (!localDatetime) return undefined
return new Date(localDatetime).toISOString()
}
@@ -0,0 +1,41 @@
export const RECONCILIATION_SECTIONS = [
{
checkType: 'UNACKNOWLEDGED_CRITICAL_ALERT',
title: 'Unacknowledged critical alerts',
description: 'Critical alerts open longer than the configured threshold.',
},
{
checkType: 'PENDING_ORDER_NO_RESULT',
title: 'Pending orders without results',
description: 'Orders placed but not resulted within the expected window.',
},
{
checkType: 'ACTIVE_INPATIENT_NO_OBSERVATION',
title: 'Stale observations',
description: 'Active inpatients without recent vital sign recordings.',
},
]
const CHECK_TYPE_LABELS = {
UNACKNOWLEDGED_CRITICAL_ALERT: 'Unacknowledged critical alerts',
UnacknowledgedCriticalAlert: 'Unacknowledged critical alerts',
PENDING_ORDER_NO_RESULT: 'Pending orders without results',
PendingOrderNoResult: 'Pending orders without results',
ACTIVE_INPATIENT_NO_OBSERVATION: 'Stale observations',
ActiveInpatientNoObservation: 'Stale observations',
}
export function checkTypeLabel(checkType) {
return CHECK_TYPE_LABELS[checkType] ?? checkType
}
export function formatReconciliationTimestamp(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString([], {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -0,0 +1,95 @@
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useAuthStore } from '@/stores/auth'
const CLINICAL_ROLES = ['NURSE', 'PHYSICIAN', 'ADMIN']
export const OPS_NAV_LINK = {
to: '/operations/gateways',
label: 'Operations',
icon: 'ops',
}
export const MAIN_NAV_LINKS = [
{ to: '/ward', label: 'Virtual Ward', icon: 'ward', roles: CLINICAL_ROLES },
{ to: '/departments', label: 'Departments', icon: 'departments', roles: CLINICAL_ROLES },
{ to: '/sepsis', label: 'Sepsis Board', icon: 'sepsis', roles: CLINICAL_ROLES },
{ to: '/alerts', label: 'Alert Center', icon: 'alerts', roles: CLINICAL_ROLES },
{ to: '/feedback', label: 'Feedback Summary', icon: 'feedback', roles: ['PHYSICIAN', 'ADMIN'] },
{ to: '/admin/reconciliation', label: 'Data Quality', icon: 'reconciliation', roles: CLINICAL_ROLES },
]
export function canAccessOps(role) {
if (import.meta.env.VITE_SHOW_OPS === 'true') return isDashboardRole(role)
return role === 'ADMIN'
}
export const ADMIN_NAV_LINKS = [
{ to: '/admin/thresholds', label: 'Alert Thresholds', icon: 'admin', roles: ['ADMIN'] },
{ to: '/admin/users', label: 'User Management', icon: 'users', roles: ['ADMIN'] },
{ to: '/admin/audit', label: 'Audit Logs', icon: 'audit', roles: ['ADMIN'] },
]
export const MOBILE_NAV_LINKS = [
{ to: '/ward', label: 'Ward', icon: 'ward', roles: CLINICAL_ROLES },
{ to: '/alerts', label: 'Alerts', icon: 'alerts', roles: CLINICAL_ROLES },
{ to: '/sepsis', label: 'Sepsis', icon: 'sepsis', roles: CLINICAL_ROLES },
{ to: '/admin/reconciliation', label: 'Quality', icon: 'reconciliation', roles: CLINICAL_ROLES },
]
export function isDashboardRole(role) {
return CLINICAL_ROLES.includes(role)
}
export function defaultRouteForRole(role) {
if (isDashboardRole(role)) return '/ward'
return '/login'
}
export function roleCanAccessRoute(role, meta = {}) {
if (meta.public) return true
if (!role || !isDashboardRole(role)) return false
if (meta.allowedRoles && !meta.allowedRoles.includes(role)) return false
return true
}
export function filterNavLinks(links, role) {
if (!role) return []
return links.filter((link) => link.roles.includes(role))
}
export function useRoleAccess() {
const auth = useAuthStore()
const { role } = storeToRefs(auth)
const isNurse = computed(() => role.value === 'NURSE')
const isPhysician = computed(() => role.value === 'PHYSICIAN')
const isAdmin = computed(() => role.value === 'ADMIN')
const isClinical = computed(() => isDashboardRole(role.value))
const mainNavLinks = computed(() => {
const links = filterNavLinks(MAIN_NAV_LINKS, role.value)
if (canAccessOps(role.value)) links.push(OPS_NAV_LINK)
return links
})
const adminNavLinks = computed(() => filterNavLinks(ADMIN_NAV_LINKS, role.value))
const mobileNavLinks = computed(() => filterNavLinks(MOBILE_NAV_LINKS, role.value))
const showAdminSection = computed(() => adminNavLinks.value.length > 0)
const nursePatientLayout = computed(() => isNurse.value)
const physicianPatientLayout = computed(() => isPhysician.value)
return {
role,
isNurse,
isPhysician,
isAdmin,
isClinical,
mainNavLinks,
adminNavLinks,
mobileNavLinks,
showAdminSection,
nursePatientLayout,
physicianPatientLayout,
}
}
@@ -0,0 +1,97 @@
export function emptyThresholdForm() {
return {
observationCode: '',
displayName: '',
unit: '',
criticalLow: '',
warningLow: '',
warningHigh: '',
criticalHigh: '',
}
}
function parseOptionalNumber(value) {
if (value === '' || value == null) return null
const numeric = Number(value)
return Number.isNaN(numeric) ? NaN : numeric
}
export function thresholdFormToPayload(values) {
return {
observationCode: values.observationCode.trim(),
displayName: values.displayName.trim(),
unit: values.unit.trim(),
criticalLow: parseOptionalNumber(values.criticalLow),
warningLow: parseOptionalNumber(values.warningLow),
warningHigh: parseOptionalNumber(values.warningHigh),
criticalHigh: parseOptionalNumber(values.criticalHigh),
}
}
export function thresholdToForm(threshold) {
return {
observationCode: threshold.observationCode ?? '',
displayName: threshold.displayName ?? '',
unit: threshold.unit ?? '',
criticalLow: threshold.criticalLow ?? '',
warningLow: threshold.warningLow ?? '',
warningHigh: threshold.warningHigh ?? '',
criticalHigh: threshold.criticalHigh ?? '',
}
}
export function validateThresholdForm(values) {
const errors = {}
if (!values.observationCode?.trim()) errors.observationCode = 'Observation code is required.'
if (!values.displayName?.trim()) errors.displayName = 'Display name is required.'
if (!values.unit?.trim()) errors.unit = 'Unit is required.'
const criticalLow = parseOptionalNumber(values.criticalLow)
const warningLow = parseOptionalNumber(values.warningLow)
const warningHigh = parseOptionalNumber(values.warningHigh)
const criticalHigh = parseOptionalNumber(values.criticalHigh)
for (const [key, value] of Object.entries({
criticalLow,
warningLow,
warningHigh,
criticalHigh,
})) {
if (Number.isNaN(value)) errors[key] = 'Must be a valid number.'
}
if (
criticalLow != null && !Number.isNaN(criticalLow)
&& warningLow != null && !Number.isNaN(warningLow)
&& criticalLow >= warningLow
) {
errors.warningLow = 'Warning low must be greater than critical low.'
}
if (
warningLow != null && !Number.isNaN(warningLow)
&& warningHigh != null && !Number.isNaN(warningHigh)
&& warningLow >= warningHigh
) {
errors.warningHigh = 'Warning high must be greater than warning low.'
}
if (
warningHigh != null && !Number.isNaN(warningHigh)
&& criticalHigh != null && !Number.isNaN(criticalHigh)
&& warningHigh >= criticalHigh
) {
errors.criticalHigh = 'Critical high must be greater than warning high.'
}
return {
valid: Object.keys(errors).length === 0,
errors,
payload: thresholdFormToPayload(values),
}
}
export function formatThresholdValue(value) {
if (value == null || value === '') return '—'
return String(value)
}
@@ -0,0 +1,9 @@
import { computed } from 'vue'
export function useApiMode() {
const baseUrl = import.meta.env.VITE_API_URL ?? ''
const isGatewayProxy = computed(() =>
baseUrl.includes('5081') || import.meta.env.VITE_GATEWAY_MODE === 'true'
)
return { isGatewayProxy, baseUrl }
}
@@ -0,0 +1,138 @@
import { computed, ref, onMounted, onUnmounted } from 'vue'
const THEMES = {
light: {
text: '#6b7280',
grid: 'rgba(0, 0, 0, 0.06)',
title: '#374151',
accentLine: '#111827',
},
dark: {
text: '#9ca3af',
grid: 'rgba(255, 255, 255, 0.08)',
title: '#d1d5db',
accentLine: '#e5e7eb',
},
}
function readDarkMode() {
return typeof document !== 'undefined'
&& document.documentElement.classList.contains('dark')
}
function prefersReducedMotion() {
return typeof window !== 'undefined'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
function useHtmlDarkMode() {
const darkMode = ref(readDarkMode())
let observer
onMounted(() => {
darkMode.value = readDarkMode()
observer = new MutationObserver(() => {
darkMode.value = readDarkMode()
})
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})
onUnmounted(() => observer?.disconnect())
return darkMode
}
export function useChartTheme() {
const darkMode = useHtmlDarkMode()
const theme = computed(() => (darkMode.value ? THEMES.dark : THEMES.light))
const animation = computed(() => ({
duration: prefersReducedMotion() ? 0 : 400,
}))
const scaleDefaults = computed(() => ({
ticks: { color: theme.value.text },
grid: { color: theme.value.grid },
title: { color: theme.value.title },
}))
const legendDefaults = computed(() => ({
labels: {
color: theme.value.text,
boxWidth: 12,
font: { size: 11 },
},
}))
const accentLine = computed(() => theme.value.accentLine)
function buildOptions(overrides = {}) {
return computed(() => {
const {
tooltip: tooltipOverride,
legend: legendOverride,
...restPlugins
} = overrides.plugins ?? {}
const base = {
responsive: true,
maintainAspectRatio: true,
animation: animation.value,
scales: {
x: {
...scaleDefaults.value,
ticks: { ...scaleDefaults.value.ticks, maxRotation: 45 },
...(overrides.scales?.x ?? {}),
},
y: {
...scaleDefaults.value,
...(overrides.scales?.y ?? {}),
},
},
plugins: {
legend: {
display: false,
...legendDefaults.value,
...(legendOverride ?? {}),
},
tooltip: {
padding: 12,
boxPadding: 6,
intersect: false,
mode: 'nearest',
titleFont: { size: 14 },
bodyFont: { size: 14 },
...(tooltipOverride ?? {}),
},
...restPlugins,
},
interaction: overrides.interaction ?? {
mode: 'nearest',
intersect: false,
axis: 'x',
},
}
if (overrides.scales?.count) {
base.scales.count = {
...scaleDefaults.value,
...overrides.scales.count,
}
}
if (overrides.scales?.criteria) {
base.scales.criteria = overrides.scales.criteria
}
return base
})
}
return {
darkMode,
theme,
accentLine,
scaleDefaults,
legendDefaults,
buildOptions,
}
}
@@ -0,0 +1,67 @@
import { onBeforeUnmount, watch } from 'vue'
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',')
function getFocusableElements(container) {
return [...container.querySelectorAll(FOCUSABLE)]
.filter((el) => el.offsetParent !== null || el === document.activeElement)
}
export function useFocusTrap(containerRef, isActive) {
let previousFocus = null
function onKeydown(event) {
if (event.key !== 'Tab' || !containerRef.value) return
const focusable = getFocusableElements(containerRef.value)
if (!focusable.length) {
event.preventDefault()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
function activate() {
previousFocus = document.activeElement
document.addEventListener('keydown', onKeydown)
requestAnimationFrame(() => {
if (!containerRef.value) return
const focusable = getFocusableElements(containerRef.value)
;(focusable[0] ?? containerRef.value).focus()
})
}
function deactivate() {
document.removeEventListener('keydown', onKeydown)
if (previousFocus && typeof previousFocus.focus === 'function') {
previousFocus.focus()
}
previousFocus = null
}
watch(isActive, (active) => {
if (active) activate()
else deactivate()
}, { immediate: true })
onBeforeUnmount(deactivate)
return { deactivate }
}
@@ -0,0 +1,70 @@
export const ROLE_OPTIONS = [
{ value: 'NURSE', label: 'Nurse' },
{ value: 'PHYSICIAN', label: 'Physician' },
{ value: 'ADMIN', label: 'Admin' },
{ value: 'INTEGRATION', label: 'Integration' },
]
export function roleLabel(role) {
return ROLE_OPTIONS.find((r) => r.value === role)?.label ?? role
}
export function emptyUserForm() {
return {
username: '',
password: '',
displayName: '',
role: 'NURSE',
isActive: true,
}
}
export function userToForm(user = {}) {
return {
username: user.username ?? '',
password: '',
displayName: user.displayName ?? '',
role: user.role ?? 'NURSE',
isActive: user.isActive ?? true,
}
}
export function validateUserForm(values, mode) {
const errors = {}
if (mode === 'create') {
if (!values.username?.trim()) errors.username = 'Username is required'
if (!values.password) errors.password = 'Password is required'
else if (values.password.length < 8) errors.password = 'Password must be at least 8 characters'
}
if (!values.displayName?.trim()) errors.displayName = 'Display name is required'
if (!values.role) errors.role = 'Role is required'
const valid = Object.keys(errors).length === 0
const payload = mode === 'create'
? {
username: values.username.trim(),
password: values.password,
displayName: values.displayName.trim(),
role: values.role,
}
: {
displayName: values.displayName.trim(),
role: values.role,
isActive: values.isActive,
}
return { valid, errors, payload }
}
export function formatLastLogin(iso) {
if (!iso) return 'Never'
return new Date(iso).toLocaleString([], {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
+47 -8
View File
@@ -1,5 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { canAccessOps, defaultRouteForRole, isDashboardRole, roleCanAccessRoute } from '@/composables/roleAccess'
const CLINICAL = ['NURSE', 'PHYSICIAN', 'ADMIN']
const routes = [
{
@@ -16,37 +19,67 @@ const routes = [
path: '/ward',
name: 'WardDashboard',
component: () => import('@/views/WardDashboard.vue'),
meta: { title: 'Virtual Ward', layout: 'default' },
meta: { title: 'Virtual Ward', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/departments',
name: 'DepartmentOverview',
component: () => import('@/views/DepartmentOverviewView.vue'),
meta: { title: 'Department Overview', layout: 'default' },
meta: { title: 'Department Overview', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/patients/:encounterId',
name: 'PatientDetail',
component: () => import('@/views/PatientDetail.vue'),
meta: { title: 'Patient Detail', layout: 'default' },
meta: { title: 'Patient Detail', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/alerts',
name: 'AlertCenter',
component: () => import('@/views/AlertCenter.vue'),
meta: { title: 'Alert Center', layout: 'default' },
meta: { title: 'Alert Center', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/sepsis',
name: 'SepsisBoard',
component: () => import('@/views/SepsisBoardView.vue'),
meta: { title: 'Sepsis Bundle Board', layout: 'default' },
meta: { title: 'Sepsis Bundle Board', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/feedback',
name: 'FeedbackSummary',
component: () => import('@/views/FeedbackSummary.vue'),
meta: { title: 'Feedback Summary', layout: 'default' },
meta: { title: 'Feedback Summary', layout: 'default', allowedRoles: ['PHYSICIAN', 'ADMIN'] },
},
{
path: '/admin/thresholds',
name: 'ThresholdManagement',
component: () => import('@/views/ThresholdManagementView.vue'),
meta: { title: 'Alert Thresholds', layout: 'default', allowedRoles: ['ADMIN'] },
},
{
path: '/admin/users',
name: 'UserManagement',
component: () => import('@/views/UserManagementView.vue'),
meta: { title: 'User Management', layout: 'default', allowedRoles: ['ADMIN'] },
},
{
path: '/admin/audit',
name: 'AuditLog',
component: () => import('@/views/AuditLogView.vue'),
meta: { title: 'Audit Logs', layout: 'default', allowedRoles: ['ADMIN'] },
},
{
path: '/admin/reconciliation',
name: 'Reconciliation',
component: () => import('@/views/ReconciliationView.vue'),
meta: { title: 'Data Quality', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/operations/gateways',
name: 'GatewayOperations',
component: () => import('@/views/GatewayOperations.vue'),
meta: { title: 'Gateway Operations', layout: 'default', opsRoute: true },
},
]
@@ -62,8 +95,14 @@ router.beforeEach((to) => {
if (!to.meta.public && !auth.isAuthenticated) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (to.path === '/login' && auth.isAuthenticated) {
return { path: '/ward' }
if (to.path === '/login' && auth.isAuthenticated && isDashboardRole(auth.role)) {
return { path: defaultRouteForRole(auth.role) }
}
if (!to.meta.public && auth.isAuthenticated && to.meta.opsRoute && !canAccessOps(auth.role)) {
return { path: defaultRouteForRole(auth.role) }
}
if (!to.meta.public && auth.isAuthenticated && !roleCanAccessRoute(auth.role, to.meta)) {
return { path: defaultRouteForRole(auth.role) }
}
})
+3
View File
@@ -10,6 +10,9 @@ export const useAuthStore = defineStore('auth', {
getters: {
isAuthenticated: (state) => !!state.token,
role: (state) => state.user?.role ?? null,
isAdmin: (state) => state.user?.role === 'ADMIN',
isNurse: (state) => state.user?.role === 'NURSE',
isPhysician: (state) => state.user?.role === 'PHYSICIAN',
displayName: (state) =>
state.user?.displayName ?? state.user?.username ?? 'Unknown user',
userId: (state) => state.user?.userId ?? null,
@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fetchGatewayFleet, fetchGatewayDetail } from '@/api/operations'
export const useOperationsStore = defineStore('operations', () => {
const fleet = ref([])
const selectedGateway = ref(null)
const loading = ref(false)
const error = ref(null)
const statusFilter = ref(null)
async function fetchFleet() {
loading.value = true
error.value = null
try {
fleet.value = await fetchGatewayFleet(statusFilter.value)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function fetchGatewayDetail(id) {
loading.value = true
error.value = null
try {
selectedGateway.value = await fetchGatewayDetail(id)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
function setStatusFilter(status) {
statusFilter.value = status
fetchFleet()
}
return { fleet, selectedGateway, loading, error, statusFilter, fetchFleet, fetchGatewayDetail, setStatusFilter }
})
@@ -0,0 +1,321 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import * as auditApi from '@/api/audit'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import {
AUDIT_ACTIONS,
PHI_ACCESS_TYPES,
actionLabel,
accessTypeLabel,
formatAuditTimestamp,
formatJsonBlock,
defaultFromDate,
defaultToDate,
toIsoOffset,
} from '@/composables/auditFormat'
const activeTab = ref('audit')
const loading = ref(true)
const error = ref('')
const page = ref(1)
const pageSize = 50
const totalPages = ref(1)
const totalCount = ref(0)
const items = ref([])
const expandedId = ref(null)
const filters = reactive({
from: defaultFromDate(),
to: defaultToDate(),
entityType: '',
action: '',
accessType: '',
patientId: '',
userId: '',
})
async function load() {
loading.value = true
error.value = ''
try {
const common = {
from: toIsoOffset(filters.from),
to: toIsoOffset(filters.to),
page: page.value,
pageSize,
}
const result = activeTab.value === 'audit'
? await auditApi.fetchAuditLogs({
...common,
entityType: filters.entityType || undefined,
action: filters.action || undefined,
userId: filters.userId || undefined,
})
: await auditApi.fetchPhiAccessLogs({
...common,
accessType: filters.accessType || undefined,
patientId: filters.patientId || undefined,
userId: filters.userId || undefined,
})
items.value = result.items ?? []
totalPages.value = result.totalPages ?? 1
totalCount.value = result.totalCount ?? 0
} catch (err) {
error.value = err.message
items.value = []
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
expandedId.value = null
load()
}
function switchTab(tab) {
activeTab.value = tab
page.value = 1
expandedId.value = null
load()
}
function toggleExpand(id) {
expandedId.value = expandedId.value === id ? null : id
}
function prevPage() {
if (page.value > 1) {
page.value -= 1
load()
}
}
function nextPage() {
if (page.value < totalPages.value) {
page.value += 1
load()
}
}
onMounted(load)
</script>
<template>
<div>
<div class="mb-4">
<h1 class="text-xl font-bold dark:text-white">Audit Logs</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Browse clinical audit events and PHI access history for compliance review.
</p>
</div>
<div class="mb-4 flex gap-2 border-b border-gray-200 dark:border-gray-700">
<button
type="button"
class="border-b-2 px-4 py-2 text-sm font-medium transition"
:class="activeTab === 'audit'
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="switchTab('audit')"
>
Clinical audit
</button>
<button
type="button"
class="border-b-2 px-4 py-2 text-sm font-medium transition"
:class="activeTab === 'phi'
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="switchTab('phi')"
>
PHI access
</button>
</div>
<form
class="mb-4 grid gap-4 rounded-lg border border-gray-200 p-4 dark:border-gray-700 sm:grid-cols-2 lg:grid-cols-4"
@submit.prevent="applyFilters"
>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
From
</label>
<input
v-model="filters.from"
type="datetime-local"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
To
</label>
<input
v-model="filters.to"
type="datetime-local"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<template v-if="activeTab === 'audit'">
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Entity type
</label>
<input
v-model="filters.entityType"
type="text"
placeholder="e.g. Patient"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Action
</label>
<select
v-model="filters.action"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option value="">All actions</option>
<option v-for="opt in AUDIT_ACTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</template>
<template v-else>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Patient ID
</label>
<input
v-model="filters.patientId"
type="text"
placeholder="UUID"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Access type
</label>
<select
v-model="filters.accessType"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option value="">All types</option>
<option v-for="opt in PHI_ACCESS_TYPES" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</template>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
User ID
</label>
<input
v-model="filters.userId"
type="text"
placeholder="UUID"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<div class="flex items-end sm:col-span-2 lg:col-span-1">
<Button type="submit" size="sm">Apply filters</Button>
</div>
</form>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<Skeleton v-if="loading" :rows="8" />
<EmptyState v-else-if="items.length === 0" message="No log entries match the current filters" />
<template v-else>
<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-800">
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Time</th>
<th v-if="activeTab === 'audit'" class="px-4 py-3">Action</th>
<th v-else class="px-4 py-3">Access type</th>
<th class="px-4 py-3">User</th>
<th v-if="activeTab === 'audit'" class="px-4 py-3">Entity</th>
<th v-else class="px-4 py-3">Patient</th>
<th v-if="activeTab === 'phi'" class="px-4 py-3">Resource</th>
<th class="px-4 py-3" />
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template v-for="entry in items" :key="entry.id">
<tr>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ formatAuditTimestamp(activeTab === 'audit' ? entry.createdAt : entry.accessedAt) }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ activeTab === 'audit' ? actionLabel(entry.action) : accessTypeLabel(entry.accessType) }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ entry.userDisplayName ?? '—' }}
</td>
<td v-if="activeTab === 'audit'" class="px-4 py-3 text-gray-700 dark:text-gray-300">
<span class="font-medium">{{ entry.entityType }}</span>
<span class="block font-mono text-xs text-gray-500 dark:text-gray-400">{{ entry.entityId }}</span>
</td>
<td v-else class="px-4 py-3 font-mono text-xs text-gray-700 dark:text-gray-300">
{{ entry.patientId ?? '—' }}
</td>
<td v-if="activeTab === 'phi'" class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ entry.resourcePath }}
</td>
<td class="px-4 py-3 text-right">
<Button
v-if="activeTab === 'audit' && (entry.previousValueJson || entry.newValueJson)"
size="sm"
variant="secondary"
@click="toggleExpand(entry.id)"
>
{{ expandedId === entry.id ? 'Hide' : 'Details' }}
</Button>
</td>
</tr>
<tr v-if="activeTab === 'audit' && expandedId === entry.id">
<td colspan="6" class="bg-gray-50 px-4 py-4 dark:bg-gray-800/50">
<div class="grid gap-4 lg:grid-cols-2">
<div v-if="entry.previousValueJson">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Previous</p>
<pre class="overflow-x-auto rounded border border-gray-200 bg-white p-4 text-xs dark:border-gray-700 dark:bg-gray-900">{{ formatJsonBlock(entry.previousValueJson) }}</pre>
</div>
<div v-if="entry.newValueJson">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">New</p>
<pre class="overflow-x-auto rounded border border-gray-200 bg-white p-4 text-xs dark:border-gray-700 dark:bg-gray-900">{{ formatJsonBlock(entry.newValueJson) }}</pre>
</div>
</div>
<p v-if="entry.reason" class="mt-4 text-sm text-gray-600 dark:text-gray-400">
Reason: {{ entry.reason }}
</p>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span>{{ totalCount }} entries page {{ page }} of {{ totalPages }}</span>
<div class="flex gap-2">
<Button size="sm" variant="secondary" :disabled="page <= 1" @click="prevPage">Previous</Button>
<Button size="sm" variant="secondary" :disabled="page >= totalPages" @click="nextPage">Next</Button>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,114 @@
<script setup>
import { computed, ref } from 'vue'
import { useOperationsStore } from '@/stores/operationsStore'
import { usePolling } from '@/composables/usePolling'
const store = useOperationsStore()
const drawerOpen = ref(false)
const tabs = [
{ label: 'All', value: null },
{ label: 'Degraded', value: 'DEGRADED' },
{ label: 'Offline', value: 'OFFLINE' },
]
usePolling(() => store.fetchFleet(), 15_000)
function statusBadgeClass(status) {
if (status === 'ONLINE') return 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
if (status === 'DEGRADED') return 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
return 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
}
function formatMinutes(m) {
if (m == null) return '—'
return `${Math.round(m)} min ago`
}
async function openDetail(gateway) {
await store.fetchGatewayDetail(gateway.id)
drawerOpen.value = true
}
</script>
<template>
<div class="space-y-8">
<header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Gateway Fleet</h1>
<div class="flex gap-2">
<button
v-for="tab in tabs"
:key="tab.label"
type="button"
class="rounded-lg px-4 py-2 text-sm font-medium transition duration-150"
:class="store.statusFilter === tab.value
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'"
@click="store.setStatusFilter(tab.value)"
>
{{ tab.label }}
</button>
</div>
</header>
<p v-if="store.error" class="text-sm text-red-600">{{ store.error }}</p>
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th class="px-4 py-2 text-left text-sm font-medium">Site</th>
<th class="px-4 py-2 text-left text-sm font-medium">Gateway</th>
<th class="px-4 py-2 text-left text-sm font-medium">Department</th>
<th class="px-4 py-2 text-left text-sm font-medium">Status</th>
<th class="px-4 py-2 text-left text-sm font-medium">Buffer</th>
<th class="px-4 py-2 text-left text-sm font-medium">Last Heartbeat</th>
<th class="px-4 py-2 text-left text-sm font-medium">Last Sync</th>
<th class="px-4 py-2 text-left text-sm font-medium">Minutes Offline</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
<tr
v-for="gw in store.fleet"
:key="gw.id"
class="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900/50"
@click="openDetail(gw)"
>
<td class="px-4 py-2 text-sm">{{ gw.siteName }}</td>
<td class="px-4 py-2 text-sm font-medium">{{ gw.gatewayCode }}</td>
<td class="px-4 py-2 text-sm">{{ gw.department }}</td>
<td class="px-4 py-2">
<span class="rounded-full px-2 py-1 text-xs font-medium" :class="statusBadgeClass(gw.status)">
{{ gw.status }}
</span>
</td>
<td class="px-4 py-2 text-sm">{{ gw.reportedBufferDepth }}</td>
<td class="px-4 py-2 text-sm">{{ formatMinutes(gw.minutesSinceHeartbeat) }}</td>
<td class="px-4 py-2 text-sm">{{ gw.lastSyncAt ? new Date(gw.lastSyncAt).toLocaleString() : '—' }}</td>
<td class="px-4 py-2 text-sm">{{ gw.minutesSinceHeartbeat != null ? Math.round(gw.minutesSinceHeartbeat) : '—' }}</td>
</tr>
</tbody>
</table>
</div>
<aside
v-if="drawerOpen && store.selectedGateway"
class="fixed inset-y-0 right-0 z-50 w-full max-w-md border-l border-gray-200 bg-white p-4 shadow-lg dark:border-gray-800 dark:bg-gray-900"
>
<button type="button" class="mb-4 text-sm text-blue-600" @click="drawerOpen = false">Close</button>
<h2 class="mb-4 text-lg font-semibold">{{ store.selectedGateway.gateway.gatewayCode }}</h2>
<h3 class="mb-2 text-sm font-medium text-gray-500">Recent sync batches</h3>
<ul class="space-y-2">
<li
v-for="batch in store.selectedGateway.recentBatches"
:key="batch.batchId"
class="rounded-lg border border-gray-200 p-4 text-sm dark:border-gray-800"
>
<span class="font-medium">{{ batch.status }}</span>
{{ new Date(batch.submittedAt).toLocaleString() }}
<span v-if="batch.conflictCount"> ({{ batch.conflictCount }} conflicts)</span>
</li>
</ul>
</aside>
</div>
</template>
+7 -1
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { defaultRouteForRole, isDashboardRole } from '@/composables/roleAccess'
import Button from '@/components/ui/Button.vue'
const router = useRouter()
@@ -18,7 +19,12 @@ async function submit() {
loading.value = true
try {
await auth.login(username.value, password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/ward'
if (!isDashboardRole(auth.role)) {
auth.logout()
error.value = 'This account is for API integration only. Sign in with a clinical user.'
return
}
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : defaultRouteForRole(auth.role)
await router.push(redirect)
} catch (e) {
error.value = e.message ?? 'Login failed'
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { usePolling } from '@/composables/usePolling'
import { useReplayControls } from '@/composables/useReplayControls'
import { useRoleAccess } from '@/composables/roleAccess'
import { useAlertStore } from '@/stores/alerts'
import { useScoringStore } from '@/stores/scoring'
import * as encountersApi from '@/api/encounters'
@@ -21,11 +22,14 @@ import GcsHistory from '@/components/charts/GcsHistory.vue'
import QsofaHistory from '@/components/charts/QsofaHistory.vue'
import SofaHistory from '@/components/charts/SofaHistory.vue'
import EncounterTimeline from '@/components/patient/EncounterTimeline.vue'
import DischargeSummaryPanel from '@/components/patient/DischargeSummaryPanel.vue'
import ReplayControls from '@/components/replay/ReplayControls.vue'
import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
import CollapsibleSection from '@/components/ui/CollapsibleSection.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
const route = useRoute()
const { nursePatientLayout, physicianPatientLayout } = useRoleAccess()
const alertStore = useAlertStore()
const scoringStore = useScoringStore()
const { alerts } = storeToRefs(alertStore)
@@ -66,6 +70,8 @@ const openAlerts = computed(() =>
alerts.value.filter(a => a.status === 'Open' || a.status === 'Escalated'),
)
const isDischarged = computed(() => encounter.value?.status === 'Discharged')
const replayObservations = computed(() =>
observations.value.filter(o => isAtOrBefore(o.recordedAt)),
)
@@ -198,26 +204,43 @@ onBeforeUnmount(() => {
<template>
<Skeleton v-if="loading && !encounter" :rows="6" />
<div v-else-if="encounter" class="w-full min-w-0 space-y-8">
<div v-else-if="encounter" class="w-full min-w-0 space-y-6 lg:space-y-8">
<div class="flex flex-wrap items-center gap-4">
<RouterLink to="/ward" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<RouterLink
to="/ward"
class="inline-flex min-h-11 items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
&larr; Ward
</RouterLink>
</div>
<PatientBanner :encounter="encounter" />
<div class="grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="space-y-4">
<DischargeSummaryPanel
:encounter-id="route.params.encounterId"
:discharged="isDischarged"
/>
<div class="flex flex-col gap-4 lg:grid lg:grid-cols-3">
<div
class="order-1 space-y-4"
:class="{
'lg:order-3': nursePatientLayout,
'lg:order-1': physicianPatientLayout || (!nursePatientLayout && !physicianPatientLayout),
}"
>
<ScoresPanel />
<GcsHistory v-if="replayGcsHistory.length" :history="replayGcsHistory" />
</div>
<VitalsPanel
class="order-2 min-w-0 lg:order-2"
:encounter-id="route.params.encounterId"
:observations="replayObservations"
@recorded="loadAll"
/>
<AlertsList
class="order-3 min-w-0 lg:order-3"
:class="{ 'lg:order-1': nursePatientLayout }"
:encounter-id="route.params.encounterId"
:selected-id="selectedAlert?.id"
@select="onSelectAlert"
@@ -230,13 +253,15 @@ onBeforeUnmount(() => {
:medications="medications"
/>
<div class="grid min-w-0 gap-4 lg:grid-cols-2">
<div class="space-y-4">
<div class="flex flex-col gap-4 lg:grid lg:grid-cols-2">
<div class="order-2 space-y-4 lg:order-1">
<SofaScorePanel :encounter-id="route.params.encounterId" />
<SofaHistory v-if="replaySofaHistory.length" :history="replaySofaHistory" />
</div>
<div class="order-1 lg:order-2">
<OrdersPanel :orders="orders" />
</div>
</div>
<SepsisBundlePanel
:bundle="sepsisBundle"
@@ -244,9 +269,19 @@ onBeforeUnmount(() => {
:sofa="sofa"
/>
<div class="hidden lg:block">
<EncounterTimeline :events="replayTimelineEvents" />
</div>
<CollapsibleSection
class="lg:hidden"
section-id="encounter-timeline"
title="Encounter timeline"
:default-open="false"
>
<EncounterTimeline :events="replayTimelineEvents" />
</CollapsibleSection>
<div id="clinical-review" class="w-full min-w-0 space-y-8">
<div id="clinical-review" class="hidden w-full min-w-0 space-y-6 lg:block lg:space-y-8">
<TrendsGrid :observations="replayObservations" :medications="replayMedications" />
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
<QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" />
@@ -262,5 +297,37 @@ onBeforeUnmount(() => {
@jump-to-alert="jumpToNextAlert"
/>
</div>
<CollapsibleSection
class="lg:hidden"
section-id="clinical-review"
title="Vital trends & clinical review"
:default-open="false"
>
<div class="space-y-6">
<TrendsGrid :observations="replayObservations" :medications="replayMedications" />
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
<QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" />
</div>
</CollapsibleSection>
<CollapsibleSection
class="lg:hidden"
section-id="replay-controls"
title="Scenario replay"
:default-open="false"
>
<ReplayControls
:alerts="openAlerts"
:is-paused="isPaused"
:progress="progress"
:formatted-time="formattedTime"
:speed="speed"
@pause="pause()"
@resume="resume()"
@set-speed="setSpeed"
@jump-to-alert="jumpToNextAlert"
/>
</CollapsibleSection>
</div>
</template>
@@ -0,0 +1,139 @@
<script setup>
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import * as reconciliationApi from '@/api/reconciliation'
import Button from '@/components/ui/Button.vue'
import Badge from '@/components/ui/Badge.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import {
RECONCILIATION_SECTIONS,
formatReconciliationTimestamp,
} from '@/composables/reconciliationFormat'
const includeResolved = ref(false)
const loading = ref(true)
const error = ref('')
const sections = ref([])
async function loadSection(section) {
const result = await reconciliationApi.fetchReconciliationAlerts({
checkType: section.checkType,
resolved: includeResolved.value ? undefined : false,
pageSize: 100,
})
return {
...section,
items: result.items ?? [],
totalCount: result.totalCount ?? 0,
}
}
async function load() {
loading.value = true
error.value = ''
try {
sections.value = await Promise.all(RECONCILIATION_SECTIONS.map(loadSection))
} catch (err) {
error.value = err.message
sections.value = []
} finally {
loading.value = false
}
}
function onToggleResolved() {
load()
}
onMounted(load)
</script>
<template>
<div>
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-xl font-bold dark:text-white">Data Quality</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Reconciliation findings from periodic workflow checks across the ward.
</p>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="includeResolved"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
@change="onToggleResolved"
>
Include resolved
</label>
</div>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<Skeleton v-if="loading" :rows="10" />
<div v-else class="space-y-8">
<section
v-for="section in sections"
:key="section.checkType"
class="rounded-lg border border-gray-200 dark:border-gray-700"
>
<div class="border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="font-semibold text-gray-900 dark:text-white">{{ section.title }}</h2>
<Badge :variant="section.totalCount > 0 ? 'warning' : 'success'">
{{ section.totalCount }} finding{{ section.totalCount === 1 ? '' : 's' }}
</Badge>
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ section.description }}</p>
</div>
<EmptyState
v-if="section.items.length === 0"
message="No findings in this category"
/>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Detected</th>
<th class="px-4 py-3">Details</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3 text-right">Patient</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="item in section.items" :key="item.id">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ formatReconciliationTimestamp(item.createdAt) }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ item.details }}</td>
<td class="px-4 py-3">
<Badge :variant="item.resolvedAt ? 'success' : 'warning'">
{{ item.resolvedAt ? 'Resolved' : 'Open' }}
</Badge>
</td>
<td class="px-4 py-3 text-right">
<RouterLink
v-if="item.encounterId"
:to="`/patients/${item.encounterId}`"
class="text-sm font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
View patient
</RouterLink>
<span v-else class="text-gray-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<div class="mt-4">
<Button size="sm" variant="secondary" @click="load">Refresh</Button>
</div>
</div>
</template>
@@ -0,0 +1,155 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import * as thresholdsApi from '@/api/thresholds'
import ThresholdFormModal from '@/components/admin/ThresholdFormModal.vue'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import { emptyThresholdForm, formatThresholdValue, thresholdToForm } from '@/composables/thresholdForm'
const thresholds = ref([])
const loading = ref(true)
const error = ref('')
const modalOpen = ref(false)
const modalMode = ref('edit')
const editingThreshold = ref(null)
const submitting = ref(false)
const submitError = ref('')
const modalTitle = computed(() =>
modalMode.value === 'create' ? 'Create Threshold' : 'Edit Threshold',
)
const modalInitialValues = computed(() =>
modalMode.value === 'create'
? emptyThresholdForm()
: thresholdToForm(editingThreshold.value ?? {}),
)
async function loadThresholds() {
loading.value = true
error.value = ''
try {
thresholds.value = await thresholdsApi.fetchThresholds()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
function openCreate() {
modalMode.value = 'create'
editingThreshold.value = null
submitError.value = ''
modalOpen.value = true
}
function openEdit(threshold) {
modalMode.value = 'edit'
editingThreshold.value = threshold
submitError.value = ''
modalOpen.value = true
}
function closeModal() {
modalOpen.value = false
}
async function onSubmit(payload) {
submitting.value = true
submitError.value = ''
try {
if (modalMode.value === 'create') {
await thresholdsApi.createThreshold(payload)
} else {
await thresholdsApi.updateThreshold(editingThreshold.value.id, payload)
}
modalOpen.value = false
await loadThresholds()
} catch (err) {
submitError.value = err.message
} finally {
submitting.value = false
}
}
async function onDelete(threshold) {
if (!window.confirm(`Delete threshold for ${threshold.observationCode}?`)) return
error.value = ''
try {
await thresholdsApi.deleteThreshold(threshold.id)
await loadThresholds()
} catch (err) {
error.value = err.message
}
}
onMounted(loadThresholds)
</script>
<template>
<div>
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-xl font-bold dark:text-white">Alert Thresholds</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Configure warning and critical bounds used for clinical alerting.
</p>
</div>
<Button size="sm" @click="openCreate">Create Threshold</Button>
</div>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<Skeleton v-if="loading" :rows="6" />
<EmptyState v-else-if="thresholds.length === 0" message="No thresholds configured" />
<div v-else 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-800">
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Code</th>
<th class="px-4 py-3">Display</th>
<th class="px-4 py-3">Unit</th>
<th class="px-4 py-3 text-right">Critical Low</th>
<th class="px-4 py-3 text-right">Warning Low</th>
<th class="px-4 py-3 text-right">Warning High</th>
<th class="px-4 py-3 text-right">Critical High</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="threshold in thresholds" :key="threshold.id">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{{ threshold.observationCode }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ threshold.displayName }}</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ threshold.unit }}</td>
<td class="px-4 py-3 text-right">{{ formatThresholdValue(threshold.criticalLow) }}</td>
<td class="px-4 py-3 text-right">{{ formatThresholdValue(threshold.warningLow) }}</td>
<td class="px-4 py-3 text-right">{{ formatThresholdValue(threshold.warningHigh) }}</td>
<td class="px-4 py-3 text-right">{{ formatThresholdValue(threshold.criticalHigh) }}</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-2">
<Button size="sm" variant="secondary" @click="openEdit(threshold)">Edit</Button>
<Button size="sm" variant="danger" @click="onDelete(threshold)">Delete</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<ThresholdFormModal
:open="modalOpen"
:title="modalTitle"
:initial-values="modalInitialValues"
:submitting="submitting"
:error="submitError"
:read-only-code="modalMode === 'edit'"
@close="closeModal"
@submit="onSubmit"
/>
</div>
</template>
@@ -0,0 +1,151 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import * as usersApi from '@/api/users'
import UserFormModal from '@/components/admin/UserFormModal.vue'
import Button from '@/components/ui/Button.vue'
import Badge from '@/components/ui/Badge.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import {
emptyUserForm,
userToForm,
roleLabel,
formatLastLogin,
} from '@/composables/userForm'
const users = ref([])
const loading = ref(true)
const error = ref('')
const modalOpen = ref(false)
const modalMode = ref('edit')
const editingUser = ref(null)
const submitting = ref(false)
const submitError = ref('')
const modalTitle = computed(() =>
modalMode.value === 'create' ? 'Create User' : 'Edit User',
)
const modalInitialValues = computed(() =>
modalMode.value === 'create'
? emptyUserForm()
: userToForm(editingUser.value ?? {}),
)
async function loadUsers() {
loading.value = true
error.value = ''
try {
users.value = await usersApi.fetchUsers()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
function openCreate() {
modalMode.value = 'create'
editingUser.value = null
submitError.value = ''
modalOpen.value = true
}
function openEdit(user) {
modalMode.value = 'edit'
editingUser.value = user
submitError.value = ''
modalOpen.value = true
}
function closeModal() {
modalOpen.value = false
}
async function onSubmit(payload) {
submitting.value = true
submitError.value = ''
try {
if (modalMode.value === 'create') {
await usersApi.createUser(payload)
} else {
await usersApi.updateUser(editingUser.value.id, payload)
}
modalOpen.value = false
await loadUsers()
} catch (err) {
submitError.value = err.message
} finally {
submitting.value = false
}
}
onMounted(loadUsers)
</script>
<template>
<div>
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-xl font-bold dark:text-white">User Management</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Create clinical accounts, assign roles, and deactivate users.
</p>
</div>
<Button size="sm" @click="openCreate">Create User</Button>
</div>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<Skeleton v-if="loading" :rows="6" />
<EmptyState v-else-if="users.length === 0" message="No users found" />
<div v-else 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-800">
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Username</th>
<th class="px-4 py-3">Display name</th>
<th class="px-4 py-3">Role</th>
<th class="px-4 py-3">Last login</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="user in users" :key="user.id">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{{ user.username }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ user.displayName }}</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ roleLabel(user.role) }}</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ formatLastLogin(user.lastLoginAt) }}
</td>
<td class="px-4 py-3">
<Badge :variant="user.isActive ? 'success' : 'critical'">
{{ user.isActive ? 'Active' : 'Inactive' }}
</Badge>
</td>
<td class="px-4 py-3">
<div class="flex justify-end">
<Button size="sm" variant="secondary" @click="openEdit(user)">Edit</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<UserFormModal
:open="modalOpen"
:title="modalTitle"
:mode="modalMode"
:initial-values="modalInitialValues"
:submitting="submitting"
:error="submitError"
@close="closeModal"
@submit="onSubmit"
/>
</div>
</template>