feature: Digitization Workstation UI
This commit is contained in:
@@ -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