feature: Degraded Operations Visibility
This commit is contained in:
@@ -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
|
public class EncountersController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IEncounterService _encounters;
|
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>
|
/// <summary>
|
||||||
/// Lists encounters for ward dashboards with denormalized clinical summary fields.
|
/// Lists encounters for ward dashboards with denormalized clinical summary fields.
|
||||||
@@ -115,4 +122,31 @@ public class EncountersController : ControllerBase
|
|||||||
var timeline = await _encounters.GetTimelineAsync(id);
|
var timeline = await _encounters.GetTimelineAsync(id);
|
||||||
return Ok(ApiResponse<object>.Ok(timeline));
|
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);
|
||||||
@@ -148,6 +148,9 @@ try
|
|||||||
|
|
||||||
builder.Services.Configure<ClinicalSyncOptions>(builder.Configuration.GetSection(ClinicalSyncOptions.Section));
|
builder.Services.Configure<ClinicalSyncOptions>(builder.Configuration.GetSection(ClinicalSyncOptions.Section));
|
||||||
|
|
||||||
|
builder.Services.Configure<GatewayMonitoringOptions>(
|
||||||
|
builder.Configuration.GetSection(GatewayMonitoringOptions.Section));
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("Dashboard", policy =>
|
options.AddPolicy("Dashboard", policy =>
|
||||||
@@ -160,6 +163,7 @@ try
|
|||||||
|
|
||||||
builder.Services.AddScoped<IPatientService, PatientService>();
|
builder.Services.AddScoped<IPatientService, PatientService>();
|
||||||
builder.Services.AddScoped<IEncounterService, EncounterService>();
|
builder.Services.AddScoped<IEncounterService, EncounterService>();
|
||||||
|
builder.Services.AddScoped<IDischargeSummaryService, DischargeSummaryService>();
|
||||||
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
|
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
|
||||||
builder.Services.AddScoped<IObservationService, ObservationService>();
|
builder.Services.AddScoped<IObservationService, ObservationService>();
|
||||||
builder.Services.AddScoped<IObservationQueryService, ObservationQueryService>();
|
builder.Services.AddScoped<IObservationQueryService, ObservationQueryService>();
|
||||||
@@ -198,6 +202,7 @@ try
|
|||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
|
builder.Services.AddScoped<IUserService, UserService>();
|
||||||
builder.Services.AddScoped<IAuditService, AuditService>();
|
builder.Services.AddScoped<IAuditService, AuditService>();
|
||||||
builder.Services.AddScoped<IClinicalSyncService, ClinicalSyncService>();
|
builder.Services.AddScoped<IClinicalSyncService, ClinicalSyncService>();
|
||||||
builder.Services.AddScoped<ClinicalSyncBatchProcessor>();
|
builder.Services.AddScoped<ClinicalSyncBatchProcessor>();
|
||||||
@@ -205,6 +210,8 @@ try
|
|||||||
builder.Services.AddScoped<IPhiAccessLogService, PhiAccessLogService>();
|
builder.Services.AddScoped<IPhiAccessLogService, PhiAccessLogService>();
|
||||||
builder.Services.AddScoped<ISiteService, SiteService>();
|
builder.Services.AddScoped<ISiteService, SiteService>();
|
||||||
builder.Services.AddScoped<IGatewayRegistryService, GatewayRegistryService>();
|
builder.Services.AddScoped<IGatewayRegistryService, GatewayRegistryService>();
|
||||||
|
builder.Services.AddScoped<IOperationsService, OperationsService>();
|
||||||
|
builder.Services.AddHostedService<GatewayStaleDetectorService>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
builder.Services.AddHostedService<ThresholdCacheLoader>();
|
||||||
builder.Services.AddHostedService<KafkaTopicProvisioner>();
|
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>
|
<ItemGroup>
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
<PackageReference Include="Confluent.Kafka" Version="2.14.0" />
|
<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="Elastic.Clients.Elasticsearch" Version="8.13.12" />
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||||
<PackageReference Include="Hl7.Fhir.R4" Version="5.11.1" />
|
<PackageReference Include="Hl7.Fhir.R4" Version="5.11.1" />
|
||||||
|
|||||||
@@ -210,5 +210,9 @@
|
|||||||
},
|
},
|
||||||
"ClinicalSync": {
|
"ClinicalSync": {
|
||||||
"SuppressPagingForSyncedAlerts": true
|
"SuppressPagingForSyncedAlerts": true
|
||||||
|
},
|
||||||
|
"GatewayMonitoring": {
|
||||||
|
"StaleThresholdMinutes": 10,
|
||||||
|
"PollIntervalMinutes": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 | #1–18, #20–23, #25 |
|
||||||
|
| **Partial** | 1 | #26 (core WCAG improvements shipped; full audit not completed) |
|
||||||
|
| **Open** | 5 | #19, #24, #27, #28 |
|
||||||
|
|
||||||
|
**Recently completed (dashboard phases 22–23):** 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
|
# 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 |
|
| # | Issue | Priority | Part | Backend exists? | Status |
|
||||||
|---|-------|----------|------|----------------|--------|
|
|---|-------|----------|------|----------------|--------|
|
||||||
| 1 | No SOFA trend chart | P0 | A | Yes | Open |
|
| 1 | No SOFA trend chart | P0 | A | Yes | **Done** — `SofaHistory.vue` |
|
||||||
| 2 | No GCS trend chart / history endpoint | P0 | A | Partial (no history endpoint) | Open |
|
| 2 | No GCS trend chart / history endpoint | P0 | A | Yes | **Done** — `GcsHistory.vue`, `GET /gcs/history` |
|
||||||
| 3 | No qSOFA history view | P0 | A | No (Redis-only, no persistence) | Open |
|
| 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 | Open |
|
| 4 | No medication overlay on vital charts | P0 | A | Yes | **Done** — `medicationMarkerPlugin.js` |
|
||||||
| 5 | No encounter timeline view | P1 | A | Yes | Open |
|
| 5 | No encounter timeline view | P1 | A | Yes | **Done** — `EncounterTimeline.vue` |
|
||||||
| 6 | No patient demographics / allergy banner | P1 | A | Yes | Open |
|
| 6 | No patient demographics / allergy banner | P1 | A | Yes | **Done** — `PatientBanner.vue` |
|
||||||
| 7 | Ward table missing SOFA, GCS, staleness | P1 | B | Partial (DTO needs extension) | Open |
|
| 7 | Ward table missing SOFA, GCS, staleness | P1 | B | Yes | **Done** — extended `WardEncounterSummary` + `PatientRow` |
|
||||||
| 8 | No sepsis bundle compliance board | P2 | B | Yes | Open |
|
| 8 | No sepsis bundle compliance board | P2 | B | Yes | **Done** — `SepsisBoardView.vue` |
|
||||||
| 9 | No department overview with aggregates | P2 | B | Partial | Open |
|
| 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) | Open |
|
| 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) | Open |
|
| 11 | No search/filter on ward table | P2 | B | Yes (analytics search) | **Done** — `WardToolbar.vue` |
|
||||||
| 12 | No critical alert notification/sound | P1 | C | No (needs push or client detect) | Open |
|
| 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) | Open |
|
| 13 | No handoff/shift summary report | P1 | C | Yes (data available) | **Done** — `HandoffReport.vue` |
|
||||||
| 14 | No discharge summary view | P2 | C | Partial (needs download endpoint) | Open |
|
| 14 | No discharge summary view | P2 | C | Yes | **Done** — `DischargeSummaryPanel.vue`, `GET /discharge-summary` |
|
||||||
| 15 | Alert ack lacks role context | P3 | C | Partial | Open |
|
| 15 | Alert ack lacks role context | P3 | C | Yes | **Done** — `alertAcknowledge.js`, `AcknowledgeModal.vue` |
|
||||||
| 16 | No vitals entry form | P3 | C | Yes | Open |
|
| 16 | No vitals entry form | P3 | C | Yes | **Done** — `VitalsEntryForm.vue`, `GcsEntryForm.vue` |
|
||||||
| 17 | No threshold management UI | P2 | D | Yes | Open |
|
| 17 | No threshold management UI | P2 | D | Yes | **Done** — `ThresholdManagementView.vue` |
|
||||||
| 18 | No user management UI | P2 | D | No (needs backend endpoints) | Open |
|
| 18 | No user management UI | P2 | D | Yes | **Done** — `UsersController`, `UserManagementView.vue` |
|
||||||
| 19 | No gateway status dashboard | P2 | D | Yes | Open |
|
| 19 | No gateway status dashboard | P2 | D | Yes | Open |
|
||||||
| 20 | No audit log viewer | P4 | D | Yes | Open |
|
| 20 | No audit log viewer | P4 | D | Yes | **Done** — `AuditLogView.vue` |
|
||||||
| 21 | No reconciliation dashboard | P4 | D | Yes | Open |
|
| 21 | No reconciliation dashboard | P4 | D | Yes | **Done** — `ReconciliationView.vue` |
|
||||||
| 22 | No role-based navigation | P3 | E | N/A (frontend-only) | Open |
|
| 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 | Open |
|
| 23 | Dark mode chart inconsistency | P3 | E | N/A | **Done** — `useChartTheme.js` |
|
||||||
| 24 | No keyboard shortcuts | P3 | E | N/A | Open |
|
| 24 | No keyboard shortcuts | P3 | E | N/A | Open |
|
||||||
| 25 | No mobile optimization for patient detail | P3 | 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 | Open |
|
| 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 |
|
| 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 |
|
| 28 | No real-time push (polling only) | P4 | F | No (needs SSE/SignalR) | Open |
|
||||||
|
|
||||||
@@ -988,32 +1004,33 @@ flowchart TD
|
|||||||
|
|
||||||
### Sprint-sized batches
|
### Sprint-sized batches
|
||||||
|
|
||||||
| Batch | Items | Outcome |
|
| 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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **6 — Real-Time** | P4 SSE/SignalR push | Sub-second alert notification, reduced polling load | Open |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backend work required
|
## 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 |
|
| Item | Backend work needed | Status |
|
||||||
|------|-------------------|
|
|------|---------------------|--------|
|
||||||
| GCS history chart | New `GET /encounters/{id}/gcs/history` endpoint |
|
| GCS history chart | `GET /encounters/{id}/gcs/history` endpoint | **Done** |
|
||||||
| qSOFA history | Decide persistence strategy (Redis → DB) + new history endpoint |
|
| qSOFA history | `qsofa_evaluations` table + `GET /qsofa/history` | **Done** |
|
||||||
| Ward table columns | Extend `WardEncounterSummary` DTO with SOFA/GCS/staleness |
|
| Ward table columns | Extend `WardEncounterSummary` DTO with SOFA/GCS/staleness | **Done** |
|
||||||
| Discharge summary view | New `GET /encounters/{id}/discharge-summary` download endpoint |
|
| Discharge summary view | `GET /encounters/{id}/discharge-summary` + content download | **Done** |
|
||||||
| User management | New `UsersController` with CRUD endpoints |
|
| User management | `UsersController` with list/create/patch | **Done** |
|
||||||
| Real-time push | New SSE endpoint or SignalR hub |
|
| Gateway status dashboard | Fleet/summary APIs exist (Phase 23); dashboard UI not built | Open (frontend) |
|
||||||
| Grafana expansion | Depends on platform gap P5 metrics being implemented first |
|
| 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.
|
||||||
@@ -192,6 +192,114 @@
|
|||||||
"fields": ""
|
"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": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+51
@@ -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 ==="
|
||||||
Executable
+25
@@ -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,12 +2,14 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import AppShell from '@/components/layout/AppShell.vue'
|
import AppShell from '@/components/layout/AppShell.vue'
|
||||||
|
import DegradedModeBanner from '@/components/DegradedModeBanner.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const useShell = computed(() => !route.meta.public)
|
const useShell = computed(() => !route.meta.public)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<DegradedModeBanner />
|
||||||
<AppShell v-if="useShell">
|
<AppShell v-if="useShell">
|
||||||
<RouterView v-slot="{ Component }">
|
<RouterView v-slot="{ Component }">
|
||||||
<KeepAlive include="WardDashboard">
|
<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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
})}`)
|
||||||
|
}
|
||||||
@@ -52,4 +52,30 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
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 }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { api } from './client'
|
import { api, BASE_URL, authHeaders } from './client'
|
||||||
|
|
||||||
export function fetchActiveEncounters(department, { page = 1, pageSize = 20 } = {}) {
|
export function fetchActiveEncounters(department, { page = 1, pageSize = 20 } = {}) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -51,3 +51,28 @@ export function submitVitalsObservations(encounterId, observations) {
|
|||||||
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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})}`)
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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 { computed } from 'vue'
|
||||||
import Card from '@/components/ui/Card.vue'
|
import Card from '@/components/ui/Card.vue'
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
import Badge from '@/components/ui/Badge.vue'
|
||||||
|
import SeverityBadge from '@/components/ui/SeverityBadge.vue'
|
||||||
import Button from '@/components/ui/Button.vue'
|
import Button from '@/components/ui/Button.vue'
|
||||||
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
|
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
|
||||||
import { alertTypeLabel } from '@/api/normalize'
|
import { alertTypeLabel } from '@/api/normalize'
|
||||||
@@ -23,10 +24,6 @@ const actionHint = computed(() => {
|
|||||||
return hints[props.alert.alertType] ?? null
|
return hints[props.alert.alertType] ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
function severityVariant(severity) {
|
|
||||||
return severity === 'Critical' ? 'critical' : 'warning'
|
|
||||||
}
|
|
||||||
|
|
||||||
function showActions(status) {
|
function showActions(status) {
|
||||||
return status !== 'Resolved'
|
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="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<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 variant="info" size="xs">{{ alert.status }}</Badge>
|
||||||
<Badge v-if="actionHint" variant="info" size="xs">{{ actionHint }}</Badge>
|
<Badge v-if="actionHint" variant="info" size="xs">{{ actionHint }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,6 +74,7 @@ function formatTime(iso) {
|
|||||||
v-if="canAcknowledge(alert.status)"
|
v-if="canAcknowledge(alert.status)"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
:aria-label="`Acknowledge ${alertTypeLabel(alert.alertType)} alert`"
|
||||||
@click="emit('acknowledge')"
|
@click="emit('acknowledge')"
|
||||||
>
|
>
|
||||||
Acknowledge
|
Acknowledge
|
||||||
@@ -85,6 +83,7 @@ function formatTime(iso) {
|
|||||||
v-if="canResolve(alert.status)"
|
v-if="canResolve(alert.status)"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
:aria-label="`Resolve ${alertTypeLabel(alert.alertType)} alert`"
|
||||||
@click="emit('resolve')"
|
@click="emit('resolve')"
|
||||||
>
|
>
|
||||||
Resolve
|
Resolve
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, shallowRef, markRaw } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Line } from 'vue-chartjs'
|
import { Line } from 'vue-chartjs'
|
||||||
import { Chart as ChartJS, registerables } from 'chart.js'
|
import { Chart as ChartJS, registerables } from 'chart.js'
|
||||||
import { formatTime } from '@/composables/chartFormat'
|
import { formatTime } from '@/composables/chartFormat'
|
||||||
|
import { useChartTheme } from '@/composables/useChartTheme'
|
||||||
|
|
||||||
ChartJS.register(...registerables)
|
ChartJS.register(...registerables)
|
||||||
|
|
||||||
const props = defineProps({ history: { type: Array, required: true } })
|
const props = defineProps({ history: { type: Array, required: true } })
|
||||||
|
const { buildOptions, darkMode, accentLine } = useChartTheme()
|
||||||
|
|
||||||
const COMPONENT_DATASETS = [
|
const COMPONENT_DATASETS = [
|
||||||
{ label: 'Eye', key: 'eyeScore', color: '#3b82f6' },
|
{ label: 'Eye', key: 'eyeScore', color: '#3b82f6' },
|
||||||
@@ -33,6 +35,7 @@ const sortedHistory = computed(() =>
|
|||||||
const chartData = computed(() => {
|
const chartData = computed(() => {
|
||||||
const sorted = sortedHistory.value
|
const sorted = sortedHistory.value
|
||||||
const labels = sorted.map(h => formatTime(h.calculatedAt))
|
const labels = sorted.map(h => formatTime(h.calculatedAt))
|
||||||
|
const totalLineColor = accentLine.value
|
||||||
|
|
||||||
const componentDatasets = COMPONENT_DATASETS.map(({ label, key, color }) => ({
|
const componentDatasets = COMPONENT_DATASETS.map(({ label, key, color }) => ({
|
||||||
label,
|
label,
|
||||||
@@ -52,7 +55,7 @@ const chartData = computed(() => {
|
|||||||
{
|
{
|
||||||
label: 'GCS Total',
|
label: 'GCS Total',
|
||||||
data: sorted.map(h => h.totalScore),
|
data: sorted.map(h => h.totalScore),
|
||||||
borderColor: '#111827',
|
borderColor: totalLineColor,
|
||||||
backgroundColor: sorted.map(h => {
|
backgroundColor: sorted.map(h => {
|
||||||
if (h.totalScore <= 8) return 'rgba(220, 38, 38, 0.15)'
|
if (h.totalScore <= 8) return 'rgba(220, 38, 38, 0.15)'
|
||||||
if (h.totalScore <= 12) return 'rgba(245, 158, 11, 0.15)'
|
if (h.totalScore <= 12) return 'rgba(245, 158, 11, 0.15)'
|
||||||
@@ -71,12 +74,9 @@ const chartData = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const chartOptions = shallowRef(markRaw({
|
const chartOptions = computed(() => {
|
||||||
responsive: true,
|
darkMode.value
|
||||||
maintainAspectRatio: true,
|
return buildOptions({
|
||||||
animation: {
|
|
||||||
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
|
||||||
},
|
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
@@ -89,7 +89,6 @@ const chartOptions = shallowRef(markRaw({
|
|||||||
legend: {
|
legend: {
|
||||||
display: true,
|
display: true,
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
labels: { boxWidth: 12, font: { size: 11 } },
|
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
@@ -101,7 +100,8 @@ const chartOptions = shallowRef(markRaw({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}).value
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -113,7 +113,11 @@ const chartOptions = shallowRef(markRaw({
|
|||||||
<span class="text-amber-600 dark:text-amber-400">Moderate 9–12</span>,
|
<span class="text-amber-600 dark:text-amber-400">Moderate 9–12</span>,
|
||||||
<span class="text-red-600 dark:text-red-400">Severe 3–8</span>.
|
<span class="text-red-600 dark:text-red-400">Severe 3–8</span>.
|
||||||
</p>
|
</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" />
|
<Line :data="chartData" :options="chartOptions" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, shallowRef, markRaw } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Line } from 'vue-chartjs'
|
import { Line } from 'vue-chartjs'
|
||||||
import { Chart, registerables } from 'chart.js'
|
import { Chart, registerables } from 'chart.js'
|
||||||
import { formatTime } from '@/composables/chartFormat'
|
import { formatTime } from '@/composables/chartFormat'
|
||||||
|
import { useChartTheme } from '@/composables/useChartTheme'
|
||||||
|
|
||||||
Chart.register(...registerables)
|
Chart.register(...registerables)
|
||||||
|
|
||||||
const props = defineProps({ history: { type: Array, required: true } })
|
const props = defineProps({ history: { type: Array, required: true } })
|
||||||
|
const { buildOptions, darkMode } = useChartTheme()
|
||||||
|
|
||||||
const chartData = computed(() => ({
|
const chartData = computed(() => ({
|
||||||
labels: props.history.map(h => formatTime(h.calculatedAt)),
|
labels: props.history.map(h => formatTime(h.calculatedAt)),
|
||||||
@@ -25,25 +27,24 @@ const chartData = computed(() => ({
|
|||||||
}],
|
}],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const chartOptions = shallowRef(markRaw({
|
const chartOptions = computed(() => {
|
||||||
responsive: true,
|
darkMode.value
|
||||||
maintainAspectRatio: true,
|
return buildOptions({
|
||||||
animation: {
|
|
||||||
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
|
||||||
},
|
|
||||||
scales: {
|
scales: {
|
||||||
y: { min: 0, max: 20, title: { display: true, text: 'NEWS2 Score' } },
|
y: { min: 0, max: 20, title: { display: true, text: 'NEWS2 Score' } },
|
||||||
},
|
},
|
||||||
plugins: {
|
}).value
|
||||||
legend: { display: false },
|
})
|
||||||
},
|
|
||||||
}))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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>
|
<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" />
|
<Line :data="chartData" :options="chartOptions" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, shallowRef, markRaw } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Line } from 'vue-chartjs'
|
import { Line } from 'vue-chartjs'
|
||||||
import { Chart as ChartJS, registerables } from 'chart.js'
|
import { Chart as ChartJS, registerables } from 'chart.js'
|
||||||
import { formatTime } from '@/composables/chartFormat'
|
import { formatTime } from '@/composables/chartFormat'
|
||||||
|
import { useChartTheme } from '@/composables/useChartTheme'
|
||||||
|
|
||||||
ChartJS.register(...registerables)
|
ChartJS.register(...registerables)
|
||||||
|
|
||||||
const props = defineProps({ history: { type: Array, required: true } })
|
const props = defineProps({ history: { type: Array, required: true } })
|
||||||
|
const { buildOptions, darkMode, accentLine } = useChartTheme()
|
||||||
|
|
||||||
const CRITERION_LABELS = [
|
const CRITERION_LABELS = [
|
||||||
{ label: 'Resp rate', key: 'respRate', color: '#3b82f6' },
|
{ label: 'Resp rate', key: 'respRate', color: '#3b82f6' },
|
||||||
@@ -37,6 +39,7 @@ const sortedHistory = computed(() =>
|
|||||||
const chartData = computed(() => {
|
const chartData = computed(() => {
|
||||||
const sorted = sortedHistory.value
|
const sorted = sortedHistory.value
|
||||||
const labels = sorted.map(h => formatTime(h.evaluatedAt))
|
const labels = sorted.map(h => formatTime(h.evaluatedAt))
|
||||||
|
const totalLineColor = accentLine.value
|
||||||
|
|
||||||
const criterionDatasets = CRITERION_LABELS.map(({ label, key, color }) => ({
|
const criterionDatasets = CRITERION_LABELS.map(({ label, key, color }) => ({
|
||||||
label,
|
label,
|
||||||
@@ -57,7 +60,7 @@ const chartData = computed(() => {
|
|||||||
{
|
{
|
||||||
label: 'Active criteria',
|
label: 'Active criteria',
|
||||||
data: sorted.map(h => h.activeCriteria),
|
data: sorted.map(h => h.activeCriteria),
|
||||||
borderColor: '#111827',
|
borderColor: totalLineColor,
|
||||||
backgroundColor: sorted.map(h => {
|
backgroundColor: sorted.map(h => {
|
||||||
if (h.activeCriteria >= 2) return 'rgba(220, 38, 38, 0.2)'
|
if (h.activeCriteria >= 2) return 'rgba(220, 38, 38, 0.2)'
|
||||||
if (h.activeCriteria === 1) return 'rgba(245, 158, 11, 0.2)'
|
if (h.activeCriteria === 1) return 'rgba(245, 158, 11, 0.2)'
|
||||||
@@ -78,12 +81,9 @@ const chartData = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const chartOptions = shallowRef(markRaw({
|
const chartOptions = computed(() => {
|
||||||
responsive: true,
|
darkMode.value
|
||||||
maintainAspectRatio: true,
|
return buildOptions({
|
||||||
animation: {
|
|
||||||
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
|
||||||
},
|
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
scales: {
|
scales: {
|
||||||
count: {
|
count: {
|
||||||
@@ -106,7 +106,6 @@ const chartOptions = shallowRef(markRaw({
|
|||||||
legend: {
|
legend: {
|
||||||
display: true,
|
display: true,
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
labels: { boxWidth: 12, font: { size: 11 } },
|
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
@@ -122,7 +121,8 @@ const chartOptions = shallowRef(markRaw({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}).value
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -134,7 +134,11 @@ const chartOptions = shallowRef(markRaw({
|
|||||||
<span class="text-amber-600 dark:text-amber-400">1</span>,
|
<span class="text-amber-600 dark:text-amber-400">1</span>,
|
||||||
<span class="text-red-600 dark:text-red-400">≥2</span>.
|
<span class="text-red-600 dark:text-red-400">≥2</span>.
|
||||||
</p>
|
</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" />
|
<Line :data="chartData" :options="chartOptions" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, shallowRef, markRaw } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Chart } from 'vue-chartjs'
|
import { Chart } from 'vue-chartjs'
|
||||||
import { Chart as ChartJS, registerables } from 'chart.js'
|
import { Chart as ChartJS, registerables } from 'chart.js'
|
||||||
import { formatTime } from '@/composables/chartFormat'
|
import { formatTime } from '@/composables/chartFormat'
|
||||||
|
import { useChartTheme } from '@/composables/useChartTheme'
|
||||||
|
|
||||||
ChartJS.register(...registerables)
|
ChartJS.register(...registerables)
|
||||||
|
|
||||||
const props = defineProps({ history: { type: Array, required: true } })
|
const props = defineProps({ history: { type: Array, required: true } })
|
||||||
|
const { buildOptions, darkMode, accentLine } = useChartTheme()
|
||||||
|
|
||||||
const ORGAN_DATASETS = [
|
const ORGAN_DATASETS = [
|
||||||
{ label: 'Respiratory', key: 'respiratoryScore', color: '#3b82f6' },
|
{ label: 'Respiratory', key: 'respiratoryScore', color: '#3b82f6' },
|
||||||
@@ -30,6 +32,7 @@ const sortedHistory = computed(() =>
|
|||||||
const chartData = computed(() => {
|
const chartData = computed(() => {
|
||||||
const sorted = sortedHistory.value
|
const sorted = sortedHistory.value
|
||||||
const labels = sorted.map(h => formatTime(h.calculatedAt))
|
const labels = sorted.map(h => formatTime(h.calculatedAt))
|
||||||
|
const totalLineColor = accentLine.value
|
||||||
|
|
||||||
const organDatasets = ORGAN_DATASETS.map(({ label, key, color }) => ({
|
const organDatasets = ORGAN_DATASETS.map(({ label, key, color }) => ({
|
||||||
type: 'line',
|
type: 'line',
|
||||||
@@ -53,7 +56,7 @@ const chartData = computed(() => {
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
label: 'SOFA Total',
|
label: 'SOFA Total',
|
||||||
data: sorted.map(h => h.totalScore),
|
data: sorted.map(h => h.totalScore),
|
||||||
borderColor: '#111827',
|
borderColor: totalLineColor,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
pointBackgroundColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
|
pointBackgroundColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
|
||||||
pointBorderColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
|
pointBorderColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
|
||||||
@@ -67,12 +70,9 @@ const chartData = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const chartOptions = shallowRef(markRaw({
|
const chartOptions = computed(() => {
|
||||||
responsive: true,
|
darkMode.value
|
||||||
maintainAspectRatio: true,
|
return buildOptions({
|
||||||
animation: {
|
|
||||||
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
|
||||||
},
|
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
scales: {
|
scales: {
|
||||||
x: { stacked: true },
|
x: { stacked: true },
|
||||||
@@ -87,7 +87,6 @@ const chartOptions = shallowRef(markRaw({
|
|||||||
legend: {
|
legend: {
|
||||||
display: true,
|
display: true,
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
labels: { boxWidth: 12, font: { size: 11 } },
|
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
@@ -102,7 +101,8 @@ const chartOptions = shallowRef(markRaw({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}).value
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -114,7 +114,11 @@ const chartOptions = shallowRef(markRaw({
|
|||||||
<span class="text-amber-600 dark:text-amber-400">6–9</span>,
|
<span class="text-amber-600 dark:text-amber-400">6–9</span>,
|
||||||
<span class="text-red-600 dark:text-red-400">10+</span>.
|
<span class="text-red-600 dark:text-red-400">10+</span>.
|
||||||
</p>
|
</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" />
|
<Chart type="line" :data="chartData" :options="chartOptions" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
formatMedicationTooltip,
|
formatMedicationTooltip,
|
||||||
getMedicationsNearTimestamp,
|
getMedicationsNearTimestamp,
|
||||||
} from '@/composables/chartMedications'
|
} from '@/composables/chartMedications'
|
||||||
|
import { useChartTheme } from '@/composables/useChartTheme'
|
||||||
import { medicationMarkerPlugin } from '@/plugins/medicationMarkerPlugin'
|
import { medicationMarkerPlugin } from '@/plugins/medicationMarkerPlugin'
|
||||||
|
|
||||||
Chart.register(...registerables, medicationMarkerPlugin)
|
Chart.register(...registerables, medicationMarkerPlugin)
|
||||||
@@ -21,6 +22,8 @@ const props = defineProps({
|
|||||||
medications: { type: Array, default: () => [] },
|
medications: { type: Array, default: () => [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { buildOptions, darkMode } = useChartTheme()
|
||||||
|
|
||||||
const lineData = computed(() => {
|
const lineData = computed(() => {
|
||||||
const data = toValue(props.chartData)
|
const data = toValue(props.chartData)
|
||||||
return data?.datasets ? data : { labels: [], timestamps: [], datasets: [] }
|
return data?.datasets ? data : { labels: [], timestamps: [], datasets: [] }
|
||||||
@@ -30,18 +33,14 @@ const chartMedications = computed(() =>
|
|||||||
filterMedicationsForWindow(props.observations, props.observationCode, props.medications),
|
filterMedicationsForWindow(props.observations, props.observationCode, props.medications),
|
||||||
)
|
)
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
const chartOptions = computed(() => {
|
||||||
responsive: true,
|
darkMode.value
|
||||||
maintainAspectRatio: true,
|
const options = buildOptions({
|
||||||
animation: {
|
|
||||||
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
|
||||||
},
|
|
||||||
scales: {
|
scales: {
|
||||||
y: { min: props.yMin, max: props.yMax },
|
y: { min: props.yMin, max: props.yMax },
|
||||||
x: { ticks: { maxTicksAuto: true, maxRotation: 45 } },
|
x: { ticks: { maxTicksAuto: true, maxRotation: 45 } },
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: false },
|
|
||||||
medicationMarkers: {
|
medicationMarkers: {
|
||||||
medications: chartMedications.value,
|
medications: chartMedications.value,
|
||||||
timestamps: lineData.value.timestamps ?? [],
|
timestamps: lineData.value.timestamps ?? [],
|
||||||
@@ -60,13 +59,20 @@ const chartOptions = computed(() => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}).value
|
||||||
|
|
||||||
|
return options
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<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>
|
<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" />
|
<Line :data="lineData" :options="chartOptions" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,12 +11,18 @@ useCriticalAlertPolling(settingsStore.pollInterval)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="flex min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<CriticalAlertBanner />
|
<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">
|
<div class="mx-auto w-full max-w-7xl">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useRoleAccess } from '@/composables/roleAccess'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { mainNavLinks, adminNavLinks, showAdminSection } = useRoleAccess()
|
||||||
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' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function linkClasses(path) {
|
function linkClasses(path) {
|
||||||
const active = route.path.startsWith(path)
|
const active = route.path.startsWith(path)
|
||||||
@@ -26,7 +20,7 @@ function linkClasses(path) {
|
|||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 px-4 py-4" aria-label="Main navigation">
|
<nav class="flex-1 px-4 py-4" aria-label="Main navigation">
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
<li v-for="link in links" :key="link.to">
|
<li v-for="link in mainNavLinks" :key="link.to">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="link.to"
|
: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="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"
|
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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
|
<svg
|
||||||
v-else-if="link.icon === 'sepsis'"
|
v-else-if="link.icon === 'sepsis'"
|
||||||
class="h-6 w-6 shrink-0"
|
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"
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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
|
<svg
|
||||||
v-else-if="link.icon === 'departments'"
|
v-else-if="link.icon === 'departments'"
|
||||||
class="h-6 w-6 shrink-0"
|
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"
|
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>
|
||||||
|
<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
|
<svg
|
||||||
v-else
|
v-else
|
||||||
class="h-6 w-6 shrink-0"
|
class="h-6 w-6 shrink-0"
|
||||||
@@ -111,6 +135,74 @@ function linkClasses(path) {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useRoleAccess } from '@/composables/roleAccess'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { mobileNavLinks } = useRoleAccess()
|
||||||
const links = [
|
|
||||||
{ to: '/ward', label: 'Ward', icon: 'ward' },
|
|
||||||
{ to: '/alerts', label: 'Alerts', icon: 'alerts' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const activePath = computed(() => route.path)
|
const activePath = computed(() => route.path)
|
||||||
</script>
|
</script>
|
||||||
@@ -18,7 +15,7 @@ const activePath = computed(() => route.path)
|
|||||||
aria-label="Mobile navigation"
|
aria-label="Mobile navigation"
|
||||||
>
|
>
|
||||||
<ul class="flex h-16 items-stretch">
|
<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
|
<RouterLink
|
||||||
:to="link.to"
|
:to="link.to"
|
||||||
class="flex h-full flex-col items-center justify-center gap-2 px-4 py-2 text-xs font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
|
class="flex h-full flex-col items-center justify-center gap-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"
|
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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
|
<svg
|
||||||
v-else
|
v-else
|
||||||
class="h-6 w-6"
|
class="h-6 w-6"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { storeToRefs } from 'pinia'
|
|||||||
import { useAlertStore } from '@/stores/alerts'
|
import { useAlertStore } from '@/stores/alerts'
|
||||||
import { usePolling } from '@/composables/usePolling'
|
import { usePolling } from '@/composables/usePolling'
|
||||||
import Card from '@/components/ui/Card.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 Button from '@/components/ui/Button.vue'
|
||||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
import AcknowledgeModal from '@/components/alerts/AcknowledgeModal.vue'
|
import AcknowledgeModal from '@/components/alerts/AcknowledgeModal.vue'
|
||||||
@@ -32,10 +32,6 @@ const visibleAlerts = computed(() =>
|
|||||||
alerts.value.filter(a => a.status !== 'Resolved'),
|
alerts.value.filter(a => a.status !== 'Resolved'),
|
||||||
)
|
)
|
||||||
|
|
||||||
function severityVariant(severity) {
|
|
||||||
return severity === 'Critical' ? 'critical' : 'warning'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAcknowledge(note) {
|
async function handleAcknowledge(note) {
|
||||||
if (!confirmingAlert.value) return
|
if (!confirmingAlert.value) return
|
||||||
await alertStore.acknowledge(confirmingAlert.value.id, note)
|
await alertStore.acknowledge(confirmingAlert.value.id, note)
|
||||||
@@ -58,18 +54,24 @@ async function resolve(alertId) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<EmptyState v-if="!loading && visibleAlerts.length === 0" message="No open alerts" />
|
<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
|
<li
|
||||||
v-for="alert in visibleAlerts"
|
v-for="alert in visibleAlerts"
|
||||||
:id="`alert-row-${alert.id}`"
|
:id="`alert-row-${alert.id}`"
|
||||||
:key="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="py-4 first:pt-0 last:pb-0"
|
||||||
:class="selectedId === alert.id ? 'bg-blue-50/50 dark:bg-blue-950/20' : ''"
|
>
|
||||||
|
<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)"
|
@click="emit('select', alert)"
|
||||||
>
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<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">
|
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
{{ alertTypeLabel(alert.alertType) }}
|
{{ alertTypeLabel(alert.alertType) }}
|
||||||
</span>
|
</span>
|
||||||
@@ -89,6 +91,7 @@ async function resolve(alertId) {
|
|||||||
v-if="alert.status === 'Open' || alert.status === 'Escalated'"
|
v-if="alert.status === 'Open' || alert.status === 'Escalated'"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
:aria-label="`Acknowledge ${alertTypeLabel(alert.alertType)} alert`"
|
||||||
@click.stop="confirmingAlert = alert"
|
@click.stop="confirmingAlert = alert"
|
||||||
>
|
>
|
||||||
Ack
|
Ack
|
||||||
@@ -97,11 +100,13 @@ async function resolve(alertId) {
|
|||||||
v-if="alert.status === 'Acknowledged'"
|
v-if="alert.status === 'Acknowledged'"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
:aria-label="`Resolve ${alertTypeLabel(alert.alertType)} alert`"
|
||||||
@click.stop="resolve(alert.id)"
|
@click.stop="resolve(alert.id)"
|
||||||
>
|
>
|
||||||
Resolve
|
Resolve
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
<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">
|
<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>
|
<span class="sr-only">{{ isPaused ? 'Resume' : 'Pause' }}</span>
|
||||||
{{ isPaused ? '▶' : '⏸' }}
|
{{ isPaused ? '▶' : '⏸' }}
|
||||||
</Button>
|
</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-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full bg-blue-500 transition-all duration-300"
|
class="h-full rounded-full bg-blue-500 transition-all duration-300"
|
||||||
@@ -43,10 +48,13 @@ const speedPresets = [
|
|||||||
<button
|
<button
|
||||||
v-for="preset in speedPresets"
|
v-for="preset in speedPresets"
|
||||||
:key="preset.value"
|
: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
|
:class="speed === preset.value
|
||||||
? 'bg-blue-500 text-white'
|
? 'bg-blue-500 text-white'
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400'"
|
: '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)"
|
@click="emit('set-speed', preset.value)"
|
||||||
>
|
>
|
||||||
{{ preset.label }}
|
{{ preset.label }}
|
||||||
@@ -54,7 +62,7 @@ const speedPresets = [
|
|||||||
</div>
|
</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">
|
<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 →
|
Next Alert →
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ const classes = computed(() => {
|
|||||||
const base =
|
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'
|
'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 = {
|
const sizes = {
|
||||||
sm: 'px-4 py-2 text-xs',
|
sm: 'min-h-11 px-4 py-2 text-xs',
|
||||||
md: 'px-4 py-2 text-sm',
|
md: 'min-h-11 px-4 py-2 text-sm',
|
||||||
lg: 'px-6 py-2 text-base',
|
lg: 'min-h-12 px-6 py-3 text-base',
|
||||||
}
|
}
|
||||||
const variants = {
|
const variants = {
|
||||||
primary:
|
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>
|
<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 emit = defineEmits(['close'])
|
||||||
const modalRef = ref(null)
|
const modalRef = ref(null)
|
||||||
|
const isActive = computed(() => props.open)
|
||||||
|
|
||||||
|
useFocusTrap(modalRef, isActive)
|
||||||
|
|
||||||
function onKeydown(e) {
|
function onKeydown(e) {
|
||||||
if (e.key === 'Escape') emit('close')
|
if (e.key === 'Escape') emit('close')
|
||||||
@@ -11,15 +20,33 @@ function onKeydown(e) {
|
|||||||
|
|
||||||
onMounted(() => document.addEventListener('keydown', onKeydown))
|
onMounted(() => document.addEventListener('keydown', onKeydown))
|
||||||
onBeforeUnmount(() => document.removeEventListener('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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="open" class="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
|
<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
|
||||||
<div ref="modalRef" role="dialog" aria-modal="true"
|
class="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
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">
|
aria-hidden="true"
|
||||||
<h2 v-if="title" class="mb-4 text-lg font-semibold dark:text-white">{{ title }}</h2>
|
@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 />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</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(() =>
|
const vitalsStaleness = computed(() =>
|
||||||
observationStaleness(props.patient.lastObservationAt, props.patient.status),
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article
|
<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="flex items-start justify-between gap-4">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import {
|
|||||||
} from '@/composables/wardFormat'
|
} from '@/composables/wardFormat'
|
||||||
import { formatDepartment } from '@/composables/sepsisFormat'
|
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 riskVariant = computed(() => {
|
||||||
const score = props.patient.news2Score ?? 0
|
const score = props.patient.news2Score ?? 0
|
||||||
@@ -38,10 +43,27 @@ const gcsVariant = computed(() => {
|
|||||||
const vitalsStaleness = computed(() =>
|
const vitalsStaleness = computed(() =>
|
||||||
observationStaleness(props.patient.lastObservationAt, props.patient.status),
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||||
{{ patientRoom(patient) }}
|
{{ patientRoom(patient) }}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import PatientRow from './PatientRow.vue'
|
import PatientRow from './PatientRow.vue'
|
||||||
import PatientCard from './PatientCard.vue'
|
import PatientCard from './PatientCard.vue'
|
||||||
@@ -12,26 +13,50 @@ defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['sort'])
|
const emit = defineEmits(['sort'])
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const focusedRowIndex = ref(-1)
|
||||||
|
|
||||||
function goToPatient(encounterId) {
|
function goToPatient(encounterId) {
|
||||||
router.push({ name: 'PatientDetail', params: { 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Mobile: card list -->
|
<!-- Mobile: card list -->
|
||||||
<div class="space-y-4 md:hidden">
|
<div class="space-y-4 md:hidden" role="list" aria-label="Ward patients">
|
||||||
<PatientCard
|
<PatientCard
|
||||||
v-for="patient in patients"
|
v-for="patient in patients"
|
||||||
:key="patient.encounterId"
|
:key="patient.encounterId"
|
||||||
:patient="patient"
|
:patient="patient"
|
||||||
@click="goToPatient(patient.encounterId)"
|
@activate="goToPatient(patient.encounterId)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop: scrollable table -->
|
<!-- Desktop: scrollable table -->
|
||||||
<div class="hidden overflow-x-auto rounded-lg border border-gray-200 md:block dark:border-gray-700">
|
<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">
|
<thead class="sticky top-0 bg-gray-50 dark:bg-gray-800">
|
||||||
<tr>
|
<tr>
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
@@ -63,10 +88,10 @@ function goToPatient(encounterId) {
|
|||||||
align="right"
|
align="right"
|
||||||
@sort="emit('sort', $event)"
|
@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
|
SOFA
|
||||||
</th>
|
</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
|
GCS
|
||||||
</th>
|
</th>
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
@@ -77,13 +102,13 @@ function goToPatient(encounterId) {
|
|||||||
align="right"
|
align="right"
|
||||||
@sort="emit('sort', $event)"
|
@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
|
Attending
|
||||||
</th>
|
</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
|
LOS
|
||||||
</th>
|
</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
|
Last vitals
|
||||||
</th>
|
</th>
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
@@ -102,13 +127,18 @@ function goToPatient(encounterId) {
|
|||||||
/>
|
/>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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
|
<PatientRow
|
||||||
v-for="patient in patients"
|
v-for="(patient, index) in patients"
|
||||||
:key="patient.encounterId"
|
:key="patient.encounterId"
|
||||||
:patient="patient"
|
:patient="patient"
|
||||||
class="cursor-pointer transition duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
|
:tabindex="focusedRowIndex === index || (focusedRowIndex < 0 && index === 0) ? 0 : -1"
|
||||||
@click="goToPatient(patient.encounterId)"
|
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>
|
</tbody>
|
||||||
</table>
|
</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',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { canAccessOps, defaultRouteForRole, isDashboardRole, roleCanAccessRoute } from '@/composables/roleAccess'
|
||||||
|
|
||||||
|
const CLINICAL = ['NURSE', 'PHYSICIAN', 'ADMIN']
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -16,37 +19,67 @@ const routes = [
|
|||||||
path: '/ward',
|
path: '/ward',
|
||||||
name: 'WardDashboard',
|
name: 'WardDashboard',
|
||||||
component: () => import('@/views/WardDashboard.vue'),
|
component: () => import('@/views/WardDashboard.vue'),
|
||||||
meta: { title: 'Virtual Ward', layout: 'default' },
|
meta: { title: 'Virtual Ward', layout: 'default', allowedRoles: CLINICAL },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/departments',
|
path: '/departments',
|
||||||
name: 'DepartmentOverview',
|
name: 'DepartmentOverview',
|
||||||
component: () => import('@/views/DepartmentOverviewView.vue'),
|
component: () => import('@/views/DepartmentOverviewView.vue'),
|
||||||
meta: { title: 'Department Overview', layout: 'default' },
|
meta: { title: 'Department Overview', layout: 'default', allowedRoles: CLINICAL },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/patients/:encounterId',
|
path: '/patients/:encounterId',
|
||||||
name: 'PatientDetail',
|
name: 'PatientDetail',
|
||||||
component: () => import('@/views/PatientDetail.vue'),
|
component: () => import('@/views/PatientDetail.vue'),
|
||||||
meta: { title: 'Patient Detail', layout: 'default' },
|
meta: { title: 'Patient Detail', layout: 'default', allowedRoles: CLINICAL },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/alerts',
|
path: '/alerts',
|
||||||
name: 'AlertCenter',
|
name: 'AlertCenter',
|
||||||
component: () => import('@/views/AlertCenter.vue'),
|
component: () => import('@/views/AlertCenter.vue'),
|
||||||
meta: { title: 'Alert Center', layout: 'default' },
|
meta: { title: 'Alert Center', layout: 'default', allowedRoles: CLINICAL },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/sepsis',
|
path: '/sepsis',
|
||||||
name: 'SepsisBoard',
|
name: 'SepsisBoard',
|
||||||
component: () => import('@/views/SepsisBoardView.vue'),
|
component: () => import('@/views/SepsisBoardView.vue'),
|
||||||
meta: { title: 'Sepsis Bundle Board', layout: 'default' },
|
meta: { title: 'Sepsis Bundle Board', layout: 'default', allowedRoles: CLINICAL },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/feedback',
|
path: '/feedback',
|
||||||
name: 'FeedbackSummary',
|
name: 'FeedbackSummary',
|
||||||
component: () => import('@/views/FeedbackSummary.vue'),
|
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) {
|
if (!to.meta.public && !auth.isAuthenticated) {
|
||||||
return { path: '/login', query: { redirect: to.fullPath } }
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
if (to.path === '/login' && auth.isAuthenticated) {
|
if (to.path === '/login' && auth.isAuthenticated && isDashboardRole(auth.role)) {
|
||||||
return { path: '/ward' }
|
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) }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
getters: {
|
getters: {
|
||||||
isAuthenticated: (state) => !!state.token,
|
isAuthenticated: (state) => !!state.token,
|
||||||
role: (state) => state.user?.role ?? null,
|
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) =>
|
displayName: (state) =>
|
||||||
state.user?.displayName ?? state.user?.username ?? 'Unknown user',
|
state.user?.displayName ?? state.user?.username ?? 'Unknown user',
|
||||||
userId: (state) => state.user?.userId ?? null,
|
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>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { defaultRouteForRole, isDashboardRole } from '@/composables/roleAccess'
|
||||||
import Button from '@/components/ui/Button.vue'
|
import Button from '@/components/ui/Button.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -18,7 +19,12 @@ async function submit() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await auth.login(username.value, password.value)
|
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)
|
await router.push(redirect)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message ?? 'Login failed'
|
error.value = e.message ?? 'Login failed'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { usePolling } from '@/composables/usePolling'
|
import { usePolling } from '@/composables/usePolling'
|
||||||
import { useReplayControls } from '@/composables/useReplayControls'
|
import { useReplayControls } from '@/composables/useReplayControls'
|
||||||
|
import { useRoleAccess } from '@/composables/roleAccess'
|
||||||
import { useAlertStore } from '@/stores/alerts'
|
import { useAlertStore } from '@/stores/alerts'
|
||||||
import { useScoringStore } from '@/stores/scoring'
|
import { useScoringStore } from '@/stores/scoring'
|
||||||
import * as encountersApi from '@/api/encounters'
|
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 QsofaHistory from '@/components/charts/QsofaHistory.vue'
|
||||||
import SofaHistory from '@/components/charts/SofaHistory.vue'
|
import SofaHistory from '@/components/charts/SofaHistory.vue'
|
||||||
import EncounterTimeline from '@/components/patient/EncounterTimeline.vue'
|
import EncounterTimeline from '@/components/patient/EncounterTimeline.vue'
|
||||||
|
import DischargeSummaryPanel from '@/components/patient/DischargeSummaryPanel.vue'
|
||||||
import ReplayControls from '@/components/replay/ReplayControls.vue'
|
import ReplayControls from '@/components/replay/ReplayControls.vue'
|
||||||
import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
|
import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
|
||||||
|
import CollapsibleSection from '@/components/ui/CollapsibleSection.vue'
|
||||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { nursePatientLayout, physicianPatientLayout } = useRoleAccess()
|
||||||
const alertStore = useAlertStore()
|
const alertStore = useAlertStore()
|
||||||
const scoringStore = useScoringStore()
|
const scoringStore = useScoringStore()
|
||||||
const { alerts } = storeToRefs(alertStore)
|
const { alerts } = storeToRefs(alertStore)
|
||||||
@@ -66,6 +70,8 @@ const openAlerts = computed(() =>
|
|||||||
alerts.value.filter(a => a.status === 'Open' || a.status === 'Escalated'),
|
alerts.value.filter(a => a.status === 'Open' || a.status === 'Escalated'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isDischarged = computed(() => encounter.value?.status === 'Discharged')
|
||||||
|
|
||||||
const replayObservations = computed(() =>
|
const replayObservations = computed(() =>
|
||||||
observations.value.filter(o => isAtOrBefore(o.recordedAt)),
|
observations.value.filter(o => isAtOrBefore(o.recordedAt)),
|
||||||
)
|
)
|
||||||
@@ -198,26 +204,43 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Skeleton v-if="loading && !encounter" :rows="6" />
|
<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">
|
<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"
|
||||||
|
>
|
||||||
← Ward
|
← Ward
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PatientBanner :encounter="encounter" />
|
<PatientBanner :encounter="encounter" />
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<DischargeSummaryPanel
|
||||||
<div class="space-y-4">
|
: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 />
|
<ScoresPanel />
|
||||||
<GcsHistory v-if="replayGcsHistory.length" :history="replayGcsHistory" />
|
<GcsHistory v-if="replayGcsHistory.length" :history="replayGcsHistory" />
|
||||||
</div>
|
</div>
|
||||||
<VitalsPanel
|
<VitalsPanel
|
||||||
|
class="order-2 min-w-0 lg:order-2"
|
||||||
:encounter-id="route.params.encounterId"
|
:encounter-id="route.params.encounterId"
|
||||||
:observations="replayObservations"
|
:observations="replayObservations"
|
||||||
@recorded="loadAll"
|
@recorded="loadAll"
|
||||||
/>
|
/>
|
||||||
<AlertsList
|
<AlertsList
|
||||||
|
class="order-3 min-w-0 lg:order-3"
|
||||||
|
:class="{ 'lg:order-1': nursePatientLayout }"
|
||||||
:encounter-id="route.params.encounterId"
|
:encounter-id="route.params.encounterId"
|
||||||
:selected-id="selectedAlert?.id"
|
:selected-id="selectedAlert?.id"
|
||||||
@select="onSelectAlert"
|
@select="onSelectAlert"
|
||||||
@@ -230,13 +253,15 @@ onBeforeUnmount(() => {
|
|||||||
:medications="medications"
|
:medications="medications"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-4 lg:grid-cols-2">
|
<div class="flex flex-col gap-4 lg:grid lg:grid-cols-2">
|
||||||
<div class="space-y-4">
|
<div class="order-2 space-y-4 lg:order-1">
|
||||||
<SofaScorePanel :encounter-id="route.params.encounterId" />
|
<SofaScorePanel :encounter-id="route.params.encounterId" />
|
||||||
<SofaHistory v-if="replaySofaHistory.length" :history="replaySofaHistory" />
|
<SofaHistory v-if="replaySofaHistory.length" :history="replaySofaHistory" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="order-1 lg:order-2">
|
||||||
<OrdersPanel :orders="orders" />
|
<OrdersPanel :orders="orders" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SepsisBundlePanel
|
<SepsisBundlePanel
|
||||||
:bundle="sepsisBundle"
|
:bundle="sepsisBundle"
|
||||||
@@ -244,9 +269,19 @@ onBeforeUnmount(() => {
|
|||||||
:sofa="sofa"
|
:sofa="sofa"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="hidden lg:block">
|
||||||
<EncounterTimeline :events="replayTimelineEvents" />
|
<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" />
|
<TrendsGrid :observations="replayObservations" :medications="replayMedications" />
|
||||||
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
|
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
|
||||||
<QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" />
|
<QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" />
|
||||||
@@ -262,5 +297,37 @@ onBeforeUnmount(() => {
|
|||||||
@jump-to-alert="jumpToNextAlert"
|
@jump-to-alert="jumpToNextAlert"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
Reference in New Issue
Block a user