Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/LocalDevelopement/dsi.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
"responses": [
{
"uuid": "aea874b6-6d35-4fce-8e4b-2ec5d1deaa5d",
"body": "{\n \"access_token\": \"eeyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJhdWQiOiJDaGVja1BlcmZvcm1hbmNlRGF0YSIsInN1YiI6IjE2ZDMyMmUzLWU4YmMtNGZiNC05NTA5LTExOWY5YWNiYzAzNCIsIm5hbWUiOiJNb2Nrb29uIFVzZXIiLCJnaXZlbl9uYW1lIjoiTW9ja29vbiIsInN1cm5hbWUiOiJVc2VyIiwiZW1haWwiOiJtb2NrLnVzZXJAZXhhbXBsZS5jb20iLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTkxNjIzOTAyMiwib3JnYW5pc2F0aW9uIjoie1wiaWRcIjpcIm1vY2tvb24tb3JnLTQ1NlwiLFwibmFtZVwiOlwiTW9ja29vbiBPcmdhbmlzYXRpb25cIn0ifQ.\",\n \"id_token\": \"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJhdWQiOiJDaGVja1BlcmZvcm1hbmNlRGF0YSIsInN1YiI6IjE2ZDMyMmUzLWU4YmMtNGZiNC05NTA5LTExOWY5YWNiYzAzNCIsIm5hbWUiOiJNb2Nrb29uIFVzZXIiLCJnaXZlbl9uYW1lIjoiTW9ja29vbiIsInN1cm5hbWUiOiJVc2VyIiwiZW1haWwiOiJtb2NrLnVzZXJAZXhhbXBsZS5jb20iLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTkxNjIzOTAyMiwib3JnYW5pc2F0aW9uIjoie1wiaWRcIjpcIm1vY2tvb24tb3JnLTQ1NlwiLFwibmFtZVwiOlwiTW9ja29vbiBPcmdhbmlzYXRpb25cIn0ifQ.\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 3600\n}",
"body": "{\n \"access_token\": \"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJhdWQiOiJDaGVja1BlcmZvcm1hbmNlRGF0YSIsInN1YiI6IjE2ZDMyMmUzLWU4YmMtNGZiNC05NTA5LTExOWY5YWNiYzAzNCIsIm5hbWUiOiJNb2Nrb29uIFVzZXIiLCJnaXZlbl9uYW1lIjoiTW9ja29vbiIsInN1cm5hbWUiOiJVc2VyIiwiZW1haWwiOiJtb2NrLnVzZXJAZXhhbXBsZS5jb20iLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTkxNjIzOTAyMiwib3JnYW5pc2F0aW9uIjoie1wiaWRcIjpcIm1vY2tvb24tb3JnLTQ1NlwiLFwibmFtZVwiOlwiTW9ja29vbiBPcmdhbmlzYXRpb25cIn0ifQ.\",\n \"id_token\": \"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJhdWQiOiJDaGVja1BlcmZvcm1hbmNlRGF0YSIsInN1YiI6IjE2ZDMyMmUzLWU4YmMtNGZiNC05NTA5LTExOWY5YWNiYzAzNCIsIm5hbWUiOiJNb2Nrb29uIFVzZXIiLCJnaXZlbl9uYW1lIjoiTW9ja29vbiIsInN1cm5hbWUiOiJVc2VyIiwiZW1haWwiOiJtb2NrLnVzZXJAZXhhbXBsZS5jb20iLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTkxNjIzOTAyMiwib3JnYW5pc2F0aW9uIjoie1wiaWRcIjpcIm1vY2tvb24tb3JnLTQ1NlwiLFwibmFtZVwiOlwiTW9ja29vbiBPcmdhbmlzYXRpb25cIn0ifQ.\",\n \"token_type\": \"Bearer\",\n \"expires_in\": 3600\n}",
"latency": 0,
"statusCode": 200,
"label": "",
Expand Down Expand Up @@ -309,7 +309,7 @@
"id": "97jr",
"name": "User4to16",
"documentation": "",
"value": "[{\n \"id\": \"mockoon-org-456\",\n \"name\": \"Mockoon Organisation 4 - 16\",\n \"establishmentNumber\": \"4070\",\n \"localAuthority\": {\n \"code\": \"860\"\n },\n \"legalName\": \"Mockoon Legal Name\",\n \"urn\": \"136774\",\n \"uid\": \"UID1234\",\n \"upin\": \"upin\",\n \"ukprn\": \"ukprn\",\n \"EstablishmentNumber\": \"est1234\",\n \"address\": \"Acia Lane\",\n \"telephone\": \"0123456888\",\n \"statutoryLowAge\": 4,\n \"statutoryHighAge\": 16, \n \"legacyId\": \"Leg123\",\n \"companyRegistrationNumber\": \"CRN123\",\n \"role\": {\n \"id\": 0,\n \"name\": \"End User\"\n }\n }\n ]"
"value": "[{\n \"id\": \"mockoon-org-456\",\n \"name\": \"Mockoon Organisation 4 - 16\",\n \"establishmentNumber\": \"4070\",\n \"localAuthority\": {\n \"code\": \"860\"\n },\n \"legalName\": \"Mockoon Legal Name\",\n \"urn\": \"142313\",\n \"uid\": \"UID1234\",\n \"upin\": \"upin\",\n \"ukprn\": \"ukprn\",\n \"EstablishmentNumber\": \"est1234\",\n \"address\": \"Acia Lane\",\n \"telephone\": \"0123456888\",\n \"statutoryLowAge\": 4,\n \"statutoryHighAge\": 16, \n \"legacyId\": \"Leg123\",\n \"companyRegistrationNumber\": \"CRN123\",\n \"role\": {\n \"id\": 0,\n \"name\": \"End User\"\n }\n }\n ]"
},
{
"uuid": "808588ff-4654-49b4-93d0-56cb4731b561",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public async Task<AmendmentRequestsResult> GetAmendmentRequestsAsync(Guid window
RequestTypeDescription = r.RequestTypeDescription,
ReferenceNumber = r.ReferenceNumber,
Status = r.Status,
Submitted = r.Submitted
Submitted = r.Submitted,
}).ToList()
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace DfE.CheckPerformanceData.Application.AmendmentRequests;

public interface IUrnAmendmentRequestsService
{
Task<UrnAmendmentRequestsResult> GetAllSubmittedAmendmentRequestsAsync(CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ public sealed class SubmittedRequestData
public required string ReferenceNumber { get; init; }
public required RequestStatus Status { get; init; }
public required DateTime Submitted { get; init; }
public Guid WindowId { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using DfE.CheckPerformanceData.Domain.Enums;

namespace DfE.CheckPerformanceData.Application.AmendmentRequests;

public class UrnAmendmentRequestDto
{
public required string PupilName { get; init; }
public required RequestType RequestType { get; init; }
public required string RequestTypeDescription { get; init; }
public required string ReferenceNumber { get; init; }
public required RequestStatus Status { get; init; }
public required DateTime Submitted { get; init; }
public string WindowName { get; init; }

Check warning on line 13 in src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestDto.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'WindowName' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public Guid WindowId { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace DfE.CheckPerformanceData.Application.AmendmentRequests;

public sealed class UrnAmendmentRequestsResult
{
public required IReadOnlyList<OpenWindow> OpenWindows { get; init; }
public required IReadOnlyList<UrnAmendmentRequestDto> SubmittedRows { get; init; }
}

public class OpenWindow
{
public string WindowName { get; init; }

Check warning on line 11 in src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsResult.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'WindowName' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public Guid WindowId { get; init; }
public DateTime WindowEndDate { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using DfE.CheckPerformanceData.Application.CurrentUser;
using DfE.CheckPerformanceData.Application.RequestSubmission;
using DfE.CheckPerformanceData.Application.WindowManagement;
using DfE.CheckPerformanceData.Domain.Time;

namespace DfE.CheckPerformanceData.Application.AmendmentRequests;

public sealed class UrnAmendmentRequestsService(
IRequestRepository requestRepository,
IWindowRepository windowRepository,
ICurrentUserService currentUserService,
TimeProvider timeProvider) : IUrnAmendmentRequestsService
{
public async Task<UrnAmendmentRequestsResult> GetAllSubmittedAmendmentRequestsAsync(CancellationToken cancellationToken)
{

long urn = long.Parse(currentUserService.OrganisationUrn);
IReadOnlyList<SubmittedRequestData> submitted = await requestRepository.GetAllSubmittedRequestsAsync(urn);
List<CheckingWindowDto> allWindows = await windowRepository.GetAllWindowsAsync(cancellationToken);

// The repository returns raw rows, so IsOpen is unset here — evaluate against the clock.
DateTime now = UkTime.Now(timeProvider);
List<OpenWindow> currentOpenWindows = allWindows
.Where(w => w.IsOpenAt(now))
.Select(w => new OpenWindow
{
WindowId = w.Id,
WindowName = w.Title,
WindowEndDate = w.EndDate
})
.ToList();

return new UrnAmendmentRequestsResult
{
OpenWindows = currentOpenWindows,
SubmittedRows = submitted.Select(r => new UrnAmendmentRequestDto
{
PupilName = PupilNameFormatter.Format(r.PupilFirstname, r.PupilSurname),
RequestType = r.RequestType,
RequestTypeDescription = r.RequestTypeDescription,
ReferenceNumber = r.ReferenceNumber,
Status = r.Status,
Submitted = r.Submitted,
WindowId = r.WindowId,
WindowName = allWindows.Find(w => w.Id == r.WindowId)?.Title

Check warning on line 45 in src/DfE.CheckPerformanceData.Application/AmendmentRequests/UrnAmendmentRequestsService.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference assignment.
}).ToList()
};
}


}


Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public static IServiceCollection AddApplicationDependencies(this IServiceCollect
// format check rather than throwing anywhere.
services.AddScoped<IFormatValidator, WholeNumberFormatValidator>();
services.AddScoped<IAmendmentRequestsService, AmendmentRequestsService>();
services.AddScoped<IUrnAmendmentRequestsService, UrnAmendmentRequestsService>();
services.AddScoped<IBulkSubmissionService, BulkSubmissionService>();
services.AddScoped<ISubmittedRequestService, SubmittedRequestService>();
services.AddScoped<IEditAdviceService, EditAdviceService>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public interface IRequestRepository
Task<Guid> UpsertAsync(ChangeRequestData data);
Task<IReadOnlyList<AmendmentRequestData>> GetAmendmentRequestsAsync(Guid windowId, long organisationUrn);
Task<IReadOnlyList<SubmittedRequestData>> GetSubmittedRequestsAsync(Guid windowId, long organisationUrn);
Task<IReadOnlyList<SubmittedRequestData>> GetAllSubmittedRequestsAsync(long organisationUrn);
Task<AmendmentRequestData?> GetAmendmentRequestAsync(Guid windowId, long organisationUrn, string referenceNumber);
Task<ConfirmDataCorrectData?> GetConfirmDataCorrectAsync(Guid windowId, long organisationUrn, string referenceNumber);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using DfE.CheckPerformanceData.Domain.Enums;
using DfE.CheckPerformanceData.Domain.Time;

namespace DfE.CheckPerformanceData.Application.WindowManagement;

Expand Down Expand Up @@ -32,6 +33,13 @@ public sealed class CheckingWindowDto
public DateTime? ValidatedAt { get; set; }
public bool IsOpen { get; set; }

/// <summary>
/// Whether the window is open at the given moment. <paramref name="ukNow"/> must be UK
/// wall-clock time (<see cref="UkTime.Now"/>) — Start/End are admin-chosen UK deadlines stored
/// without a zone, so comparing them to a UTC instant is an hour out through BST.
/// </summary>
public bool IsOpenAt(DateTime ukNow) => StartDate <= ukNow && ukNow <= EndDate;

/// <summary>
/// The CSV + schema pairs ingested for this window, in sort order. A Post16 window has two
/// (included + non-included); every other type has one. The legacy scalar
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
using DfE.CheckPerformanceData.Domain.Time;

namespace DfE.CheckPerformanceData.Application.WindowManagement;

public class WindowService(IWindowRepository windowRepository, TimeProvider timeProvider): IWindowService
{
public async Task<PageResult?> GetAllDataAsync(CancellationToken cancellationToken)
{
DateTimeOffset now = timeProvider.GetLocalNow();
DateTime now = UkTime.Now(timeProvider);
List<CheckingWindowDto> windows = await windowRepository.GetAllWindowsAsync(cancellationToken);

foreach (CheckingWindowDto window in windows)
{
window.IsOpen = window.StartDate <= now.DateTime && now.DateTime <= window.EndDate;
window.IsOpen = window.IsOpenAt(now);
}

return new PageResult
Expand All @@ -19,7 +21,7 @@
}

public async Task<CheckingWindowDto> GetByIdAsync(Guid id, CancellationToken cancellationToken) =>
await windowRepository.GetByIdAsync(id, cancellationToken);

Check warning on line 24 in src/DfE.CheckPerformanceData.Application/WindowManagement/WindowService.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference return.

// Start and end dates carry the admin-chosen time-of-day (defaulting to 00:00 / 17:00
// for new windows, but editable), so both are persisted exactly as supplied.
Expand Down
24 changes: 24 additions & 0 deletions src/DfE.CheckPerformanceData.Domain/Time/UkTime.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace DfE.CheckPerformanceData.Domain.Time;

/// <summary>
/// UK wall-clock time. Checking window boundaries (<c>StartDate</c>/<c>EndDate</c>) are wall-clock
/// UK deadlines chosen by an admin — "closes 17:00" means 17:00 in London, not 17:00 UTC — so any
/// open/closed comparison has to be made against UK local time, not against a UTC instant.
///
/// Deliberately does NOT use <see cref="TimeProvider.GetLocalNow"/>: that resolves to
/// <see cref="TimeZoneInfo.Local"/>, which is the host's zone. The deploy containers run UTC, so
/// GetLocalNow() is an hour out through BST while looking correct on a developer machine set to
/// London.
/// </summary>
public static class UkTime
{
/// <summary>
/// The Europe/London zone — BST in summer, GMT in winter. The IANA id resolves on the Linux
/// deploy target and, via .NET's ICU mapping, on Windows too. Resolved once at type load.
/// </summary>
public static readonly TimeZoneInfo Zone = TimeZoneInfo.FindSystemTimeZoneById("Europe/London");

/// <summary>UK wall-clock now, taken from the supplied clock's UTC instant.</summary>
public static DateTime Now(TimeProvider clock) =>
TimeZoneInfo.ConvertTimeFromUtc(clock.GetUtcNow().UtcDateTime, Zone);
}
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,24 @@ await db.ChangeRequests
})
.ToListAsync();

public async Task<IReadOnlyList<SubmittedRequestData>> GetAllSubmittedRequestsAsync(
long organisationUrn) =>
await db.ChangeRequests
.Where(r => r.OrganisationUrn == organisationUrn)
.OrderByDescending(r => r.Submitted)
.Select(r => new SubmittedRequestData
{
PupilFirstname = r.PupilFirstname,
PupilSurname = r.PupilSurname,
RequestType = r.RequestType,
RequestTypeDescription = r.RequestTypeDescription,
ReferenceNumber = r.ReferenceNumber,
Status = r.Status,
Submitted = r.Submitted,
WindowId = r.WindowId
})
.ToListAsync();

public async Task<AmendmentRequestData?> GetAmendmentRequestAsync(
Guid windowId, long organisationUrn, string referenceNumber) =>
await db.ChangeRequests
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using DfE.CheckPerformanceData.Application.AmendmentRequests;
using DfE.CheckPerformanceData.Web.Controllers.SubmittedRequest;
using DfE.CheckPerformanceData.Web.Controllers.ViewModels;
using DfE.CheckPerformanceData.Web.Extensions;
using Microsoft.AspNetCore.Mvc;

namespace DfE.CheckPerformanceData.Web.Controllers;

public class EstablishmentAmendmentRequestsController(
IUrnAmendmentRequestsService urnAmendmentRequestsService,
ISubmittedRequestService submittedRequestService): Controller
{
[Route("/establishment-amendment-requests")]
public async Task<IActionResult> Index(CancellationToken cancellationToken)
{
UrnAmendmentRequestsResult result = await urnAmendmentRequestsService.GetAllSubmittedAmendmentRequestsAsync(cancellationToken);
HashSet<Guid> openWindowIds = result.OpenWindows.Select(w => w.WindowId).ToHashSet();
EstablishmentAmendmentRequestsViewModel viewModel = new EstablishmentAmendmentRequestsViewModel
{
ActiveWindows = result.OpenWindows.Select(w => new ActiveWindow
{
WindowId = w.WindowId.ToString(),
WindowTitle = w.WindowName,
DeadlineText = w.WindowEndDate.ToString("dd-MM-yyyy")
}).ToList(),
Rows = result.SubmittedRows.Select(r => new AmendmentItem
{
PupilName = r.PupilName,
ReferenceNumber = r.ReferenceNumber,
RequestType = r.RequestType.ToString(),
Status = r.Status,
DateSubmitted = r.Submitted.ToString("dd-MM-yyyy"),
WindowName = r.WindowName,
WindowId = r.WindowId.ToString(),
WindowIsOpen = openWindowIds.Contains(r.WindowId)
}).ToList()

};
return View(viewModel);
}

[Route("/establishment-amendment-requests/{windowId:guid}/view/{referenceNumber}")]
public async Task<IActionResult> View(Guid windowId, string referenceNumber, CancellationToken cancellationToken)
{
SubmittedRequestView? request = await submittedRequestService.GetAsync(windowId, referenceNumber);
if (request == null)
{
return NotFound();
}

return View("~/Views/AmendmentRequests/UrnAmendmentView.cshtml", new SubmittedRequestViewModel
{
WindowId = windowId,
WhatToChange = request.WhatToChange,
Status = request.Status,
ConfirmingDelete = false,
PupilName = request.PupilName,
FirstRecordDisplay = request.FirstRecordDisplay,
SecondRecordDisplay = request.SecondRecordDisplay,
Rows = request.Rows.Select(r => new SubmittedRequestRow
{
Title = r.Title,
DisplayValue = r.DisplayValue
}).ToList(),
Files = request.Files.Select(f => new SubmittedRequestFile
{
OriginalFileName = f.OriginalFileName,
StoredFileName = f.StoredFileName,
FileSizeBytes = f.FileSizeBytes
}).ToList(),
ReferenceNumber = request.ReferenceNumber,
SubmittedByEmail = request.SubmittedByEmail,
SubmittedAt = request.SubmittedAt,
WithdrawnByEmail = request.WithdrawnByEmail,
WithdrawnAtText = LondonTime.ToSubmittedAtText(request.WithdrawnAt)
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ public Task<IActionResult> ViewConfirmation(Guid windowId, string referenceNumbe

// "Are you sure?" page for an amendment request — a confirm-mode rendering of View.
[Route("/{windowId}/AmendmentRequests/{referenceNumber}/delete")]
public Task<IActionResult> ConfirmDelete(Guid windowId, string referenceNumber) =>
RenderAmendment(windowId, referenceNumber, confirmingDelete: true);
public Task<IActionResult> ConfirmDelete(Guid windowId, string referenceNumber, bool allEst = false) =>
RenderAmendment(windowId, referenceNumber, confirmingDelete: true, allEst);

// "Are you sure?" page for a confirm-data request — a confirm-mode rendering of ViewConfirmation.
[Route("/{windowId}/AmendmentRequests/{referenceNumber}/delete-confirmation")]
public Task<IActionResult> ConfirmDeleteConfirmation(Guid windowId, string referenceNumber) =>
RenderConfirmation(windowId, referenceNumber, confirmingDelete: true);

private async Task<IActionResult> RenderAmendment(Guid windowId, string referenceNumber, bool confirmingDelete)
private async Task<IActionResult> RenderAmendment(Guid windowId, string referenceNumber, bool confirmingDelete, bool allEst = false)
{
var request = await service.GetAsync(windowId, referenceNumber);
if (request is null)
Expand Down Expand Up @@ -62,7 +62,8 @@ private async Task<IActionResult> RenderAmendment(Guid windowId, string referenc
SubmittedByEmail = request.SubmittedByEmail,
SubmittedAt = request.SubmittedAt,
WithdrawnByEmail = request.WithdrawnByEmail,
WithdrawnAtText = LondonTime.ToSubmittedAtText(request.WithdrawnAt)
WithdrawnAtText = LondonTime.ToSubmittedAtText(request.WithdrawnAt),
AllEst = allEst
});
}

Expand Down Expand Up @@ -106,7 +107,7 @@ public async Task<IActionResult> DownloadEvidence(Guid windowId, string referenc
[HttpPost]
[ValidateAntiForgeryToken]
[Route("/{windowId}/AmendmentRequests/{referenceNumber}/delete")]
public async Task<IActionResult> Delete(Guid windowId, string referenceNumber)
public async Task<IActionResult> Delete(Guid windowId, string referenceNumber, bool allEst = false)
{
var result = await requestService.DeleteAsync(windowId, referenceNumber);

Expand All @@ -127,6 +128,12 @@ public async Task<IActionResult> Delete(Guid windowId, string referenceNumber)
TempData["DeletedMessage"] = result.WasHardDeleted
? $"{result.PupilName} has been removed from your saved request"
: $"{result.PupilName}(reference number - {referenceNumber}) has been removed from your submitted request.";

if (allEst)
{
return RedirectToAction("Index", "EstablishmentAmendmentRequests");
}

return RedirectToAction("Index", "AmendmentRequests", new { windowId });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public sealed class SubmittedRequestViewModel
public DateTime? SubmittedAt { get; init; }
public string? WithdrawnByEmail { get; init; }
public string WithdrawnAtText { get; init; } = string.Empty;
public bool AllEst { get; set; }

public string WhatToChangeLabel => WhatToChange switch
{
Expand Down
Loading
Loading