feature: Digitization Workstation UI
This commit is contained in:
@@ -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
|
||||
);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user