diff --git a/application/CohortManager/src/Functions/Shared/Common/Pagination/IPaginationService.cs b/application/CohortManager/src/Functions/Shared/Common/Pagination/IPaginationService.cs index 6e698143b3..e3d9293b94 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Pagination/IPaginationService.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Pagination/IPaginationService.cs @@ -1,6 +1,7 @@ namespace Common; using Microsoft.Azure.Functions.Worker.Http; +using Model.Pagination; public interface IPaginationService { diff --git a/application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationService.cs b/application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationService.cs index b72f3331b2..fca5804a0b 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationService.cs +++ b/application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationService.cs @@ -1,6 +1,7 @@ namespace Common; using Microsoft.Azure.Functions.Worker.Http; +using Model.Pagination; public class PaginationService : IPaginationService { diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs index 927bf01769..5ae6f96f47 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/IValidationExceptionData.cs @@ -1,15 +1,21 @@ namespace Data.Database; +using System; +using System.Linq.Expressions; +using System.Threading.Tasks; using Common; using Model; +using Model.DTO; using Model.Enums; public interface IValidationExceptionData { Task Create(ValidationException exception); - Task?> GetFilteredExceptions(ExceptionStatus? exceptionStatus, SortOrder? sortOrder, ExceptionCategory exceptionCategory); + Task> GetFilteredExceptions(ExceptionStatus? exceptionStatus, SortOrder? sortOrder, ExceptionCategory exceptionCategory); Task GetExceptionById(int exceptionId); Task RemoveOldException(string nhsNumber, string screeningName); Task UpdateExceptionServiceNowId(int exceptionId, string serviceNowId); - Task?> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory); + Task> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory); + Task> GetByFilter(Expression> filter); + Task GetExceptionsWithReportsByNhsNumber(string nhsNumber); } diff --git a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs index 8f6eaacaeb..eb655bb23f 100644 --- a/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs +++ b/application/CohortManager/src/Functions/Shared/Data/Database/ValidationExceptionData.cs @@ -2,6 +2,7 @@ namespace Data.Database; using System; using System.Data; +using System.Linq.Expressions; using System.Net; using System.Text.Json; using System.Threading.Tasks; @@ -17,17 +18,17 @@ public class ValidationExceptionData : IValidationExceptionData { private readonly ILogger _logger; private readonly IDataServiceClient _validationExceptionDataServiceClient; + public ValidationExceptionData( ILogger logger, - IDataServiceClient validationExceptionDataServiceClient, - IDataServiceClient demographicDataServiceClient + IDataServiceClient validationExceptionDataServiceClient ) { _logger = logger; _validationExceptionDataServiceClient = validationExceptionDataServiceClient; } - public async Task?> GetFilteredExceptions(ExceptionStatus? exceptionStatus, SortOrder? sortOrder, ExceptionCategory exceptionCategory) + public async Task> GetFilteredExceptions(ExceptionStatus? exceptionStatus, SortOrder? sortOrder, ExceptionCategory exceptionCategory) { var category = (int)exceptionCategory; var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.Category != null && x.Category.Value == category); @@ -49,12 +50,12 @@ IDataServiceClient demographicDataServiceClient return GetValidationExceptionWithDetails(exception); } - public async Task Create(ValidationException exception) { var exceptionToUpdate = new ExceptionManagement().FromValidationException(exception); return await _validationExceptionDataServiceClient.Add(exceptionToUpdate); } + public async Task RemoveOldException(string nhsNumber, string screeningName) { var exceptions = await GetExceptionRecords(nhsNumber, screeningName); @@ -63,7 +64,6 @@ public async Task RemoveOldException(string nhsNumber, string screeningNam return false; } - // we only need to get the last unresolved exception for the nhs number and screening service var validationExceptionToUpdate = exceptions.Where(x => DateToString(x.DateResolved) == "9999-12-31") .OrderByDescending(x => x.DateCreated).FirstOrDefault(); @@ -117,7 +117,7 @@ public async Task UpdateExceptionServiceNowId(int exceptio } } - public async Task?> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory) + public async Task> GetReportExceptions(DateTime? reportDate, ExceptionCategory exceptionCategory) { if (exceptionCategory is not (ExceptionCategory.Confusion or ExceptionCategory.Superseded or ExceptionCategory.NBO)) { @@ -129,8 +129,41 @@ public async Task UpdateExceptionServiceNowId(int exceptio if (filteredExceptions == null || !filteredExceptions.Any()) return []; - var results = filteredExceptions.Select(GetValidationExceptionWithDetails); - return results.Where(x => x != null).ToList()!; + return MapToValidationExceptions(filteredExceptions); + } + + public async Task> GetByFilter(Expression> filter) + { + var result = await _validationExceptionDataServiceClient.GetByFilter(filter) ?? Enumerable.Empty(); + return result.ToList(); + } + + public async Task GetExceptionsWithReportsByNhsNumber(string nhsNumber) + { + var validationExceptions = await GetValidationExceptionsByNhsNumber(nhsNumber); + + if (validationExceptions.Count == 0) + { + return new ValidationExceptionsByNhsNumberResponse + { + Exceptions = [], + Reports = [], + NhsNumber = nhsNumber + }; + } + + var reports = GenerateExceptionReports(validationExceptions); + return new ValidationExceptionsByNhsNumberResponse + { + Exceptions = validationExceptions, + Reports = reports, + NhsNumber = nhsNumber + }; + } + + private List MapToValidationExceptions(IEnumerable exceptions) + { + return exceptions.Select(GetValidationExceptionWithDetails).Where(x => x != null).ToList()!; } private ServiceResponseModel CreateSuccessResponse(string message) => CreateResponse(true, HttpStatusCode.OK, message); @@ -183,7 +216,7 @@ public async Task UpdateExceptionServiceNowId(int exceptio NhsNumber = long.TryParse(errorRecordData.NhsNumber, out long nhsNumber) ? nhsNumber : 0, GivenName = errorRecordData.FirstName, FamilyName = errorRecordData.FamilyName, - DateOfBirth = MappingUtilities.FormatDateTime(MappingUtilities.ParseDates(errorRecordData.DateOfBirth)), + DateOfBirth = MappingUtilities.FormatDateTime(MappingUtilities.ParseDates(errorRecordData.DateOfBirth ?? string.Empty)), SupersededByNhsNumber = long.TryParse(errorRecordData.SupersededByNhsNumber, out long superseded) ? superseded : null, Gender = errorRecordData.Gender, AddressLine1 = errorRecordData.AddressLine1, @@ -277,10 +310,8 @@ private ServiceResponseModel CreateResponse(bool success, HttpStatusCode statusC private async Task?> GetExceptionRecords(string nhsNumber, string screeningName) { - var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber && x.ScreeningName == screeningName); return exceptions?.ToList(); - } private static string DateToString(DateTime? datetime) @@ -311,4 +342,39 @@ private static List SortExceptions(SortOrder? sortOrder, IE ? [.. filteredList.OrderBy(dateProperty)] : [.. filteredList.OrderByDescending(dateProperty)]; } + + + + private async Task> GetValidationExceptionsByNhsNumber(string nhsNumber) + { + var exceptions = await _validationExceptionDataServiceClient.GetByFilter(x => x.NhsNumber == nhsNumber); + if (exceptions == null || !exceptions.Any()) + { + return []; + } + + return [.. exceptions + .Select(GetValidationExceptionWithDetails) + .Where(x => x != null) + .Cast() + .OrderByDescending(x => x.DateCreated)]; + } + + private static List GenerateExceptionReports(List validationExceptions) + { + return [.. validationExceptions + .Where(x => x.Category.HasValue && (x.Category.Value == 12 || x.Category.Value == 13)) + .GroupBy(x => new + { + Date = x.DateCreated?.Date ?? DateTime.Now.Date, + Category = x.Category + }) + .Select(g => new ValidationExceptionReport + { + ReportDate = g.Key.Date, + Category = g.Key.Category, + ExceptionCount = g.Count() + }) + .OrderByDescending(r => r.ReportDate)]; + } } diff --git a/application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationResult.cs b/application/CohortManager/src/Functions/Shared/Model/DTO/PaginationResult.cs similarity index 77% rename from application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationResult.cs rename to application/CohortManager/src/Functions/Shared/Model/DTO/PaginationResult.cs index 86e3f04c89..3d990b2b3f 100644 --- a/application/CohortManager/src/Functions/Shared/Common/Pagination/PaginationResult.cs +++ b/application/CohortManager/src/Functions/Shared/Model/DTO/PaginationResult.cs @@ -1,8 +1,8 @@ -namespace Common; +namespace Model.Pagination; public class PaginationResult { - public IEnumerable Items { get; set; } + public required IEnumerable Items { get; set; } public bool IsFirstPage { get; set; } public bool HasNextPage { get; set; } public bool HasPreviousPage { get; set; } diff --git a/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs b/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs new file mode 100644 index 0000000000..d995e81b40 --- /dev/null +++ b/application/CohortManager/src/Functions/Shared/Model/DTO/ValidationExceptionsByNhsNumberResponse.cs @@ -0,0 +1,21 @@ +namespace Model.DTO; + +using Model.Pagination; + +/// +/// Response model for NHS number search containing paginated exceptions and associated reports +/// +public class ValidationExceptionsByNhsNumberResponse +{ + public string NhsNumber { get; set; } = string.Empty; + public List Exceptions { get; set; } = []; + public List Reports { get; set; } = []; + public PaginationResult PaginatedExceptions { get; set; } = new() { Items = [] }; +} + +public class ValidationExceptionReport +{ + public DateTime ReportDate { get; set; } + public int? Category { get; set; } + public int ExceptionCount { get; set; } +} diff --git a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs index e90f849288..56d5a7d96f 100644 --- a/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs +++ b/application/CohortManager/src/Functions/screeningDataServices/GetValidationExceptions/GetValidationExceptions.cs @@ -10,7 +10,9 @@ namespace NHS.CohortManager.ScreeningDataServices; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; using Model; +using Model.DTO; using Model.Enums; +using Model.Pagination; /// /// Azure Function for retrieving and managing validation exceptions. @@ -129,4 +131,53 @@ public async Task UpdateExceptionServiceNowId([HttpTrigger(Aut return _createResponse.CreateHttpResponse(HttpStatusCode.InternalServerError, req); } } + + /// + /// Retrieves validation exceptions and reports for a specific NHS number. + /// + [Function(nameof(GetValidationExceptionsByNhsNumber))] + public async Task GetValidationExceptionsByNhsNumber([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req) + { + var nhsNumber = req.Query["nhsNumber"]; + var page = _httpParserHelper.GetQueryParameterAsInt(req, "page", 1); + var pageSize = _httpParserHelper.GetQueryParameterAsInt(req, "pageSize", 10); + + if (string.IsNullOrWhiteSpace(nhsNumber)) + { + return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "NHS number is required."); + } + + var cleanedNhsNumber = nhsNumber.Replace(" ", ""); + if (!ValidationHelper.ValidateNHSNumber(cleanedNhsNumber)) + { + return _createResponse.CreateHttpResponse(HttpStatusCode.BadRequest, req, "Invalid NHS number format."); + } + + try + { + var result = await _validationData.GetExceptionsWithReportsByNhsNumber(cleanedNhsNumber); + + if (result.Exceptions.Count == 0) + { + return _createResponse.CreateHttpResponse(HttpStatusCode.NoContent, req); + } + + var paginatedExceptions = _paginationService.GetPaginatedResult(result.Exceptions.AsQueryable(), page, pageSize); + var headers = _paginationService.AddNavigationHeaders(req, paginatedExceptions); + + var response = new ValidationExceptionsByNhsNumberResponse + { + NhsNumber = result.NhsNumber, + PaginatedExceptions = paginatedExceptions, + Reports = result.Reports + }; + + return _createResponse.CreateHttpResponseWithHeaders(HttpStatusCode.OK, req, JsonSerializer.Serialize(response), headers); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving validation exceptions for provided NHS number"); + return _createResponse.CreateHttpResponse(HttpStatusCode.InternalServerError, req); + } + } } diff --git a/application/CohortManager/src/Web/app/components/conditionalHeaderSearch.tsx b/application/CohortManager/src/Web/app/components/conditionalHeaderSearch.tsx new file mode 100644 index 0000000000..33e8dfde70 --- /dev/null +++ b/application/CohortManager/src/Web/app/components/conditionalHeaderSearch.tsx @@ -0,0 +1,18 @@ +interface ConditionalHeaderSearchProps { + readonly children: React.ReactNode; + readonly pathname: string; +} + +export function ConditionalHeaderSearch({ + children, + pathname, +}: ConditionalHeaderSearchProps) { + const isNoResultsPage = pathname === "/exceptions/noResults"; + + // Don't render the header search if we're on the no results page + if (isNoResultsPage) { + return null; + } + + return <>{children}; +} diff --git a/application/CohortManager/src/Web/app/components/header.tsx b/application/CohortManager/src/Web/app/components/header.tsx index f6af3d909c..f7ed2a5a50 100644 --- a/application/CohortManager/src/Web/app/components/header.tsx +++ b/application/CohortManager/src/Web/app/components/header.tsx @@ -1,5 +1,8 @@ import Link from "next/link"; +import { headers } from "next/headers"; import { auth, signOut } from "@/app/lib/auth"; +import { SearchNhsNumber } from "./search-nhs-number"; +import { ConditionalHeaderSearch } from "./conditionalHeaderSearch"; interface HeaderProps { readonly serviceName?: string; @@ -9,76 +12,87 @@ export default async function Header({ serviceName = process.env.SERVICE_NAME, }: Readonly) { const session = await auth(); + const headersList = await headers(); + const pathname = headersList.get("x-invoke-path") || headersList.get("x-url") || ""; return (
-
- -
+ - NHS - - - {serviceName} - -
+ + {serviceName} + +
+ + {session?.user && ( +
+ + + +
+ )} - {session?.user && ( - - )} + {session?.user && ( + + )} +
); diff --git a/application/CohortManager/src/Web/app/components/search-nhs-number.tsx b/application/CohortManager/src/Web/app/components/search-nhs-number.tsx new file mode 100644 index 0000000000..05f4a0e88b --- /dev/null +++ b/application/CohortManager/src/Web/app/components/search-nhs-number.tsx @@ -0,0 +1,79 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useState, FormEvent } from "react"; + +export function SearchNhsNumber() { + const router = useRouter(); + const [nhsNumber, setNhsNumber] = useState(""); + + const isValidNhsNumber = (value: string): boolean => { + const cleaned = value.replaceAll(" ", ""); + return cleaned.length === 10 && /^\d+$/.test(cleaned); + }; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const cleanedNhsNumber = nhsNumber.replaceAll(" ", ""); + + if (!isValidNhsNumber(nhsNumber)) { + router.push(`/exceptions/noResults`); + return; + } + + router.push(`/exceptions/search?nhsNumber=${encodeURIComponent(cleanedNhsNumber)}`); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setNhsNumber(value); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + const form = e.currentTarget.form; + if (form) { + form.requestSubmit(); + } + } + }; + + return ( + + + + ); +} diff --git a/application/CohortManager/src/Web/app/exceptions/noResults/page.tsx b/application/CohortManager/src/Web/app/exceptions/noResults/page.tsx new file mode 100644 index 0000000000..bbc924ca29 --- /dev/null +++ b/application/CohortManager/src/Web/app/exceptions/noResults/page.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; +import { auth } from "@/app/lib/auth"; +import { canAccessCohortManager } from "@/app/lib/access"; +import Breadcrumb from "@/app/components/breadcrumb"; +import Unauthorised from "@/app/components/unauthorised"; +import { SearchNhsNumber } from "@/app/components/search-nhs-number"; + +export const metadata: Metadata = { + title: `No results - ${process.env.SERVICE_NAME} - NHS`, +}; + +const BREADCRUMB_ITEMS = [ + { label: "Home", url: "/" }, + { label: "Search exceptions", url: "/exceptions/search" }, +]; + +export default async function NoResultsPage() { + const session = await auth(); + const isCohortManager = await canAccessCohortManager(session); + + if (!isCohortManager) { + return ; + } + + return ( + <> + +
+
+
+

No results

+ + + +
+

Try checking the NHS number:

+
    +
  • is 10 digits (like 999 123 4567)
  • +
  • does not contain special characters
  • +
  • does not contain letters
  • +
+
+
+
+
+ + ); +} diff --git a/application/CohortManager/src/Web/app/exceptions/search/page.tsx b/application/CohortManager/src/Web/app/exceptions/search/page.tsx new file mode 100644 index 0000000000..c68a96c7ff --- /dev/null +++ b/application/CohortManager/src/Web/app/exceptions/search/page.tsx @@ -0,0 +1,308 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { auth } from "@/app/lib/auth"; +import { canAccessCohortManager } from "@/app/lib/access"; +import { fetchExceptionsByNhsNumber } from "@/app/lib/fetchExceptions"; +import { getRuleMapping } from "@/app/lib/ruleMapping"; +import ExceptionsTable from "@/app/components/exceptionsTable"; +import Breadcrumb from "@/app/components/breadcrumb"; +import Unauthorised from "@/app/components/unauthorised"; +import { ExceptionDetails } from "@/app/types"; +import Pagination from "@/app/components/pagination"; + +export const metadata: Metadata = { + title: `Search exceptions by NHS number - ${process.env.SERVICE_NAME} - NHS`, +}; + +interface ApiException { + ExceptionId: number; + NhsNumber: string; + DateCreated: string; + RuleId: number; + RuleDescription: string; + ServiceNowId: string | null; + ServiceNowCreatedDate: string | null; +} + +interface ValidationExceptionReport { + ReportDate: string; + FileName: string; + ScreeningName: string; + CohortName: string; + Category: number | null; + ExceptionCount: number; +} + +interface SearchParams { + nhsNumber?: string; + page?: string; +} + +const BREADCRUMB_ITEMS = [ + { label: "Home", url: "/" }, + { label: "Search exceptions", url: "/exceptions/search" }, +]; + +const PAGE_SIZE = 10; +const RELEVANT_REPORT_CATEGORIES = new Set([12, 13]); + +// Helper functions +const parseCurrentPage = (page?: string): number => { + return Math.max(1, Number.parseInt(page || "1", 10)); +}; + +const transformApiException = (exception: ApiException): ExceptionDetails => { + const ruleMapping = getRuleMapping(exception.RuleId, exception.RuleDescription); + return { + exceptionId: exception.ExceptionId, + dateCreated: exception.DateCreated, + shortDescription: ruleMapping.ruleDescription, + nhsNumber: exception.NhsNumber, + serviceNowId: exception.ServiceNowId ?? "", + serviceNowCreatedDate: exception.ServiceNowCreatedDate?.toString() + }; +}; + +const calculatePaginationInfo = (currentPage: number, itemsLength: number, totalCount: number) => { + if (totalCount === 0) return { startItem: 0, endItem: 0 }; + + const startItem = (currentPage - 1) * PAGE_SIZE + 1; + const endItem = Math.min(startItem + itemsLength - 1, totalCount); + return { startItem, endItem }; +}; + +const getCategoryLabel = (category: number): string => { + return category === 13 ? "NHS Number Change" : "Possible Confusion"; +}; + +const formatReportDate = (dateString: string): string => { + if (new RegExp(/^\d{4}-\d{2}-\d{2}/).exec(dateString)) { + return dateString.split('T')[0]; + } + + const date = new Date(dateString); + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0') + ].join('-'); +}; + +const buildReportUrl = (reportDate: string, category: number, nhsNumber: string): string => { + return `/reports/${formatReportDate(reportDate)}?category=${category}&nhsNumber=${nhsNumber}`; +}; + +// Component: No NHS Number State +function NoNhsNumberState() { + return ( +
+
+
+

Search exceptions by NHS number

+

+ Please enter an NHS number in the search box in the header. +

+
+
+
+ ); +} + +// Component: Error State +function ErrorState({ error }: { readonly error: unknown }) { + const errorMessage = error instanceof Error + ? error.message + : "An error occurred while fetching exceptions. Please try again."; + + return ( +
+
+
+

Search exceptions by NHS number

+
+
+

{errorMessage}

+
+
+
+
+
+ ); +} + +// Component: Results Header +function ResultsHeader({ startItem, endItem, totalCount }: { + readonly startItem: number; + readonly endItem: number; + readonly totalCount: number; +}) { + return ( +
+

Exceptions

+

+ Showing {startItem} to {endItem} of {totalCount} results +

+
+ ); +} + +// Component: Reports Table Row +function ReportsTableRow({ report, nhsNumber, index }: { + readonly report: ValidationExceptionReport; + readonly nhsNumber: string; + readonly index: number; +}) { + const categoryLabel = getCategoryLabel(report.Category!); + const reportUrl = buildReportUrl(report.ReportDate, report.Category!, nhsNumber); + const formattedDate = new Date(report.ReportDate).toLocaleDateString("en-GB"); + + return ( + + {formattedDate} + {categoryLabel} + + View report + + + ); +} + +// Component: Reports Section +function ReportsSection({ reports, nhsNumber }: { + readonly reports: ValidationExceptionReport[]; + readonly nhsNumber: string; +}) { + const filteredReports = reports.filter(report => + RELEVANT_REPORT_CATEGORIES.has(report.Category!) + ); + + return ( + <> +

Reports

+
+
+ {filteredReports.length > 0 ? ( + + + + + + + + + + {filteredReports.map((report, index) => ( + + ))} + +
DateDemographic changeAction
+ ) : ( +

No reports available for {nhsNumber}

+ )} +
+
+ + ); +} + +// Main Page Component +export default async function Page({ + searchParams, +}: { + readonly searchParams?: Promise; +}) { + const session = await auth(); + const isCohortManager = await canAccessCohortManager(session); + + if (!isCohortManager) { + return ; + } + + const resolvedSearchParams = searchParams ? await searchParams : {}; + const nhsNumber = resolvedSearchParams.nhsNumber; + const currentPage = parseCurrentPage(resolvedSearchParams.page); + + if (!nhsNumber) { + return ( + <> + + + + ); + } + + const response = await fetchExceptionsByNhsNumber({ + nhsNumber, + page: currentPage, + pageSize: PAGE_SIZE, + }); + + const totalCount = response.data.PaginatedExceptions.TotalItems || 0; + + // Redirect to No Results page if no results found + if (totalCount === 0) { + redirect(`/exceptions/noResults`); + } + + try { + const exceptionDetails = response.data.PaginatedExceptions.Items.map(transformApiException); + const { startItem, endItem } = calculatePaginationInfo( + currentPage, + response.data.PaginatedExceptions.Items.length, + totalCount + ); + + const linkHeader = response.headers?.get("Link") || response.linkHeader; + const totalPages = response.data.PaginatedExceptions.TotalPages || 1; + const reports = response.data.Reports; + + return ( + <> + +
+
+
+

+ Search results for {nhsNumber} +

+ + +
+
+ +
+
+ + {totalPages > 1 && ( + `/exceptions/search?nhsNumber=${nhsNumber}&page=${page}`} + /> + )} + + +
+
+
+ + ); + } catch (error) { + return ( + <> + + + + ); + } +} diff --git a/application/CohortManager/src/Web/app/globals.scss b/application/CohortManager/src/Web/app/globals.scss index 6b9cb0f77c..6100477dab 100644 --- a/application/CohortManager/src/Web/app/globals.scss +++ b/application/CohortManager/src/Web/app/globals.scss @@ -162,3 +162,32 @@ color: #00703c; } +.nhsuk-header__content { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; +} + +.nhsuk-header__service { + white-space: nowrap; + flex-shrink: 0; +} + +.nhsuk-header__search { + flex: 1; + min-width: 250px; +} + +.nhsuk-header .nhsuk-header__search { + margin: 0 100px; +} + +.nhsuk-header__search-form { + display: flex; + fill: currentColor; +} + +.nhsuk-header__account { + flex-shrink: 0; +} diff --git a/application/CohortManager/src/Web/app/lib/fetchExceptions.ts b/application/CohortManager/src/Web/app/lib/fetchExceptions.ts index 8fda1a352b..2bea9faf4e 100644 --- a/application/CohortManager/src/Web/app/lib/fetchExceptions.ts +++ b/application/CohortManager/src/Web/app/lib/fetchExceptions.ts @@ -48,3 +48,58 @@ export async function fetchExceptions(params: FetchExceptionsParams = {}) { headers: response.headers, }; } + +type FetchExceptionsByNhsNumberParams = { + nhsNumber: string; + page?: number; + pageSize?: number; +}; + +export async function fetchExceptionsByNhsNumber( + params: FetchExceptionsByNhsNumberParams +) { + const query = new URLSearchParams(); + + query.append("nhsNumber", params.nhsNumber); + query.append("page", (params.page ?? 1).toString()); + query.append("pageSize", (params.pageSize ?? 10).toString()); + + const apiUrl = `${ + process.env.EXCEPTIONS_API_URL + }/api/GetValidationExceptionsByNhsNumber?${query.toString()}`; + + const response = await fetch(apiUrl); + + if (response.status === 204 || response.status === 404) { + return { + data: { + NhsNumber: params.nhsNumber, + PaginatedExceptions: { + Items: [], + TotalItems: 0, + CurrentPage: params.page ?? 1, + TotalPages: 0, + HasNextPage: false, + HasPreviousPage: false, + IsFirstPage: true, + }, + Reports: [], + }, + linkHeader: null, + headers: response.headers, + }; + } + + if (!response.ok) { + throw new Error(`Error fetching data: ${response.statusText}`); + } + + const data = await response.json(); + const linkHeader = response.headers.get("Link"); + + return { + data, + linkHeader, + headers: response.headers, + }; +} diff --git a/application/CohortManager/src/Web/app/reports/[date]/page.tsx b/application/CohortManager/src/Web/app/reports/[date]/page.tsx index 8a31c11449..66c20000ab 100644 --- a/application/CohortManager/src/Web/app/reports/[date]/page.tsx +++ b/application/CohortManager/src/Web/app/reports/[date]/page.tsx @@ -8,6 +8,7 @@ import Unauthorised from "@/app/components/unauthorised"; import DataError from "@/app/components/dataError"; import ReportsInformationTable from "@/app/components/reportsInformationTable"; import Pagination from "@/app/components/pagination"; +import UserFeedback from "@/app/components/userFeedback"; import { type ExceptionAPIDetails } from "@/app/types/exceptionsApi"; export const metadata: Metadata = { @@ -21,6 +22,7 @@ export default async function Page(props: { readonly searchParams?: Promise<{ readonly category?: string; readonly page?: string; + readonly nhsNumber?: string; }>; }) { const session = await auth(); @@ -41,6 +43,7 @@ export default async function Page(props: { ? await props.searchParams : {}; const categoryId = Number(resolvedSearchParams.category); + const nhsNumber = resolvedSearchParams.nhsNumber; const currentPage = Math.max( 1, Number.parseInt(resolvedSearchParams.page || "1", 10) @@ -66,13 +69,19 @@ export default async function Page(props: { const reportData = response.data; const linkHeader = response.headers?.get("Link") || response.linkHeader; + // Filter items by NHS number if provided + const filteredItems = nhsNumber + ? reportData.Items.filter((item: ExceptionAPIDetails) => item.NhsNumber === nhsNumber) + : reportData.Items; + const totalPages = reportData.TotalPages || 1; - const totalItems = Number(reportData.TotalItems) || 0; - const startItem = totalItems > 0 ? (currentPage - 1) * pageSize + 1 : 0; - const endItem = - totalItems > 0 - ? Math.min(startItem + reportData.Items.length - 1, totalItems) - : 0; + const totalItems = nhsNumber ? filteredItems.length : Number(reportData.TotalItems) || 0; + const filteredStartValue = totalItems > 0 ? 1 : 0; + const paginatedStartValue = totalItems > 0 ? (currentPage - 1) * pageSize + 1 : 0; + const startItem = nhsNumber + ? Math.max(1, filteredStartValue) + : Math.max(0, paginatedStartValue); + const endItem = Math.max(0, totalItems); return ( <> @@ -97,12 +106,10 @@ export default async function Page(props: {
- {reportData.Items?.length ? ( + {filteredItems?.length ? ( ) : (

No report available for {formatDate(date)}

@@ -124,6 +131,7 @@ export default async function Page(props: { )}
+ ); diff --git a/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/GetValidationExceptionTests/GetValidationExceptionsTests.cs b/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/GetValidationExceptionTests/GetValidationExceptionsTests.cs index 13fbed30fd..07088529eb 100644 --- a/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/GetValidationExceptionTests/GetValidationExceptionsTests.cs +++ b/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/GetValidationExceptionTests/GetValidationExceptionsTests.cs @@ -17,7 +17,7 @@ namespace NHS.CohortManager.Tests.UnitTests.ScreeningDataServicesTests; using System.Text; using NHS.CohortManager.Tests.TestUtils; using FluentAssertions; - +using Model.Pagination; [TestClass] public class GetValidationExceptionsTests diff --git a/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/ValidationExceptionDataTests/ValidationExceptionDataTests.cs b/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/ValidationExceptionDataTests/ValidationExceptionDataTests.cs index 1b4abb3fc9..2868019378 100644 --- a/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/ValidationExceptionDataTests/ValidationExceptionDataTests.cs +++ b/tests/UnitTests/ScreeningDataServicesTests/ValidationExceptionDataTests/ValidationExceptionDataTests/ValidationExceptionDataTests.cs @@ -24,15 +24,16 @@ public class ValidationExceptionDataTests public ValidationExceptionDataTests() { - validationExceptionData = new ValidationExceptionData(_logger.Object, _validationExceptionDataServiceClient.Object, _demographicDataServiceClient.Object); + validationExceptionData = new ValidationExceptionData(_logger.Object, _validationExceptionDataServiceClient.Object); _exceptionList = new List { new() { ExceptionId = 1, CohortName = "Cohort1", DateCreated = DateTime.UtcNow.Date, NhsNumber = "1111111111", RuleDescription = "RuleA", Category = 3, ServiceNowId = "ServiceNow1", ServiceNowCreatedDate = DateTime.UtcNow.Date }, new() { ExceptionId = 2, CohortName = "Cohort2", DateCreated = DateTime.UtcNow.Date.AddDays(-1), NhsNumber = "2222222222", RuleDescription = "RuleB", Category = 3, ServiceNowId = "ServiceNow2", ServiceNowCreatedDate = DateTime.UtcNow.Date.AddDays(-1) }, new() { ExceptionId = 3, CohortName = "Cohort3", DateCreated = DateTime.UtcNow.Date.AddDays(-2), NhsNumber = "3333333333", RuleDescription = "RuleC", Category = 3, ServiceNowId = null }, new() { ExceptionId = 4, CohortName = "Cohort4", DateCreated = DateTime.Today.AddDays(-3), NhsNumber = "4444444444", RuleDescription = "RuleD", Category = 3, ServiceNowId = null }, - new() { ExceptionId = 5, CohortName = "Cohort5", DateCreated = DateTime.UtcNow.Date, NhsNumber = "5555555555", RuleDescription = "Confusion Rule", Category = 12, ServiceNowId = null }, - new() { ExceptionId = 6, CohortName = "Cohort6", DateCreated = DateTime.UtcNow.Date.AddDays(-1), NhsNumber = "6666666666", RuleDescription = "Superseded Rule", Category = 13, ServiceNowId = null } + new() { ExceptionId = 5, CohortName = "Cohort5", DateCreated = DateTime.UtcNow.Date, NhsNumber = "9998136431", RuleDescription = "Confusion Rule", Category = 12, ServiceNowId = null, ErrorRecord = "{\"NhsNumber\":\"9998136431\",\"FirstName\":\"John\",\"FamilyName\":\"Doe\"}" }, + new() { ExceptionId = 6, CohortName = "Cohort6", DateCreated = DateTime.UtcNow.Date.AddDays(-1), NhsNumber = "9998136431", RuleDescription = "Superseded Rule", Category = 13, ServiceNowId = null, ErrorRecord = "{\"NhsNumber\":\"9998136431\",\"FirstName\":\"Jane\",\"FamilyName\":\"Smith\"}" }, + new() { ExceptionId = 7, CohortName = "Cohort7", DateCreated = DateTime.UtcNow.Date.AddDays(-2), NhsNumber = "9998136431", RuleDescription = "Other Rule", Category = 5, ServiceNowId = null, ErrorRecord = "{\"NhsNumber\":\"9998136431\",\"FirstName\":\"Bob\",\"FamilyName\":\"Johnson\"}" } }; _exceptionCategory = ExceptionCategory.NBO; } @@ -614,4 +615,67 @@ public async Task UpdateExceptionServiceNowId_NullServiceNowId_ServiceNowIdAndSe _exceptionList[0].ServiceNowId.Should().BeNull(); _exceptionList[0].ServiceNowCreatedDate.Should().BeNull(); } + + [TestMethod] + public async Task GetExceptionsWithReportsByNhsNumber_ValidNhsNumber_ReturnsExceptionsAndReports() + { + var nhsNumber = "9998136431"; + var testExceptions = _exceptionList.Where(e => e.NhsNumber == nhsNumber).ToList(); + _validationExceptionDataServiceClient.Setup(x => x.GetByFilter(It.IsAny>>())).ReturnsAsync(testExceptions); + + var result = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); + + result.Exceptions.Should().NotBeNull(); + result.Exceptions.Should().HaveCount(3); + result.Exceptions.Should().BeInDescendingOrder(exceptions => exceptions.DateCreated); + result.Reports.Should().NotBeNull(); + result.Reports.Should().HaveCount(2); + result.Reports.Should().Contain(report => report.Category == 12 && report.ExceptionCount == 1); + result.Reports.Should().Contain(report => report.Category == 13 && report.ExceptionCount == 1); + result.Reports.Should().BeInDescendingOrder(report => report.ReportDate); + result.NhsNumber.Should().Be(nhsNumber); + } + + [TestMethod] + public async Task GetExceptionsWithReportsByNhsNumber_ValidNhsNumber_ReturnsCorrectData() + { + var nhsNumber = "9998136431"; + var testExceptions = _exceptionList.Where(e => e.NhsNumber == nhsNumber).ToList(); + _validationExceptionDataServiceClient.Setup(x => x.GetByFilter(It.IsAny>>())).ReturnsAsync(testExceptions); + + var result = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); + + result.Exceptions.Should().HaveCount(3); + result.NhsNumber.Should().Be(nhsNumber); + result.Reports.Should().HaveCount(2); + } + + [TestMethod] + public async Task GetExceptionsWithReportsByNhsNumber_NoExceptionsFound_ReturnsEmptyResults() + { + var nhsNumber = "1234567890"; + _validationExceptionDataServiceClient.Setup(x => x.GetByFilter(It.IsAny>>())).ReturnsAsync(new List()); + + var result = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); + + result.Exceptions.Should().NotBeNull(); + result.Exceptions.Should().BeEmpty(); + result.Reports.Should().NotBeNull(); + result.Reports.Should().BeEmpty(); + result.NhsNumber.Should().Be(nhsNumber); + } + + [TestMethod] + public async Task GetExceptionsWithReportsByNhsNumber_OnlyNonReportCategories_ReturnsEmptyReports() + { + var nhsNumber = "3333333333"; + var testExceptions = _exceptionList.Where(e => e.NhsNumber == nhsNumber).ToList(); + _validationExceptionDataServiceClient.Setup(x => x.GetByFilter(It.IsAny>>())).ReturnsAsync(testExceptions); + + var result = await validationExceptionData.GetExceptionsWithReportsByNhsNumber(nhsNumber); + + result.Exceptions.Should().HaveCount(1); + result.Reports.Should().BeEmpty(); + result.NhsNumber.Should().Be(nhsNumber); + } }