diff --git a/client/src/components/ProjectIdentificationStage.tsx b/client/src/components/ProjectIdentificationStage.tsx new file mode 100644 index 0000000..1137a09 --- /dev/null +++ b/client/src/components/ProjectIdentificationStage.tsx @@ -0,0 +1,234 @@ +import { useMemo, useState } from 'react'; +import { DataTable } from '@/shared/dataTable.tsx'; +import { + currentFiscalYear, + projectListQueryOptions, + type ProjectListRow, + type ProjectListStatus, + type ProjectListSummary, +} from '@/queries/projectList.ts'; +import { useQuery } from '@tanstack/react-query'; +import type { ColumnDef } from '@tanstack/react-table'; + +type ProjectListTab = 'issues' | 'clean' | 'all'; + +const tabs: { id: ProjectListTab; label: string }[] = [ + { id: 'issues', label: 'Issues' }, + { id: 'clean', label: 'Clean' }, + { id: 'all', label: 'All' }, +]; + +function displayValue(value: string | null): string { + return value && value.trim() ? value : '-'; +} + +function displayStatus(status: ProjectListStatus): string { + return status === '204 outside college' ? '204 outside CAES' : status; +} + +function statusClassName(status: ProjectListStatus): string { + if (status === 'Clean') { + return 'badge badge-success badge-outline'; + } + + if (status === '204 outside college' || status === 'SFN mismatch') { + return 'badge badge-warning'; + } + + return 'badge badge-error badge-outline'; +} + +function rowsForTab(rows: ProjectListRow[], tab: ProjectListTab) { + if (tab === 'issues') { + return rows.filter((row) => row.status !== 'Clean'); + } + + if (tab === 'clean') { + return rows.filter((row) => row.status === 'Clean'); + } + + return rows; +} + +function sfnDistributionText(summary: ProjectListSummary): string { + if (summary.sfnDistribution.length === 0) { + return '-'; + } + + return summary.sfnDistribution + .map((item) => `${item.sfn}: ${item.count}`) + .join(' / '); +} + +export function ProjectIdentificationStage() { + const fiscalYear = currentFiscalYear(); + const { data, error, isError, isFetching, isLoading, refetch } = useQuery( + projectListQueryOptions(fiscalYear) + ); + const [activeTab, setActiveTab] = useState('issues'); + + const columns = useMemo[]>( + () => [ + { + accessorFn: (row) => row.nifaProject ?? '', + cell: ({ row }) => displayValue(row.original.nifaProject), + header: 'NIFA Project', + id: 'nifaProject', + }, + { + accessorFn: (row) => row.accession ?? '', + cell: ({ row }) => displayValue(row.original.accession), + header: 'Accession', + id: 'accession', + }, + { + accessorFn: (row) => row.awardNumber ?? '', + cell: ({ row }) => displayValue(row.original.awardNumber), + header: 'Award #', + id: 'awardNumber', + }, + { + accessorFn: (row) => row.ae ?? '', + cell: ({ row }) => displayValue(row.original.ae), + header: 'AE', + id: 'ae', + }, + { + accessorFn: (row) => row.pi ?? '', + cell: ({ row }) => displayValue(row.original.pi), + header: 'PI', + id: 'pi', + }, + { + accessorFn: (row) => row.orgr ?? '', + cell: ({ row }) => displayValue(row.original.orgr), + header: 'ORGR', + id: 'orgr', + }, + { + accessorFn: (row) => row.sfn ?? '', + cell: ({ row }) => displayValue(row.original.sfn), + header: 'SFN', + id: 'sfn', + }, + { + accessorFn: (row) => displayStatus(row.status), + cell: ({ row }) => ( + + {displayStatus(row.original.status)} + + ), + header: 'Status', + id: 'status', + }, + ], + [] + ); + + if (isLoading) { + return

Loading project list...

; + } + + if (isError) { + const message = + error instanceof Error + ? error.message + : 'The project list could not be loaded.'; + + return ( +
+
+

Unable to load project list

+

{message}

+ +
+
+ ); + } + + if (!data) { + return

Loading project list...

; + } + + const visibleRows = rowsForTab(data.rows, activeTab); + const summaryCards = [ + ['Active NIFA', data.summary.activeNifa], + ['All NIFA', data.summary.allNifa], + ['PGM records', data.summary.pgmRecords], + ['ALN codes', data.summary.alnCodes], + ['Issues to resolve', data.summary.issuesToResolve], + ['SFN distribution', sfnDistributionText(data.summary)], + ]; + + return ( +
+
+
+

+ Reference & Issue Resolution +

+

+ Project list · {data.counts.all} +

+
+ +
+ +
+
+ {summaryCards.map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+ +
+ {tabs.map((tab) => { + const count = data.counts[tab.id]; + + return ( + + ); + })} +
+ + +
+
+ ); +} diff --git a/client/src/queries/projectList.ts b/client/src/queries/projectList.ts new file mode 100644 index 0000000..52d5652 --- /dev/null +++ b/client/src/queries/projectList.ts @@ -0,0 +1,68 @@ +import { fetchJson } from '@/lib/api.ts'; +import { queryOptions } from '@tanstack/react-query'; + +export type ProjectListStatus = + | '204 outside college' + | 'Clean' + | 'Expired' + | 'No PGM match' + | 'Not in All Projects' + | 'SFN mismatch' + | string; + +export interface ProjectListRow { + accession: string | null; + ae: string | null; + awardNumber: string | null; + nifaProject: string | null; + orgr: string | null; + pi: string | null; + sfn: string | null; + status: ProjectListStatus; +} + +export interface ProjectListCounts { + all: number; + clean: number; + issues: number; +} + +export interface SfnDistribution { + count: number; + sfn: string; +} + +export interface ProjectListSummary { + activeNifa: number; + allNifa: number; + alnCodes: number; + issuesToResolve: number; + pgmRecords: number; + sfnDistribution: SfnDistribution[]; +} + +export interface ProjectListResponse { + counts: ProjectListCounts; + cycleEnd: string; + cycleStart: string; + fiscalYear: string; + rows: ProjectListRow[]; + summary: ProjectListSummary; +} + +export function currentFiscalYear(date = new Date()): string { + const calendarYear = date.getFullYear(); + const fiscalYear = date.getMonth() >= 9 ? calendarYear + 1 : calendarYear; + return `FY${String(fiscalYear % 100).padStart(2, '0')}`; +} + +export const projectListQueryOptions = (fiscalYear = currentFiscalYear()) => + queryOptions({ + queryFn: ({ signal }) => + fetchJson( + `/api/projectlist?fy=${encodeURIComponent(fiscalYear)}`, + {}, + signal + ), + queryKey: ['projectList', fiscalYear] as const, + }); diff --git a/client/src/routes/(authenticated)/workflow.$stageId.tsx b/client/src/routes/(authenticated)/workflow.$stageId.tsx index 3f0b8a8..e0aff34 100644 --- a/client/src/routes/(authenticated)/workflow.$stageId.tsx +++ b/client/src/routes/(authenticated)/workflow.$stageId.tsx @@ -4,6 +4,7 @@ import { } from '@/mockData.ts'; import { workflowSnapshotQueryOptions } from '@/queries.ts'; import { DataClassificationStage } from '@/components/dataClassification/DataClassificationStage.tsx'; +import { ProjectIdentificationStage } from '@/components/ProjectIdentificationStage.tsx'; import { SectionPanel } from '@/components/SectionPanel.tsx'; import { FlatFileImportPanel } from '@/components/FlatFileImportPanel.tsx'; import { WorkflowShell } from '@/components/WorkflowShell.tsx'; @@ -48,9 +49,12 @@ function WorkflowStageRoute() {
{workflowStageId === 'project-identification' ? ( - - - + <> + + + + + ) : workflowStageId === 'data-classification' ? ( ) : ( diff --git a/client/src/test/routes/(authenticated)/workflow.test.tsx b/client/src/test/routes/(authenticated)/workflow.test.tsx index 867ba13..0fa5e1f 100644 --- a/client/src/test/routes/(authenticated)/workflow.test.tsx +++ b/client/src/test/routes/(authenticated)/workflow.test.tsx @@ -1,8 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { screen, waitFor } from '@testing-library/react'; import { server } from '@/test/mswUtils.ts'; import { renderRoute } from '@/test/routerUtils.tsx'; +import { userEvent } from '@testing-library/user-event'; const mockUser = { email: 'shannon@example.edu', @@ -11,14 +12,80 @@ const mockUser = { roles: ['User'], }; +const projectListResponse = { + counts: { + all: 3, + clean: 1, + issues: 2, + }, + cycleEnd: '2026-09-30', + cycleStart: '2025-10-01', + fiscalYear: 'FY26', + rows: [ + { + accession: '1053852', + ae: 'K1234', + awardNumber: '2025-111', + nifaProject: 'CA-A-111-H', + orgr: 'ATM', + pi: 'Larkspur, S.', + sfn: '201', + status: 'Clean', + }, + { + accession: '1055356', + ae: 'K2222', + awardNumber: '2025-222', + nifaProject: 'CA-B-222-CG', + orgr: 'ANS', + pi: 'Okonkwo, Y.', + sfn: '204', + status: '204 outside college', + }, + { + accession: '1078258', + ae: null, + awardNumber: '2025-333', + nifaProject: 'CA-C-333-CG', + orgr: 'VEN', + pi: 'Naidoo, T.', + sfn: '204', + status: 'No PGM match', + }, + ], + summary: { + activeNifa: 25, + allNifa: 42, + alnCodes: 7, + issuesToResolve: 2, + pgmRecords: 18, + sfnDistribution: [ + { count: 1, sfn: '201' }, + { count: 2, sfn: '204' }, + ], + }, +}; + +afterEach(() => { + vi.useRealTimers(); +}); + describe('AD419 workflow routes', () => { it('redirects the authenticated homepage to the first workflow stage', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date('2026-07-07T12:00:00-07:00')); + let requestedFy: string | null = null; + server.use( http.get('/api/user/me', () => { return HttpResponse.json(mockUser); }), http.get('/api/imports/recent', () => { return HttpResponse.json([]); + }), + http.get('/api/projectlist', ({ request }) => { + requestedFy = new URL(request.url).searchParams.get('fy'); + return HttpResponse.json(projectListResponse); }) ); @@ -34,12 +101,148 @@ describe('AD419 workflow routes', () => { ).toBeInTheDocument(); expect(screen.getByLabelText('Dataset')).toBeInTheDocument(); expect(screen.getByLabelText('Import file')).toBeInTheDocument(); + expect( + await screen.findByRole('heading', { name: 'Project list · 3' }) + ).toBeInTheDocument(); + expect(screen.getByText('Active NIFA')).toBeInTheDocument(); + expect(screen.getByText('Issues to resolve')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /issues\s*2/i })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect( + screen.getByRole('tab', { name: /clean\s*1/i }) + ).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /all\s*3/i })).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'NIFA Project' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'Accession' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'Award #' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'AE' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'PI' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'ORGR' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'SFN' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'Status' }) + ).toBeInTheDocument(); + expect(screen.getByText('204 outside CAES')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Finalize' })).toBeDisabled(); await waitFor(() => { expect(router.state.location.pathname).toBe( '/workflow/project-identification' ); }); + expect(requestedFy).toBe('FY26'); + } finally { + cleanup(); + } + }); + + it('filters the project list tabs and search text', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date('2026-07-07T12:00:00-07:00')); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + server.use( + http.get('/api/user/me', () => { + return HttpResponse.json(mockUser); + }), + http.get('/api/imports/recent', () => { + return HttpResponse.json([]); + }), + http.get('/api/projectlist', () => { + return HttpResponse.json(projectListResponse); + }) + ); + + const { cleanup } = renderRoute({ + initialPath: '/workflow/project-identification', + }); + + try { + expect(await screen.findByText('Okonkwo, Y.')).toBeInTheDocument(); + expect(screen.queryByText('Larkspur, S.')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: /clean\s*1/i })); + expect(await screen.findByText('Larkspur, S.')).toBeInTheDocument(); + expect(screen.queryByText('Okonkwo, Y.')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: /all\s*3/i })); + expect(await screen.findByText('Naidoo, T.')).toBeInTheDocument(); + await user.type( + screen.getByPlaceholderText('Search project, accession, PI...'), + '1078258' + ); + + expect(screen.getByText('Naidoo, T.')).toBeInTheDocument(); + expect(screen.queryByText('Okonkwo, Y.')).not.toBeInTheDocument(); + expect(screen.queryByText('Larkspur, S.')).not.toBeInTheDocument(); + } finally { + cleanup(); + } + }); + + it('shows project list errors and retries the query', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date('2026-07-07T12:00:00-07:00')); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + let projectListRequests = 0; + + server.use( + http.get('/api/user/me', () => { + return HttpResponse.json(mockUser); + }), + http.get('/api/imports/recent', () => { + return HttpResponse.json([]); + }), + http.get('/api/projectlist', () => { + projectListRequests += 1; + + if (projectListRequests === 1) { + return HttpResponse.json( + { message: 'Project list unavailable.' }, + { status: 500 } + ); + } + + return HttpResponse.json(projectListResponse); + }) + ); + + const { cleanup } = renderRoute({ + initialPath: '/workflow/project-identification', + }); + + try { + expect( + await screen.findByRole('heading', { + name: 'Unable to load project list', + }) + ).toBeInTheDocument(); + expect( + screen.queryByText('Loading project list...') + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Retry' })); + + expect( + await screen.findByRole('heading', { name: 'Project list · 3' }) + ).toBeInTheDocument(); + expect(projectListRequests).toBe(2); } finally { cleanup(); } diff --git a/database/data/StoredProcedures/GetProjectList.sql b/database/data/StoredProcedures/GetProjectList.sql index 97e7f2d..b7e62ab 100644 --- a/database/data/StoredProcedures/GetProjectList.sql +++ b/database/data/StoredProcedures/GetProjectList.sql @@ -34,6 +34,8 @@ BEGIN pgm.ProjectId, pgm.ProjectNumber, pgm.SponsorAwardNumber, + pgm.AwardStartDate, + pgm.AwardEndDate, REPLACE(pgm.SponsorAwardNumber, '-', '') AS AwardKey, CASE WHEN aln.ProgramNumber IS NULL THEN NULL @@ -66,6 +68,9 @@ BEGIN a.ProjectNumber AS NifaProjectNumber, ap.AwardNumber AS NifaAwardNumber, ap.Title, + a.ProjectDirector AS ActiveProjectDirector, + ap.ProjectDirector AS AllProjectDirector, + ap.Department AS Orgr, ap.ProjectStartDate, ap.ProjectEndDate, CASE WHEN ap.ProjectNumber IS NULL THEN 0 ELSE 1 END AS InAllProjects, @@ -77,7 +82,9 @@ BEGIN ELSE 'UNKNOWN' -- unrecognized suffix: fail closed below, never silently Clean END AS NifaSfn FROM [data].[ActiveProjects] a - LEFT JOIN [data].[AllProjects] ap ON ap.ProjectNumber = a.ProjectNumber + LEFT JOIN [data].[AllProjects] ap + ON NULLIF(LTRIM(RTRIM(ap.ProjectNumber)), '') = NULLIF(LTRIM(RTRIM(a.ProjectNumber)), '') + AND NULLIF(LTRIM(RTRIM(ap.AccessionNumber)), '') = NULLIF(LTRIM(RTRIM(a.AccessionNumber)), '') WHERE ISNULL(a.ExcludeFromUi, 0) = 0 ), ActiveWithPgm AS @@ -87,6 +94,8 @@ BEGIN ac.NifaProjectNumber, ac.NifaAwardNumber, ac.Title, + COALESCE(NULLIF(ac.ActiveProjectDirector, ''), NULLIF(ac.AllProjectDirector, '')) AS Pi, + ac.Orgr, ac.ProjectStartDate, ac.ProjectEndDate, ac.InAllProjects, @@ -124,12 +133,14 @@ BEGIN -- Not in All Projects SELECT - CAST(AccessionNumber AS NVARCHAR(50)) AS AccessionNumber, - CAST(NifaProjectNumber AS NVARCHAR(20)) AS NifaProjectNumber, - CAST(NifaAwardNumber AS NVARCHAR(100)) AS NifaAwardNumber, - CAST(Title AS NVARCHAR(MAX)) AS Title, - CAST(PgmProjectNumbers AS NVARCHAR(MAX)) AS PgmProjectNumbers, - CAST('Not in All Projects' AS NVARCHAR(30)) AS status + CAST(NifaProjectNumber AS NVARCHAR(20)) AS NifaProject, + CAST(AccessionNumber AS NVARCHAR(50)) AS Accession, + CAST(NifaAwardNumber AS NVARCHAR(100)) AS AwardNumber, + CAST(PgmProjectNumbers AS NVARCHAR(MAX)) AS Ae, + CAST(Pi AS NVARCHAR(200)) AS Pi, + CAST(Orgr AS NVARCHAR(300)) AS Orgr, + CAST(NifaSfn AS NVARCHAR(10)) AS Sfn, + CAST('Not in All Projects' AS NVARCHAR(30)) AS Status FROM ActiveWithPgm WHERE InAllProjects = 0 @@ -137,11 +148,13 @@ BEGIN -- Expired (project dates fall outside the cycle window) SELECT - CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaProjectNumber AS NVARCHAR(20)), + CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaAwardNumber AS NVARCHAR(100)), - CAST(Title AS NVARCHAR(MAX)), CAST(PgmProjectNumbers AS NVARCHAR(MAX)), + CAST(Pi AS NVARCHAR(200)), + CAST(Orgr AS NVARCHAR(300)), + CAST(NifaSfn AS NVARCHAR(10)), CAST('Expired' AS NVARCHAR(30)) FROM ActiveWithPgm WHERE InAllProjects = 1 @@ -154,11 +167,13 @@ BEGIN -- No PGM match SELECT - CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaProjectNumber AS NVARCHAR(20)), + CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaAwardNumber AS NVARCHAR(100)), - CAST(Title AS NVARCHAR(MAX)), CAST(PgmProjectNumbers AS NVARCHAR(MAX)), + CAST(Pi AS NVARCHAR(200)), + CAST(Orgr AS NVARCHAR(300)), + CAST(NifaSfn AS NVARCHAR(10)), CAST('No PGM match' AS NVARCHAR(30)) FROM ActiveWithPgm WHERE InAllProjects = 1 @@ -168,11 +183,13 @@ BEGIN -- SFN mismatch SELECT - CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaProjectNumber AS NVARCHAR(20)), + CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaAwardNumber AS NVARCHAR(100)), - CAST(Title AS NVARCHAR(MAX)), CAST(PgmProjectNumbers AS NVARCHAR(MAX)), + CAST(Pi AS NVARCHAR(200)), + CAST(Orgr AS NVARCHAR(300)), + CAST(NifaSfn AS NVARCHAR(10)), CAST('SFN mismatch' AS NVARCHAR(30)) FROM ActiveWithPgm WHERE HasSfnMismatch = 1 @@ -181,11 +198,13 @@ BEGIN -- Clean (fails nothing) SELECT - CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaProjectNumber AS NVARCHAR(20)), + CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaAwardNumber AS NVARCHAR(100)), - CAST(Title AS NVARCHAR(MAX)), CAST(PgmProjectNumbers AS NVARCHAR(MAX)), + CAST(Pi AS NVARCHAR(200)), + CAST(Orgr AS NVARCHAR(300)), + CAST(NifaSfn AS NVARCHAR(10)), CAST('Clean' AS NVARCHAR(30)) FROM ActiveWithPgm WHERE InAllProjects = 1 @@ -200,19 +219,25 @@ BEGIN -- 204 outside college: a PGM 204 award not tied to any active project SELECT - CAST(NULL AS NVARCHAR(50)) AS AccessionNumber, - CAST(NULL AS NVARCHAR(20)) AS NifaProjectNumber, - CAST(pc.SponsorAwardNumber AS NVARCHAR(100)) AS NifaAwardNumber, - CAST(NULL AS NVARCHAR(MAX)) AS Title, - STRING_AGG(CAST(pc.ProjectNumber AS NVARCHAR(MAX)), ', ') AS PgmProjectNumbers, - CAST('204 outside college' AS NVARCHAR(30)) AS status + CAST(NULL AS NVARCHAR(20)) AS NifaProject, + CAST(NULL AS NVARCHAR(50)) AS Accession, + CAST(pc.SponsorAwardNumber AS NVARCHAR(100)) AS AwardNumber, + STRING_AGG(CAST(pc.ProjectNumber AS NVARCHAR(MAX)), ', ') AS Ae, + CAST(NULL AS NVARCHAR(200)) AS Pi, + CAST(NULL AS NVARCHAR(300)) AS Orgr, + CAST('204' AS NVARCHAR(10)) AS Sfn, + CAST('204 outside college' AS NVARCHAR(30)) AS Status FROM PgmClassified pc WHERE pc.PgmSfnBucket = '204' + AND (pc.AwardEndDate IS NULL OR pc.AwardEndDate >= @cycleStart) + AND (pc.AwardStartDate IS NULL OR pc.AwardStartDate <= @cycleEnd) AND NOT EXISTS ( SELECT 1 FROM [data].[ActiveProjects] a - JOIN [data].[AllProjects] ap ON ap.ProjectNumber = a.ProjectNumber + JOIN [data].[AllProjects] ap + ON NULLIF(LTRIM(RTRIM(ap.ProjectNumber)), '') = NULLIF(LTRIM(RTRIM(a.ProjectNumber)), '') + AND NULLIF(LTRIM(RTRIM(ap.AccessionNumber)), '') = NULLIF(LTRIM(RTRIM(a.AccessionNumber)), '') WHERE ISNULL(a.ExcludeFromUi, 0) = 0 AND ap.AwardNumber IS NOT NULL AND REPLACE(ap.AwardNumber, '-', '') = pc.AwardKey diff --git a/server/Controllers/ProjectListController.cs b/server/Controllers/ProjectListController.cs new file mode 100644 index 0000000..0f72224 --- /dev/null +++ b/server/Controllers/ProjectListController.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; +using Server.Models; +using Server.ProjectList; + +namespace Server.Controllers; + +public sealed class ProjectListController(IProjectListService projectListService) : ApiControllerBase +{ + [HttpGet] + public async Task Get([FromQuery] string? fy, CancellationToken cancellationToken) + { + if (!FiscalYearCycle.TryParse(fy, out var cycle)) + { + return BadRequest("A fiscal year query value like FY26 is required."); + } + + var response = await projectListService.GetAsync(cycle!, cancellationToken); + return Ok(response); + } +} diff --git a/server/Models/FiscalYearCycle.cs b/server/Models/FiscalYearCycle.cs new file mode 100644 index 0000000..447ba03 --- /dev/null +++ b/server/Models/FiscalYearCycle.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace Server.Models; + +public sealed record FiscalYearCycle( + string FiscalYear, + DateOnly CycleStart, + DateOnly CycleEnd) +{ + public static bool TryParse(string? fiscalYear, out FiscalYearCycle? cycle) + { + cycle = null; + + if (string.IsNullOrWhiteSpace(fiscalYear)) + { + return false; + } + + var normalized = fiscalYear.Trim().ToUpperInvariant(); + var yearText = normalized.StartsWith("FY", StringComparison.Ordinal) + ? normalized[2..] + : string.Empty; + + if (yearText.Length is not (2 or 4) || + !int.TryParse(yearText, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedYear)) + { + return false; + } + + var endYear = yearText.Length == 2 ? 2000 + parsedYear : parsedYear; + if (endYear is < 2 or > 9999) + { + return false; + } + + var startYear = endYear - 1; + cycle = new FiscalYearCycle( + $"FY{endYear % 100:00}", + new DateOnly(startYear, 10, 1), + new DateOnly(endYear, 9, 30)); + return true; + } +} diff --git a/server/Models/ProjectList/ProjectListDtos.cs b/server/Models/ProjectList/ProjectListDtos.cs new file mode 100644 index 0000000..7c75057 --- /dev/null +++ b/server/Models/ProjectList/ProjectListDtos.cs @@ -0,0 +1,36 @@ +namespace Server.Models.ProjectList; + +public sealed record ProjectListResponse( + string FiscalYear, + DateOnly CycleStart, + DateOnly CycleEnd, + ProjectListCountsDto Counts, + ProjectListSummaryDto Summary, + IReadOnlyList Rows); + +public sealed record ProjectListCountsDto( + int Issues, + int Clean, + int All); + +public sealed record ProjectListSummaryDto( + int ActiveNifa, + int AllNifa, + int PgmRecords, + int AlnCodes, + int IssuesToResolve, + IReadOnlyList SfnDistribution); + +public sealed record SfnDistributionDto( + string Sfn, + int Count); + +public sealed record ProjectListRowDto( + string? NifaProject, + string? Accession, + string? AwardNumber, + string? Ae, + string? Pi, + string? Orgr, + string? Sfn, + string Status); diff --git a/server/Program.cs b/server/Program.cs index dda14fe..5b05935 100644 --- a/server/Program.cs +++ b/server/Program.cs @@ -10,6 +10,7 @@ using Server.Core.Import; using Server.Core.Notification; using Server.Helpers; +using Server.ProjectList; var builder = WebApplication.CreateBuilder(args); @@ -55,6 +56,7 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // add auth policies here // add db context (check secrets first, then config, then default) diff --git a/server/ProjectList/IProjectListService.cs b/server/ProjectList/IProjectListService.cs new file mode 100644 index 0000000..2387826 --- /dev/null +++ b/server/ProjectList/IProjectListService.cs @@ -0,0 +1,9 @@ +using Server.Models.ProjectList; +using Server.Models; + +namespace Server.ProjectList; + +public interface IProjectListService +{ + Task GetAsync(FiscalYearCycle cycle, CancellationToken cancellationToken); +} diff --git a/server/ProjectList/ProjectListResponseFactory.cs b/server/ProjectList/ProjectListResponseFactory.cs new file mode 100644 index 0000000..566814f --- /dev/null +++ b/server/ProjectList/ProjectListResponseFactory.cs @@ -0,0 +1,41 @@ +using Server.Models.ProjectList; +using Server.Models; + +namespace Server.ProjectList; + +public static class ProjectListResponseFactory +{ + private const string CleanStatus = "Clean"; + + public static ProjectListResponse Create( + FiscalYearCycle cycle, + IReadOnlyList rows, + int activeNifa, + int allNifa, + int pgmRecords, + int alnCodes) + { + var issues = rows.Count(row => row.Status != CleanStatus); + var clean = rows.Count(row => row.Status == CleanStatus); + var sfnDistribution = rows + .Where(row => !string.IsNullOrWhiteSpace(row.Sfn)) + .GroupBy(row => row.Sfn!) + .OrderBy(group => group.Key) + .Select(group => new SfnDistributionDto(group.Key, group.Count())) + .ToList(); + + return new ProjectListResponse( + cycle.FiscalYear, + cycle.CycleStart, + cycle.CycleEnd, + new ProjectListCountsDto(issues, clean, rows.Count), + new ProjectListSummaryDto( + activeNifa, + allNifa, + pgmRecords, + alnCodes, + issues, + sfnDistribution), + rows); + } +} diff --git a/server/ProjectList/ProjectListService.cs b/server/ProjectList/ProjectListService.cs new file mode 100644 index 0000000..5c7dd75 --- /dev/null +++ b/server/ProjectList/ProjectListService.cs @@ -0,0 +1,83 @@ +using System.Data; +using Dapper; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Server.Core.Data; +using Server.Models; +using Server.Models.ProjectList; + +namespace Server.ProjectList; + +public sealed class ProjectListService( + DataDbContext dataDbContext, + IConfiguration configuration) : IProjectListService +{ + public async Task GetAsync(FiscalYearCycle cycle, CancellationToken cancellationToken) + { + var connectionString = DataDbConnection.Resolve( + configuration, + dataDbContext.Database.GetConnectionString()); + + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + + var rows = (await connection.QueryAsync(new CommandDefinition( + "[data].[GetProjectList]", + new + { + cycleStart = cycle.CycleStart.ToDateTime(TimeOnly.MinValue), + cycleEnd = cycle.CycleEnd.ToDateTime(TimeOnly.MinValue), + }, + commandType: CommandType.StoredProcedure, + commandTimeout: DataDbConnection.ImportCommandTimeoutSeconds, + cancellationToken: cancellationToken))).ToList(); + + var summaryCounts = await connection.QuerySingleAsync(new CommandDefinition( + SummaryCountsSql, + new + { + cycleStart = cycle.CycleStart.ToDateTime(TimeOnly.MinValue), + cycleEnd = cycle.CycleEnd.ToDateTime(TimeOnly.MinValue), + }, + commandTimeout: DataDbConnection.ImportCommandTimeoutSeconds, + cancellationToken: cancellationToken)); + + return ProjectListResponseFactory.Create( + cycle, + rows, + summaryCounts.ActiveNifa, + summaryCounts.AllNifa, + summaryCounts.PgmRecords, + summaryCounts.AlnCodes); + } + + private const string SummaryCountsSql = """ + SELECT + ActiveNifa = ( + SELECT COUNT(*) + FROM [data].[ActiveProjects] + WHERE ISNULL([ExcludeFromUi], 0) = 0 + ), + AllNifa = ( + SELECT COUNT(*) + FROM [data].[AllProjects] + WHERE ([ProjectEndDate] IS NULL OR [ProjectEndDate] >= @cycleStart) + AND ([ProjectStartDate] IS NULL OR [ProjectStartDate] <= @cycleEnd) + ), + PgmRecords = ( + SELECT COUNT(*) + FROM [data].[PGMProjects] + ), + AlnCodes = ( + SELECT COUNT(*) + FROM [data].[AssistanceListingNumbers] + WHERE [ProgramNumber] IS NOT NULL + ); + """; + + private sealed record ProjectListSummaryCounts( + int ActiveNifa, + int AllNifa, + int PgmRecords, + int AlnCodes); +} diff --git a/tests/server.tests/Models/FiscalYearCycleTests.cs b/tests/server.tests/Models/FiscalYearCycleTests.cs new file mode 100644 index 0000000..f014a63 --- /dev/null +++ b/tests/server.tests/Models/FiscalYearCycleTests.cs @@ -0,0 +1,46 @@ +using FluentAssertions; +using Server.Models; + +namespace Server.Tests.Models; + +public class FiscalYearCycleTests +{ + [Theory] + [InlineData("FY00", "FY00", 1999, 10, 1, 2000, 9, 30)] + [InlineData("FY25", "FY25", 2024, 10, 1, 2025, 9, 30)] + [InlineData("fy26", "FY26", 2025, 10, 1, 2026, 9, 30)] + [InlineData("FY99", "FY99", 2098, 10, 1, 2099, 9, 30)] + [InlineData("FY2025", "FY25", 2024, 10, 1, 2025, 9, 30)] + public void TryParse_maps_fiscal_year_to_cycle_dates( + string input, + string expectedFiscalYear, + int startYear, + int startMonth, + int startDay, + int endYear, + int endMonth, + int endDay) + { + var parsed = FiscalYearCycle.TryParse(input, out var cycle); + + parsed.Should().BeTrue(); + cycle.Should().NotBeNull(); + cycle!.FiscalYear.Should().Be(expectedFiscalYear); + cycle.CycleStart.Should().Be(new DateOnly(startYear, startMonth, startDay)); + cycle.CycleEnd.Should().Be(new DateOnly(endYear, endMonth, endDay)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("FY")] + [InlineData("2027")] + [InlineData("fall-2026")] + [InlineData("FY0001")] + [InlineData("FY10000")] + public void TryParse_rejects_missing_or_invalid_values(string? input) + { + FiscalYearCycle.TryParse(input, out var cycle).Should().BeFalse(); + cycle.Should().BeNull(); + } +} diff --git a/tests/server.tests/ProjectList/ProjectListControllerTests.cs b/tests/server.tests/ProjectList/ProjectListControllerTests.cs new file mode 100644 index 0000000..1467d48 --- /dev/null +++ b/tests/server.tests/ProjectList/ProjectListControllerTests.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Server.Controllers; +using Server.Models; +using Server.Models.ProjectList; +using Server.ProjectList; + +namespace Server.Tests.ProjectList; + +public class ProjectListControllerTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("twenty-six")] + [InlineData("FY10000")] + public async Task Get_returns_bad_request_for_missing_or_invalid_fy(string? fy) + { + var controller = new ProjectListController(new StubProjectListService()); + + var result = await controller.Get(fy, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Get_passes_cycle_to_service_and_returns_response() + { + var service = new StubProjectListService(); + var controller = new ProjectListController(service); + + var result = await controller.Get("FY26", CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeSameAs(service.Response); + service.ReceivedCycle.Should().Be(new FiscalYearCycle( + "FY26", + new DateOnly(2025, 10, 1), + new DateOnly(2026, 9, 30))); + } + + private sealed class StubProjectListService : IProjectListService + { + public FiscalYearCycle? ReceivedCycle { get; private set; } + + public ProjectListResponse Response { get; } = new( + "FY26", + new DateOnly(2025, 10, 1), + new DateOnly(2026, 9, 30), + new ProjectListCountsDto(1, 1, 2), + new ProjectListSummaryDto(2, 3, 4, 5, 1, []), + [ + new ProjectListRowDto("CA-A-111-H", "1000001", "2025-1", "K1234", "Larkspur, S.", "ATM", "201", "Clean"), + new ProjectListRowDto("CA-B-222-CG", "1000002", "2025-2", null, "Okonkwo, Y.", "ANS", "204", "SFN mismatch"), + ]); + + public Task GetAsync(FiscalYearCycle cycle, CancellationToken cancellationToken) + { + ReceivedCycle = cycle; + return Task.FromResult(Response); + } + } +} diff --git a/tests/server.tests/ProjectList/ProjectListResponseFactoryTests.cs b/tests/server.tests/ProjectList/ProjectListResponseFactoryTests.cs new file mode 100644 index 0000000..1092ebd --- /dev/null +++ b/tests/server.tests/ProjectList/ProjectListResponseFactoryTests.cs @@ -0,0 +1,37 @@ +using FluentAssertions; +using Server.Models; +using Server.Models.ProjectList; +using Server.ProjectList; + +namespace Server.Tests.ProjectList; + +public class ProjectListResponseFactoryTests +{ + [Fact] + public void Create_counts_tabs_and_sfn_distribution_from_rows() + { + FiscalYearCycle.TryParse("FY26", out var cycle).Should().BeTrue(); + var rows = new[] + { + new ProjectListRowDto("CA-A-111-H", "1000001", "2025-1", "K1234", "Larkspur, S.", "ATM", "201", "Clean"), + new ProjectListRowDto("CA-B-222-CG", "1000002", "2025-2", null, "Okonkwo, Y.", "ANS", "204", "SFN mismatch"), + new ProjectListRowDto("CA-C-333-CG", "1000003", "2025-3", null, "Naidoo, T.", "VEN", "204", "No PGM match"), + }; + + var response = ProjectListResponseFactory.Create(cycle!, rows, 10, 20, 30, 40); + + response.FiscalYear.Should().Be("FY26"); + response.CycleStart.Should().Be(new DateOnly(2025, 10, 1)); + response.CycleEnd.Should().Be(new DateOnly(2026, 9, 30)); + response.Counts.Should().Be(new ProjectListCountsDto(2, 1, 3)); + response.Summary.ActiveNifa.Should().Be(10); + response.Summary.AllNifa.Should().Be(20); + response.Summary.PgmRecords.Should().Be(30); + response.Summary.AlnCodes.Should().Be(40); + response.Summary.IssuesToResolve.Should().Be(2); + response.Summary.SfnDistribution.Should().Equal( + new SfnDistributionDto("201", 1), + new SfnDistributionDto("204", 2)); + response.Rows.Should().Equal(rows); + } +}