feature: Digitization Workstation UI

This commit is contained in:
voltsrage
2026-06-27 12:15:43 +08:00
parent 88e70b3dbe
commit e22d33b654
55 changed files with 6411 additions and 143 deletions
@@ -1,9 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// Patient-scoped endpoints for digitization history and audit trail.
/// Patient registry search and digitization history.
/// </summary>
[ApiController]
[Route("api/v1/patients")]
@@ -12,9 +11,27 @@ using Microsoft.AspNetCore.Mvc;
public class PatientsController : ControllerBase
{
private readonly IDigitizationHistoryService _history;
private readonly IPatientRegistryService _patients;
public PatientsController(IDigitizationHistoryService history) =>
public PatientsController(
IDigitizationHistoryService history,
IPatientRegistryService patients)
{
_history = history;
_patients = patients;
}
/// <summary>
/// Searches live patients by MRN or full name (minimum 2 characters).
/// </summary>
[HttpGet("search")]
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<PatientSearchResult>>), StatusCodes.Status200OK)]
public async Task<IActionResult> Search([FromQuery] string q)
{
var results = await _patients.SearchAsync(q ?? string.Empty);
return Ok(ApiResponse<IReadOnlyList<PatientSearchResult>>.Ok(results));
}
/// <summary>
/// Returns the complete digitization history for a patient, including all
@@ -29,4 +46,4 @@ public class PatientsController : ControllerBase
var history = await _history.GetPatientHistoryAsync(patientId);
return Ok(ApiResponse<PatientDigitizationHistoryResponse>.Ok(history));
}
}
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
/// <summary>
/// User directory for batch assignment and operational lookups.
/// </summary>
[ApiController]
[Route("api/v1/users")]
[Produces("application/json")]
[Authorize]
public class UsersController : ControllerBase
{
private readonly IUserDirectoryService _users;
public UsersController(IUserDirectoryService users) => _users = users;
/// <summary>
/// Lists active users, optionally filtered by role.
/// </summary>
[HttpGet]
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<UserSummaryResponse>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List([FromQuery] string? role)
{
UserRole? parsedRole = null;
if (!string.IsNullOrWhiteSpace(role))
parsedRole = UserRoleExtensions.FromDbString(role);
var results = await _users.ListByRoleAsync(parsedRole);
return Ok(ApiResponse<IReadOnlyList<UserSummaryResponse>>.Ok(results));
}
}
@@ -68,4 +68,17 @@ public class WorkQueueController : ControllerBase
var result = await _workQueue.GetClinicalApprovalQueueAsync(page, pageSize);
return Ok(ApiResponse<WorkQueueResponse>.Ok(result));
}
/// <summary>
/// Returns aggregate work queue health metrics for the supervisor dashboard.
/// </summary>
[HttpGet("overview")]
[Authorize(Roles = "ADMINISTRATOR")]
[ProducesResponseType(typeof(ApiResponse<WorkQueueOverviewResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetOverview()
{
var overview = await _workQueue.GetOverviewAsync();
return Ok(ApiResponse<WorkQueueOverviewResponse>.Ok(overview));
}
}
@@ -0,0 +1,6 @@
/// <summary>Patient match returned by GET /api/v1/patients/search.</summary>
public record PatientSearchResult(
Guid Id,
string FullName,
string Mrn
);
@@ -0,0 +1,7 @@
/// <summary>User summary for assignment and directory lookups.</summary>
public record UserSummaryResponse(
Guid Id,
string Username,
string FullName,
string Role
);
@@ -0,0 +1,10 @@
/// <summary>
/// Aggregate work queue health metrics for the supervisor dashboard.
/// Returned by GET /api/v1/work-queue/overview.
/// </summary>
public record WorkQueueOverviewResponse(
Dictionary<string, int> StatusCounts,
double AverageTimeInQueueMinutes,
double RejectRate,
double OldestPendingVerificationMinutes
);
+2
View File
@@ -62,6 +62,8 @@ try
builder.Services.AddScoped<IDocumentStorageService, DocumentStorageService>();
builder.Services.AddScoped<IVerificationService, VerificationService>();
builder.Services.AddScoped<IWorkQueueService, WorkQueueService>();
builder.Services.AddScoped<IPatientRegistryService, PatientRegistryService>();
builder.Services.AddScoped<IUserDirectoryService, UserDirectoryService>();
builder.Services.AddScoped<IPromotionService, PromotionService>();
builder.Services.AddScoped<IIdempotencyService, IdempotencyService>();
builder.Services.AddScoped<IMrnGenerator, MrnGenerator>();
@@ -0,0 +1,4 @@
public interface IPatientRegistryService
{
Task<IReadOnlyList<PatientSearchResult>> SearchAsync(string query, int limit = 20);
}
@@ -0,0 +1,4 @@
public interface IUserDirectoryService
{
Task<IReadOnlyList<UserSummaryResponse>> ListByRoleAsync(UserRole? role);
}
@@ -24,4 +24,9 @@ public interface IWorkQueueService
/// This is the clinical approver's work queue.
/// </summary>
Task<WorkQueueResponse> GetClinicalApprovalQueueAsync(int page, int pageSize);
/// <summary>
/// Returns aggregate work queue health metrics for the supervisor dashboard.
/// </summary>
Task<WorkQueueOverviewResponse> GetOverviewAsync();
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
public class PatientRegistryService : IPatientRegistryService
{
private readonly AppDbContext _db;
public PatientRegistryService(AppDbContext db) => _db = db;
public async Task<IReadOnlyList<PatientSearchResult>> SearchAsync(string query, int limit = 20)
{
var trimmed = query.Trim();
if (trimmed.Length < 2)
return Array.Empty<PatientSearchResult>();
var pattern = $"%{trimmed}%";
return await _db.Patients
.AsNoTracking()
.Where(p =>
EF.Functions.ILike(p.FullName, pattern) ||
EF.Functions.ILike(p.Mrn, pattern))
.OrderBy(p => p.FullName)
.Take(limit)
.Select(p => new PatientSearchResult(p.Id, p.FullName, p.Mrn))
.ToListAsync();
}
}
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore;
public class UserDirectoryService : IUserDirectoryService
{
private readonly AppDbContext _db;
public UserDirectoryService(AppDbContext db) => _db = db;
public async Task<IReadOnlyList<UserSummaryResponse>> ListByRoleAsync(UserRole? role)
{
var query = _db.Users.AsNoTracking().Where(u => u.IsActive);
if (role.HasValue)
query = query.Where(u => u.Role == role.Value);
return await query
.OrderBy(u => u.FullName)
.Select(u => new UserSummaryResponse(
u.Id,
u.Username,
u.FullName,
u.Role.ToDbString()))
.ToListAsync();
}
}
@@ -9,10 +9,12 @@ using Microsoft.EntityFrameworkCore;
public class WorkQueueService : IWorkQueueService
{
private readonly AppDbContext _db;
private readonly ILogger<WorkQueueService> _logger;
public WorkQueueService(AppDbContext db)
public WorkQueueService(AppDbContext db, ILogger<WorkQueueService> logger)
{
_db = db;
_logger = logger;
}
public async Task<WorkQueueResponse> GetVerificationQueueAsync(int page, int pageSize)
@@ -80,4 +82,77 @@ public class WorkQueueService : IWorkQueueService
return new WorkQueueResponse(queueName, items, page, pageSize, totalCount, totalPages);
}
public async Task<WorkQueueOverviewResponse> GetOverviewAsync()
{
var now = DateTimeOffset.UtcNow;
var statusGroups = await _db.DigitizationBatches
.AsNoTracking()
.GroupBy(b => b.Status)
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToListAsync();
var statusCounts = new Dictionary<string, int>();
foreach (var status in Enum.GetValues<BatchStatus>())
{
var count = statusGroups.FirstOrDefault(g => g.Status == status)?.Count ?? 0;
statusCounts[status.ToDbString()] = count;
}
var pendingBatches = await _db.DigitizationBatches
.AsNoTracking()
.Where(b => b.Status == BatchStatus.PendingVerification)
.Select(b => b.UpdatedAt)
.ToListAsync();
double avgTimeInQueueMinutes = 0;
double oldestPendingMinutes = 0;
if (pendingBatches.Count > 0)
{
var ages = pendingBatches
.Select(updatedAt => (now - updatedAt).TotalMinutes)
.ToList();
avgTimeInQueueMinutes = ages.Average();
oldestPendingMinutes = ages.Max();
}
var cutoff = now.AddHours(-24);
var recentEvents = await _db.DigitizationEvents
.AsNoTracking()
.Where(e => e.OccurredAt >= cutoff)
.Where(e => e.EventType == DigitizationEventType.Rejected
|| e.EventType == DigitizationEventType.Verified
|| e.EventType == DigitizationEventType.VerifiedPendingClinical)
.GroupBy(e => e.EventType)
.Select(g => new { EventType = g.Key, Count = g.Count() })
.ToListAsync();
var rejections = recentEvents
.Where(e => e.EventType == DigitizationEventType.Rejected)
.Sum(e => e.Count);
var verifications = recentEvents
.Where(e => e.EventType == DigitizationEventType.Verified
|| e.EventType == DigitizationEventType.VerifiedPendingClinical)
.Sum(e => e.Count);
var totalDecisions = rejections + verifications;
var rejectRate = totalDecisions > 0
? (double)rejections / totalDecisions
: 0.0;
_logger.LogDebug(
"Work queue overview: {PendingCount} pending, avg queue {AvgMinutes:F1}m, reject rate {RejectRate:P1}",
pendingBatches.Count, avgTimeInQueueMinutes, rejectRate);
return new WorkQueueOverviewResponse(
StatusCounts: statusCounts,
AverageTimeInQueueMinutes: Math.Round(avgTimeInQueueMinutes, 1),
RejectRate: Math.Round(rejectRate, 4),
OldestPendingVerificationMinutes: Math.Round(oldestPendingMinutes, 1));
}
}