feature: Barcode/QR Cover Sheet System
This commit is contained in:
@@ -0,0 +1,143 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for barcode-assisted batch creation via cover sheet codes.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Database")]
|
||||||
|
public class CoverSheetBatchTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
private HttpClient _intakeClient = null!;
|
||||||
|
|
||||||
|
public CoverSheetBatchTests(ApiFixture fixture) => _fixture = fixture;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await DbResetHelper.ResetAsync(db);
|
||||||
|
await DataSeeder.SeedAsync(db);
|
||||||
|
_intakeClient = await AuthHelper.LoginAsync(_fixture, "intake1");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithCoverSheetCode_CreatesBatchAndRedeemsCoverSheet()
|
||||||
|
{
|
||||||
|
var code = await GenerateCoverSheetCodeAsync("VITALS_SHEET", "BACKFILL");
|
||||||
|
|
||||||
|
var response = await UploadWithCoverSheetAsync(code);
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var batchId = body.GetProperty("data").GetProperty("id").GetGuid();
|
||||||
|
body.GetProperty("data").GetProperty("batchType").GetString()
|
||||||
|
.Should().Be("VITALS_SHEET");
|
||||||
|
body.GetProperty("data").GetProperty("track").GetString()
|
||||||
|
.Should().Be("BACKFILL");
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var sheet = await db.CoverSheets.FirstAsync(c => c.Code == code);
|
||||||
|
sheet.IsUsed.Should().BeTrue();
|
||||||
|
sheet.BatchId.Should().Be(batchId);
|
||||||
|
sheet.UsedAt.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithUsedCoverSheetCode_Returns409()
|
||||||
|
{
|
||||||
|
var code = await GenerateCoverSheetCodeAsync("VITALS_SHEET", "BACKFILL");
|
||||||
|
|
||||||
|
var first = await UploadWithCoverSheetAsync(code);
|
||||||
|
first.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var second = await UploadWithCoverSheetAsync(code);
|
||||||
|
second.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||||||
|
|
||||||
|
var body = await second.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
body.GetProperty("error").GetProperty("code").GetString()
|
||||||
|
.Should().Be("COVER_SHEET_ALREADY_USED");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithUnknownCoverSheetCode_Returns404()
|
||||||
|
{
|
||||||
|
var response = await UploadWithCoverSheetAsync("VCR-CS-DEADBEEF");
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
body.GetProperty("error").GetProperty("code").GetString()
|
||||||
|
.Should().Be("COVER_SHEET_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithCoverSheetPreAssigned_AutoAssignsBatch()
|
||||||
|
{
|
||||||
|
Guid entryUserId;
|
||||||
|
using (var scope = _fixture.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
entryUserId = await BatchSeedHelper.UserIdAsync(db, "entry1");
|
||||||
|
}
|
||||||
|
|
||||||
|
var code = await GenerateCoverSheetCodeAsync(
|
||||||
|
"LAB_RESULTS", "BACKFILL", assignToUserId: entryUserId);
|
||||||
|
|
||||||
|
var response = await UploadWithCoverSheetAsync(code);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
body.GetProperty("data").GetProperty("batchType").GetString()
|
||||||
|
.Should().Be("LAB_RESULTS");
|
||||||
|
body.GetProperty("data").GetProperty("enteredByUserId").GetGuid()
|
||||||
|
.Should().Be(entryUserId);
|
||||||
|
body.GetProperty("data").GetProperty("status").GetString()
|
||||||
|
.Should().Be("IN_ENTRY");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateCoverSheetCodeAsync(
|
||||||
|
string batchType,
|
||||||
|
string track,
|
||||||
|
Guid? assignToUserId = null)
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
count = 1,
|
||||||
|
batchType,
|
||||||
|
track,
|
||||||
|
assignToUserId
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await _intakeClient.PostAsJsonAsync("/api/v1/cover-sheets/generate", payload);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
return body.GetProperty("data")[0].GetProperty("code").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HttpResponseMessage> UploadWithCoverSheetAsync(string coverSheetCode)
|
||||||
|
{
|
||||||
|
var fileContent = new ByteArrayContent(
|
||||||
|
System.Text.Encoding.ASCII.GetBytes(
|
||||||
|
$"%PDF-1.4\n%%EOF\n%test-{Guid.NewGuid()}"));
|
||||||
|
fileContent.Headers.ContentType =
|
||||||
|
new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||||
|
|
||||||
|
var formData = new MultipartFormDataContent
|
||||||
|
{
|
||||||
|
{ fileContent, "file", "test.pdf" },
|
||||||
|
{ new StringContent(coverSheetCode), "coverSheetCode" }
|
||||||
|
};
|
||||||
|
|
||||||
|
return await _intakeClient.PostAsync("/api/v1/digitization-batches", formData);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for cover sheet PDF generation endpoints and the PDF builder.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Database")]
|
||||||
|
public class CoverSheetPdfTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly ApiFixture _fixture;
|
||||||
|
private HttpClient _intakeClient = null!;
|
||||||
|
|
||||||
|
public CoverSheetPdfTests(ApiFixture fixture) => _fixture = fixture;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
await DbResetHelper.ResetAsync(db);
|
||||||
|
await DataSeeder.SeedAsync(db);
|
||||||
|
_intakeClient = await AuthHelper.LoginAsync(_fixture, "intake1");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DisposeAsync() => Task.CompletedTask;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Generate_ProducesValidPdfWithCoverSheetMetadata()
|
||||||
|
{
|
||||||
|
var sheet = new CoverSheet
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = "VCR-CS-A3F7B2D1",
|
||||||
|
BatchType = BatchType.VitalsSheet,
|
||||||
|
Track = BatchTrack.Backfill,
|
||||||
|
CreatedAt = new DateTimeOffset(2026, 6, 27, 12, 0, 0, TimeSpan.Zero),
|
||||||
|
Patient = new Patient
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
FullName = "Maria Garcia",
|
||||||
|
Mrn = "MRN-12345"
|
||||||
|
},
|
||||||
|
AssignToUser = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
FullName = "Entry Clerk One"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var pdf = CoverSheetPdfGenerator.Generate(sheet);
|
||||||
|
|
||||||
|
var text = Encoding.ASCII.GetString(pdf);
|
||||||
|
text.Should().StartWith("%PDF-1.4");
|
||||||
|
text.Should().Contain("VCR-CS-A3F7B2D1");
|
||||||
|
text.Should().Contain("VITALS_SHEET");
|
||||||
|
text.Should().Contain("BACKFILL");
|
||||||
|
text.Should().Contain("Maria Garcia");
|
||||||
|
text.Should().Contain("MRN-12345");
|
||||||
|
text.Should().Contain("Entry Clerk One");
|
||||||
|
text.Should().Contain("2026-06-27");
|
||||||
|
text.Should().Contain("Attach to front of chart section");
|
||||||
|
text.Should().Contain("/Subtype /Image");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateBatch_ProducesMultiPagePdf()
|
||||||
|
{
|
||||||
|
var sheets = new[]
|
||||||
|
{
|
||||||
|
new CoverSheet
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = "VCR-CS-11111111",
|
||||||
|
BatchType = BatchType.VitalsSheet,
|
||||||
|
Track = BatchTrack.Backfill,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
},
|
||||||
|
new CoverSheet
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = "VCR-CS-22222222",
|
||||||
|
BatchType = BatchType.LabResults,
|
||||||
|
Track = BatchTrack.Backfill,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var pdf = CoverSheetPdfGenerator.GenerateBatch(sheets);
|
||||||
|
var text = Encoding.ASCII.GetString(pdf);
|
||||||
|
|
||||||
|
text.Should().Contain("/Count 2");
|
||||||
|
text.Should().Contain("VCR-CS-11111111");
|
||||||
|
text.Should().Contain("VCR-CS-22222222");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GeneratePdf_Endpoint_ReturnsPdfForCoverSheetId()
|
||||||
|
{
|
||||||
|
var sheetId = await GenerateCoverSheetIdAsync();
|
||||||
|
|
||||||
|
var response = await _intakeClient.PostAsync($"/api/v1/cover-sheets/{sheetId}/pdf", null);
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
response.Content.Headers.ContentType!.MediaType.Should().Be("application/pdf");
|
||||||
|
|
||||||
|
var bytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
|
var text = Encoding.ASCII.GetString(bytes);
|
||||||
|
text.Should().StartWith("%PDF-1.4");
|
||||||
|
text.Should().Contain("VCR-CS-");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GenerateBatchPdf_Endpoint_ReturnsMultiPagePdf()
|
||||||
|
{
|
||||||
|
var ids = new List<Guid>
|
||||||
|
{
|
||||||
|
await GenerateCoverSheetIdAsync(),
|
||||||
|
await GenerateCoverSheetIdAsync()
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await _intakeClient.PostAsJsonAsync(
|
||||||
|
"/api/v1/cover-sheets/batch-pdf",
|
||||||
|
new { coverSheetIds = ids });
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
response.Content.Headers.ContentType!.MediaType.Should().Be("application/pdf");
|
||||||
|
|
||||||
|
var text = Encoding.ASCII.GetString(await response.Content.ReadAsByteArrayAsync());
|
||||||
|
text.Should().Contain("/Count 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GeneratePdf_UnknownId_Returns404()
|
||||||
|
{
|
||||||
|
var response = await _intakeClient.PostAsync(
|
||||||
|
$"/api/v1/cover-sheets/{Guid.NewGuid()}/pdf", null);
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Guid> GenerateCoverSheetIdAsync()
|
||||||
|
{
|
||||||
|
var response = await _intakeClient.PostAsJsonAsync(
|
||||||
|
"/api/v1/cover-sheets/generate",
|
||||||
|
new { count = 1, batchType = "VITALS_SHEET", track = "BACKFILL" });
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
return body.GetProperty("data")[0].GetProperty("id").GetGuid();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/cover-sheets")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Authorize]
|
||||||
|
public class CoverSheetController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ICoverSheetService _coverSheets;
|
||||||
|
|
||||||
|
public CoverSheetController(ICoverSheetService coverSheets)
|
||||||
|
{
|
||||||
|
_coverSheets = coverSheets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates one or more cover sheets with unique barcode codes.
|
||||||
|
/// Each cover sheet encodes batch type, track, optional patient, and
|
||||||
|
/// optional entry clerk assignment.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("generate")]
|
||||||
|
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<List<CoverSheetResponse>>), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Generate([FromBody] GenerateCoverSheetsRequest request)
|
||||||
|
{
|
||||||
|
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
var sheets = await _coverSheets.GenerateAsync(request, actorUserId);
|
||||||
|
var response = sheets.Select(MapToResponse).ToList();
|
||||||
|
return StatusCode(201, ApiResponse<List<CoverSheetResponse>>.Created(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a cover sheet by its barcode code. Used during barcode-assisted
|
||||||
|
/// upload to auto-populate batch type, track, patient, and clerk assignment.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("lookup/{code}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<CoverSheetResponse>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Lookup(string code)
|
||||||
|
{
|
||||||
|
var sheet = await _coverSheets.LookupByCodeAsync(code);
|
||||||
|
if (sheet is null)
|
||||||
|
return NotFound(ApiResponse<object>.Fail(404, "Cover sheet not found.", "COVER_SHEET_NOT_FOUND"));
|
||||||
|
|
||||||
|
return Ok(ApiResponse<CoverSheetResponse>.Ok(MapToResponse(sheet)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists cover sheets with optional filters.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<List<CoverSheetResponse>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List(
|
||||||
|
[FromQuery] bool? isUsed,
|
||||||
|
[FromQuery] Guid? patientId,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
var sheets = await _coverSheets.ListAsync(isUsed, patientId, page, pageSize);
|
||||||
|
var response = sheets.Select(MapToResponse).ToList();
|
||||||
|
return Ok(ApiResponse<List<CoverSheetResponse>>.Ok(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a printable PDF containing cover sheets with QR codes.
|
||||||
|
/// Each page has the cover sheet code as a QR code, plus human-readable
|
||||||
|
/// batch type, patient info, and generation date.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{id:guid}/pdf")]
|
||||||
|
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||||
|
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GeneratePdf(Guid id)
|
||||||
|
{
|
||||||
|
var sheet = await _coverSheets.LookupByIdAsync(id);
|
||||||
|
if (sheet is null)
|
||||||
|
return NotFound(ApiResponse<object>.Fail(404, "Cover sheet not found.", "COVER_SHEET_NOT_FOUND"));
|
||||||
|
|
||||||
|
var pdfBytes = CoverSheetPdfGenerator.Generate(sheet);
|
||||||
|
return File(pdfBytes, "application/pdf", $"coversheet-{sheet.Code}.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a batch PDF containing multiple cover sheets (one per page).
|
||||||
|
/// Accepts a list of cover sheet IDs.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("batch-pdf")]
|
||||||
|
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||||
|
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> GenerateBatchPdf([FromBody] BatchPdfRequest request)
|
||||||
|
{
|
||||||
|
var sheets = await _coverSheets.GetByIdsAsync(request.CoverSheetIds);
|
||||||
|
var pdfBytes = CoverSheetPdfGenerator.GenerateBatch(sheets);
|
||||||
|
return File(pdfBytes, "application/pdf", $"coversheets-batch-{DateTime.UtcNow:yyyyMMdd}.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CoverSheetResponse MapToResponse(CoverSheet sheet) => new(
|
||||||
|
Id: sheet.Id,
|
||||||
|
Code: sheet.Code,
|
||||||
|
BatchType: sheet.BatchType.ToDbString(),
|
||||||
|
Track: sheet.Track.ToDbString(),
|
||||||
|
PatientId: sheet.PatientId,
|
||||||
|
PatientName: sheet.Patient?.FullName,
|
||||||
|
PatientMrn: sheet.Patient?.Mrn,
|
||||||
|
AssignToUserId: sheet.AssignToUserId,
|
||||||
|
AssignToUserName: sheet.AssignToUser?.FullName,
|
||||||
|
IsUsed: sheet.IsUsed,
|
||||||
|
BatchId: sheet.BatchId,
|
||||||
|
CreatedAt: sheet.CreatedAt,
|
||||||
|
UsedAt: sheet.UsedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ public class DigitizationBatchesController : ControllerBase
|
|||||||
private readonly IDocumentStorageService _storage;
|
private readonly IDocumentStorageService _storage;
|
||||||
private readonly IPromotionService _promotion;
|
private readonly IPromotionService _promotion;
|
||||||
private readonly IBatchEventService _batchEventService;
|
private readonly IBatchEventService _batchEventService;
|
||||||
|
private readonly ICoverSheetService _coverSheets;
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
private static readonly HashSet<string> _allowedMimeTypes = new()
|
private static readonly HashSet<string> _allowedMimeTypes = new()
|
||||||
@@ -29,13 +30,15 @@ public class DigitizationBatchesController : ControllerBase
|
|||||||
IDocumentStorageService storage,
|
IDocumentStorageService storage,
|
||||||
IPromotionService promotion,
|
IPromotionService promotion,
|
||||||
IBatchEventService batchEventService,
|
IBatchEventService batchEventService,
|
||||||
AppDbContext db)
|
AppDbContext db,
|
||||||
|
ICoverSheetService coverSheets)
|
||||||
{
|
{
|
||||||
_batches = batches;
|
_batches = batches;
|
||||||
_storage = storage;
|
_storage = storage;
|
||||||
_promotion = promotion;
|
_promotion = promotion;
|
||||||
_batchEventService = batchEventService;
|
_batchEventService = batchEventService;
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_coverSheets = coverSheets;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -60,10 +63,40 @@ public class DigitizationBatchesController : ControllerBase
|
|||||||
return BadRequest(ApiResponse<object>.Fail(400,
|
return BadRequest(ApiResponse<object>.Fail(400,
|
||||||
"Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE"));
|
"Accepted formats: PDF, JPEG, PNG.", "INVALID_MIME_TYPE"));
|
||||||
|
|
||||||
var parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant());
|
CoverSheet? coverSheet = null;
|
||||||
var parsedTrack = string.IsNullOrEmpty(form.Track)
|
BatchType parsedBatchType;
|
||||||
|
BatchTrack parsedTrack;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(form.CoverSheetCode))
|
||||||
|
{
|
||||||
|
coverSheet = await _coverSheets.LookupByCodeAsync(form.CoverSheetCode);
|
||||||
|
if (coverSheet is null)
|
||||||
|
return NotFound(ApiResponse<object>.Fail(404,
|
||||||
|
"Cover sheet not found.", "COVER_SHEET_NOT_FOUND"));
|
||||||
|
|
||||||
|
if (coverSheet.IsUsed)
|
||||||
|
return Conflict(ApiResponse<object>.Fail(409,
|
||||||
|
$"Cover sheet {coverSheet.Code} has already been used.",
|
||||||
|
"COVER_SHEET_ALREADY_USED"));
|
||||||
|
|
||||||
|
parsedBatchType = coverSheet.BatchType;
|
||||||
|
parsedTrack = coverSheet.Track;
|
||||||
|
if (coverSheet.PatientId.HasValue)
|
||||||
|
form.PatientId ??= coverSheet.PatientId;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(form.BatchType))
|
||||||
|
return BadRequest(ApiResponse<object>.Fail(400,
|
||||||
|
"BatchType is required when no cover sheet code is provided.",
|
||||||
|
"MISSING_BATCH_TYPE"));
|
||||||
|
|
||||||
|
parsedBatchType = BatchTypeExtensions.FromDbString(form.BatchType.ToUpperInvariant());
|
||||||
|
parsedTrack = string.IsNullOrEmpty(form.Track)
|
||||||
? BatchTrack.Backfill
|
? BatchTrack.Backfill
|
||||||
: BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant());
|
: BatchTrackExtensions.FromDbString(form.Track.ToUpperInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
var actorUserId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||||
|
|
||||||
using var stream = form.File.OpenReadStream();
|
using var stream = form.File.OpenReadStream();
|
||||||
@@ -71,8 +104,17 @@ public class DigitizationBatchesController : ControllerBase
|
|||||||
stream, form.File.ContentType, parsedBatchType, parsedTrack,
|
stream, form.File.ContentType, parsedBatchType, parsedTrack,
|
||||||
form.PatientId, form.SupersedesBatchId, actorUserId);
|
form.PatientId, form.SupersedesBatchId, actorUserId);
|
||||||
|
|
||||||
|
var batch = result.Batch;
|
||||||
|
if (coverSheet is not null)
|
||||||
|
{
|
||||||
|
await _coverSheets.RedeemAsync(coverSheet.Id, batch.Id);
|
||||||
|
|
||||||
|
if (coverSheet.AssignToUserId.HasValue)
|
||||||
|
batch = await _batches.AssignAsync(batch.Id, coverSheet.AssignToUserId.Value, actorUserId);
|
||||||
|
}
|
||||||
|
|
||||||
return StatusCode(201, ApiResponse<BatchDetailResponse>.Created(
|
return StatusCode(201, ApiResponse<BatchDetailResponse>.Created(
|
||||||
BatchDetailResponse.FromEntity(result.Batch, supersession: result.Supersession)));
|
BatchDetailResponse.FromEntity(batch, supersession: result.Supersession)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>();
|
public DbSet<LiveEncounter> LiveEncounters => Set<LiveEncounter>();
|
||||||
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
|
public DbSet<LiveObservation> LiveObservations => Set<LiveObservation>();
|
||||||
public DbSet<PromotionAttempt> PromotionAttempts => Set<PromotionAttempt>();
|
public DbSet<PromotionAttempt> PromotionAttempts => Set<PromotionAttempt>();
|
||||||
|
public DbSet<CoverSheet> CoverSheets => Set<CoverSheet>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
public class CoverSheetConfiguration : IEntityTypeConfiguration<CoverSheet>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<CoverSheet> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("cover_sheets");
|
||||||
|
builder.HasKey(c => c.Id);
|
||||||
|
builder.Property(c => c.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
builder.Property(c => c.Code).HasColumnName("code").HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(c => c.PatientId).HasColumnName("patient_id");
|
||||||
|
builder.Property(c => c.BatchType).HasColumnName("batch_type")
|
||||||
|
.HasConversion(v => v.ToDbString(), v => BatchTypeExtensions.FromDbString(v))
|
||||||
|
.HasMaxLength(30).IsRequired();
|
||||||
|
builder.Property(c => c.Track).HasColumnName("track")
|
||||||
|
.HasConversion(v => v.ToDbString(), v => BatchTrackExtensions.FromDbString(v))
|
||||||
|
.HasMaxLength(20).IsRequired();
|
||||||
|
builder.Property(c => c.AssignToUserId).HasColumnName("assign_to_user_id");
|
||||||
|
builder.Property(c => c.GeneratedByUserId).HasColumnName("generated_by_user_id").IsRequired();
|
||||||
|
builder.Property(c => c.IsUsed).HasColumnName("is_used").HasDefaultValue(false);
|
||||||
|
builder.Property(c => c.BatchId).HasColumnName("batch_id");
|
||||||
|
builder.Property(c => c.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||||
|
builder.Property(c => c.UsedAt).HasColumnName("used_at");
|
||||||
|
|
||||||
|
builder.HasIndex(c => c.Code).IsUnique().HasDatabaseName("ix_cover_sheets_code");
|
||||||
|
builder.HasIndex(c => new { c.IsUsed, c.CreatedAt })
|
||||||
|
.HasFilter("is_used = false")
|
||||||
|
.HasDatabaseName("ix_cover_sheets_unused");
|
||||||
|
|
||||||
|
builder.HasOne(c => c.Patient)
|
||||||
|
.WithMany().HasForeignKey(c => c.PatientId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
builder.HasOne(c => c.AssignToUser)
|
||||||
|
.WithMany().HasForeignKey(c => c.AssignToUserId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
builder.HasOne(c => c.GeneratedByUser)
|
||||||
|
.WithMany().HasForeignKey(c => c.GeneratedByUserId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne(c => c.Batch)
|
||||||
|
.WithMany().HasForeignKey(c => c.BatchId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1572
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace VigilCareRecordsAPI.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCoverSheets : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "cover_sheets",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
code = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
patient_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
batch_type = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||||
|
track = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
assign_to_user_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
generated_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
is_used = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
batch_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
|
||||||
|
used_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_cover_sheets", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_cover_sheets_digitization_batches_batch_id",
|
||||||
|
column: x => x.batch_id,
|
||||||
|
principalTable: "digitization_batches",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_cover_sheets_patients_patient_id",
|
||||||
|
column: x => x.patient_id,
|
||||||
|
principalSchema: "clinical",
|
||||||
|
principalTable: "patients",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_cover_sheets_users_assign_to_user_id",
|
||||||
|
column: x => x.assign_to_user_id,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_cover_sheets_users_generated_by_user_id",
|
||||||
|
column: x => x.generated_by_user_id,
|
||||||
|
principalTable: "users",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_cover_sheets_assign_to_user_id",
|
||||||
|
table: "cover_sheets",
|
||||||
|
column: "assign_to_user_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_cover_sheets_batch_id",
|
||||||
|
table: "cover_sheets",
|
||||||
|
column: "batch_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_cover_sheets_code",
|
||||||
|
table: "cover_sheets",
|
||||||
|
column: "code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_cover_sheets_generated_by_user_id",
|
||||||
|
table: "cover_sheets",
|
||||||
|
column: "generated_by_user_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_cover_sheets_patient_id",
|
||||||
|
table: "cover_sheets",
|
||||||
|
column: "patient_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_cover_sheets_unused",
|
||||||
|
table: "cover_sheets",
|
||||||
|
columns: new[] { "is_used", "created_at" },
|
||||||
|
filter: "is_used = false");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "cover_sheets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -230,6 +230,85 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CoverSheet", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AssignToUserId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("assign_to_user_id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("BatchId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("batch_id");
|
||||||
|
|
||||||
|
b.Property<string>("BatchType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)")
|
||||||
|
.HasColumnName("batch_type");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasColumnName("code");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("NOW()");
|
||||||
|
|
||||||
|
b.Property<Guid>("GeneratedByUserId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("generated_by_user_id");
|
||||||
|
|
||||||
|
b.Property<bool>("IsUsed")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_used");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PatientId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("patient_id");
|
||||||
|
|
||||||
|
b.Property<string>("Track")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasColumnName("track");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("UsedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("used_at");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignToUserId");
|
||||||
|
|
||||||
|
b.HasIndex("BatchId");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_cover_sheets_code");
|
||||||
|
|
||||||
|
b.HasIndex("GeneratedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("PatientId");
|
||||||
|
|
||||||
|
b.HasIndex("IsUsed", "CreatedAt")
|
||||||
|
.HasDatabaseName("ix_cover_sheets_unused")
|
||||||
|
.HasFilter("is_used = false");
|
||||||
|
|
||||||
|
b.ToTable("cover_sheets", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DigitizationBatch", b =>
|
modelBuilder.Entity("DigitizationBatch", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1269,6 +1348,38 @@ namespace VigilCareRecordsAPI.Data.Migrations
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CoverSheet", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("User", "AssignToUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AssignToUserId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("DigitizationBatch", "Batch")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("BatchId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("User", "GeneratedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("GeneratedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Patient", "Patient")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PatientId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("AssignToUser");
|
||||||
|
|
||||||
|
b.Navigation("Batch");
|
||||||
|
|
||||||
|
b.Navigation("GeneratedByUser");
|
||||||
|
|
||||||
|
b.Navigation("Patient");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DigitizationBatch", b =>
|
modelBuilder.Entity("DigitizationBatch", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("User", "ApprovedByUser")
|
b.HasOne("User", "ApprovedByUser")
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
public class CoverSheet
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Code { get; set; } = null!;
|
||||||
|
public Guid? PatientId { get; set; }
|
||||||
|
public BatchType BatchType { get; set; }
|
||||||
|
public BatchTrack Track { get; set; }
|
||||||
|
public Guid? AssignToUserId { get; set; }
|
||||||
|
public Guid GeneratedByUserId { get; set; }
|
||||||
|
public bool IsUsed { get; set; }
|
||||||
|
public Guid? BatchId { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public DateTimeOffset? UsedAt { get; set; }
|
||||||
|
|
||||||
|
public Patient? Patient { get; set; }
|
||||||
|
public User? AssignToUser { get; set; }
|
||||||
|
public User GeneratedByUser { get; set; } = null!;
|
||||||
|
public DigitizationBatch? Batch { get; set; }
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ public class CreateBatchForm
|
|||||||
[Required]
|
[Required]
|
||||||
public IFormFile File { get; set; } = null!;
|
public IFormFile File { get; set; } = null!;
|
||||||
|
|
||||||
[Required]
|
/// <summary>
|
||||||
public string BatchType { get; set; } = null!;
|
/// Required unless <see cref="CoverSheetCode"/> is provided; cover sheet values override when both are sent.
|
||||||
|
/// </summary>
|
||||||
|
public string? BatchType { get; set; }
|
||||||
|
|
||||||
public string? Track { get; set; }
|
public string? Track { get; set; }
|
||||||
|
|
||||||
@@ -15,5 +17,8 @@ public class CreateBatchForm
|
|||||||
public Guid? SupersedesBatchId { get; set; }
|
public Guid? SupersedesBatchId { get; set; }
|
||||||
|
|
||||||
public CreateBatchRequest ToMetadata() =>
|
public CreateBatchRequest ToMetadata() =>
|
||||||
new(BatchType, Track, PatientId, SupersedesBatchId);
|
new(BatchType ?? throw new InvalidOperationException("BatchType is required."),
|
||||||
|
Track, PatientId, SupersedesBatchId);
|
||||||
|
|
||||||
|
public string? CoverSheetCode { get; set; }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
public record BatchPdfRequest(List<Guid> CoverSheetIds);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
public record CoverSheetResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Code,
|
||||||
|
string BatchType,
|
||||||
|
string Track,
|
||||||
|
Guid? PatientId,
|
||||||
|
string? PatientName,
|
||||||
|
string? PatientMrn,
|
||||||
|
Guid? AssignToUserId,
|
||||||
|
string? AssignToUserName,
|
||||||
|
bool IsUsed,
|
||||||
|
Guid? BatchId,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset? UsedAt
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
public record GenerateCoverSheetsRequest(
|
||||||
|
int Count,
|
||||||
|
string BatchType,
|
||||||
|
string Track,
|
||||||
|
Guid? PatientId,
|
||||||
|
Guid? AssignToUserId
|
||||||
|
);
|
||||||
@@ -121,6 +121,7 @@ try
|
|||||||
builder.Services.AddScoped<IAttestationService, AttestationService>();
|
builder.Services.AddScoped<IAttestationService, AttestationService>();
|
||||||
builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>();
|
builder.Services.AddScoped<ILiveCaptureService, LiveCaptureService>();
|
||||||
builder.Services.AddScoped<IBatchEventService, BatchEventService>();
|
builder.Services.AddScoped<IBatchEventService, BatchEventService>();
|
||||||
|
builder.Services.AddScoped<ICoverSheetService, CoverSheetService>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<MetricsCollectorService>();
|
builder.Services.AddHostedService<MetricsCollectorService>();
|
||||||
builder.Services.AddHostedService<PromotionRetryService>();
|
builder.Services.AddHostedService<PromotionRetryService>();
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text;
|
||||||
|
using QRCoder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds printable cover sheet PDFs using raw PDF 1.4 objects and QRCoder for QR images.
|
||||||
|
/// Each page includes a header, QR code, human-readable metadata, and a footer instruction line.
|
||||||
|
/// </summary>
|
||||||
|
public static class CoverSheetPdfGenerator
|
||||||
|
{
|
||||||
|
private const float PageWidth = 612f;
|
||||||
|
private const float PageHeight = 792f;
|
||||||
|
private const float QrDisplaySize = 200f;
|
||||||
|
private const float LeftMargin = 72f;
|
||||||
|
|
||||||
|
public static byte[] Generate(CoverSheet sheet) =>
|
||||||
|
GenerateBatch(new[] { sheet });
|
||||||
|
|
||||||
|
public static byte[] GenerateBatch(IReadOnlyList<CoverSheet> sheets)
|
||||||
|
{
|
||||||
|
if (sheets.Count == 0)
|
||||||
|
throw new ArgumentException("At least one cover sheet is required.", nameof(sheets));
|
||||||
|
|
||||||
|
var pages = sheets.Select(BuildPage).ToList();
|
||||||
|
return BuildPdf(pages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PageData(string ContentStream, byte[] ImageRgb, int ImageWidth, int ImageHeight);
|
||||||
|
|
||||||
|
private static PageData BuildPage(CoverSheet sheet)
|
||||||
|
{
|
||||||
|
var (rgb, width, height) = GenerateQrRgb(sheet.Code);
|
||||||
|
var content = BuildContentStream(sheet);
|
||||||
|
return new PageData(content, rgb, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (byte[] Rgb, int Width, int Height) GenerateQrRgb(string code)
|
||||||
|
{
|
||||||
|
using var generator = new QRCodeGenerator();
|
||||||
|
using var data = generator.CreateQrCode(code, QRCodeGenerator.ECCLevel.M);
|
||||||
|
|
||||||
|
var modules = data.ModuleMatrix.Count;
|
||||||
|
const int scale = 8;
|
||||||
|
const int quiet = 2;
|
||||||
|
var size = (modules + quiet * 2) * scale;
|
||||||
|
var rgb = new byte[size * size * 3];
|
||||||
|
|
||||||
|
for (var y = 0; y < size; y++)
|
||||||
|
{
|
||||||
|
for (var x = 0; x < size; x++)
|
||||||
|
{
|
||||||
|
var mx = x / scale - quiet;
|
||||||
|
var my = y / scale - quiet;
|
||||||
|
var dark = mx >= 0 && my >= 0 && mx < modules && my < modules && data.ModuleMatrix[my][mx];
|
||||||
|
var color = dark ? (byte)0 : (byte)255;
|
||||||
|
var i = (y * size + x) * 3;
|
||||||
|
rgb[i] = color;
|
||||||
|
rgb[i + 1] = color;
|
||||||
|
rgb[i + 2] = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (rgb, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildContentStream(CoverSheet sheet)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var qrX = (PageWidth - QrDisplaySize) / 2f;
|
||||||
|
const float qrY = 470f;
|
||||||
|
|
||||||
|
sb.AppendLine(CultureInfo.InvariantCulture, $"q {QrDisplaySize} 0 0 {QrDisplaySize} {qrX} {qrY} cm /Im1 Do Q");
|
||||||
|
|
||||||
|
WriteTextLine(sb, 18f, LeftMargin, 740f, "VigilCare Records - Cover Sheet");
|
||||||
|
|
||||||
|
var y = 430f;
|
||||||
|
y = WriteTextLine(sb, 12f, LeftMargin, y, $"Code: {sheet.Code}");
|
||||||
|
y = WriteTextLine(sb, 12f, LeftMargin, y, $"Batch Type: {sheet.BatchType.ToDbString()}");
|
||||||
|
y = WriteTextLine(sb, 12f, LeftMargin, y, $"Track: {sheet.Track.ToDbString()}");
|
||||||
|
|
||||||
|
if (sheet.Patient is not null)
|
||||||
|
{
|
||||||
|
y = WriteTextLine(sb, 12f, LeftMargin, y,
|
||||||
|
$"Patient: {sheet.Patient.FullName} (MRN {sheet.Patient.Mrn})");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sheet.AssignToUser is not null)
|
||||||
|
{
|
||||||
|
y = WriteTextLine(sb, 12f, LeftMargin, y,
|
||||||
|
$"Assigned To: {sheet.AssignToUser.FullName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var generated = sheet.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
WriteTextLine(sb, 12f, LeftMargin, y, $"Generated: {generated}");
|
||||||
|
|
||||||
|
WriteTextLine(sb, 10f, LeftMargin, 48f,
|
||||||
|
"Attach to front of chart section. Scanner reads QR code automatically.");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float WriteTextLine(StringBuilder sb, float fontSize, float x, float y, string text)
|
||||||
|
{
|
||||||
|
sb.AppendLine("BT");
|
||||||
|
sb.AppendLine(CultureInfo.InvariantCulture, $"/F1 {fontSize} Tf");
|
||||||
|
sb.AppendLine(CultureInfo.InvariantCulture, $"{x} {y} Td");
|
||||||
|
sb.AppendLine(CultureInfo.InvariantCulture, $"({EscapePdfString(text)}) Tj");
|
||||||
|
sb.AppendLine("ET");
|
||||||
|
return y - fontSize - 8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] BuildPdf(IReadOnlyList<PageData> pages)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
ms.Write("%PDF-1.4\n"u8);
|
||||||
|
|
||||||
|
var offsets = new List<long>();
|
||||||
|
const int catalogId = 1;
|
||||||
|
const int pagesId = 2;
|
||||||
|
const int fontId = 3;
|
||||||
|
var pageIds = Enumerable.Range(0, pages.Count).Select(i => 4 + i * 3).ToArray();
|
||||||
|
var contentIds = pageIds.Select(id => id + 1).ToArray();
|
||||||
|
var imageIds = pageIds.Select(id => id + 2).ToArray();
|
||||||
|
|
||||||
|
offsets.Add(ms.Position);
|
||||||
|
WriteAscii(ms, $"{catalogId} 0 obj\n<< /Type /Catalog /Pages {pagesId} 0 R >>\nendobj\n");
|
||||||
|
|
||||||
|
offsets.Add(ms.Position);
|
||||||
|
var kids = string.Join(" ", pageIds.Select(id => $"{id} 0 R"));
|
||||||
|
WriteAscii(ms, $"{pagesId} 0 obj\n<< /Type /Pages /Kids [{kids}] /Count {pages.Count} >>\nendobj\n");
|
||||||
|
|
||||||
|
offsets.Add(ms.Position);
|
||||||
|
WriteAscii(ms, $"{fontId} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n");
|
||||||
|
|
||||||
|
for (var i = 0; i < pages.Count; i++)
|
||||||
|
{
|
||||||
|
var page = pages[i];
|
||||||
|
var pageId = pageIds[i];
|
||||||
|
var contentId = contentIds[i];
|
||||||
|
var imageId = imageIds[i];
|
||||||
|
var contentBytes = Encoding.ASCII.GetBytes(page.ContentStream);
|
||||||
|
var imageBytes = FlateEncode(page.ImageRgb);
|
||||||
|
|
||||||
|
offsets.Add(ms.Position);
|
||||||
|
WriteAscii(ms,
|
||||||
|
$"{pageId} 0 obj\n" +
|
||||||
|
$"<< /Type /Page /Parent {pagesId} 0 R " +
|
||||||
|
$"/MediaBox [0 0 {PageWidth} {PageHeight}] " +
|
||||||
|
$"/Contents {contentId} 0 R " +
|
||||||
|
$"/Resources << /Font << /F1 {fontId} 0 R >> /XObject << /Im1 {imageId} 0 R >> >> >>\n" +
|
||||||
|
"endobj\n");
|
||||||
|
|
||||||
|
offsets.Add(ms.Position);
|
||||||
|
WriteAscii(ms,
|
||||||
|
$"{contentId} 0 obj\n<< /Length {contentBytes.Length} >>\nstream\n");
|
||||||
|
ms.Write(contentBytes);
|
||||||
|
WriteAscii(ms, "\nendstream\nendobj\n");
|
||||||
|
|
||||||
|
offsets.Add(ms.Position);
|
||||||
|
WriteAscii(ms,
|
||||||
|
$"{imageId} 0 obj\n" +
|
||||||
|
$"<< /Type /XObject /Subtype /Image " +
|
||||||
|
$"/Width {page.ImageWidth} /Height {page.ImageHeight} " +
|
||||||
|
"/ColorSpace /DeviceRGB /BitsPerComponent 8 " +
|
||||||
|
$"/Filter /FlateDecode /Length {imageBytes.Length} >>\nstream\n");
|
||||||
|
ms.Write(imageBytes);
|
||||||
|
WriteAscii(ms, "\nendstream\nendobj\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
var xrefOffset = ms.Position;
|
||||||
|
WriteAscii(ms, "xref\n");
|
||||||
|
WriteAscii(ms, $"0 {offsets.Count + 1}\n");
|
||||||
|
WriteAscii(ms, "0000000000 65535 f \n");
|
||||||
|
foreach (var offset in offsets)
|
||||||
|
WriteAscii(ms, $"{offset:D10} 00000 n \n");
|
||||||
|
|
||||||
|
WriteAscii(ms,
|
||||||
|
"trailer\n" +
|
||||||
|
$"<< /Size {offsets.Count + 1} /Root {catalogId} 0 R >>\n" +
|
||||||
|
"startxref\n" +
|
||||||
|
$"{xrefOffset}\n" +
|
||||||
|
"%%EOF\n");
|
||||||
|
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteAscii(Stream stream, string text) =>
|
||||||
|
stream.Write(Encoding.ASCII.GetBytes(text));
|
||||||
|
|
||||||
|
private static byte[] FlateEncode(byte[] data)
|
||||||
|
{
|
||||||
|
using var output = new MemoryStream();
|
||||||
|
using (var deflate = new ZLibStream(output, CompressionLevel.Optimal, leaveOpen: true))
|
||||||
|
deflate.Write(data, 0, data.Length);
|
||||||
|
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapePdfString(string value) =>
|
||||||
|
value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||||
|
.Replace("(", "\\(", StringComparison.Ordinal)
|
||||||
|
.Replace(")", "\\)", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public class CoverSheetService : ICoverSheetService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ILogger<CoverSheetService> _logger;
|
||||||
|
|
||||||
|
public CoverSheetService(AppDbContext db, ILogger<CoverSheetService> logger)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<CoverSheet>> GenerateAsync(
|
||||||
|
GenerateCoverSheetsRequest request, Guid actorUserId)
|
||||||
|
{
|
||||||
|
var count = Math.Clamp(request.Count, 1, 100);
|
||||||
|
var batchType = BatchTypeExtensions.FromDbString(request.BatchType.ToUpperInvariant());
|
||||||
|
var track = string.IsNullOrEmpty(request.Track)
|
||||||
|
? BatchTrack.Backfill
|
||||||
|
: BatchTrackExtensions.FromDbString(request.Track.ToUpperInvariant());
|
||||||
|
|
||||||
|
if (request.PatientId.HasValue)
|
||||||
|
{
|
||||||
|
var patientExists = await _db.Patients.AnyAsync(p => p.Id == request.PatientId.Value);
|
||||||
|
if (!patientExists)
|
||||||
|
throw new NotFoundException("Patient not found.", "PATIENT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.AssignToUserId.HasValue)
|
||||||
|
{
|
||||||
|
var user = await _db.Users.FindAsync(request.AssignToUserId.Value);
|
||||||
|
if (user is null || !user.IsActive)
|
||||||
|
throw new NotFoundException("User not found or inactive.", "USER_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
var sheets = new List<CoverSheet>(count);
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var code = $"VCR-CS-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||||
|
sheets.Add(new CoverSheet
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = code,
|
||||||
|
BatchType = batchType,
|
||||||
|
Track = track,
|
||||||
|
PatientId = request.PatientId,
|
||||||
|
AssignToUserId = request.AssignToUserId,
|
||||||
|
GeneratedByUserId = actorUserId,
|
||||||
|
IsUsed = false,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.CoverSheets.AddRange(sheets);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Generated {Count} cover sheets for batch type {BatchType}, track {Track}",
|
||||||
|
count, batchType.ToDbString(), track.ToDbString());
|
||||||
|
|
||||||
|
return sheets;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CoverSheet?> LookupByCodeAsync(string code)
|
||||||
|
{
|
||||||
|
return await _db.CoverSheets
|
||||||
|
.Include(c => c.Patient)
|
||||||
|
.Include(c => c.AssignToUser)
|
||||||
|
.FirstOrDefaultAsync(c => c.Code == code.ToUpperInvariant().Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CoverSheet?> LookupByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
return await _db.CoverSheets
|
||||||
|
.Include(c => c.Patient)
|
||||||
|
.Include(c => c.AssignToUser)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<CoverSheet>> GetByIdsAsync(IReadOnlyList<Guid> ids)
|
||||||
|
{
|
||||||
|
if (ids.Count == 0)
|
||||||
|
return new List<CoverSheet>();
|
||||||
|
|
||||||
|
var idSet = ids.Distinct().ToList();
|
||||||
|
var sheets = await _db.CoverSheets
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(c => c.Patient)
|
||||||
|
.Include(c => c.AssignToUser)
|
||||||
|
.Where(c => idSet.Contains(c.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var byId = sheets.ToDictionary(c => c.Id);
|
||||||
|
return idSet
|
||||||
|
.Where(byId.ContainsKey)
|
||||||
|
.Select(id => byId[id])
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RedeemAsync(Guid coverSheetId, Guid batchId)
|
||||||
|
{
|
||||||
|
var sheet = await _db.CoverSheets.FindAsync(coverSheetId);
|
||||||
|
if (sheet is null)
|
||||||
|
throw new NotFoundException("Cover sheet not found.", "COVER_SHEET_NOT_FOUND");
|
||||||
|
|
||||||
|
if (sheet.IsUsed)
|
||||||
|
throw new ConflictException(
|
||||||
|
$"Cover sheet {sheet.Code} has already been used for batch {sheet.BatchId}.",
|
||||||
|
"COVER_SHEET_ALREADY_USED");
|
||||||
|
|
||||||
|
sheet.IsUsed = true;
|
||||||
|
sheet.BatchId = batchId;
|
||||||
|
sheet.UsedAt = DateTimeOffset.UtcNow;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<CoverSheet>> ListAsync(
|
||||||
|
bool? isUsed, Guid? patientId, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var query = _db.CoverSheets
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(c => c.Patient)
|
||||||
|
.Include(c => c.AssignToUser)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (isUsed.HasValue)
|
||||||
|
query = query.Where(c => c.IsUsed == isUsed.Value);
|
||||||
|
if (patientId.HasValue)
|
||||||
|
query = query.Where(c => c.PatientId == patientId.Value);
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
public interface ICoverSheetService
|
||||||
|
{
|
||||||
|
Task<List<CoverSheet>> GenerateAsync(GenerateCoverSheetsRequest request, Guid actorUserId);
|
||||||
|
Task<CoverSheet?> LookupByCodeAsync(string code);
|
||||||
|
Task<CoverSheet?> LookupByIdAsync(Guid id);
|
||||||
|
Task<List<CoverSheet>> GetByIdsAsync(IReadOnlyList<Guid> ids);
|
||||||
|
Task RedeemAsync(Guid coverSheetId, Guid batchId);
|
||||||
|
Task<List<CoverSheet>> ListAsync(bool? isUsed, Guid? patientId, int page, int pageSize);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
||||||
<PackageReference Include="prometheus-net" Version="8.2.1" />
|
<PackageReference Include="prometheus-net" Version="8.2.1" />
|
||||||
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||||
|
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||||
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
|
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
|
||||||
|
|||||||
@@ -667,7 +667,7 @@ Clinical data entry is high-stakes work. Entry clerks need immediate confirmatio
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## P4 — No frontend tests
|
## ~~P4 — No frontend tests~~ DONE
|
||||||
|
|
||||||
### Problem
|
### Problem
|
||||||
|
|
||||||
@@ -816,7 +816,7 @@ A batch stuck in `APPROVED` with exhausted retries is invisible in Prometheus da
|
|||||||
| 19 | EntryForm missing allergy/med fields | P4 | E | Open |
|
| 19 | EntryForm missing allergy/med fields | P4 | E | Open |
|
||||||
| 20 | No corrections/supersession UI | P4 | E | Open |
|
| 20 | No corrections/supersession UI | P4 | E | Open |
|
||||||
| 21 | No toast/notification system | P4 | E | Open |
|
| 21 | No toast/notification system | P4 | E | Open |
|
||||||
| 22 | No frontend tests | P4 | E | Open |
|
| 22 | No frontend tests | P4 | E | Done |
|
||||||
| 23 | No field-level draft audit | P5 | F | Open |
|
| 23 | No field-level draft audit | P5 | F | Open |
|
||||||
| 24 | Integration test gaps | P5 | F | Done |
|
| 24 | Integration test gaps | P5 | F | Done |
|
||||||
| 25 | MetricsCollector missing retry gauges | P5 | F | Done |
|
| 25 | MetricsCollector missing retry gauges | P5 | F | Done |
|
||||||
|
|||||||
+751
@@ -0,0 +1,751 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs Phase 10 verification checks from docs/plans/phase-10-plan.md.
|
||||||
|
#
|
||||||
|
# Covers cover sheet generation, lookup, list filters, PDF endpoints,
|
||||||
|
# barcode-assisted batch upload, redeem/auto-assign, and reuse prevention.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# docker compose up -d (PostgreSQL, Redis, MinIO)
|
||||||
|
# dotnet ef database update --project VigilCareRecordsAPI
|
||||||
|
# dotnet run --project VigilCareRecordsAPI
|
||||||
|
# Phase 1–9 seed data (intake1, entry1, admin1)
|
||||||
|
#
|
||||||
|
# Environment overrides:
|
||||||
|
# VIGILCARE_API_URL default: http://localhost:5217
|
||||||
|
# VIGILCARE_COMPOSE_FILE default: <repo>/docker-compose.yml
|
||||||
|
# VIGILCARE_PG_HOST default: localhost
|
||||||
|
# VIGILCARE_PG_PORT default: 5437
|
||||||
|
# VIGILCARE_PG_DB default: vigilcare_records
|
||||||
|
# VIGILCARE_PG_USER default: postgres
|
||||||
|
# VIGILCARE_PG_PASSWORD default: password
|
||||||
|
# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions
|
||||||
|
# VIGILCARE_SKIP_TEST_CHECKS set to 1 to skip dotnet integration tests
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# chmod +x scripts/run-vigilcare-records-phase-10-verification.sh
|
||||||
|
# ./scripts/run-vigilcare-records-phase-10-verification.sh
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
FIXTURE_PDF="$SCRIPT_DIR/fixtures/test-scan.pdf"
|
||||||
|
|
||||||
|
API_URL="${VIGILCARE_API_URL:-http://localhost:5217}"
|
||||||
|
COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}"
|
||||||
|
COMPOSE=(docker compose -f "$COMPOSE_FILE")
|
||||||
|
PG_HOST="${VIGILCARE_PG_HOST:-localhost}"
|
||||||
|
PG_PORT="${VIGILCARE_PG_PORT:-5437}"
|
||||||
|
PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}"
|
||||||
|
PG_USER="${VIGILCARE_PG_USER:-postgres}"
|
||||||
|
PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}"
|
||||||
|
SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}"
|
||||||
|
SKIP_TEST_CHECKS="${VIGILCARE_SKIP_TEST_CHECKS:-0}"
|
||||||
|
|
||||||
|
# Resolved at runtime from the live API (seed IDs vary per environment)
|
||||||
|
ENTRY_CLERK1_ID=""
|
||||||
|
PATIENT1_ID=""
|
||||||
|
|
||||||
|
PASS_COUNT=0
|
||||||
|
FAIL_COUNT=0
|
||||||
|
FAILED_TESTS=()
|
||||||
|
|
||||||
|
# Shared state populated during the run
|
||||||
|
INTAKE_TOKEN=""
|
||||||
|
ADMIN_TOKEN=""
|
||||||
|
ENTRY_TOKEN=""
|
||||||
|
UPLOAD_CODE=""
|
||||||
|
UPLOAD_COVER_SHEET_ID=""
|
||||||
|
UPLOAD_BATCH_ID=""
|
||||||
|
ASSIGN_CODE=""
|
||||||
|
PDF_SHEET_IDS=()
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '%s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
section() {
|
||||||
|
log ""
|
||||||
|
log "== $1 =="
|
||||||
|
}
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
PASS_COUNT=$((PASS_COUNT + 1))
|
||||||
|
log " PASS: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||||
|
FAILED_TESTS+=("$1")
|
||||||
|
log " FAIL: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
local cmd="$1"
|
||||||
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||||
|
log "ERROR: required command not found: $cmd"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_fixture_pdf() {
|
||||||
|
if [[ -f "$FIXTURE_PDF" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "$FIXTURE_PDF")"
|
||||||
|
cat >"$FIXTURE_PDF" <<'EOF'
|
||||||
|
%PDF-1.0
|
||||||
|
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Resources<<>>>>endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000009 00000 n
|
||||||
|
0000000058 00000 n
|
||||||
|
0000000115 00000 n
|
||||||
|
trailer<</Size 4/Root 1 0 R>>
|
||||||
|
startxref
|
||||||
|
206
|
||||||
|
%%EOF
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
unique_pdf_path() {
|
||||||
|
local suffix="$1"
|
||||||
|
local path="/tmp/vigilcare-p10-${suffix}-${RANDOM}.pdf"
|
||||||
|
printf '%%PDF-1.4\nphase10-%s-%s\n%%%%EOF\n' "$suffix" "$(date +%s%N)" >"$path"
|
||||||
|
printf '%s' "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
http_code() {
|
||||||
|
curl -sS -o /dev/null -w '%{http_code}' "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
json_post() {
|
||||||
|
local url="$1"
|
||||||
|
local body="$2"
|
||||||
|
local token="${3:-}"
|
||||||
|
if [[ -n "$token" ]]; then
|
||||||
|
curl -sS -X POST "$url" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$body"
|
||||||
|
else
|
||||||
|
curl -sS -X POST "$url" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$body"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
json_get() {
|
||||||
|
local url="$1"
|
||||||
|
local token="$2"
|
||||||
|
curl -sS "$url" -H "Authorization: Bearer $token"
|
||||||
|
}
|
||||||
|
|
||||||
|
login() {
|
||||||
|
local username="$1"
|
||||||
|
local password="${2:-password}"
|
||||||
|
json_post "$API_URL/api/v1/auth/login" \
|
||||||
|
"{\"username\":\"$username\",\"password\":\"$password\"}"
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_data_field() {
|
||||||
|
local json="$1"
|
||||||
|
local field="$2"
|
||||||
|
jq -er ".data.$field // empty" <<<"$json"
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_bool_field() {
|
||||||
|
local json="$1"
|
||||||
|
local field="$2"
|
||||||
|
jq -er ".data.$field | if . == null then empty else tostring end" <<<"$json"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_directory_ids() {
|
||||||
|
local users_json patients_json
|
||||||
|
users_json="$(json_get "$API_URL/api/v1/users?role=DATA_ENTRY_CLERK" "$INTAKE_TOKEN")"
|
||||||
|
patients_json="$(json_get "$API_URL/api/v1/patients/search?q=Patient" "$INTAKE_TOKEN")"
|
||||||
|
|
||||||
|
ENTRY_CLERK1_ID="$(jq -er '.data[0].id // empty' <<<"$users_json" 2>/dev/null || true)"
|
||||||
|
PATIENT1_ID="$(jq -er '.data[0].id // empty' <<<"$patients_json" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [[ -n "$ENTRY_CLERK1_ID" ]]; then
|
||||||
|
pass "resolved entry clerk ID for auto-assign test"
|
||||||
|
else
|
||||||
|
fail "resolved entry clerk ID for auto-assign test"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$PATIENT1_ID" ]]; then
|
||||||
|
pass "resolved patient ID for patient-linked cover sheet test"
|
||||||
|
else
|
||||||
|
fail "resolved patient ID for patient-linked cover sheet test"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_error_code() {
|
||||||
|
local json="$1"
|
||||||
|
jq -er '.error.code // empty' <<<"$json" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_api_reachable() {
|
||||||
|
local code
|
||||||
|
code="$(http_code "$API_URL/swagger/index.html" || true)"
|
||||||
|
if [[ "$code" != "200" ]]; then
|
||||||
|
log "ERROR: API not reachable at $API_URL (HTTP $code)."
|
||||||
|
log "Start infrastructure with: docker compose up -d"
|
||||||
|
log "Apply migrations with: dotnet ef database update --project VigilCareRecordsAPI"
|
||||||
|
log "Start API with: dotnet run --project VigilCareRecordsAPI"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_service_running() {
|
||||||
|
local service="$1"
|
||||||
|
"${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service"
|
||||||
|
}
|
||||||
|
|
||||||
|
psql_available() {
|
||||||
|
[[ "$SKIP_DB_CHECKS" == "1" ]] && return 1
|
||||||
|
compose_service_running postgres && return 0
|
||||||
|
command -v psql >/dev/null 2>&1 && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
psql_query() {
|
||||||
|
if [[ "$SKIP_DB_CHECKS" == "1" ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if compose_service_running postgres; then
|
||||||
|
"${COMPOSE[@]}" exec -T postgres \
|
||||||
|
psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
|
||||||
|
elif command -v psql >/dev/null 2>&1; then
|
||||||
|
PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1"
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_with_cover_sheet() {
|
||||||
|
local token="$1"
|
||||||
|
local code="$2"
|
||||||
|
local pdf="$3"
|
||||||
|
curl -sS -X POST "$API_URL/api/v1/digitization-batches" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-F "file=@${pdf};type=application/pdf" \
|
||||||
|
-F "coverSheetCode=$code"
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_with_cover_sheet_status() {
|
||||||
|
local token="$1"
|
||||||
|
local code="$2"
|
||||||
|
local pdf="$3"
|
||||||
|
local body_file http_code_val
|
||||||
|
body_file="$(mktemp)"
|
||||||
|
http_code_val="$(curl -sS -o "$body_file" -w '%{http_code}' -X POST "$API_URL/api/v1/digitization-batches" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-F "file=@${pdf};type=application/pdf" \
|
||||||
|
-F "coverSheetCode=$code")"
|
||||||
|
cat "$body_file"
|
||||||
|
rm -f "$body_file"
|
||||||
|
printf '\n__HTTP_STATUS__:%s' "$http_code_val"
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_cover_sheets() {
|
||||||
|
local token="$1"
|
||||||
|
local body="$2"
|
||||||
|
json_post "$API_URL/api/v1/cover-sheets/generate" "$body" "$token"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_authentication() {
|
||||||
|
section "0. Authentication"
|
||||||
|
|
||||||
|
local intake_json admin_json entry_json
|
||||||
|
intake_json="$(login intake1)"
|
||||||
|
admin_json="$(login admin1)"
|
||||||
|
entry_json="$(login entry1)"
|
||||||
|
|
||||||
|
INTAKE_TOKEN="$(extract_data_field "$intake_json" token)"
|
||||||
|
ADMIN_TOKEN="$(extract_data_field "$admin_json" token)"
|
||||||
|
ENTRY_TOKEN="$(extract_data_field "$entry_json" token)"
|
||||||
|
|
||||||
|
if [[ -z "$INTAKE_TOKEN" ]]; then
|
||||||
|
log "ERROR: intake1 login failed."
|
||||||
|
log "Response: ${intake_json:-<empty>}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$INTAKE_TOKEN" ]]; then
|
||||||
|
pass "intake1 login returns JWT"
|
||||||
|
else
|
||||||
|
fail "intake1 login returns JWT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$ADMIN_TOKEN" ]]; then
|
||||||
|
pass "admin1 login returns JWT"
|
||||||
|
else
|
||||||
|
fail "admin1 login returns JWT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$ENTRY_TOKEN" ]]; then
|
||||||
|
pass "entry1 login returns JWT"
|
||||||
|
else
|
||||||
|
fail "entry1 login returns JWT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
resolve_directory_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
test_database_schema() {
|
||||||
|
section "1. Database — cover_sheets table"
|
||||||
|
|
||||||
|
if ! psql_available; then
|
||||||
|
log " SKIP: PostgreSQL checks (set VIGILCARE_SKIP_DB_CHECKS=0 and start postgres)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local table_exists index_code index_unused
|
||||||
|
table_exists="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'cover_sheets';
|
||||||
|
")"
|
||||||
|
index_code="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE tablename = 'cover_sheets' AND indexname = 'ix_cover_sheets_code';
|
||||||
|
")"
|
||||||
|
index_unused="$(psql_query "
|
||||||
|
SELECT count(*)
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE tablename = 'cover_sheets' AND indexname = 'ix_cover_sheets_unused';
|
||||||
|
")"
|
||||||
|
|
||||||
|
if [[ "$table_exists" == "1" ]]; then
|
||||||
|
pass "cover_sheets table exists"
|
||||||
|
else
|
||||||
|
fail "cover_sheets table exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$index_code" == "1" ]]; then
|
||||||
|
pass "unique index ix_cover_sheets_code exists"
|
||||||
|
else
|
||||||
|
fail "unique index ix_cover_sheets_code exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$index_unused" == "1" ]]; then
|
||||||
|
pass "filtered index ix_cover_sheets_unused exists"
|
||||||
|
else
|
||||||
|
fail "filtered index ix_cover_sheets_unused exists"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_cover_sheet_generation() {
|
||||||
|
section "2. Cover sheet generation (plan §1)"
|
||||||
|
|
||||||
|
local gen_json count unique_count bad_format
|
||||||
|
gen_json="$(generate_cover_sheets "$INTAKE_TOKEN" \
|
||||||
|
'{"count":5,"batchType":"VITALS_SHEET","track":"BACKFILL"}')"
|
||||||
|
|
||||||
|
if [[ "$(jq -er '.success' <<<"$gen_json")" == "true" ]]; then
|
||||||
|
pass "POST /cover-sheets/generate returns success for intake1"
|
||||||
|
else
|
||||||
|
fail "POST /cover-sheets/generate returns success for intake1"
|
||||||
|
log " response: $gen_json"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
count="$(jq '.data | length' <<<"$gen_json")"
|
||||||
|
unique_count="$(jq '[.data[].code] | unique | length' <<<"$gen_json")"
|
||||||
|
if [[ "$count" == "5" && "$unique_count" == "5" ]]; then
|
||||||
|
pass "generate creates 5 cover sheets with unique codes"
|
||||||
|
else
|
||||||
|
fail "generate creates 5 cover sheets with unique codes (count=$count unique=$unique_count)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
bad_format="$(jq -r '[.data[].code | test("^VCR-CS-[0-9A-F]{8}$")] | all' <<<"$gen_json")"
|
||||||
|
if [[ "$bad_format" == "true" ]]; then
|
||||||
|
pass "all codes match VCR-CS-{8-hex} format"
|
||||||
|
else
|
||||||
|
fail "all codes match VCR-CS-{8-hex} format"
|
||||||
|
fi
|
||||||
|
|
||||||
|
UPLOAD_CODE="$(jq -r '.data[0].code' <<<"$gen_json")"
|
||||||
|
UPLOAD_COVER_SHEET_ID="$(jq -r '.data[0].id' <<<"$gen_json")"
|
||||||
|
PDF_SHEET_IDS=($(jq -r '.data[0].id, .data[1].id' <<<"$gen_json"))
|
||||||
|
|
||||||
|
local entry_json entry_token entry_code
|
||||||
|
entry_json="$(login entry1)"
|
||||||
|
entry_token="$(extract_data_field "$entry_json" token)"
|
||||||
|
entry_code="$(http_code -X POST "$API_URL/api/v1/cover-sheets/generate" \
|
||||||
|
-H "Authorization: Bearer $entry_token" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"count":1,"batchType":"VITALS_SHEET","track":"BACKFILL"}')"
|
||||||
|
if [[ "$entry_code" == "403" ]]; then
|
||||||
|
pass "generate denied for DATA_ENTRY_CLERK (403)"
|
||||||
|
else
|
||||||
|
fail "generate denied for DATA_ENTRY_CLERK (403) — got HTTP $entry_code"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local admin_json
|
||||||
|
admin_json="$(generate_cover_sheets "$ADMIN_TOKEN" \
|
||||||
|
'{"count":1,"batchType":"LAB_RESULTS","track":"BACKFILL"}')"
|
||||||
|
if [[ "$(jq -er '.success' <<<"$admin_json")" == "true" ]]; then
|
||||||
|
pass "generate allowed for ADMINISTRATOR"
|
||||||
|
else
|
||||||
|
fail "generate allowed for ADMINISTRATOR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_cover_sheet_lookup_and_list() {
|
||||||
|
section "3. Cover sheet lookup and list (plan §2)"
|
||||||
|
|
||||||
|
if [[ -z "$UPLOAD_CODE" ]]; then
|
||||||
|
fail "lookup requires generated cover sheet code"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local lookup_json is_used batch_type
|
||||||
|
lookup_json="$(json_get "$API_URL/api/v1/cover-sheets/lookup/$UPLOAD_CODE" "$INTAKE_TOKEN")"
|
||||||
|
|
||||||
|
if [[ "$(jq -er '.success' <<<"$lookup_json")" == "true" ]]; then
|
||||||
|
pass "GET /cover-sheets/lookup/{code} returns success"
|
||||||
|
else
|
||||||
|
fail "GET /cover-sheets/lookup/{code} returns success"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
is_used="$(extract_bool_field "$lookup_json" isUsed)"
|
||||||
|
batch_type="$(extract_data_field "$lookup_json" batchType)"
|
||||||
|
if [[ "$is_used" == "false" ]]; then
|
||||||
|
pass "lookup shows isUsed=false before upload"
|
||||||
|
else
|
||||||
|
fail "lookup shows isUsed=false before upload (isUsed=$is_used)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$batch_type" == "VITALS_SHEET" ]]; then
|
||||||
|
pass "lookup returns batchType VITALS_SHEET"
|
||||||
|
else
|
||||||
|
fail "lookup returns batchType VITALS_SHEET (got $batch_type)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local unknown_json unknown_code
|
||||||
|
unknown_json="$(json_get "$API_URL/api/v1/cover-sheets/lookup/VCR-CS-DEADBEEF" "$INTAKE_TOKEN")"
|
||||||
|
unknown_code="$(extract_error_code "$unknown_json")"
|
||||||
|
if [[ "$unknown_code" == "COVER_SHEET_NOT_FOUND" ]]; then
|
||||||
|
pass "lookup unknown code returns COVER_SHEET_NOT_FOUND"
|
||||||
|
else
|
||||||
|
fail "lookup unknown code returns COVER_SHEET_NOT_FOUND (got ${unknown_code:-<none>})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local list_json unused_count
|
||||||
|
list_json="$(json_get "$API_URL/api/v1/cover-sheets?isUsed=false&page=1&pageSize=20" "$INTAKE_TOKEN")"
|
||||||
|
unused_count="$(jq '.data | length' <<<"$list_json")"
|
||||||
|
if [[ "$(jq -er '.success' <<<"$list_json")" == "true" && "$unused_count" -ge 5 ]]; then
|
||||||
|
pass "GET /cover-sheets?isUsed=false lists unused sheets"
|
||||||
|
else
|
||||||
|
fail "GET /cover-sheets?isUsed=false lists unused sheets (count=$unused_count)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_pdf_generation() {
|
||||||
|
section "4. Cover sheet PDF generation (plan Step 5)"
|
||||||
|
|
||||||
|
if [[ -z "$UPLOAD_COVER_SHEET_ID" || ${#PDF_SHEET_IDS[@]} -lt 2 ]]; then
|
||||||
|
fail "PDF tests require generated cover sheet IDs"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pdf_tmp headers_file pdf_code content_type pdf_header
|
||||||
|
pdf_tmp="$(mktemp)"
|
||||||
|
headers_file="$(mktemp)"
|
||||||
|
pdf_code="$(curl -sS -D "$headers_file" -o "$pdf_tmp" -w '%{http_code}' -X POST \
|
||||||
|
"$API_URL/api/v1/cover-sheets/${UPLOAD_COVER_SHEET_ID}/pdf" \
|
||||||
|
-H "Authorization: Bearer $INTAKE_TOKEN")"
|
||||||
|
content_type="$(awk -F': ' 'tolower($1)=="content-type"{print $2}' "$headers_file" | tr -d '\r' | head -1)"
|
||||||
|
rm -f "$headers_file"
|
||||||
|
|
||||||
|
if [[ "$pdf_code" == "200" ]]; then
|
||||||
|
pass "POST /cover-sheets/{id}/pdf returns 200"
|
||||||
|
else
|
||||||
|
fail "POST /cover-sheets/{id}/pdf returns 200 (got HTTP $pdf_code)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$content_type" == application/pdf* ]]; then
|
||||||
|
pass "single PDF response Content-Type is application/pdf"
|
||||||
|
else
|
||||||
|
fail "single PDF response Content-Type is application/pdf (got ${content_type:-<none>})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pdf_header="$(head -c 8 "$pdf_tmp" || true)"
|
||||||
|
if [[ "$pdf_header" == %PDF-1.* ]]; then
|
||||||
|
pass "single PDF body starts with %PDF header"
|
||||||
|
else
|
||||||
|
fail "single PDF body starts with %PDF header"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -aq "$UPLOAD_CODE" "$pdf_tmp" 2>/dev/null; then
|
||||||
|
pass "single PDF embeds cover sheet code text"
|
||||||
|
else
|
||||||
|
fail "single PDF embeds cover sheet code text"
|
||||||
|
fi
|
||||||
|
rm -f "$pdf_tmp"
|
||||||
|
|
||||||
|
local batch_pdf_tmp batch_pdf_code
|
||||||
|
batch_pdf_tmp="$(mktemp)"
|
||||||
|
batch_pdf_code="$(curl -sS -o "$batch_pdf_tmp" -w '%{http_code}' -X POST \
|
||||||
|
"$API_URL/api/v1/cover-sheets/batch-pdf" \
|
||||||
|
-H "Authorization: Bearer $INTAKE_TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"coverSheetIds\":[\"${PDF_SHEET_IDS[0]}\",\"${PDF_SHEET_IDS[1]}\"]}")"
|
||||||
|
|
||||||
|
if [[ "$batch_pdf_code" == "200" ]]; then
|
||||||
|
pass "POST /cover-sheets/batch-pdf returns 200"
|
||||||
|
else
|
||||||
|
fail "POST /cover-sheets/batch-pdf returns 200 (got HTTP $batch_pdf_code)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -aq '/Count 2' "$batch_pdf_tmp" 2>/dev/null || strings "$batch_pdf_tmp" | grep -q '/Count 2'; then
|
||||||
|
pass "batch PDF contains two pages (/Count 2)"
|
||||||
|
else
|
||||||
|
fail "batch PDF contains two pages (/Count 2)"
|
||||||
|
fi
|
||||||
|
rm -f "$batch_pdf_tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
test_barcode_assisted_upload() {
|
||||||
|
section "5. Barcode-assisted upload (plan §3)"
|
||||||
|
|
||||||
|
if [[ -z "$UPLOAD_CODE" ]]; then
|
||||||
|
fail "barcode upload requires generated cover sheet code"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pdf upload_json batch_type track status entered_by
|
||||||
|
pdf="$(unique_pdf_path upload)"
|
||||||
|
upload_json="$(upload_with_cover_sheet "$INTAKE_TOKEN" "$UPLOAD_CODE" "$pdf")"
|
||||||
|
rm -f "$pdf"
|
||||||
|
|
||||||
|
if [[ "$(jq -er '.success' <<<"$upload_json")" == "true" ]]; then
|
||||||
|
pass "POST /digitization-batches with coverSheetCode creates batch"
|
||||||
|
else
|
||||||
|
fail "POST /digitization-batches with coverSheetCode creates batch"
|
||||||
|
log " response: $upload_json"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
batch_type="$(extract_data_field "$upload_json" batchType)"
|
||||||
|
track="$(extract_data_field "$upload_json" track)"
|
||||||
|
status="$(extract_data_field "$upload_json" status)"
|
||||||
|
UPLOAD_BATCH_ID="$(extract_data_field "$upload_json" id)"
|
||||||
|
|
||||||
|
if [[ "$batch_type" == "VITALS_SHEET" && "$track" == "BACKFILL" ]]; then
|
||||||
|
pass "batch inherits batchType and track from cover sheet"
|
||||||
|
else
|
||||||
|
fail "batch inherits batchType and track from cover sheet (type=$batch_type track=$track)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local redeemed_json redeemed_used redeemed_batch_id
|
||||||
|
redeemed_json="$(json_get "$API_URL/api/v1/cover-sheets/lookup/$UPLOAD_CODE" "$INTAKE_TOKEN")"
|
||||||
|
redeemed_used="$(extract_bool_field "$redeemed_json" isUsed)"
|
||||||
|
redeemed_batch_id="$(extract_data_field "$redeemed_json" batchId)"
|
||||||
|
|
||||||
|
if [[ "$redeemed_used" == "true" && "$redeemed_batch_id" == "$UPLOAD_BATCH_ID" ]]; then
|
||||||
|
pass "cover sheet redeemed and linked to batch after upload"
|
||||||
|
else
|
||||||
|
fail "cover sheet redeemed and linked to batch after upload"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if psql_available && [[ -n "$UPLOAD_COVER_SHEET_ID" ]]; then
|
||||||
|
local db_used db_batch
|
||||||
|
db_used="$(psql_query "
|
||||||
|
SELECT is_used FROM cover_sheets WHERE id = '$UPLOAD_COVER_SHEET_ID';
|
||||||
|
")"
|
||||||
|
db_batch="$(psql_query "
|
||||||
|
SELECT batch_id FROM cover_sheets WHERE id = '$UPLOAD_COVER_SHEET_ID';
|
||||||
|
")"
|
||||||
|
if [[ "$db_used" == "t" && "$db_batch" == "$UPLOAD_BATCH_ID" ]]; then
|
||||||
|
pass "database row shows is_used=true with batch_id"
|
||||||
|
else
|
||||||
|
fail "database row shows is_used=true with batch_id"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_cover_sheet_reuse_and_unknown() {
|
||||||
|
section "6. Reuse prevention and unknown code (plan §4)"
|
||||||
|
|
||||||
|
if [[ -z "$UPLOAD_CODE" ]]; then
|
||||||
|
fail "reuse test requires uploaded cover sheet code"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pdf reuse_response reuse_status reuse_code unknown_response unknown_status unknown_code
|
||||||
|
pdf="$(unique_pdf_path reuse)"
|
||||||
|
reuse_response="$(upload_with_cover_sheet_status "$INTAKE_TOKEN" "$UPLOAD_CODE" "$pdf")"
|
||||||
|
rm -f "$pdf"
|
||||||
|
|
||||||
|
reuse_status="${reuse_response##*__HTTP_STATUS__:}"
|
||||||
|
reuse_response="${reuse_response%$'\n'__HTTP_STATUS__:*}"
|
||||||
|
reuse_code="$(extract_error_code "$reuse_response")"
|
||||||
|
|
||||||
|
if [[ "$reuse_status" == "409" && "$reuse_code" == "COVER_SHEET_ALREADY_USED" ]]; then
|
||||||
|
pass "reused cover sheet code returns 409 COVER_SHEET_ALREADY_USED"
|
||||||
|
else
|
||||||
|
fail "reused cover sheet code returns 409 COVER_SHEET_ALREADY_USED (HTTP $reuse_status code=${reuse_code:-<none>})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
pdf="$(unique_pdf_path unknown)"
|
||||||
|
unknown_response="$(upload_with_cover_sheet_status "$INTAKE_TOKEN" "VCR-CS-DEADBEEF" "$pdf")"
|
||||||
|
rm -f "$pdf"
|
||||||
|
|
||||||
|
unknown_status="${unknown_response##*__HTTP_STATUS__:}"
|
||||||
|
unknown_response="${unknown_response%$'\n'__HTTP_STATUS__:*}"
|
||||||
|
unknown_code="$(extract_error_code "$unknown_response")"
|
||||||
|
|
||||||
|
if [[ "$unknown_status" == "404" && "$unknown_code" == "COVER_SHEET_NOT_FOUND" ]]; then
|
||||||
|
pass "unknown cover sheet code on upload returns 404 COVER_SHEET_NOT_FOUND"
|
||||||
|
else
|
||||||
|
fail "unknown cover sheet code on upload returns 404 COVER_SHEET_NOT_FOUND (HTTP $unknown_status code=${unknown_code:-<none>})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_auto_assign_cover_sheet() {
|
||||||
|
section "7. Pre-assigned cover sheet auto-assigns batch"
|
||||||
|
|
||||||
|
if [[ -z "$ENTRY_CLERK1_ID" ]]; then
|
||||||
|
fail "auto-assign test skipped — no entry clerk ID"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local gen_json assign_code pdf upload_json status entered_by
|
||||||
|
gen_json="$(generate_cover_sheets "$INTAKE_TOKEN" \
|
||||||
|
"{\"count\":1,\"batchType\":\"LAB_RESULTS\",\"track\":\"BACKFILL\",\"assignToUserId\":\"$ENTRY_CLERK1_ID\"}")"
|
||||||
|
|
||||||
|
if [[ "$(jq -er '.success' <<<"$gen_json")" != "true" ]]; then
|
||||||
|
fail "generate pre-assigned cover sheet"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
assign_code="$(jq -r '.data[0].code' <<<"$gen_json")"
|
||||||
|
pdf="$(unique_pdf_path assign)"
|
||||||
|
upload_json="$(upload_with_cover_sheet "$INTAKE_TOKEN" "$assign_code" "$pdf")"
|
||||||
|
rm -f "$pdf"
|
||||||
|
|
||||||
|
status="$(extract_data_field "$upload_json" status)"
|
||||||
|
entered_by="$(extract_data_field "$upload_json" enteredByUserId)"
|
||||||
|
|
||||||
|
if [[ "$status" == "IN_ENTRY" ]]; then
|
||||||
|
pass "pre-assigned upload transitions batch to IN_ENTRY"
|
||||||
|
else
|
||||||
|
fail "pre-assigned upload transitions batch to IN_ENTRY (status=$status)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$entered_by" == "$ENTRY_CLERK1_ID" ]]; then
|
||||||
|
pass "pre-assigned upload sets enteredByUserId to entry clerk"
|
||||||
|
else
|
||||||
|
fail "pre-assigned upload sets enteredByUserId to entry clerk (got $entered_by)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_patient_linked_cover_sheet() {
|
||||||
|
section "8. Patient-linked cover sheet populates batch patient"
|
||||||
|
|
||||||
|
if [[ -z "$PATIENT1_ID" ]]; then
|
||||||
|
fail "patient-linked test skipped — no patient ID"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local gen_json patient_code pdf upload_json patient_id
|
||||||
|
gen_json="$(generate_cover_sheets "$INTAKE_TOKEN" \
|
||||||
|
"{\"count\":1,\"batchType\":\"MEDICATION_LIST\",\"track\":\"BACKFILL\",\"patientId\":\"$PATIENT1_ID\"}")"
|
||||||
|
|
||||||
|
if [[ "$(jq -er '.success' <<<"$gen_json")" != "true" ]]; then
|
||||||
|
fail "generate patient-linked cover sheet"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
patient_code="$(jq -r '.data[0].code' <<<"$gen_json")"
|
||||||
|
pdf="$(unique_pdf_path patient)"
|
||||||
|
upload_json="$(upload_with_cover_sheet "$INTAKE_TOKEN" "$patient_code" "$pdf")"
|
||||||
|
rm -f "$pdf"
|
||||||
|
|
||||||
|
patient_id="$(extract_data_field "$upload_json" patientId)"
|
||||||
|
if [[ "$patient_id" == "$PATIENT1_ID" ]]; then
|
||||||
|
pass "patient-linked cover sheet sets batch patientId"
|
||||||
|
else
|
||||||
|
fail "patient-linked cover sheet sets batch patientId (got $patient_id)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
test_integration_tests() {
|
||||||
|
section "9. dotnet integration tests — CoverSheetBatchTests, CoverSheetPdfTests"
|
||||||
|
|
||||||
|
if [[ "$SKIP_TEST_CHECKS" == "1" ]]; then
|
||||||
|
log " SKIP: dotnet integration tests (VIGILCARE_SKIP_TEST_CHECKS=1)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v dotnet >/dev/null 2>&1; then
|
||||||
|
log " SKIP: dotnet not installed"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if dotnet test "$REPO_ROOT/VigilCareRecordsAPI.Tests/VigilCareRecordsAPI.Tests.csproj" \
|
||||||
|
--filter "FullyQualifiedName~CoverSheet" \
|
||||||
|
--no-restore >/tmp/vigilcare-p10-tests.log 2>&1; then
|
||||||
|
pass "CoverSheet integration tests passed"
|
||||||
|
else
|
||||||
|
fail "CoverSheet integration tests passed"
|
||||||
|
log " see /tmp/vigilcare-p10-tests.log"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_manual_ui_checklist() {
|
||||||
|
section "10. Manual Vue UI checks (plan §5)"
|
||||||
|
log " Login as intake1 → /cover-sheets"
|
||||||
|
log " - Generate 5 VITALS_SHEET covers"
|
||||||
|
log " - Print Cover Sheets opens PDF in new tab"
|
||||||
|
log " - Cover sheet list shows unused sheets"
|
||||||
|
log " Navigate to /intake"
|
||||||
|
log " - Scan/type a cover sheet code (Enter triggers lookup)"
|
||||||
|
log " - Lookup auto-fills batch type, track, patient"
|
||||||
|
log " - Upload with Cover Sheet marks sheet as Used with linked batch ID"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
require_cmd curl
|
||||||
|
require_cmd jq
|
||||||
|
ensure_fixture_pdf
|
||||||
|
|
||||||
|
log "VigilCare Records — Phase 10 verification"
|
||||||
|
log "API: $API_URL"
|
||||||
|
|
||||||
|
assert_api_reachable
|
||||||
|
|
||||||
|
test_authentication
|
||||||
|
test_database_schema
|
||||||
|
test_cover_sheet_generation
|
||||||
|
test_cover_sheet_lookup_and_list
|
||||||
|
test_pdf_generation
|
||||||
|
test_barcode_assisted_upload
|
||||||
|
test_cover_sheet_reuse_and_unknown
|
||||||
|
test_auto_assign_cover_sheet
|
||||||
|
test_patient_linked_cover_sheet
|
||||||
|
test_integration_tests
|
||||||
|
print_manual_ui_checklist
|
||||||
|
|
||||||
|
log ""
|
||||||
|
log "Results: $PASS_COUNT passed, $FAIL_COUNT failed"
|
||||||
|
if (( FAIL_COUNT > 0 )); then
|
||||||
|
log "Failed checks:"
|
||||||
|
for item in "${FAILED_TESTS[@]}"; do
|
||||||
|
log " - $item"
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "All Phase 10 API verification checks passed."
|
||||||
|
log "Complete the manual Vue UI checklist above if not already done."
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Generated
+1609
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
@@ -16,14 +18,18 @@
|
|||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jsdom": "^28.0.3",
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "^24.13.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.7",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"@vue/test-utils": "^2.4.11",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"autoprefixer": "^10.5.2",
|
"autoprefixer": "^10.5.2",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vite": "^8.1.0",
|
"vite": "^8.1.0",
|
||||||
|
"vitest": "^4.1.9",
|
||||||
"vue-tsc": "^3.3.5"
|
"vue-tsc": "^3.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import EntryForm from '@/components/EntryForm.vue'
|
||||||
|
import { useBatchStore } from '@/stores/batches'
|
||||||
|
import type { BatchDetailResponse } from '@/types'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
del: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
uploadFile: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
|
||||||
|
return {
|
||||||
|
id: 'b1',
|
||||||
|
status: 'IN_ENTRY',
|
||||||
|
batchType: 'VITALS',
|
||||||
|
track: 'TRACK_A',
|
||||||
|
patientId: null,
|
||||||
|
documentRef: 'docs/scan.pdf',
|
||||||
|
documentUrl: null,
|
||||||
|
enableRetroactiveAlerts: false,
|
||||||
|
enteredByUserId: 'u1',
|
||||||
|
verifiedByUserId: null,
|
||||||
|
approvedByUserId: null,
|
||||||
|
rejectionReason: null,
|
||||||
|
promotedAt: null,
|
||||||
|
promotionEncounterId: null,
|
||||||
|
supersedesBatchId: null,
|
||||||
|
clinicianAttestation: false,
|
||||||
|
createdAt: '2026-06-27T10:00:00Z',
|
||||||
|
updatedAt: '2026-06-27T10:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('EntryForm', () => {
|
||||||
|
it('renders patient demographics fields', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Patient Demographics')
|
||||||
|
expect(wrapper.text()).toContain('Full Name')
|
||||||
|
expect(wrapper.text()).toContain('Date of Birth')
|
||||||
|
expect(wrapper.text()).toContain('Sex')
|
||||||
|
expect(wrapper.text()).toContain('Blood Type')
|
||||||
|
expect(wrapper.text()).toContain('Emergency Contact')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders encounter context fields', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Encounter Context')
|
||||||
|
expect(wrapper.text()).toContain('Admission Date')
|
||||||
|
expect(wrapper.text()).toContain('Department')
|
||||||
|
expect(wrapper.text()).toContain('Room / Bed')
|
||||||
|
expect(wrapper.text()).toContain('Admission Reason')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders observations section with add button', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Observations')
|
||||||
|
expect(wrapper.text()).toContain('+ Add Observation')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders submit button', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
const submitBtn = wrapper.find('button')
|
||||||
|
const buttons = wrapper.findAll('button')
|
||||||
|
const submitButton = buttons.find((b) => b.text().includes('Submit for Verification'))
|
||||||
|
expect(submitButton).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays batch status', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch({ status: 'IN_ENTRY' }), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('IN ENTRY')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('conditional sections by batch type', () => {
|
||||||
|
it('hides allergies section for VITALS batch', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).not.toContain('Allergies')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows allergies section for ALLERGY_UPDATE batch', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Allergies')
|
||||||
|
expect(wrapper.text()).toContain('No known allergies')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides medications section for VITALS batch', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).not.toContain('Medications')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows medications section for MEDICATION_LIST batch', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch({ batchType: 'MEDICATION_LIST' }), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Medications')
|
||||||
|
expect(wrapper.text()).toContain('No active medications')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows both allergies and medications for MIXED batch', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch({ batchType: 'MIXED' }), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Allergies')
|
||||||
|
expect(wrapper.text()).toContain('Medications')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides encounter summary fields for VITALS batch', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).not.toContain('Encounter Status')
|
||||||
|
expect(wrapper.text()).not.toContain('Discharge Diagnosis')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows encounter summary fields for ENCOUNTER_SUMMARY batch', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch({ batchType: 'ENCOUNTER_SUMMARY' }), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Encounter Status')
|
||||||
|
expect(wrapper.text()).toContain('Discharge Diagnosis')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('draft loading', () => {
|
||||||
|
it('populates patient fields from draft', async () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
store.currentDraft = {
|
||||||
|
patient: {
|
||||||
|
id: 'dp1',
|
||||||
|
batchId: 'b1',
|
||||||
|
fullName: 'John Doe',
|
||||||
|
dateOfBirth: '1990-05-15',
|
||||||
|
sex: 'male',
|
||||||
|
bloodType: 'O+',
|
||||||
|
emergencyContact: '555-1234',
|
||||||
|
allergiesJson: null,
|
||||||
|
noKnownAllergies: false,
|
||||||
|
medicationsJson: null,
|
||||||
|
noActiveMedications: false,
|
||||||
|
},
|
||||||
|
encounter: null,
|
||||||
|
observations: [],
|
||||||
|
}
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const nameInput = wrapper.find('input[type="text"]')
|
||||||
|
expect(nameInput.element.value).toBe('John Doe')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('save on blur', () => {
|
||||||
|
it('calls saveDraftPatient on patient field blur', async () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
store.saveDraftPatient = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const nameInput = wrapper.find('input[type="text"]')
|
||||||
|
await nameInput.setValue('Jane Doe')
|
||||||
|
await nameInput.trigger('blur')
|
||||||
|
|
||||||
|
expect(store.saveDraftPatient).toHaveBeenCalledWith(
|
||||||
|
'b1',
|
||||||
|
expect.objectContaining({ fullName: 'Jane Doe' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls saveDraftEncounter on encounter field blur', async () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
store.saveDraftEncounter = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const roomInput = wrapper.findAll('input[type="text"]').find((i) => {
|
||||||
|
const label = i.element.closest('div')?.querySelector('label')
|
||||||
|
return label?.textContent?.includes('Room')
|
||||||
|
})
|
||||||
|
if (roomInput) {
|
||||||
|
await roomInput.setValue('4B-01')
|
||||||
|
await roomInput.trigger('blur')
|
||||||
|
expect(store.saveDraftEncounter).toHaveBeenCalled()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('submit for verification', () => {
|
||||||
|
it('calls submitForVerification on click', async () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
store.submitForVerification = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification'))
|
||||||
|
await submitBtn!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(store.submitForVerification).toHaveBeenCalledWith('b1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays error message on submit failure', async () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
store.submitForVerification = vi.fn().mockRejectedValue(new Error('BATCH_INCOMPLETE'))
|
||||||
|
|
||||||
|
const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification'))
|
||||||
|
await submitBtn!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('BATCH_INCOMPLETE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows submitting state on button', async () => {
|
||||||
|
let resolvePromise: () => void
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
store.submitForVerification = vi.fn().mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolvePromise = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification'))
|
||||||
|
await submitBtn!.trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Submitting...')
|
||||||
|
|
||||||
|
resolvePromise!()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Submit for Verification')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('observations', () => {
|
||||||
|
it('calls addObservation on add button click', async () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
store.addObservation = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const addBtn = wrapper.findAll('button').find((b) => b.text().includes('+ Add Observation'))
|
||||||
|
await addBtn!.trigger('click')
|
||||||
|
|
||||||
|
expect(store.addObservation).toHaveBeenCalledWith('b1', expect.objectContaining({
|
||||||
|
observationCode: '',
|
||||||
|
value: 0,
|
||||||
|
unit: '',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('blood type options', () => {
|
||||||
|
it('renders all 8 blood type options plus empty', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
const selects = wrapper.findAll('select')
|
||||||
|
const bloodTypeSelect = selects.find((s) => {
|
||||||
|
const opts = s.findAll('option')
|
||||||
|
return opts.some((o) => o.text() === 'A+')
|
||||||
|
})
|
||||||
|
expect(bloodTypeSelect).toBeTruthy()
|
||||||
|
const options = bloodTypeSelect!.findAll('option')
|
||||||
|
expect(options.length).toBe(9) // "Unknown" + 8 blood types
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('department options', () => {
|
||||||
|
it('renders department dropdown with clinical departments', () => {
|
||||||
|
const wrapper = mount(EntryForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
const selects = wrapper.findAll('select')
|
||||||
|
const deptSelect = selects.find((s) => {
|
||||||
|
const opts = s.findAll('option')
|
||||||
|
return opts.some((o) => o.text() === 'ICU')
|
||||||
|
})
|
||||||
|
expect(deptSelect).toBeTruthy()
|
||||||
|
const options = deptSelect!.findAll('option')
|
||||||
|
expect(options.length).toBeGreaterThan(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ObservationRow from '@/components/ObservationRow.vue'
|
||||||
|
import type { DraftObservation } from '@/types'
|
||||||
|
|
||||||
|
function makeObservation(overrides: Partial<DraftObservation> = {}): DraftObservation {
|
||||||
|
return {
|
||||||
|
id: 'obs-1',
|
||||||
|
batchId: 'b1',
|
||||||
|
observationCode: 'HEART_RATE',
|
||||||
|
value: 72,
|
||||||
|
unit: 'bpm',
|
||||||
|
recordedAt: '2026-06-27T10:00:00Z',
|
||||||
|
note: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ObservationRow', () => {
|
||||||
|
it('renders observation code options', () => {
|
||||||
|
const wrapper = mount(ObservationRow, {
|
||||||
|
props: { observation: makeObservation() },
|
||||||
|
})
|
||||||
|
const options = wrapper.findAll('select option')
|
||||||
|
const values = options.map((o) => o.element.value)
|
||||||
|
expect(values).toContain('HEART_RATE')
|
||||||
|
expect(values).toContain('TEMP_C')
|
||||||
|
expect(values).toContain('BP_SYSTOLIC')
|
||||||
|
expect(values).toContain('SPO2')
|
||||||
|
expect(values).toContain('POTASSIUM_MEQ_L')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders observation values in inputs', () => {
|
||||||
|
const wrapper = mount(ObservationRow, {
|
||||||
|
props: { observation: makeObservation({ value: 98.6, unit: '°F' }) },
|
||||||
|
})
|
||||||
|
const numberInput = wrapper.find('input[type="number"]')
|
||||||
|
expect(numberInput.element.value).toBe('98.6')
|
||||||
|
|
||||||
|
const textInputs = wrapper.findAll('input[type="text"]')
|
||||||
|
const unitInput = textInputs[0]
|
||||||
|
expect(unitInput.element.value).toBe('°F')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update event on code change', async () => {
|
||||||
|
const wrapper = mount(ObservationRow, {
|
||||||
|
props: { observation: makeObservation() },
|
||||||
|
})
|
||||||
|
const select = wrapper.find('select')
|
||||||
|
await select.setValue('TEMP_C')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update')).toBeTruthy()
|
||||||
|
expect(wrapper.emitted('update')![0]).toEqual(['observationCode', 'TEMP_C'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update event on value change', async () => {
|
||||||
|
const wrapper = mount(ObservationRow, {
|
||||||
|
props: { observation: makeObservation() },
|
||||||
|
})
|
||||||
|
const numberInput = wrapper.find('input[type="number"]')
|
||||||
|
await numberInput.setValue('80')
|
||||||
|
await numberInput.trigger('change')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update')).toBeTruthy()
|
||||||
|
const emitted = wrapper.emitted('update')![0]
|
||||||
|
expect(emitted[0]).toBe('value')
|
||||||
|
expect(emitted[1]).toBe(80)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows delete button in entry mode (not readonly)', () => {
|
||||||
|
const wrapper = mount(ObservationRow, {
|
||||||
|
props: { observation: makeObservation() },
|
||||||
|
})
|
||||||
|
const deleteBtn = wrapper.find('button')
|
||||||
|
expect(deleteBtn.exists()).toBe(true)
|
||||||
|
expect(deleteBtn.text()).toBe('Remove')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits delete event on remove click', async () => {
|
||||||
|
const wrapper = mount(ObservationRow, {
|
||||||
|
props: { observation: makeObservation({ id: 'obs-42' }) },
|
||||||
|
})
|
||||||
|
await wrapper.find('button').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('delete')).toBeTruthy()
|
||||||
|
expect(wrapper.emitted('delete')![0]).toEqual(['obs-42'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables inputs in readonly mode', () => {
|
||||||
|
const wrapper = mount(ObservationRow, {
|
||||||
|
props: { observation: makeObservation(), readonly: true },
|
||||||
|
})
|
||||||
|
const select = wrapper.find('select')
|
||||||
|
expect(select.element.disabled).toBe(true)
|
||||||
|
|
||||||
|
const numberInput = wrapper.find('input[type="number"]')
|
||||||
|
expect(numberInput.element.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides delete button in readonly mode', () => {
|
||||||
|
const wrapper = mount(ObservationRow, {
|
||||||
|
props: { observation: makeObservation(), readonly: true },
|
||||||
|
})
|
||||||
|
const button = wrapper.find('button')
|
||||||
|
expect(button.exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows verification checkbox when showVerified is true', () => {
|
||||||
|
const wrapper = mount(ObservationRow, {
|
||||||
|
props: {
|
||||||
|
observation: makeObservation(),
|
||||||
|
readonly: true,
|
||||||
|
showVerified: true,
|
||||||
|
verified: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||||
|
expect(checkbox.exists()).toBe(true)
|
||||||
|
expect(checkbox.element.checked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reflects verified state in checkbox', () => {
|
||||||
|
const wrapper = mount(ObservationRow, {
|
||||||
|
props: {
|
||||||
|
observation: makeObservation(),
|
||||||
|
readonly: true,
|
||||||
|
showVerified: true,
|
||||||
|
verified: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||||
|
expect(checkbox.element.checked).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits verify event when checkbox is toggled', async () => {
|
||||||
|
const wrapper = mount(ObservationRow, {
|
||||||
|
props: {
|
||||||
|
observation: makeObservation({ id: 'obs-99' }),
|
||||||
|
readonly: true,
|
||||||
|
showVerified: true,
|
||||||
|
verified: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||||
|
await checkbox.setValue(true)
|
||||||
|
|
||||||
|
expect(wrapper.emitted('verify')).toBeTruthy()
|
||||||
|
expect(wrapper.emitted('verify')![0]).toEqual(['obs-99', true])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides verification checkbox when showVerified is false', () => {
|
||||||
|
const wrapper = mount(ObservationRow, {
|
||||||
|
props: { observation: makeObservation(), showVerified: false },
|
||||||
|
})
|
||||||
|
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||||
|
expect(checkbox.exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import PatientSearch from '@/components/PatientSearch.vue'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { get } from '@/api/client'
|
||||||
|
|
||||||
|
const mockedGet = vi.mocked(get)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PatientSearch', () => {
|
||||||
|
it('renders a search input', () => {
|
||||||
|
const wrapper = mount(PatientSearch)
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
expect(input.exists()).toBe(true)
|
||||||
|
expect(input.attributes('placeholder')).toContain('Search')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not search when query is less than 2 characters', async () => {
|
||||||
|
const wrapper = mount(PatientSearch)
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
|
await input.setValue('J')
|
||||||
|
await input.trigger('input')
|
||||||
|
vi.advanceTimersByTime(400)
|
||||||
|
|
||||||
|
expect(mockedGet).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('searches after debounce when query is 2+ characters', async () => {
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(PatientSearch)
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
|
await input.setValue('Jane')
|
||||||
|
await input.trigger('input')
|
||||||
|
|
||||||
|
expect(mockedGet).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
expect(mockedGet).toHaveBeenCalledWith('patients/search', { q: 'Jane' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows results dropdown after search', async () => {
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: [
|
||||||
|
{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' },
|
||||||
|
{ id: 'p2', fullName: 'Jane Smith', mrn: 'MRN-002' },
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(PatientSearch)
|
||||||
|
await wrapper.find('input').setValue('Jane')
|
||||||
|
await wrapper.find('input').trigger('input')
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const items = wrapper.findAll('li')
|
||||||
|
expect(items.length).toBe(2)
|
||||||
|
expect(items[0].text()).toContain('Jane Doe')
|
||||||
|
expect(items[0].text()).toContain('MRN-001')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits update:modelValue on patient selection', async () => {
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(PatientSearch)
|
||||||
|
await wrapper.find('input').setValue('Jane')
|
||||||
|
await wrapper.find('input').trigger('input')
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
await wrapper.find('li').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||||
|
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['p1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows selected patient name after selection', async () => {
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(PatientSearch)
|
||||||
|
await wrapper.find('input').setValue('Jane')
|
||||||
|
await wrapper.find('input').trigger('input')
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
await wrapper.find('li').trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Selected: Jane Doe')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears results list on patient selection', async () => {
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(PatientSearch)
|
||||||
|
await wrapper.find('input').setValue('Jane')
|
||||||
|
await wrapper.find('input').trigger('input')
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
await wrapper.find('li').trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('li').length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets input value to patient name on selection', async () => {
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(PatientSearch)
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
await input.setValue('Jane')
|
||||||
|
await input.trigger('input')
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
await wrapper.find('li').trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(input.element.value).toBe('Jane Doe')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears results on API error', async () => {
|
||||||
|
mockedGet.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
const wrapper = mount(PatientSearch)
|
||||||
|
await wrapper.find('input').setValue('Jane')
|
||||||
|
await wrapper.find('input').trigger('input')
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('li').length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('debounces multiple rapid inputs', async () => {
|
||||||
|
mockedGet.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: [],
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(PatientSearch)
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
|
await input.setValue('Ja')
|
||||||
|
await input.trigger('input')
|
||||||
|
vi.advanceTimersByTime(100)
|
||||||
|
|
||||||
|
await input.setValue('Jan')
|
||||||
|
await input.trigger('input')
|
||||||
|
vi.advanceTimersByTime(100)
|
||||||
|
|
||||||
|
await input.setValue('Jane')
|
||||||
|
await input.trigger('input')
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
await vi.runAllTimersAsync()
|
||||||
|
|
||||||
|
expect(mockedGet).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockedGet).toHaveBeenCalledWith('patients/search', { q: 'Jane' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import VerificationForm from '@/components/VerificationForm.vue'
|
||||||
|
import { useBatchStore } from '@/stores/batches'
|
||||||
|
import type { BatchDetailResponse } from '@/types'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
del: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
uploadFile: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
|
||||||
|
return {
|
||||||
|
id: 'b1',
|
||||||
|
status: 'PENDING_VERIFICATION',
|
||||||
|
batchType: 'VITALS',
|
||||||
|
track: 'TRACK_A',
|
||||||
|
patientId: 'p1',
|
||||||
|
documentRef: 'docs/scan.pdf',
|
||||||
|
documentUrl: null,
|
||||||
|
enableRetroactiveAlerts: false,
|
||||||
|
enteredByUserId: 'u1',
|
||||||
|
verifiedByUserId: null,
|
||||||
|
approvedByUserId: null,
|
||||||
|
rejectionReason: null,
|
||||||
|
promotedAt: null,
|
||||||
|
promotionEncounterId: null,
|
||||||
|
supersedesBatchId: null,
|
||||||
|
clinicianAttestation: false,
|
||||||
|
createdAt: '2026-06-27T10:00:00Z',
|
||||||
|
updatedAt: '2026-06-27T10:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountWithDraft(batchOverrides: Partial<BatchDetailResponse> = {}) {
|
||||||
|
const wrapper = mount(VerificationForm, {
|
||||||
|
props: { batch: makeBatch(batchOverrides), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
store.currentDraft = {
|
||||||
|
patient: {
|
||||||
|
id: 'dp1',
|
||||||
|
batchId: 'b1',
|
||||||
|
fullName: 'Jane Doe',
|
||||||
|
dateOfBirth: '1990-05-15',
|
||||||
|
sex: 'female',
|
||||||
|
bloodType: 'A+',
|
||||||
|
emergencyContact: '555-1234',
|
||||||
|
allergiesJson: null,
|
||||||
|
noKnownAllergies: false,
|
||||||
|
medicationsJson: null,
|
||||||
|
noActiveMedications: false,
|
||||||
|
},
|
||||||
|
encounter: {
|
||||||
|
id: 'de1',
|
||||||
|
batchId: 'b1',
|
||||||
|
admissionDate: '2026-06-20T08:00:00',
|
||||||
|
department: 'ICU',
|
||||||
|
roomBed: '3A-12',
|
||||||
|
admissionReason: 'Chest pain',
|
||||||
|
dischargeDiagnosis: null,
|
||||||
|
status: null,
|
||||||
|
},
|
||||||
|
observations: [
|
||||||
|
{
|
||||||
|
id: 'obs-1',
|
||||||
|
batchId: 'b1',
|
||||||
|
observationCode: 'HEART_RATE',
|
||||||
|
value: 72,
|
||||||
|
unit: 'bpm',
|
||||||
|
recordedAt: '2026-06-27T10:00:00Z',
|
||||||
|
note: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'obs-2',
|
||||||
|
batchId: 'b1',
|
||||||
|
observationCode: 'TEMP_C',
|
||||||
|
value: 37.2,
|
||||||
|
unit: 'C',
|
||||||
|
recordedAt: '2026-06-27T10:00:00Z',
|
||||||
|
note: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return { wrapper, store }
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('VerificationForm', () => {
|
||||||
|
it('renders verification header', () => {
|
||||||
|
const wrapper = mount(VerificationForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Verification Review')
|
||||||
|
expect(wrapper.text()).toContain('Pending Verification')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders patient fields with checkboxes after draft loads', async () => {
|
||||||
|
const { wrapper } = mountWithDraft()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Patient Demographics')
|
||||||
|
expect(wrapper.text()).toContain('Jane Doe')
|
||||||
|
expect(wrapper.text()).toContain('1990-05-15')
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
expect(checkboxes.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders encounter fields after draft loads', async () => {
|
||||||
|
const { wrapper } = mountWithDraft()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Encounter Context')
|
||||||
|
expect(wrapper.text()).toContain('ICU')
|
||||||
|
expect(wrapper.text()).toContain('3A-12')
|
||||||
|
expect(wrapper.text()).toContain('Chest pain')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows rejection reason banner when present', () => {
|
||||||
|
const wrapper = mount(VerificationForm, {
|
||||||
|
props: {
|
||||||
|
batch: makeBatch({ rejectionReason: 'Temperature seems incorrect' }),
|
||||||
|
batchId: 'b1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('Previous Rejection Reason')
|
||||||
|
expect(wrapper.text()).toContain('Temperature seems incorrect')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show rejection banner when no reason', () => {
|
||||||
|
const wrapper = mount(VerificationForm, {
|
||||||
|
props: { batch: makeBatch({ rejectionReason: null }), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).not.toContain('Previous Rejection Reason')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('field check progress', () => {
|
||||||
|
it('shows 0/N checked initially', async () => {
|
||||||
|
const { wrapper } = mountWithDraft()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Fields verified:')
|
||||||
|
expect(wrapper.text()).toMatch(/0\s*\/\s*\d+/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates count when checkboxes are toggled', async () => {
|
||||||
|
const { wrapper } = mountWithDraft()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
await checkboxes[0].setValue(true)
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toMatch(/1\s*\/\s*\d+/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('approve button', () => {
|
||||||
|
it('is disabled when not all fields are checked', async () => {
|
||||||
|
const { wrapper } = mountWithDraft()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||||
|
expect(approveBtn!.element.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is enabled when all fields are checked', async () => {
|
||||||
|
const { wrapper } = mountWithDraft()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
for (const cb of checkboxes) {
|
||||||
|
await cb.setValue(true)
|
||||||
|
}
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||||
|
expect(approveBtn!.element.disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls verifyBatch with all field checks on approve', async () => {
|
||||||
|
const { wrapper, store } = mountWithDraft()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
store.verifyBatch = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
for (const cb of checkboxes) {
|
||||||
|
await cb.setValue(true)
|
||||||
|
}
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||||
|
await approveBtn!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(store.verifyBatch).toHaveBeenCalledWith(
|
||||||
|
'b1',
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ passed: true }),
|
||||||
|
]),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reject flow', () => {
|
||||||
|
it('shows reject dialog when reject button is clicked', async () => {
|
||||||
|
const wrapper = mount(VerificationForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||||
|
await rejectBtn!.trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Reject Batch')
|
||||||
|
expect(wrapper.text()).toContain('Confirm Rejection')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables confirm button when reason is empty', async () => {
|
||||||
|
const wrapper = mount(VerificationForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||||
|
await rejectBtn!.trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
|
||||||
|
expect(confirmBtn!.element.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enables confirm button when reason is entered', async () => {
|
||||||
|
const wrapper = mount(VerificationForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||||
|
await rejectBtn!.trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const textarea = wrapper.find('textarea')
|
||||||
|
await textarea.setValue('Temperature value appears incorrect')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
|
||||||
|
expect(confirmBtn!.element.disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls rejectBatch on confirm', async () => {
|
||||||
|
const wrapper = mount(VerificationForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
store.rejectBatch = vi.fn().mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||||
|
await rejectBtn!.trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const textarea = wrapper.find('textarea')
|
||||||
|
await textarea.setValue('Value incorrect')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
|
||||||
|
await confirmBtn!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Value incorrect')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes reject dialog on cancel', async () => {
|
||||||
|
const wrapper = mount(VerificationForm, {
|
||||||
|
props: { batch: makeBatch(), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||||
|
await rejectBtn!.trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Reject Batch')
|
||||||
|
|
||||||
|
const cancelBtn = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
|
||||||
|
await cancelBtn!.trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('Reject Batch')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('observations display', () => {
|
||||||
|
it('shows observation count in legend', async () => {
|
||||||
|
const { wrapper } = mountWithDraft()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Observations (2)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('allergy and medication verification', () => {
|
||||||
|
it('shows allergy fields for ALLERGY_UPDATE batch', async () => {
|
||||||
|
const { wrapper } = mountWithDraft({ batchType: 'ALLERGY_UPDATE' })
|
||||||
|
const store = useBatchStore()
|
||||||
|
store.currentDraft!.patient!.allergiesJson = JSON.stringify(['Penicillin', 'Latex'])
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
// Re-trigger the watcher by resetting draft
|
||||||
|
const draft = { ...store.currentDraft! }
|
||||||
|
store.currentDraft = null
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
store.currentDraft = draft
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Allergies')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows NKA for noKnownAllergies', async () => {
|
||||||
|
const wrapper = mount(VerificationForm, {
|
||||||
|
props: { batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), batchId: 'b1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
store.currentDraft = {
|
||||||
|
patient: {
|
||||||
|
id: 'dp1',
|
||||||
|
batchId: 'b1',
|
||||||
|
fullName: 'Jane',
|
||||||
|
dateOfBirth: '1990-01-01',
|
||||||
|
sex: 'female',
|
||||||
|
bloodType: null,
|
||||||
|
emergencyContact: null,
|
||||||
|
allergiesJson: null,
|
||||||
|
noKnownAllergies: true,
|
||||||
|
medicationsJson: null,
|
||||||
|
noActiveMedications: false,
|
||||||
|
},
|
||||||
|
encounter: {
|
||||||
|
id: 'de1',
|
||||||
|
batchId: 'b1',
|
||||||
|
admissionDate: null,
|
||||||
|
department: null,
|
||||||
|
roomBed: null,
|
||||||
|
admissionReason: null,
|
||||||
|
dischargeDiagnosis: null,
|
||||||
|
status: null,
|
||||||
|
},
|
||||||
|
observations: [],
|
||||||
|
}
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('No Known Allergies')
|
||||||
|
expect(wrapper.text()).toContain('Yes (NKA)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('shows error message on approve failure', async () => {
|
||||||
|
const { wrapper, store } = mountWithDraft()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
store.verifyBatch = vi.fn().mockRejectedValue(new Error('Server error'))
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
for (const cb of checkboxes) {
|
||||||
|
await cb.setValue(true)
|
||||||
|
}
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||||
|
await approveBtn!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Server error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
|
||||||
|
import { useAuthStore, getDefaultRouteForRole } from '@/stores/auth'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
post: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function buildRoute(path: string, meta: Record<string, unknown> = {}): RouteLocationNormalized {
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
meta,
|
||||||
|
name: undefined,
|
||||||
|
params: {},
|
||||||
|
query: {},
|
||||||
|
hash: '',
|
||||||
|
fullPath: path,
|
||||||
|
matched: [],
|
||||||
|
redirectedFrom: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGuard() {
|
||||||
|
const nextCalls: (string | undefined)[] = []
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
function runGuard(to: RouteLocationNormalized, from?: RouteLocationNormalized) {
|
||||||
|
const _from = from ?? buildRoute('/')
|
||||||
|
const next = vi.fn((dest?: string) => {
|
||||||
|
nextCalls.push(dest)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (to.path === '/login' && auth.isAuthenticated) {
|
||||||
|
next(getDefaultRouteForRole(auth.userRole))
|
||||||
|
return { next, nextCalls }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||||
|
next('/login')
|
||||||
|
return { next, nextCalls }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.roles && Array.isArray(to.meta.roles)) {
|
||||||
|
const allowedRoles = to.meta.roles as string[]
|
||||||
|
if (!allowedRoles.includes(auth.userRole)) {
|
||||||
|
const fallback = getDefaultRouteForRole(auth.userRole)
|
||||||
|
if (fallback !== '/login' && fallback !== to.path) {
|
||||||
|
next(fallback)
|
||||||
|
return { next, nextCalls }
|
||||||
|
}
|
||||||
|
next('/login')
|
||||||
|
return { next, nextCalls }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
return { next, nextCalls }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { auth, runGuard }
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('router navigation guard', () => {
|
||||||
|
describe('unauthenticated users', () => {
|
||||||
|
it('allows access to /login', () => {
|
||||||
|
const { runGuard } = setupGuard()
|
||||||
|
const { next } = runGuard(buildRoute('/login', { requiresAuth: false }))
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects to /login for protected routes', () => {
|
||||||
|
const { runGuard } = setupGuard()
|
||||||
|
const { next } = runGuard(buildRoute('/entry', { requiresAuth: true, roles: ['DATA_ENTRY_CLERK'] }))
|
||||||
|
expect(next).toHaveBeenCalledWith('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects to /login for dashboard', () => {
|
||||||
|
const { runGuard } = setupGuard()
|
||||||
|
const { next } = runGuard(buildRoute('/dashboard', { requiresAuth: true, roles: ['ADMINISTRATOR'] }))
|
||||||
|
expect(next).toHaveBeenCalledWith('/login')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('authenticated users', () => {
|
||||||
|
function authenticatedGuard(role: string) {
|
||||||
|
const { auth, runGuard } = setupGuard()
|
||||||
|
auth.token = 'valid-token'
|
||||||
|
auth.user = { id: 'u1', username: 'test', fullName: 'Test User', role }
|
||||||
|
return { auth, runGuard }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('redirects authenticated user away from /login to default route', () => {
|
||||||
|
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||||
|
const { next } = runGuard(buildRoute('/login', { requiresAuth: false }))
|
||||||
|
expect(next).toHaveBeenCalledWith('/entry')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects admin from /login to /dashboard', () => {
|
||||||
|
const { runGuard } = authenticatedGuard('ADMINISTRATOR')
|
||||||
|
const { next } = runGuard(buildRoute('/login', { requiresAuth: false }))
|
||||||
|
expect(next).toHaveBeenCalledWith('/dashboard')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows access to routes matching user role', () => {
|
||||||
|
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||||
|
const { next } = runGuard(buildRoute('/entry', { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] }))
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows admin access to any role-restricted route', () => {
|
||||||
|
const { runGuard } = authenticatedGuard('ADMINISTRATOR')
|
||||||
|
const routeMetas = [
|
||||||
|
{ requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
||||||
|
{ requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] },
|
||||||
|
{ requiresAuth: true, roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'] },
|
||||||
|
{ requiresAuth: true, roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'] },
|
||||||
|
{ requiresAuth: true, roles: ['CLINICIAN', 'ADMINISTRATOR'] },
|
||||||
|
{ requiresAuth: true, roles: ['ADMINISTRATOR'] },
|
||||||
|
]
|
||||||
|
for (const meta of routeMetas) {
|
||||||
|
const { next } = runGuard(buildRoute('/test', meta))
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows INTAKE_CLERK access to cover sheets route', () => {
|
||||||
|
const { runGuard } = authenticatedGuard('INTAKE_CLERK')
|
||||||
|
const { next } = runGuard(buildRoute('/cover-sheets', {
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ['INTAKE_CLERK', 'ADMINISTRATOR'],
|
||||||
|
}))
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects DATA_ENTRY_CLERK from cover sheets to /entry', () => {
|
||||||
|
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||||
|
const { next } = runGuard(buildRoute('/cover-sheets', {
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ['INTAKE_CLERK', 'ADMINISTRATOR'],
|
||||||
|
}))
|
||||||
|
expect(next).toHaveBeenCalledWith('/entry')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects DATA_ENTRY_CLERK from /intake to /entry', () => {
|
||||||
|
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||||
|
const { next } = runGuard(buildRoute('/intake', { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] }))
|
||||||
|
expect(next).toHaveBeenCalledWith('/entry')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects INTAKE_CLERK from /verification to /intake', () => {
|
||||||
|
const { runGuard } = authenticatedGuard('INTAKE_CLERK')
|
||||||
|
const { next } = runGuard(buildRoute('/verification', {
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||||
|
}))
|
||||||
|
expect(next).toHaveBeenCalledWith('/intake')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects CLINICIAN from /dashboard to /live-capture', () => {
|
||||||
|
const { runGuard } = authenticatedGuard('CLINICIAN')
|
||||||
|
const { next } = runGuard(buildRoute('/dashboard', { requiresAuth: true, roles: ['ADMINISTRATOR'] }))
|
||||||
|
expect(next).toHaveBeenCalledWith('/live-capture')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows access to routes with no role restriction', () => {
|
||||||
|
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||||
|
const { next } = runGuard(buildRoute('/patients', { requiresAuth: true }))
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('VERIFIER can access verification routes', () => {
|
||||||
|
const { runGuard } = authenticatedGuard('VERIFIER')
|
||||||
|
const { next } = runGuard(buildRoute('/verification', {
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||||
|
}))
|
||||||
|
expect(next).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('CLINICAL_APPROVER can access both verification and approval routes', () => {
|
||||||
|
const { runGuard } = authenticatedGuard('CLINICAL_APPROVER')
|
||||||
|
|
||||||
|
const { next: verifyNext } = runGuard(buildRoute('/verification', {
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||||
|
}))
|
||||||
|
expect(verifyNext).toHaveBeenCalledWith()
|
||||||
|
|
||||||
|
const { next: approvalNext } = runGuard(buildRoute('/approval', {
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||||
|
}))
|
||||||
|
expect(approvalNext).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
const localStorageData: Record<string, string> = {}
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
value: {
|
||||||
|
getItem: vi.fn((key: string) => localStorageData[key] ?? null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => {
|
||||||
|
localStorageData[key] = value
|
||||||
|
}),
|
||||||
|
removeItem: vi.fn((key: string) => {
|
||||||
|
delete localStorageData[key]
|
||||||
|
}),
|
||||||
|
clear: vi.fn(() => {
|
||||||
|
Object.keys(localStorageData).forEach((k) => delete localStorageData[k])
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useAuthStore, getDefaultRouteForRole } from '@/stores/auth'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
post: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/router', () => ({
|
||||||
|
default: { push: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { post, get } from '@/api/client'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
const mockedPost = vi.mocked(post)
|
||||||
|
const mockedGet = vi.mocked(get)
|
||||||
|
const mockedRouterPush = vi.mocked(router.push)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDefaultRouteForRole', () => {
|
||||||
|
it.each([
|
||||||
|
['INTAKE_CLERK', '/intake'],
|
||||||
|
['DATA_ENTRY_CLERK', '/entry'],
|
||||||
|
['VERIFIER', '/verification'],
|
||||||
|
['CLINICAL_APPROVER', '/approval'],
|
||||||
|
['CLINICIAN', '/live-capture'],
|
||||||
|
['ADMINISTRATOR', '/dashboard'],
|
||||||
|
['UNKNOWN_ROLE', '/login'],
|
||||||
|
['', '/login'],
|
||||||
|
])('returns %s for role %s', (role, expected) => {
|
||||||
|
expect(getDefaultRouteForRole(role)).toBe(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useAuthStore', () => {
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('starts unauthenticated when localStorage is empty', () => {
|
||||||
|
const store = useAuthStore()
|
||||||
|
expect(store.isAuthenticated).toBe(false)
|
||||||
|
expect(store.user).toBeNull()
|
||||||
|
expect(store.userRole).toBe('')
|
||||||
|
expect(store.userId).toBe('')
|
||||||
|
expect(store.userFullName).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrates from localStorage on creation', () => {
|
||||||
|
localStorage.setItem('vigilcare_token', 'stored-token')
|
||||||
|
localStorage.setItem('vigilcare_refresh_token', 'stored-refresh')
|
||||||
|
localStorage.setItem(
|
||||||
|
'vigilcare_user',
|
||||||
|
JSON.stringify({ id: 'u1', username: 'clerk1', fullName: 'Clerk One', role: 'DATA_ENTRY_CLERK' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const store = useAuthStore()
|
||||||
|
|
||||||
|
expect(store.isAuthenticated).toBe(true)
|
||||||
|
expect(store.userRole).toBe('DATA_ENTRY_CLERK')
|
||||||
|
expect(store.userId).toBe('u1')
|
||||||
|
expect(store.userFullName).toBe('Clerk One')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('role-based permissions', () => {
|
||||||
|
function storeWithRole(role: string) {
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.user = { id: 'u1', username: 'test', fullName: 'Test', role }
|
||||||
|
store.token = 'tok'
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
it('INTAKE_CLERK can intake only', () => {
|
||||||
|
const store = storeWithRole('INTAKE_CLERK')
|
||||||
|
expect(store.canIntake).toBe(true)
|
||||||
|
expect(store.canEntry).toBe(false)
|
||||||
|
expect(store.canVerify).toBe(false)
|
||||||
|
expect(store.canApprove).toBe(false)
|
||||||
|
expect(store.canLiveCapture).toBe(false)
|
||||||
|
expect(store.canSupervise).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DATA_ENTRY_CLERK can entry only', () => {
|
||||||
|
const store = storeWithRole('DATA_ENTRY_CLERK')
|
||||||
|
expect(store.canEntry).toBe(true)
|
||||||
|
expect(store.canIntake).toBe(false)
|
||||||
|
expect(store.canVerify).toBe(false)
|
||||||
|
expect(store.canApprove).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('VERIFIER can verify only', () => {
|
||||||
|
const store = storeWithRole('VERIFIER')
|
||||||
|
expect(store.canVerify).toBe(true)
|
||||||
|
expect(store.canIntake).toBe(false)
|
||||||
|
expect(store.canEntry).toBe(false)
|
||||||
|
expect(store.canApprove).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('CLINICAL_APPROVER can verify and approve', () => {
|
||||||
|
const store = storeWithRole('CLINICAL_APPROVER')
|
||||||
|
expect(store.canVerify).toBe(true)
|
||||||
|
expect(store.canApprove).toBe(true)
|
||||||
|
expect(store.canIntake).toBe(false)
|
||||||
|
expect(store.canEntry).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('CLINICIAN can live capture only', () => {
|
||||||
|
const store = storeWithRole('CLINICIAN')
|
||||||
|
expect(store.canLiveCapture).toBe(true)
|
||||||
|
expect(store.canIntake).toBe(false)
|
||||||
|
expect(store.canEntry).toBe(false)
|
||||||
|
expect(store.canVerify).toBe(false)
|
||||||
|
expect(store.canSupervise).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ADMINISTRATOR has all permissions', () => {
|
||||||
|
const store = storeWithRole('ADMINISTRATOR')
|
||||||
|
expect(store.canIntake).toBe(true)
|
||||||
|
expect(store.canEntry).toBe(true)
|
||||||
|
expect(store.canVerify).toBe(true)
|
||||||
|
expect(store.canApprove).toBe(true)
|
||||||
|
expect(store.canLiveCapture).toBe(true)
|
||||||
|
expect(store.canSupervise).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('sets token, user, and localStorage on successful login', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: {
|
||||||
|
token: 'jwt-tok',
|
||||||
|
refreshToken: 'ref-tok',
|
||||||
|
userId: 'u-123',
|
||||||
|
username: 'entry1',
|
||||||
|
fullName: 'Entry Clerk',
|
||||||
|
role: 'DATA_ENTRY_CLERK',
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
await store.login({ username: 'entry1', password: 'password' })
|
||||||
|
|
||||||
|
expect(store.isAuthenticated).toBe(true)
|
||||||
|
expect(store.token).toBe('jwt-tok')
|
||||||
|
expect(store.refreshToken).toBe('ref-tok')
|
||||||
|
expect(store.user).toEqual({
|
||||||
|
id: 'u-123',
|
||||||
|
username: 'entry1',
|
||||||
|
fullName: 'Entry Clerk',
|
||||||
|
role: 'DATA_ENTRY_CLERK',
|
||||||
|
})
|
||||||
|
expect(localStorage.setItem).toHaveBeenCalledWith('vigilcare_token', 'jwt-tok')
|
||||||
|
expect(localStorage.setItem).toHaveBeenCalledWith('vigilcare_refresh_token', 'ref-tok')
|
||||||
|
expect(mockedRouterPush).toHaveBeenCalledWith('/entry')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates to role-specific default route', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: {
|
||||||
|
token: 't',
|
||||||
|
refreshToken: 'r',
|
||||||
|
userId: 'u1',
|
||||||
|
username: 'admin',
|
||||||
|
fullName: 'Admin',
|
||||||
|
role: 'ADMINISTRATOR',
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
await store.login({ username: 'admin', password: 'password' })
|
||||||
|
|
||||||
|
expect(mockedRouterPush).toHaveBeenCalledWith('/dashboard')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on failed login', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({
|
||||||
|
success: false,
|
||||||
|
statusCode: 401,
|
||||||
|
data: null,
|
||||||
|
error: { message: 'Invalid credentials', code: 'AUTH_FAILED' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
await expect(store.login({ username: 'bad', password: 'wrong' })).rejects.toThrow(
|
||||||
|
'Invalid credentials',
|
||||||
|
)
|
||||||
|
expect(store.isAuthenticated).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws generic message when error has no message', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({
|
||||||
|
success: false,
|
||||||
|
statusCode: 500,
|
||||||
|
data: null,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
await expect(store.login({ username: 'x', password: 'y' })).rejects.toThrow('Login failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchCurrentUser', () => {
|
||||||
|
it('updates user from /auth/me response', async () => {
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: { id: 'u-99', username: 'verifier1', fullName: 'Verifier One', role: 'VERIFIER' },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
await store.fetchCurrentUser()
|
||||||
|
|
||||||
|
expect(store.user).toEqual({
|
||||||
|
id: 'u-99',
|
||||||
|
username: 'verifier1',
|
||||||
|
fullName: 'Verifier One',
|
||||||
|
role: 'VERIFIER',
|
||||||
|
})
|
||||||
|
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||||
|
'vigilcare_user',
|
||||||
|
JSON.stringify(store.user),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not update user on failed response', async () => {
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: false,
|
||||||
|
statusCode: 401,
|
||||||
|
data: null,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.user = { id: 'old', username: 'old', fullName: 'Old', role: 'VERIFIER' }
|
||||||
|
await store.fetchCurrentUser()
|
||||||
|
|
||||||
|
expect(store.user?.id).toBe('old')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('logout', () => {
|
||||||
|
it('clears state, localStorage, and navigates to /login', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.token = 'tok'
|
||||||
|
store.refreshToken = 'ref'
|
||||||
|
store.user = { id: 'u1', username: 'x', fullName: 'X', role: 'VERIFIER' }
|
||||||
|
|
||||||
|
await store.logout()
|
||||||
|
|
||||||
|
expect(store.token).toBeNull()
|
||||||
|
expect(store.refreshToken).toBeNull()
|
||||||
|
expect(store.user).toBeNull()
|
||||||
|
expect(store.isAuthenticated).toBe(false)
|
||||||
|
expect(localStorage.removeItem).toHaveBeenCalledWith('vigilcare_token')
|
||||||
|
expect(localStorage.removeItem).toHaveBeenCalledWith('vigilcare_refresh_token')
|
||||||
|
expect(localStorage.removeItem).toHaveBeenCalledWith('vigilcare_user')
|
||||||
|
expect(mockedRouterPush).toHaveBeenCalledWith('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('posts refresh token to server on logout', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.refreshToken = 'my-refresh-tok'
|
||||||
|
|
||||||
|
await store.logout()
|
||||||
|
|
||||||
|
expect(mockedPost).toHaveBeenCalledWith('auth/logout', { refreshToken: 'my-refresh-tok' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still clears local session if server revoke fails', async () => {
|
||||||
|
mockedPost.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.token = 'tok'
|
||||||
|
store.refreshToken = 'ref'
|
||||||
|
|
||||||
|
await store.logout()
|
||||||
|
|
||||||
|
expect(store.token).toBeNull()
|
||||||
|
expect(store.isAuthenticated).toBe(false)
|
||||||
|
expect(mockedRouterPush).toHaveBeenCalledWith('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips server call when no refresh token', async () => {
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.token = 'tok'
|
||||||
|
store.refreshToken = null
|
||||||
|
|
||||||
|
await store.logout()
|
||||||
|
|
||||||
|
expect(mockedPost).not.toHaveBeenCalled()
|
||||||
|
expect(store.token).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useBatchStore } from '@/stores/batches'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
del: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
uploadFile: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { get, post, put, del, patch, uploadFile } from '@/api/client'
|
||||||
|
|
||||||
|
const mockedGet = vi.mocked(get)
|
||||||
|
const mockedPost = vi.mocked(post)
|
||||||
|
const mockedPut = vi.mocked(put)
|
||||||
|
const mockedDel = vi.mocked(del)
|
||||||
|
const mockedPatch = vi.mocked(patch)
|
||||||
|
const mockedUploadFile = vi.mocked(uploadFile)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useBatchStore', () => {
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('has expected defaults', () => {
|
||||||
|
const store = useBatchStore()
|
||||||
|
expect(store.batches).toEqual([])
|
||||||
|
expect(store.currentBatch).toBeNull()
|
||||||
|
expect(store.currentDraft).toBeNull()
|
||||||
|
expect(store.totalCount).toBe(0)
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
expect(store.error).toBeNull()
|
||||||
|
expect(store.documentUrl).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listBatches', () => {
|
||||||
|
it('populates batches and totalCount on success', async () => {
|
||||||
|
const items = [
|
||||||
|
{ id: 'b1', status: 'UPLOADED', batchType: 'VITALS', track: 'TRACK_A' },
|
||||||
|
{ id: 'b2', status: 'IN_ENTRY', batchType: 'LABS', track: 'TRACK_A' },
|
||||||
|
]
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: { items, totalCount: 42, page: 1, pageSize: 20, totalPages: 3 },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.listBatches({ status: 'UPLOADED' })
|
||||||
|
|
||||||
|
expect(store.batches).toEqual(items)
|
||||||
|
expect(store.totalCount).toBe(42)
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
expect(store.error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets error on failure', async () => {
|
||||||
|
mockedGet.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.listBatches({})
|
||||||
|
|
||||||
|
expect(store.error).toBe('Network error')
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets generic error for non-Error exceptions', async () => {
|
||||||
|
mockedGet.mockRejectedValueOnce('string error')
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.listBatches({})
|
||||||
|
|
||||||
|
expect(store.error).toBe('Failed to load batches')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets loading=true during request', async () => {
|
||||||
|
let resolvePromise: (v: unknown) => void
|
||||||
|
mockedGet.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolvePromise = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
const promise = store.listBatches({})
|
||||||
|
|
||||||
|
expect(store.loading).toBe(true)
|
||||||
|
|
||||||
|
resolvePromise!({ success: true, statusCode: 200, data: { items: [], totalCount: 0 }, error: null })
|
||||||
|
await promise
|
||||||
|
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getBatch', () => {
|
||||||
|
it('sets currentBatch on success', async () => {
|
||||||
|
const batch = { id: 'b1', status: 'UPLOADED', batchType: 'VITALS', documentUrl: 'https://example.com/doc' }
|
||||||
|
mockedGet.mockResolvedValueOnce({ success: true, statusCode: 200, data: batch, error: null })
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.getBatch('b1')
|
||||||
|
|
||||||
|
expect(store.currentBatch).toEqual(batch)
|
||||||
|
expect(store.documentUrl).toBe('https://example.com/doc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets error on failure', async () => {
|
||||||
|
mockedGet.mockRejectedValueOnce(new Error('Not found'))
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.getBatch('bad-id')
|
||||||
|
|
||||||
|
expect(store.error).toBe('Not found')
|
||||||
|
expect(store.currentBatch).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('uploadBatch', () => {
|
||||||
|
it('returns batch data on success', async () => {
|
||||||
|
const batch = { id: 'new-batch', status: 'UPLOADED' }
|
||||||
|
mockedUploadFile.mockResolvedValueOnce({ success: true, statusCode: 201, data: batch, error: null })
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
const file = new File(['content'], 'scan.pdf', { type: 'application/pdf' })
|
||||||
|
const result = await store.uploadBatch(file, 'VITALS', 'TRACK_A')
|
||||||
|
|
||||||
|
expect(result).toEqual(batch)
|
||||||
|
expect(mockedUploadFile).toHaveBeenCalledWith('digitization-batches', file, {
|
||||||
|
batchType: 'VITALS',
|
||||||
|
track: 'TRACK_A',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes optional patientId and supersedesBatchId', async () => {
|
||||||
|
mockedUploadFile.mockResolvedValueOnce({ success: true, statusCode: 201, data: { id: 'b' }, error: null })
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
const file = new File(['content'], 'scan.pdf')
|
||||||
|
await store.uploadBatch(file, 'VITALS', 'TRACK_A', 'patient-1', 'old-batch')
|
||||||
|
|
||||||
|
expect(mockedUploadFile).toHaveBeenCalledWith('digitization-batches', file, {
|
||||||
|
batchType: 'VITALS',
|
||||||
|
track: 'TRACK_A',
|
||||||
|
patientId: 'patient-1',
|
||||||
|
supersedesBatchId: 'old-batch',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes coverSheetCode when provided', async () => {
|
||||||
|
mockedUploadFile.mockResolvedValueOnce({ success: true, statusCode: 201, data: { id: 'b' }, error: null })
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
const file = new File(['content'], 'scan.pdf')
|
||||||
|
await store.uploadBatch(
|
||||||
|
file,
|
||||||
|
'VITALS_SHEET',
|
||||||
|
'BACKFILL',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'VCR-CS-A3F7B2D1',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockedUploadFile).toHaveBeenCalledWith('digitization-batches', file, {
|
||||||
|
batchType: 'VITALS_SHEET',
|
||||||
|
track: 'BACKFILL',
|
||||||
|
coverSheetCode: 'VCR-CS-A3F7B2D1',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null on failure', async () => {
|
||||||
|
mockedUploadFile.mockRejectedValueOnce(new Error('Upload failed'))
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
const file = new File([''], 'scan.pdf')
|
||||||
|
const result = await store.uploadBatch(file, 'VITALS', 'TRACK_A')
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(store.error).toBe('Upload failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('assignBatch', () => {
|
||||||
|
it('calls PATCH with entryClerkUserId', async () => {
|
||||||
|
mockedPatch.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null })
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.assignBatch('b1', 'clerk-42')
|
||||||
|
|
||||||
|
expect(mockedPatch).toHaveBeenCalledWith('digitization-batches/b1/assign', {
|
||||||
|
entryClerkUserId: 'clerk-42',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on failure', async () => {
|
||||||
|
mockedPatch.mockResolvedValueOnce({
|
||||||
|
success: false,
|
||||||
|
statusCode: 409,
|
||||||
|
data: null,
|
||||||
|
error: { message: 'Already assigned', code: 'CONFLICT' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await expect(store.assignBatch('b1', 'clerk-42')).rejects.toThrow('Already assigned')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDraft', () => {
|
||||||
|
it('sets currentDraft on success', async () => {
|
||||||
|
const draft = {
|
||||||
|
patient: { fullName: 'Jane', dateOfBirth: '1990-01-01' },
|
||||||
|
encounter: { department: 'ICU' },
|
||||||
|
observations: [{ id: 'obs-1', observationCode: 'HEART_RATE', value: 72 }],
|
||||||
|
}
|
||||||
|
mockedGet.mockResolvedValueOnce({ success: true, statusCode: 200, data: draft, error: null })
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.getDraft('b1')
|
||||||
|
|
||||||
|
expect(store.currentDraft).toEqual(draft)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('saveDraftPatient', () => {
|
||||||
|
it('calls PUT with patient data', async () => {
|
||||||
|
mockedPut.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null })
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.saveDraftPatient('b1', { fullName: 'John Doe' })
|
||||||
|
|
||||||
|
expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/patient', {
|
||||||
|
fullName: 'John Doe',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('saveDraftEncounter', () => {
|
||||||
|
it('calls PUT with encounter data', async () => {
|
||||||
|
mockedPut.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null })
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.saveDraftEncounter('b1', { department: 'ICU' })
|
||||||
|
|
||||||
|
expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/encounter', {
|
||||||
|
department: 'ICU',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('addObservation', () => {
|
||||||
|
it('posts observation and refreshes draft', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 201, data: {}, error: null })
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: { patient: null, encounter: null, observations: [{ id: 'obs-new' }] },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.addObservation('b1', { observationCode: 'HEART_RATE', value: 72, unit: 'bpm' })
|
||||||
|
|
||||||
|
expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/draft/observations', {
|
||||||
|
observationCode: 'HEART_RATE',
|
||||||
|
value: 72,
|
||||||
|
unit: 'bpm',
|
||||||
|
})
|
||||||
|
expect(mockedGet).toHaveBeenCalledWith('digitization-batches/b1/draft')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateObservation', () => {
|
||||||
|
it('calls PUT with observation data', async () => {
|
||||||
|
mockedPut.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null })
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.updateObservation('b1', 'obs-1', { value: 80 })
|
||||||
|
|
||||||
|
expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/observations/obs-1', {
|
||||||
|
value: 80,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deleteObservation', () => {
|
||||||
|
it('calls DELETE and refreshes draft', async () => {
|
||||||
|
mockedDel.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: { patient: null, encounter: null, observations: [] },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.deleteObservation('b1', 'obs-1')
|
||||||
|
|
||||||
|
expect(mockedDel).toHaveBeenCalledWith('digitization-batches/b1/draft/observations/obs-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('submitForVerification', () => {
|
||||||
|
it('posts to submit endpoint', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.submitForVerification('b1')
|
||||||
|
|
||||||
|
expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/submit-for-verification')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('verifyBatch', () => {
|
||||||
|
it('posts field checks and pass status', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
{ fieldPath: 'patient.fullName', passed: true },
|
||||||
|
{ fieldPath: 'patient.dob', passed: true },
|
||||||
|
]
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.verifyBatch('b1', checks, true)
|
||||||
|
|
||||||
|
expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/verify', {
|
||||||
|
fieldChecks: checks,
|
||||||
|
passed: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rejectBatch', () => {
|
||||||
|
it('posts rejection reason', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
await store.rejectBatch('b1', 'Temperature value appears incorrect')
|
||||||
|
|
||||||
|
expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/reject', {
|
||||||
|
reason: 'Temperature value appears incorrect',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('approveBatch', () => {
|
||||||
|
it('posts with idempotency key and enableRetroactiveAlerts', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: { mrn: 'MRN-001', encounterId: 'enc-1', observationIds: ['o1', 'o2'] },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
const result = await store.approveBatch('b1', true)
|
||||||
|
|
||||||
|
expect(mockedPost).toHaveBeenCalledWith(
|
||||||
|
'digitization-batches/b1/approve',
|
||||||
|
{ enableRetroactiveAlerts: true },
|
||||||
|
expect.objectContaining({ 'Idempotency-Key': expect.any(String) }),
|
||||||
|
)
|
||||||
|
expect(result.data).toEqual({
|
||||||
|
mrn: 'MRN-001',
|
||||||
|
encounterId: 'enc-1',
|
||||||
|
observationIds: ['o1', 'o2'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getPatientHistory', () => {
|
||||||
|
it('returns history on success', async () => {
|
||||||
|
const history = {
|
||||||
|
patientId: 'p1',
|
||||||
|
totalBatches: 3,
|
||||||
|
promotedBatches: 2,
|
||||||
|
supersededBatches: 1,
|
||||||
|
pendingBatches: 0,
|
||||||
|
entries: [],
|
||||||
|
}
|
||||||
|
mockedGet.mockResolvedValueOnce({ success: true, statusCode: 200, data: history, error: null })
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
const result = await store.getPatientHistory('p1')
|
||||||
|
|
||||||
|
expect(result).toEqual(history)
|
||||||
|
expect(mockedGet).toHaveBeenCalledWith('patients/p1/digitization-history')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null and sets error on failure', async () => {
|
||||||
|
mockedGet.mockRejectedValueOnce(new Error('Not found'))
|
||||||
|
|
||||||
|
const store = useBatchStore()
|
||||||
|
const result = await store.getPatientHistory('bad')
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(store.error).toBe('Not found')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useLiveCaptureStore } from '@/stores/liveCapture'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
post: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { post } from '@/api/client'
|
||||||
|
|
||||||
|
const mockedPost = vi.mocked(post)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useLiveCaptureStore', () => {
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('has expected defaults', () => {
|
||||||
|
const store = useLiveCaptureStore()
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
expect(store.error).toBeNull()
|
||||||
|
expect(store.lastResult).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('recordObservations', () => {
|
||||||
|
const observations = [
|
||||||
|
{ observationCode: 'HEART_RATE', value: 72, unit: 'bpm', recordedAt: '2026-06-27T10:00:00Z', note: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('returns result on success', async () => {
|
||||||
|
const responseData = {
|
||||||
|
batchId: 'b1',
|
||||||
|
encounterId: 'enc-1',
|
||||||
|
observations: [
|
||||||
|
{
|
||||||
|
draftObservationId: 'do1',
|
||||||
|
liveObservationId: 'lo1',
|
||||||
|
observationCode: 'HEART_RATE',
|
||||||
|
value: 72,
|
||||||
|
unit: 'bpm',
|
||||||
|
recordedAt: '2026-06-27T10:00:00Z',
|
||||||
|
criticalAlert: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
criticalAlertCount: 0,
|
||||||
|
promotedAt: '2026-06-27T10:00:01Z',
|
||||||
|
}
|
||||||
|
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: responseData, error: null })
|
||||||
|
|
||||||
|
const store = useLiveCaptureStore()
|
||||||
|
const result = await store.recordObservations('enc-1', observations, true, 'password')
|
||||||
|
|
||||||
|
expect(result).toEqual(responseData)
|
||||||
|
expect(store.lastResult).toEqual(responseData)
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
expect(store.error).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws and sets error on failure response', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({
|
||||||
|
success: false,
|
||||||
|
statusCode: 400,
|
||||||
|
data: null,
|
||||||
|
error: { message: 'Attestation required', code: 'VALIDATION' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useLiveCaptureStore()
|
||||||
|
await expect(
|
||||||
|
store.recordObservations('enc-1', observations, false, 'password'),
|
||||||
|
).rejects.toThrow('Attestation required')
|
||||||
|
|
||||||
|
expect(store.error).toBe('Attestation required')
|
||||||
|
expect(store.lastResult).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on network error', async () => {
|
||||||
|
mockedPost.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
const store = useLiveCaptureStore()
|
||||||
|
await expect(
|
||||||
|
store.recordObservations('enc-1', observations, true, 'password'),
|
||||||
|
).rejects.toThrow('Network error')
|
||||||
|
|
||||||
|
expect(store.error).toBe('Network error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('openEncounterWithVitals', () => {
|
||||||
|
const observations = [
|
||||||
|
{ observationCode: 'TEMP_C', value: 37.2, unit: 'C', recordedAt: '2026-06-27T10:00:00Z', note: '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('returns result on success', async () => {
|
||||||
|
const responseData = {
|
||||||
|
batchId: 'b2',
|
||||||
|
encounterId: 'enc-new',
|
||||||
|
observations: [],
|
||||||
|
criticalAlertCount: 0,
|
||||||
|
promotedAt: '2026-06-27T10:00:01Z',
|
||||||
|
}
|
||||||
|
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 201, data: responseData, error: null })
|
||||||
|
|
||||||
|
const store = useLiveCaptureStore()
|
||||||
|
const result = await store.openEncounterWithVitals(
|
||||||
|
'patient-1', 'ICU', '3A-12', 'Chest pain', observations, true, 'password',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual(responseData)
|
||||||
|
expect(mockedPost).toHaveBeenCalledWith('live-capture/encounters', {
|
||||||
|
patientId: 'patient-1',
|
||||||
|
department: 'ICU',
|
||||||
|
roomBed: '3A-12',
|
||||||
|
admissionReason: 'Chest pain',
|
||||||
|
observations: [
|
||||||
|
{ observationCode: 'TEMP_C', value: 37.2, unit: 'C', recordedAt: '2026-06-27T10:00:00Z', note: null },
|
||||||
|
],
|
||||||
|
clinicianAttestation: true,
|
||||||
|
passwordConfirm: 'password',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends null for empty roomBed', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 201,
|
||||||
|
data: { batchId: 'b', encounterId: 'e', observations: [], criticalAlertCount: 0, promotedAt: '' },
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useLiveCaptureStore()
|
||||||
|
await store.openEncounterWithVitals(
|
||||||
|
'p1', 'ED', '', 'Fall', observations, true, 'pass',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockedPost).toHaveBeenCalledWith(
|
||||||
|
'live-capture/encounters',
|
||||||
|
expect.objectContaining({ roomBed: null }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('clears lastResult and error', () => {
|
||||||
|
const store = useLiveCaptureStore()
|
||||||
|
store.error = 'some error'
|
||||||
|
store.lastResult = { batchId: 'b', encounterId: 'e', observations: [], criticalAlertCount: 0, promotedAt: '' }
|
||||||
|
|
||||||
|
store.reset()
|
||||||
|
|
||||||
|
expect(store.lastResult).toBeNull()
|
||||||
|
expect(store.error).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import CoverSheetView from '@/views/CoverSheetView.vue'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
postBlob: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/AppHeader.vue', () => ({
|
||||||
|
default: { template: '<div />' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||||
|
default: {
|
||||||
|
props: ['modelValue'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template: '<input data-testid="patient-search" />',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { get, post } from '@/api/client'
|
||||||
|
|
||||||
|
const mockedGet = vi.mocked(get)
|
||||||
|
const mockedPost = vi.mocked(post)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
mockedGet.mockImplementation(async (url: string) => {
|
||||||
|
if (url === 'users') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: [{ id: 'clerk-1', username: 'entry1', fullName: 'Entry Clerk', role: 'DATA_ENTRY_CLERK' }],
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url === 'cover-sheets') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'cs-1',
|
||||||
|
code: 'VCR-CS-AABBCCDD',
|
||||||
|
batchType: 'VITALS_SHEET',
|
||||||
|
track: 'BACKFILL',
|
||||||
|
patientId: null,
|
||||||
|
patientName: null,
|
||||||
|
patientMrn: null,
|
||||||
|
assignToUserId: null,
|
||||||
|
assignToUserName: null,
|
||||||
|
isUsed: false,
|
||||||
|
batchId: null,
|
||||||
|
createdAt: '2026-06-27T12:00:00Z',
|
||||||
|
usedAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, statusCode: 200, data: [], error: null }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CoverSheetView', () => {
|
||||||
|
it('renders generate form and cover sheet list', async () => {
|
||||||
|
const wrapper = mount(CoverSheetView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Generate Cover Sheets')
|
||||||
|
expect(wrapper.text()).toContain('Cover Sheet List')
|
||||||
|
expect(wrapper.text()).toContain('VCR-CS-AABBCCDD')
|
||||||
|
expect(wrapper.find('select').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads entry clerks and cover sheets on mount', async () => {
|
||||||
|
mount(CoverSheetView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(mockedGet).toHaveBeenCalledWith('users', { role: 'DATA_ENTRY_CLERK' })
|
||||||
|
expect(mockedGet).toHaveBeenCalledWith('cover-sheets', expect.objectContaining({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits generate request with form values', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 201,
|
||||||
|
data: [{ id: 'new-cs-1', code: 'VCR-CS-NEW12345' }],
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(CoverSheetView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const batchTypeSelect = wrapper.findAll('select')[0]
|
||||||
|
await batchTypeSelect.setValue('VITALS_SHEET')
|
||||||
|
|
||||||
|
const countInput = wrapper.find('input[type="number"]')
|
||||||
|
await countInput.setValue(5)
|
||||||
|
|
||||||
|
await wrapper.find('form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(mockedPost).toHaveBeenCalledWith('cover-sheets/generate', {
|
||||||
|
count: 5,
|
||||||
|
batchType: 'VITALS_SHEET',
|
||||||
|
track: 'BACKFILL',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Print Cover Sheets after successful generation', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 201,
|
||||||
|
data: [
|
||||||
|
{ id: 'new-cs-1', code: 'VCR-CS-NEW12345' },
|
||||||
|
{ id: 'new-cs-2', code: 'VCR-CS-NEW67890' },
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(CoverSheetView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.findAll('select')[0].setValue('LAB_RESULTS')
|
||||||
|
await wrapper.find('form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Print Cover Sheets')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import IntakeView from '@/views/IntakeView.vue'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/AppHeader.vue', () => ({
|
||||||
|
default: { template: '<div />' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||||
|
default: {
|
||||||
|
props: ['modelValue'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template: '<input data-testid="patient-search" />',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/BatchList.vue', () => ({
|
||||||
|
default: {
|
||||||
|
props: ['batches', 'loading', 'showAssign'],
|
||||||
|
emits: ['assign', 'select'],
|
||||||
|
template: '<div data-testid="batch-list" />',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/AssignClerkDialog.vue', () => ({
|
||||||
|
default: {
|
||||||
|
props: ['show', 'batchId'],
|
||||||
|
emits: ['close', 'assigned'],
|
||||||
|
template: '<div />',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const uploadBatchMock = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/stores/batches', () => ({
|
||||||
|
useBatchStore: () => ({
|
||||||
|
batches: [],
|
||||||
|
loading: false,
|
||||||
|
listBatches: vi.fn(),
|
||||||
|
uploadBatch: uploadBatchMock,
|
||||||
|
assignBatch: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => ({ query: {} }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { get } from '@/api/client'
|
||||||
|
|
||||||
|
const mockedGet = vi.mocked(get)
|
||||||
|
|
||||||
|
const coverSheet = {
|
||||||
|
id: 'cs-1',
|
||||||
|
code: 'VCR-CS-A3F7B2D1',
|
||||||
|
batchType: 'VITALS_SHEET',
|
||||||
|
track: 'BACKFILL',
|
||||||
|
patientId: 'patient-1',
|
||||||
|
patientName: 'Maria Garcia',
|
||||||
|
patientMrn: 'MRN-001',
|
||||||
|
assignToUserId: null,
|
||||||
|
assignToUserName: null,
|
||||||
|
isUsed: false,
|
||||||
|
batchId: null,
|
||||||
|
createdAt: '2026-06-27T12:00:00Z',
|
||||||
|
usedAt: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
uploadBatchMock.mockResolvedValue({ id: 'batch-1', status: 'UPLOADED' })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('IntakeView cover sheet upload', () => {
|
||||||
|
it('renders barcode input with autofocus and lookup button', () => {
|
||||||
|
const wrapper = mount(IntakeView)
|
||||||
|
const input = wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]')
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Quick Upload with Cover Sheet')
|
||||||
|
expect(input.exists()).toBe(true)
|
||||||
|
expect(input.attributes('autofocus')).toBeDefined()
|
||||||
|
expect(wrapper.find('button.btn-secondary').text()).toBe('Lookup')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('looks up cover sheet on Enter and auto-fills form fields', async () => {
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: coverSheet,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(IntakeView)
|
||||||
|
const input = wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]')
|
||||||
|
|
||||||
|
await input.setValue('VCR-CS-A3F7B2D1')
|
||||||
|
await input.trigger('keydown.enter')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(mockedGet).toHaveBeenCalledWith('cover-sheets/lookup/VCR-CS-A3F7B2D1')
|
||||||
|
expect(wrapper.text()).toContain('Cover Sheet Found')
|
||||||
|
expect(wrapper.text()).toContain('Maria Garcia')
|
||||||
|
expect((wrapper.findAll('select')[0].element as HTMLSelectElement).value).toBe('VITALS_SHEET')
|
||||||
|
expect((wrapper.findAll('select')[1].element as HTMLSelectElement).value).toBe('BACKFILL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uploads with coverSheetCode when cover sheet is resolved', async () => {
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
statusCode: 200,
|
||||||
|
data: coverSheet,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(IntakeView)
|
||||||
|
|
||||||
|
await wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]').setValue('VCR-CS-A3F7B2D1')
|
||||||
|
await wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]').trigger('keydown.enter')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const file = new File(['pdf'], 'scan.pdf', { type: 'application/pdf' })
|
||||||
|
const fileInput = wrapper.find('input[type="file"]')
|
||||||
|
Object.defineProperty(fileInput.element, 'files', { value: [file] })
|
||||||
|
await fileInput.trigger('change')
|
||||||
|
|
||||||
|
await wrapper.find('form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(uploadBatchMock).toHaveBeenCalledWith(
|
||||||
|
file,
|
||||||
|
'VITALS_SHEET',
|
||||||
|
'BACKFILL',
|
||||||
|
'patient-1',
|
||||||
|
undefined,
|
||||||
|
'VCR-CS-A3F7B2D1',
|
||||||
|
)
|
||||||
|
expect(wrapper.text()).toContain('Upload with Cover Sheet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps manual upload available without a cover sheet', async () => {
|
||||||
|
const wrapper = mount(IntakeView)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('New Batch')
|
||||||
|
expect(wrapper.text()).toContain('Upload and Create Batch')
|
||||||
|
|
||||||
|
const file = new File(['pdf'], 'scan.pdf', { type: 'application/pdf' })
|
||||||
|
const fileInput = wrapper.find('input[type="file"]')
|
||||||
|
Object.defineProperty(fileInput.element, 'files', { value: [file] })
|
||||||
|
await fileInput.trigger('change')
|
||||||
|
await wrapper.findAll('select')[0].setValue('LAB_RESULTS')
|
||||||
|
|
||||||
|
await wrapper.find('form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(uploadBatchMock).toHaveBeenCalledWith(
|
||||||
|
file,
|
||||||
|
'LAB_RESULTS',
|
||||||
|
'BACKFILL',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows lookup error for unknown cover sheet', async () => {
|
||||||
|
mockedGet.mockResolvedValueOnce({
|
||||||
|
success: false,
|
||||||
|
statusCode: 404,
|
||||||
|
data: null,
|
||||||
|
error: { message: 'Cover sheet not found.', code: 'COVER_SHEET_NOT_FOUND' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(IntakeView)
|
||||||
|
await wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]').setValue('VCR-CS-DEADBEEF')
|
||||||
|
await wrapper.find('button.btn-secondary').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Cover sheet not found.')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -174,4 +174,13 @@ export async function uploadFile<T>(
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST JSON and receive a binary response (e.g. cover sheet PDF). */
|
||||||
|
export async function postBlob(url: string, data?: unknown): Promise<Blob> {
|
||||||
|
const response = await apiClient.post(url, data, {
|
||||||
|
responseType: 'blob',
|
||||||
|
timeout: 60000,
|
||||||
|
})
|
||||||
|
return response.data as Blob
|
||||||
|
}
|
||||||
|
|
||||||
export default apiClient
|
export default apiClient
|
||||||
@@ -8,6 +8,11 @@
|
|||||||
hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed
|
hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
transition-colors;
|
transition-colors;
|
||||||
}
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-white text-primary-700 border border-primary-300 px-4 py-2 rounded-md
|
||||||
|
hover:bg-primary-50 disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
transition-colors;
|
||||||
|
}
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@apply bg-clinical-danger text-white px-4 py-2 rounded-md
|
@apply bg-clinical-danger text-white px-4 py-2 rounded-md
|
||||||
hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed
|
hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<h1 class="text-lg font-semibold">{{ title }}</h1>
|
<h1 class="text-lg font-semibold">{{ title }}</h1>
|
||||||
<nav class="flex gap-3 ml-4">
|
<nav class="flex gap-3 ml-4">
|
||||||
<router-link v-if="auth.canIntake" to="/intake" class="nav-link">Intake</router-link>
|
<router-link v-if="auth.canIntake" to="/intake" class="nav-link">Intake</router-link>
|
||||||
|
<router-link v-if="auth.canIntake" to="/cover-sheets" class="nav-link">Cover Sheets</router-link>
|
||||||
<router-link v-if="auth.canEntry" to="/entry" class="nav-link">Entry</router-link>
|
<router-link v-if="auth.canEntry" to="/entry" class="nav-link">Entry</router-link>
|
||||||
<router-link v-if="auth.canVerify" to="/verification" class="nav-link">Verification</router-link>
|
<router-link v-if="auth.canVerify" to="/verification" class="nav-link">Verification</router-link>
|
||||||
<router-link v-if="auth.canApprove" to="/approval" class="nav-link">Approval</router-link>
|
<router-link v-if="auth.canApprove" to="/approval" class="nav-link">Approval</router-link>
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('../views/IntakeView.vue'),
|
component: () => import('../views/IntakeView.vue'),
|
||||||
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/cover-sheets',
|
||||||
|
name: 'CoverSheets',
|
||||||
|
component: () => import('../views/CoverSheetView.vue'),
|
||||||
|
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/entry',
|
path: '/entry',
|
||||||
name: 'EntryQueue',
|
name: 'EntryQueue',
|
||||||
|
|||||||
@@ -71,13 +71,16 @@ export const useBatchStore = defineStore('batches', () => {
|
|||||||
track: string,
|
track: string,
|
||||||
patientId?: string,
|
patientId?: string,
|
||||||
supersedesBatchId?: string,
|
supersedesBatchId?: string,
|
||||||
|
coverSheetCode?: string,
|
||||||
): Promise<BatchDetailResponse | null> {
|
): Promise<BatchDetailResponse | null> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const fields: Record<string, string> = { batchType, track }
|
const fields: Record<string, string> = { track }
|
||||||
|
if (batchType) fields.batchType = batchType
|
||||||
if (patientId) fields.patientId = patientId
|
if (patientId) fields.patientId = patientId
|
||||||
if (supersedesBatchId) fields.supersedesBatchId = supersedesBatchId
|
if (supersedesBatchId) fields.supersedesBatchId = supersedesBatchId
|
||||||
|
if (coverSheetCode) fields.coverSheetCode = coverSheetCode
|
||||||
|
|
||||||
const response = await uploadFile<BatchDetailResponse>(
|
const response = await uploadFile<BatchDetailResponse>(
|
||||||
'digitization-batches',
|
'digitization-batches',
|
||||||
|
|||||||
@@ -146,6 +146,30 @@ export interface UserSummary {
|
|||||||
role: string
|
role: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CoverSheetResponse {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
batchType: string
|
||||||
|
track: string
|
||||||
|
patientId: string | null
|
||||||
|
patientName: string | null
|
||||||
|
patientMrn: string | null
|
||||||
|
assignToUserId: string | null
|
||||||
|
assignToUserName: string | null
|
||||||
|
isUsed: boolean
|
||||||
|
batchId: string | null
|
||||||
|
createdAt: string
|
||||||
|
usedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateCoverSheetsRequest {
|
||||||
|
count: number
|
||||||
|
batchType: string
|
||||||
|
track: string
|
||||||
|
patientId?: string
|
||||||
|
assignToUserId?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface DigitizationEventSummary {
|
export interface DigitizationEventSummary {
|
||||||
eventType: string
|
eventType: string
|
||||||
occurredAt: string
|
occurredAt: string
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col">
|
||||||
|
<AppHeader title="Cover Sheets" />
|
||||||
|
|
||||||
|
<div class="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto flex-1 w-full">
|
||||||
|
<h1 class="text-2xl font-bold mb-6">Cover Sheet Management</h1>
|
||||||
|
|
||||||
|
<!-- Generate -->
|
||||||
|
<div class="card mb-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Generate Cover Sheets</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleGenerate" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Count (1–100)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="count"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
class="form-input max-w-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label>
|
||||||
|
<select v-model="batchType" class="form-input" required>
|
||||||
|
<option value="">Select batch type...</option>
|
||||||
|
<option value="PATIENT_REGISTRATION">Patient Registration</option>
|
||||||
|
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
||||||
|
<option value="VITALS_SHEET">Vitals Sheet</option>
|
||||||
|
<option value="LAB_RESULTS">Lab Results</option>
|
||||||
|
<option value="MEDICATION_LIST">Medication List</option>
|
||||||
|
<option value="ALLERGY_UPDATE">Allergy Update</option>
|
||||||
|
<option value="MIXED">Mixed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Track</label>
|
||||||
|
<select v-model="track" class="form-input">
|
||||||
|
<option value="BACKFILL">Backfill (Track A)</option>
|
||||||
|
<option value="LIVE_CAPTURE">Live Capture (Track B)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Patient (optional)
|
||||||
|
</label>
|
||||||
|
<PatientSearch v-model="patientId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Assign to Clerk (optional)
|
||||||
|
</label>
|
||||||
|
<select v-model="assignToUserId" class="form-input" :disabled="clerksLoading">
|
||||||
|
<option value="">No pre-assignment</option>
|
||||||
|
<option v-for="clerk in clerks" :key="clerk.id" :value="clerk.id">
|
||||||
|
{{ clerk.fullName }} ({{ clerk.username }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="generateError" class="text-clinical-danger text-sm">
|
||||||
|
{{ generateError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="generating || !batchType">
|
||||||
|
{{ generating ? 'Generating...' : 'Generate' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="lastGeneratedIds.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
:disabled="printing"
|
||||||
|
@click="printLastGenerated"
|
||||||
|
>
|
||||||
|
{{ printing ? 'Opening PDF...' : 'Print Cover Sheets' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-4">
|
||||||
|
<h2 class="text-lg font-semibold">Cover Sheet List</h2>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
|
||||||
|
<select v-model="statusFilter" class="form-input" @change="onFiltersChanged">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="unused">Unused</option>
|
||||||
|
<option value="used">Used</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-[240px]">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Patient</label>
|
||||||
|
<PatientSearch v-model="filterPatientId" @update:model-value="onFiltersChanged" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="listError" class="text-clinical-danger text-sm mb-4">
|
||||||
|
{{ listError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div v-if="listLoading" class="text-gray-500 text-center py-4">Loading...</div>
|
||||||
|
<div v-else-if="coverSheets.length === 0" class="text-gray-500 text-center py-4">
|
||||||
|
No cover sheets found.
|
||||||
|
</div>
|
||||||
|
<table v-else class="w-full min-w-[800px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b text-left text-gray-600">
|
||||||
|
<th class="py-2 px-4">Code</th>
|
||||||
|
<th class="py-2 px-4">Batch Type</th>
|
||||||
|
<th class="py-2 px-4">Track</th>
|
||||||
|
<th class="py-2 px-4">Patient</th>
|
||||||
|
<th class="py-2 px-4">Assigned To</th>
|
||||||
|
<th class="py-2 px-4">Status</th>
|
||||||
|
<th class="py-2 px-4">Linked Batch</th>
|
||||||
|
<th class="py-2 px-4">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="sheet in coverSheets"
|
||||||
|
:key="sheet.id"
|
||||||
|
class="border-b hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td class="py-2 px-4 font-mono text-xs">{{ sheet.code }}</td>
|
||||||
|
<td class="py-2 px-4">{{ formatBatchType(sheet.batchType) }}</td>
|
||||||
|
<td class="py-2 px-4">
|
||||||
|
<span
|
||||||
|
:class="sheet.track === 'BACKFILL'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-green-100 text-green-800'"
|
||||||
|
class="status-badge"
|
||||||
|
>
|
||||||
|
{{ sheet.track === 'BACKFILL' ? 'Backfill' : 'Live' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4">
|
||||||
|
<template v-if="sheet.patientName">
|
||||||
|
{{ sheet.patientName }}
|
||||||
|
<span v-if="sheet.patientMrn" class="text-gray-500">({{ sheet.patientMrn }})</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-gray-400">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4">
|
||||||
|
{{ sheet.assignToUserName ?? '—' }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4">
|
||||||
|
<span
|
||||||
|
:class="sheet.isUsed
|
||||||
|
? 'bg-gray-100 text-gray-800'
|
||||||
|
: 'bg-green-100 text-green-800'"
|
||||||
|
class="status-badge"
|
||||||
|
>
|
||||||
|
{{ sheet.isUsed ? 'Used' : 'Unused' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4">
|
||||||
|
<router-link
|
||||||
|
v-if="sheet.batchId"
|
||||||
|
:to="{ path: '/intake', query: { batchId: sheet.batchId } }"
|
||||||
|
class="font-mono text-xs text-primary-600 hover:text-primary-800"
|
||||||
|
>
|
||||||
|
{{ sheet.batchId.substring(0, 8) }}...
|
||||||
|
</router-link>
|
||||||
|
<span v-else class="text-gray-400">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4 text-gray-500">
|
||||||
|
{{ formatDate(sheet.createdAt) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-4 pt-4 border-t">
|
||||||
|
<p class="text-sm text-gray-500">Page {{ page }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
:disabled="page <= 1 || listLoading"
|
||||||
|
@click="goToPage(page - 1)"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
:disabled="!hasNextPage || listLoading"
|
||||||
|
@click="goToPage(page + 1)"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { get, post, postBlob } from '../api/client'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
|
import type { CoverSheetResponse, UserSummary } from '../types'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const count = ref(10)
|
||||||
|
const batchType = ref('')
|
||||||
|
const track = ref('BACKFILL')
|
||||||
|
const patientId = ref<string | undefined>()
|
||||||
|
const assignToUserId = ref('')
|
||||||
|
|
||||||
|
const clerks = ref<UserSummary[]>([])
|
||||||
|
const clerksLoading = ref(false)
|
||||||
|
const generating = ref(false)
|
||||||
|
const printing = ref(false)
|
||||||
|
const generateError = ref('')
|
||||||
|
const lastGeneratedIds = ref<string[]>([])
|
||||||
|
|
||||||
|
const coverSheets = ref<CoverSheetResponse[]>([])
|
||||||
|
const listLoading = ref(false)
|
||||||
|
const listError = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const statusFilter = ref<'all' | 'used' | 'unused'>('all')
|
||||||
|
const filterPatientId = ref<string | undefined>()
|
||||||
|
|
||||||
|
const hasNextPage = computed(() => coverSheets.value.length === pageSize)
|
||||||
|
|
||||||
|
function formatBatchType(type: string): string {
|
||||||
|
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClerks(): Promise<void> {
|
||||||
|
clerksLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await get<UserSummary[]>('users', { role: 'DATA_ENTRY_CLERK' })
|
||||||
|
clerks.value = response.success && response.data ? response.data : []
|
||||||
|
} catch {
|
||||||
|
clerks.value = []
|
||||||
|
} finally {
|
||||||
|
clerksLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCoverSheets(): Promise<void> {
|
||||||
|
listLoading.value = true
|
||||||
|
listError.value = ''
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
page: page.value,
|
||||||
|
pageSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter.value === 'used') params.isUsed = true
|
||||||
|
if (statusFilter.value === 'unused') params.isUsed = false
|
||||||
|
if (filterPatientId.value) params.patientId = filterPatientId.value
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await get<CoverSheetResponse[]>('cover-sheets', params)
|
||||||
|
if (response.success && response.data) {
|
||||||
|
coverSheets.value = response.data
|
||||||
|
} else {
|
||||||
|
coverSheets.value = []
|
||||||
|
listError.value = response.error?.message ?? 'Failed to load cover sheets'
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
coverSheets.value = []
|
||||||
|
listError.value = e instanceof Error ? e.message : 'Failed to load cover sheets'
|
||||||
|
} finally {
|
||||||
|
listLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFiltersChanged(): void {
|
||||||
|
page.value = 1
|
||||||
|
void loadCoverSheets()
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(nextPage: number): void {
|
||||||
|
page.value = nextPage
|
||||||
|
void loadCoverSheets()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerate(): Promise<void> {
|
||||||
|
if (!batchType.value) return
|
||||||
|
|
||||||
|
generating.value = true
|
||||||
|
generateError.value = ''
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
count: Math.min(100, Math.max(1, count.value || 10)),
|
||||||
|
batchType: batchType.value,
|
||||||
|
track: track.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patientId.value) payload.patientId = patientId.value
|
||||||
|
if (assignToUserId.value) payload.assignToUserId = assignToUserId.value
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await post<CoverSheetResponse[]>('cover-sheets/generate', payload)
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
throw new Error(response.error?.message ?? 'Generation failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
lastGeneratedIds.value = response.data.map(s => s.id)
|
||||||
|
toast.success(`Generated ${response.data.length} cover sheet(s)`)
|
||||||
|
page.value = 1
|
||||||
|
await loadCoverSheets()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Generation failed'
|
||||||
|
generateError.value = msg
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
generating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function printLastGenerated(): Promise<void> {
|
||||||
|
if (lastGeneratedIds.value.length === 0) return
|
||||||
|
|
||||||
|
printing.value = true
|
||||||
|
try {
|
||||||
|
const blob = await postBlob('cover-sheets/batch-pdf', {
|
||||||
|
coverSheetIds: lastGeneratedIds.value,
|
||||||
|
})
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
window.open(url, '_blank')
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||||
|
toast.success('Cover sheet PDF opened in a new tab')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to generate PDF')
|
||||||
|
} finally {
|
||||||
|
printing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadClerks(), loadCoverSheets()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -5,6 +5,80 @@
|
|||||||
<div class="page-container flex-1">
|
<div class="page-container flex-1">
|
||||||
<h1 class="text-2xl font-bold mb-6">Upload Scanned Document</h1>
|
<h1 class="text-2xl font-bold mb-6">Upload Scanned Document</h1>
|
||||||
|
|
||||||
|
<!-- Barcode-assisted upload -->
|
||||||
|
<div class="card mb-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Quick Upload with Cover Sheet</h2>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">
|
||||||
|
Scan or type a cover sheet barcode to auto-populate batch details.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex gap-4 items-end">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Cover Sheet Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="coverSheetCode"
|
||||||
|
@keydown.enter.prevent="lookupCoverSheet"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="VCR-CS-XXXXXXXX"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="lookupCoverSheet"
|
||||||
|
class="btn-secondary"
|
||||||
|
:disabled="!coverSheetCode.trim() || lookupLoading"
|
||||||
|
>
|
||||||
|
{{ lookupLoading ? 'Looking up...' : 'Lookup' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="lookupError" class="text-clinical-danger text-sm mt-3">
|
||||||
|
{{ lookupError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="resolvedCoverSheet"
|
||||||
|
class="mt-4 bg-green-50 border border-green-200 rounded-md p-4"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium text-green-800">Cover Sheet Found</p>
|
||||||
|
<dl class="mt-2 text-sm text-green-700 space-y-1">
|
||||||
|
<div>
|
||||||
|
<dt class="inline font-medium">Type:</dt>
|
||||||
|
<dd class="inline">{{ formatBatchType(resolvedCoverSheet.batchType) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="inline font-medium">Track:</dt>
|
||||||
|
<dd class="inline">{{ formatTrack(resolvedCoverSheet.track) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="resolvedCoverSheet.patientName">
|
||||||
|
<dt class="inline font-medium">Patient:</dt>
|
||||||
|
<dd class="inline">
|
||||||
|
{{ resolvedCoverSheet.patientName }}
|
||||||
|
({{ resolvedCoverSheet.patientMrn }})
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="resolvedCoverSheet.assignToUserName">
|
||||||
|
<dt class="inline font-medium">Assign to:</dt>
|
||||||
|
<dd class="inline">{{ resolvedCoverSheet.assignToUserName }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="text-sm text-green-600 mt-3">
|
||||||
|
Select a file below and click "Upload with Cover Sheet" to create the batch.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="clearCoverSheet"
|
||||||
|
class="text-xs text-green-700 hover:text-green-900 mt-2"
|
||||||
|
>
|
||||||
|
Clear cover sheet (use manual upload)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Upload form -->
|
<!-- Upload form -->
|
||||||
<div class="card mb-6">
|
<div class="card mb-6">
|
||||||
<h2 class="text-lg font-semibold mb-4">New Batch</h2>
|
<h2 class="text-lg font-semibold mb-4">New Batch</h2>
|
||||||
@@ -35,7 +109,11 @@
|
|||||||
<!-- Batch type -->
|
<!-- Batch type -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label>
|
||||||
<select v-model="batchType" class="form-input" required>
|
<select
|
||||||
|
v-model="batchType"
|
||||||
|
class="form-input"
|
||||||
|
:required="!resolvedCoverSheet"
|
||||||
|
>
|
||||||
<option value="">Select batch type...</option>
|
<option value="">Select batch type...</option>
|
||||||
<option value="PATIENT_REGISTRATION">Patient Registration</option>
|
<option value="PATIENT_REGISTRATION">Patient Registration</option>
|
||||||
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
||||||
@@ -72,6 +150,7 @@
|
|||||||
<span class="font-mono">{{ supersedesBatchId.substring(0, 8) }}...</span>
|
<span class="font-mono">{{ supersedesBatchId.substring(0, 8) }}...</span>
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
@click="clearCorrection"
|
@click="clearCorrection"
|
||||||
class="text-xs text-blue-600 hover:text-blue-800 mt-2"
|
class="text-xs text-blue-600 hover:text-blue-800 mt-2"
|
||||||
>
|
>
|
||||||
@@ -86,9 +165,9 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
:disabled="!selectedFile || !batchType || batchStore.loading"
|
:disabled="!canUpload"
|
||||||
>
|
>
|
||||||
{{ batchStore.loading ? 'Uploading...' : 'Upload and Create Batch' }}
|
{{ uploadButtonLabel }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,14 +197,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import { get } from '../api/client'
|
||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
import PatientSearch from '../components/PatientSearch.vue'
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
import BatchList from '../components/BatchList.vue'
|
import BatchList from '../components/BatchList.vue'
|
||||||
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
|
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
|
||||||
|
import type { CoverSheetResponse } from '../types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const batchStore = useBatchStore()
|
const batchStore = useBatchStore()
|
||||||
@@ -141,13 +222,87 @@ const assignError = ref('')
|
|||||||
const assignDialogOpen = ref(false)
|
const assignDialogOpen = ref(false)
|
||||||
const assignBatchId = ref<string | null>(null)
|
const assignBatchId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const coverSheetCode = ref('')
|
||||||
|
const resolvedCoverSheet = ref<CoverSheetResponse | null>(null)
|
||||||
|
const lookupLoading = ref(false)
|
||||||
|
const lookupError = ref('')
|
||||||
|
|
||||||
|
const canUpload = computed(() => {
|
||||||
|
if (!selectedFile.value || batchStore.loading) return false
|
||||||
|
if (resolvedCoverSheet.value) return true
|
||||||
|
return !!batchType.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadButtonLabel = computed(() => {
|
||||||
|
if (batchStore.loading) return 'Uploading...'
|
||||||
|
if (resolvedCoverSheet.value) return 'Upload with Cover Sheet'
|
||||||
|
return 'Upload and Create Batch'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(coverSheetCode, () => {
|
||||||
|
if (resolvedCoverSheet.value && coverSheetCode.value.trim().toUpperCase() !== resolvedCoverSheet.value.code) {
|
||||||
|
resolvedCoverSheet.value = null
|
||||||
|
lookupError.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatBatchType(type: string): string {
|
||||||
|
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTrack(value: string): string {
|
||||||
|
return value === 'BACKFILL' ? 'Backfill (Track A)' : 'Live Capture (Track B)'
|
||||||
|
}
|
||||||
|
|
||||||
function onFileChange(event: Event) {
|
function onFileChange(event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
selectedFile.value = input.files?.[0] ?? null
|
selectedFile.value = input.files?.[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function lookupCoverSheet(): Promise<void> {
|
||||||
|
const code = coverSheetCode.value.trim().toUpperCase()
|
||||||
|
if (!code) return
|
||||||
|
|
||||||
|
lookupLoading.value = true
|
||||||
|
lookupError.value = ''
|
||||||
|
resolvedCoverSheet.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await get<CoverSheetResponse>(
|
||||||
|
`cover-sheets/lookup/${encodeURIComponent(code)}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
throw new Error(response.error?.message ?? 'Cover sheet not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data.isUsed) {
|
||||||
|
throw new Error('Cover sheet has already been used')
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedCoverSheet.value = response.data
|
||||||
|
coverSheetCode.value = response.data.code
|
||||||
|
batchType.value = response.data.batchType
|
||||||
|
track.value = response.data.track
|
||||||
|
patientId.value = response.data.patientId ?? undefined
|
||||||
|
toast.success('Cover sheet found')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Lookup failed'
|
||||||
|
lookupError.value = msg
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
lookupLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCoverSheet(): void {
|
||||||
|
coverSheetCode.value = ''
|
||||||
|
resolvedCoverSheet.value = null
|
||||||
|
lookupError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUpload() {
|
async function handleUpload() {
|
||||||
if (!selectedFile.value || !batchType.value) return
|
if (!selectedFile.value || !canUpload.value) return
|
||||||
|
|
||||||
uploadError.value = ''
|
uploadError.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -157,15 +312,24 @@ async function handleUpload() {
|
|||||||
track.value,
|
track.value,
|
||||||
patientId.value,
|
patientId.value,
|
||||||
supersedesBatchId.value,
|
supersedesBatchId.value,
|
||||||
|
resolvedCoverSheet.value?.code,
|
||||||
)
|
)
|
||||||
if (batch) {
|
if (batch) {
|
||||||
const isCorrection = !!supersedesBatchId.value
|
const isCorrection = !!supersedesBatchId.value
|
||||||
|
const usedCoverSheet = !!resolvedCoverSheet.value
|
||||||
selectedFile.value = null
|
selectedFile.value = null
|
||||||
batchType.value = ''
|
batchType.value = ''
|
||||||
track.value = 'BACKFILL'
|
track.value = 'BACKFILL'
|
||||||
patientId.value = undefined
|
patientId.value = undefined
|
||||||
supersedesBatchId.value = undefined
|
supersedesBatchId.value = undefined
|
||||||
toast.success(isCorrection ? 'Correction batch created' : 'Batch uploaded successfully')
|
clearCoverSheet()
|
||||||
|
toast.success(
|
||||||
|
isCorrection
|
||||||
|
? 'Correction batch created'
|
||||||
|
: usedCoverSheet
|
||||||
|
? 'Batch uploaded with cover sheet'
|
||||||
|
: 'Batch uploaded successfully',
|
||||||
|
)
|
||||||
await loadRecent()
|
await loadRecent()
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/__tests__/setup.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user