From 5d5bf28c38f8168f470b97af984a473b527db281 Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Mon, 29 Jun 2026 10:57:58 -0400 Subject: [PATCH 01/29] Add ChartStringSegment entity mapped to data schema --- server.core/Data/AppDbContext.cs | 13 +++++++ server.core/Domain/ChartStringSegment.cs | 22 ++++++++++++ .../ChartStringSegmentMappingTests.cs | 34 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 server.core/Domain/ChartStringSegment.cs create mode 100644 tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs diff --git a/server.core/Data/AppDbContext.cs b/server.core/Data/AppDbContext.cs index cf7494e..4f18101 100644 --- a/server.core/Data/AppDbContext.cs +++ b/server.core/Data/AppDbContext.cs @@ -6,15 +6,28 @@ namespace Server.Core.Data; public class AppDbContext(DbContextOptions options) : DbContext(options) { public const string AppSchema = "app"; + public const string DataSchema = "data"; public const string MigrationsHistoryTable = "__EFMigrationsHistory"; public DbSet Users => Set(); + public DbSet ChartStringSegments => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.HasDefaultSchema(AppSchema); modelBuilder.Entity().ToTable("Users", AppSchema); + + modelBuilder.Entity(entity => + { + entity.ToTable("ChartStringSegments", DataSchema, table => table.ExcludeFromMigrations()); + entity.HasKey(segment => new { segment.SegmentType, segment.Code }); + entity.Property(segment => segment.SegmentType).HasConversion().HasMaxLength(20); + entity.Property(segment => segment.Code).HasMaxLength(50); + entity.Property(segment => segment.Description).HasMaxLength(300); + entity.Property(segment => segment.Sfn).HasMaxLength(3); + }); } } diff --git a/server.core/Domain/ChartStringSegment.cs b/server.core/Domain/ChartStringSegment.cs new file mode 100644 index 0000000..a2896a6 --- /dev/null +++ b/server.core/Domain/ChartStringSegment.cs @@ -0,0 +1,22 @@ +namespace Server.Core.Domain; + +public enum SegmentType +{ + FinancialDepartment, + Account, + Fund, + Activity, +} + +public class ChartStringSegment +{ + public SegmentType SegmentType { get; set; } + + public string Code { get; set; } = string.Empty; + + public string? Description { get; set; } + + public bool? IncludeInReport { get; set; } + + public string? Sfn { get; set; } +} diff --git a/tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs b/tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs new file mode 100644 index 0000000..f632b2b --- /dev/null +++ b/tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs @@ -0,0 +1,34 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Server.Core.Data; +using Server.Core.Domain; + +namespace Server.Tests.ChartStringSegments; + +public class ChartStringSegmentMappingTests +{ + [Fact] + public void Maps_to_data_schema_with_composite_key() + { + using var db = TestDbContextFactory.CreateInMemory(); + + var entityType = db.Model.FindEntityType(typeof(ChartStringSegment)); + + entityType.Should().NotBeNull(); + entityType!.GetSchema().Should().Be("data"); + entityType.GetTableName().Should().Be("ChartStringSegments"); + entityType.FindPrimaryKey()!.Properties.Select(p => p.Name) + .Should().Equal(nameof(ChartStringSegment.SegmentType), nameof(ChartStringSegment.Code)); + } + + [Fact] + public void Stores_segment_type_as_string() + { + using var db = TestDbContextFactory.CreateInMemory(); + + var property = db.Model.FindEntityType(typeof(ChartStringSegment))! + .FindProperty(nameof(ChartStringSegment.SegmentType)); + + property!.GetProviderClrType().Should().Be(typeof(string)); + } +} From 90fa7f87e4cf995f50e825bc12ca54655cc49f8f Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Mon, 29 Jun 2026 11:21:55 -0400 Subject: [PATCH 02/29] Seed sample chart string segments in development --- server.core/Data/ChartStringSegmentSeed.cs | 39 +++++++++++++++++ server.core/Data/DbInitializer.cs | 2 +- .../ChartStringSegmentSeedTests.cs | 42 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 server.core/Data/ChartStringSegmentSeed.cs create mode 100644 tests/server.tests/ChartStringSegments/ChartStringSegmentSeedTests.cs diff --git a/server.core/Data/ChartStringSegmentSeed.cs b/server.core/Data/ChartStringSegmentSeed.cs new file mode 100644 index 0000000..87ddb26 --- /dev/null +++ b/server.core/Data/ChartStringSegmentSeed.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Server.Core.Domain; + +namespace Server.Core.Data; + +public static class ChartStringSegmentSeed +{ + public static IReadOnlyList Rows { get; } = + [ + new() { SegmentType = SegmentType.FinancialDepartment, Code = "031000", Description = "Plant Sciences", IncludeInReport = true }, + new() { SegmentType = SegmentType.FinancialDepartment, Code = "031100", Description = "Entomology and Nematology", IncludeInReport = null }, + new() { SegmentType = SegmentType.FinancialDepartment, Code = "031200", Description = "Land, Air and Water Resources", IncludeInReport = null }, + new() { SegmentType = SegmentType.Account, Code = "500000", Description = "Supplies and Expense", IncludeInReport = null }, + new() { SegmentType = SegmentType.Fund, Code = "45530", Description = "AES State Appropriations", IncludeInReport = true, Sfn = "220" }, + new() { SegmentType = SegmentType.Fund, Code = "95981", Description = "USDA NIFA Hatch", IncludeInReport = true, Sfn = "201" }, + new() { SegmentType = SegmentType.Fund, Code = "70575", Description = "USDA NIFA SCRI Berry 2026", IncludeInReport = null, Sfn = "219" }, + new() { SegmentType = SegmentType.Activity, Code = "44A100", Description = "Research", IncludeInReport = true }, + new() { SegmentType = SegmentType.Activity, Code = "44A200", Description = "Extension", IncludeInReport = null }, + ]; + + public static async Task EnsureSeededAsync(AppDbContext db, CancellationToken ct = default) + { + if (await db.ChartStringSegments.AnyAsync(ct)) + { + return; + } + + db.ChartStringSegments.AddRange(Rows.Select(row => new ChartStringSegment + { + SegmentType = row.SegmentType, + Code = row.Code, + Description = row.Description, + IncludeInReport = row.IncludeInReport, + Sfn = row.Sfn, + })); + + await db.SaveChangesAsync(ct); + } +} diff --git a/server.core/Data/DbInitializer.cs b/server.core/Data/DbInitializer.cs index 50481ec..c5c97a7 100644 --- a/server.core/Data/DbInitializer.cs +++ b/server.core/Data/DbInitializer.cs @@ -35,7 +35,7 @@ public async Task InitializeAsync(bool includeDevSeed, CancellationToken cancell } private Task SeedDevelopmentAsync(CancellationToken ct) - => Task.CompletedTask; + => ChartStringSegmentSeed.EnsureSeededAsync(_db, ct); // just a placeholder for any production-safe seeding private Task SeedProductionSafeAsync(CancellationToken ct) diff --git a/tests/server.tests/ChartStringSegments/ChartStringSegmentSeedTests.cs b/tests/server.tests/ChartStringSegments/ChartStringSegmentSeedTests.cs new file mode 100644 index 0000000..0953e23 --- /dev/null +++ b/tests/server.tests/ChartStringSegments/ChartStringSegmentSeedTests.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using Server.Core.Data; +using Server.Core.Domain; + +namespace Server.Tests.ChartStringSegments; + +public class ChartStringSegmentSeedTests +{ + [Fact] + public async Task EnsureSeeded_inserts_rows_when_table_is_empty() + { + using var db = TestDbContextFactory.CreateInMemory(); + + await ChartStringSegmentSeed.EnsureSeededAsync(db); + + db.ChartStringSegments.Count().Should().Be(ChartStringSegmentSeed.Rows.Count); + } + + [Fact] + public async Task EnsureSeeded_is_idempotent() + { + using var db = TestDbContextFactory.CreateInMemory(); + + await ChartStringSegmentSeed.EnsureSeededAsync(db); + await ChartStringSegmentSeed.EnsureSeededAsync(db); + + db.ChartStringSegments.Count().Should().Be(ChartStringSegmentSeed.Rows.Count); + } + + [Fact] + public async Task EnsureSeeded_only_sets_sfn_for_fund_rows() + { + using var db = TestDbContextFactory.CreateInMemory(); + + await ChartStringSegmentSeed.EnsureSeededAsync(db); + + db.ChartStringSegments + .Where(segment => segment.Sfn != null) + .All(segment => segment.SegmentType == SegmentType.Fund) + .Should().BeTrue(); + } +} From 233f80b84879c69bdb601068417c6dde4d63aed4 Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Mon, 29 Jun 2026 11:42:13 -0400 Subject: [PATCH 03/29] Add chart string segments GET and PATCH API --- .../ChartStringSegmentsController.cs | 63 +++++++++++++++++ .../ChartStringSegmentDtos.cs | 13 ++++ .../ChartStringSegmentsControllerTests.cs | 67 +++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 server/Controllers/ChartStringSegmentsController.cs create mode 100644 server/Models/ChartStringSegments/ChartStringSegmentDtos.cs create mode 100644 tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs diff --git a/server/Controllers/ChartStringSegmentsController.cs b/server/Controllers/ChartStringSegmentsController.cs new file mode 100644 index 0000000..ce288d5 --- /dev/null +++ b/server/Controllers/ChartStringSegmentsController.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Server.Core.Data; +using Server.Core.Domain; +using Server.Models.ChartStringSegments; + +namespace Server.Controllers; + +public class ChartStringSegmentsController : ApiControllerBase +{ + private readonly AppDbContext _db; + + public ChartStringSegmentsController(AppDbContext db) + { + _db = db; + } + + // GET api/chartstringsegments + [HttpGet] + public async Task>> Get(CancellationToken cancellationToken) + { + var segments = await _db.ChartStringSegments.ToListAsync(cancellationToken); + + var dtos = segments + .OrderBy(segment => segment.SegmentType) + .ThenBy(segment => segment.Code) + .Select(segment => new ChartStringSegmentDto( + segment.SegmentType.ToString(), + segment.Code, + segment.Description, + segment.IncludeInReport, + segment.Sfn)) + .ToList(); + + return Ok(dtos); + } + + // PATCH api/chartstringsegments + [HttpPatch] + public async Task UpdateClassification( + [FromBody] UpdateClassificationRequest request, + CancellationToken cancellationToken) + { + if (!Enum.TryParse(request.SegmentType, out var segmentType)) + { + return BadRequest($"Unknown segment type '{request.SegmentType}'."); + } + + var segment = await _db.ChartStringSegments.FirstOrDefaultAsync( + candidate => candidate.SegmentType == segmentType && candidate.Code == request.Code, + cancellationToken); + + if (segment is null) + { + return NotFound(); + } + + segment.IncludeInReport = request.IncludeInReport; + await _db.SaveChangesAsync(cancellationToken); + + return NoContent(); + } +} diff --git a/server/Models/ChartStringSegments/ChartStringSegmentDtos.cs b/server/Models/ChartStringSegments/ChartStringSegmentDtos.cs new file mode 100644 index 0000000..9af0cac --- /dev/null +++ b/server/Models/ChartStringSegments/ChartStringSegmentDtos.cs @@ -0,0 +1,13 @@ +namespace Server.Models.ChartStringSegments; + +public sealed record ChartStringSegmentDto( + string SegmentType, + string Code, + string? Description, + bool? IncludeInReport, + string? Sfn); + +public sealed record UpdateClassificationRequest( + string SegmentType, + string Code, + bool? IncludeInReport); diff --git a/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs b/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs new file mode 100644 index 0000000..79baf34 --- /dev/null +++ b/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs @@ -0,0 +1,67 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Server.Controllers; +using Server.Core.Domain; +using Server.Models.ChartStringSegments; + +namespace Server.Tests.ChartStringSegments; + +public class ChartStringSegmentsControllerTests +{ + [Fact] + public async Task Get_returns_all_segments() + { + using var db = TestDbContextFactory.CreateInMemory(); + db.ChartStringSegments.AddRange( + new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "45530", Description = "AES", IncludeInReport = true, Sfn = "220" }, + new ChartStringSegment { SegmentType = SegmentType.Account, Code = "500000", Description = "S and E", IncludeInReport = null }); + await db.SaveChangesAsync(); + var controller = new ChartStringSegmentsController(db); + + var result = await controller.Get(CancellationToken.None); + + var ok = result.Result.Should().BeOfType().Subject; + ok.Value.Should().BeAssignableTo>() + .Which.Should().HaveCount(2); + } + + [Fact] + public async Task Patch_updates_include_flag() + { + using var db = TestDbContextFactory.CreateInMemory(); + db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null, Sfn = "219" }); + await db.SaveChangesAsync(); + var controller = new ChartStringSegmentsController(db); + + var result = await controller.UpdateClassification( + new UpdateClassificationRequest("Fund", "70575", true), CancellationToken.None); + + result.Should().BeOfType(); + var updated = await db.ChartStringSegments.FindAsync(SegmentType.Fund, "70575"); + updated!.IncludeInReport.Should().BeTrue(); + } + + [Fact] + public async Task Patch_returns_not_found_for_missing_segment() + { + using var db = TestDbContextFactory.CreateInMemory(); + var controller = new ChartStringSegmentsController(db); + + var result = await controller.UpdateClassification( + new UpdateClassificationRequest("Fund", "00000", false), CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Patch_returns_bad_request_for_unknown_segment_type() + { + using var db = TestDbContextFactory.CreateInMemory(); + var controller = new ChartStringSegmentsController(db); + + var result = await controller.UpdateClassification( + new UpdateClassificationRequest("Nonsense", "00000", false), CancellationToken.None); + + result.Should().BeOfType(); + } +} From 086bdbac99eb11bf29afe615137ca5efdd113648 Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Tue, 30 Jun 2026 09:22:10 -0400 Subject: [PATCH 04/29] Add chart string segments client data layer and helpers --- .../components/dataClassification/segments.ts | 31 ++++++++ client/src/queries/chartStringSegments.ts | 71 +++++++++++++++++++ .../dataClassification/segments.test.ts | 41 +++++++++++ 3 files changed, 143 insertions(+) create mode 100644 client/src/components/dataClassification/segments.ts create mode 100644 client/src/queries/chartStringSegments.ts create mode 100644 client/src/test/components/dataClassification/segments.test.ts diff --git a/client/src/components/dataClassification/segments.ts b/client/src/components/dataClassification/segments.ts new file mode 100644 index 0000000..4a3f900 --- /dev/null +++ b/client/src/components/dataClassification/segments.ts @@ -0,0 +1,31 @@ +import type { + ChartStringSegment, + SegmentType, +} from '@/queries/chartStringSegments.ts'; + +export const SEGMENT_TABS: { label: string; type: SegmentType }[] = [ + { label: 'Financial Dept', type: 'FinancialDepartment' }, + { label: 'Natural Account', type: 'Account' }, + { label: 'Fund', type: 'Fund' }, + { label: 'Activity', type: 'Activity' }, +]; + +export function segmentsForType( + segments: ChartStringSegment[], + type: SegmentType +): ChartStringSegment[] { + return segments.filter((segment) => segment.segmentType === type); +} + +export function unclassifiedCount( + segments: ChartStringSegment[], + type: SegmentType +): number { + return segmentsForType(segments, type).filter( + (segment) => segment.includeInReport === null + ).length; +} + +export function allClassified(segments: ChartStringSegment[]): boolean { + return segments.every((segment) => segment.includeInReport !== null); +} diff --git a/client/src/queries/chartStringSegments.ts b/client/src/queries/chartStringSegments.ts new file mode 100644 index 0000000..26581c9 --- /dev/null +++ b/client/src/queries/chartStringSegments.ts @@ -0,0 +1,71 @@ +import { fetchJson } from '../lib/api.ts'; +import { + queryOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; + +export type SegmentType = + | 'FinancialDepartment' + | 'Account' + | 'Fund' + | 'Activity'; + +export interface ChartStringSegment { + code: string; + description: string | null; + includeInReport: boolean | null; + segmentType: SegmentType; + sfn: string | null; +} + +const SEGMENTS_KEY = ['chartStringSegments'] as const; + +export const chartStringSegmentsQueryOptions = () => + queryOptions({ + queryFn: () => + fetchJson('/api/chartstringsegments'), + queryKey: SEGMENTS_KEY, + }); + +export interface UpdateClassificationInput { + code: string; + includeInReport: boolean; + segmentType: SegmentType; +} + +export const useUpdateSegmentClassification = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: UpdateClassificationInput) => + fetchJson('/api/chartstringsegments', { + body: JSON.stringify(input), + method: 'PATCH', + }), + onMutate: async (input) => { + await queryClient.cancelQueries({ queryKey: SEGMENTS_KEY }); + const previous = + queryClient.getQueryData(SEGMENTS_KEY); + + queryClient.setQueryData(SEGMENTS_KEY, (old) => + (old ?? []).map((segment) => + segment.segmentType === input.segmentType && + segment.code === input.code + ? { ...segment, includeInReport: input.includeInReport } + : segment + ) + ); + + return { previous }; + }, + // eslint-disable-next-line perfectionist/sort-objects + onError: (_error, _input, context) => { + if (context?.previous) { + queryClient.setQueryData(SEGMENTS_KEY, context.previous); + } + }, + onSettled: () => + queryClient.invalidateQueries({ queryKey: SEGMENTS_KEY }), + }); +}; diff --git a/client/src/test/components/dataClassification/segments.test.ts b/client/src/test/components/dataClassification/segments.test.ts new file mode 100644 index 0000000..98ed221 --- /dev/null +++ b/client/src/test/components/dataClassification/segments.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import type { ChartStringSegment } from '@/queries/chartStringSegments.ts'; +import { + allClassified, + SEGMENT_TABS, + segmentsForType, + unclassifiedCount, +} from '@/components/dataClassification/segments.ts'; + +const segments: ChartStringSegment[] = [ + { code: '45530', description: 'AES', includeInReport: true, segmentType: 'Fund', sfn: '220' }, + { code: '70575', description: 'Berry', includeInReport: null, segmentType: 'Fund', sfn: '219' }, + { code: '500000', description: 'S and E', includeInReport: null, segmentType: 'Account', sfn: null }, +]; + +describe('data classification segment helpers', () => { + it('exposes the four tabs in display order', () => { + expect(SEGMENT_TABS.map((tab) => tab.type)).toEqual([ + 'FinancialDepartment', + 'Account', + 'Fund', + 'Activity', + ]); + }); + + it('filters segments by type', () => { + expect(segmentsForType(segments, 'Fund')).toHaveLength(2); + expect(segmentsForType(segments, 'Account')).toHaveLength(1); + }); + + it('counts unclassified segments per type', () => { + expect(unclassifiedCount(segments, 'Fund')).toBe(1); + expect(unclassifiedCount(segments, 'Account')).toBe(1); + expect(unclassifiedCount(segments, 'Activity')).toBe(0); + }); + + it('reports the gate as closed while any segment is unclassified', () => { + expect(allClassified(segments)).toBe(false); + expect(allClassified(segments.filter((s) => s.includeInReport !== null))).toBe(true); + }); +}); From d53fc690d15d3d3eea84029ea74431084c8c2f5d Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Tue, 30 Jun 2026 10:48:11 -0400 Subject: [PATCH 05/29] Add presentational segment classification control and grid --- .../SegmentClassificationControl.tsx | 33 +++++++++++++++++ .../dataClassification/SegmentGrid.tsx | 37 +++++++++++++++++++ .../SegmentClassificationControl.test.tsx | 29 +++++++++++++++ .../dataClassification/SegmentGrid.test.tsx | 28 ++++++++++++++ client/src/test/setup.ts | 4 ++ 5 files changed, 131 insertions(+) create mode 100644 client/src/components/dataClassification/SegmentClassificationControl.tsx create mode 100644 client/src/components/dataClassification/SegmentGrid.tsx create mode 100644 client/src/test/components/dataClassification/SegmentClassificationControl.test.tsx create mode 100644 client/src/test/components/dataClassification/SegmentGrid.test.tsx diff --git a/client/src/components/dataClassification/SegmentClassificationControl.tsx b/client/src/components/dataClassification/SegmentClassificationControl.tsx new file mode 100644 index 0000000..b395442 --- /dev/null +++ b/client/src/components/dataClassification/SegmentClassificationControl.tsx @@ -0,0 +1,33 @@ +import type { ChartStringSegment } from '@/queries/chartStringSegments.ts'; + +export function SegmentClassificationControl({ + onClassify, + segment, +}: { + onClassify: (include: boolean) => void; + segment: ChartStringSegment; +}) { + const status = segment.includeInReport; + + return ( +
+ + + {status === null && ( + Unset + )} +
+ ); +} diff --git a/client/src/components/dataClassification/SegmentGrid.tsx b/client/src/components/dataClassification/SegmentGrid.tsx new file mode 100644 index 0000000..d9e896f --- /dev/null +++ b/client/src/components/dataClassification/SegmentGrid.tsx @@ -0,0 +1,37 @@ +import { SegmentClassificationControl } from './SegmentClassificationControl.tsx'; +import type { + ChartStringSegment, + SegmentType, +} from '@/queries/chartStringSegments.ts'; +import { DataTable } from '@/shared/dataTable.tsx'; +import type { ColumnDef } from '@tanstack/react-table'; + +export function SegmentGrid({ + onClassify, + segments, + segmentType, +}: { + onClassify: (segment: ChartStringSegment, include: boolean) => void; + segments: ChartStringSegment[]; + segmentType: SegmentType; +}) { + const columns: ColumnDef[] = [ + { accessorKey: 'code', header: 'Code' }, + { accessorKey: 'description', header: 'Name' }, + ...(segmentType === 'Fund' + ? [{ accessorKey: 'sfn', header: 'SFN' } as ColumnDef] + : []), + { + cell: ({ row }) => ( + onClassify(row.original, include)} + segment={row.original} + /> + ), + header: 'Classification', + id: 'classification', + }, + ]; + + return ; +} diff --git a/client/src/test/components/dataClassification/SegmentClassificationControl.test.tsx b/client/src/test/components/dataClassification/SegmentClassificationControl.test.tsx new file mode 100644 index 0000000..c1e9054 --- /dev/null +++ b/client/src/test/components/dataClassification/SegmentClassificationControl.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ChartStringSegment } from '@/queries/chartStringSegments.ts'; +import { SegmentClassificationControl } from '@/components/dataClassification/SegmentClassificationControl.tsx'; + +const unsetSegment: ChartStringSegment = { + code: '70575', + description: 'Berry', + includeInReport: null, + segmentType: 'Fund', + sfn: '219', +}; + +describe('SegmentClassificationControl', () => { + it('shows an Unset badge when the segment is unclassified', () => { + render(); + + expect(screen.getByText('Unset')).toBeInTheDocument(); + }); + + it('calls onClassify with true when Include is clicked', () => { + const onClassify = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Include' })); + + expect(onClassify).toHaveBeenCalledWith(true); + }); +}); diff --git a/client/src/test/components/dataClassification/SegmentGrid.test.tsx b/client/src/test/components/dataClassification/SegmentGrid.test.tsx new file mode 100644 index 0000000..e9f7921 --- /dev/null +++ b/client/src/test/components/dataClassification/SegmentGrid.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { ChartStringSegment } from '@/queries/chartStringSegments.ts'; +import { SegmentGrid } from '@/components/dataClassification/SegmentGrid.tsx'; + +const fundSegments: ChartStringSegment[] = [ + { code: '45530', description: 'AES State Appropriations', includeInReport: true, segmentType: 'Fund', sfn: '220' }, +]; + +const accountSegments: ChartStringSegment[] = [ + { code: '500000', description: 'Supplies and Expense', includeInReport: null, segmentType: 'Account', sfn: null }, +]; + +describe('SegmentGrid', () => { + it('shows the SFN column for the Fund tab', () => { + render(); + + expect(screen.getByText('SFN')).toBeInTheDocument(); + expect(screen.getByText('45530')).toBeInTheDocument(); + }); + + it('omits the SFN column for non-Fund tabs', () => { + render(); + + expect(screen.queryByText('SFN')).not.toBeInTheDocument(); + expect(screen.getByText('500000')).toBeInTheDocument(); + }); +}); diff --git a/client/src/test/setup.ts b/client/src/test/setup.ts index bb02c60..1d55db9 100644 --- a/client/src/test/setup.ts +++ b/client/src/test/setup.ts @@ -1 +1,5 @@ import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(cleanup); From 9088121cb098cf810f0ed6329aa7cb4052638f6e Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Tue, 30 Jun 2026 11:37:21 -0400 Subject: [PATCH 06/29] Render Data Classification step in the workflow Co-Authored-By: Claude Sonnet 4.6 --- .../DataClassificationStage.tsx | 97 +++++++++++++++++++ .../(authenticated)/workflow.$stageId.tsx | 11 ++- .../dataClassification.test.tsx | 74 ++++++++++++++ 3 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 client/src/components/dataClassification/DataClassificationStage.tsx create mode 100644 client/src/test/routes/(authenticated)/dataClassification.test.tsx diff --git a/client/src/components/dataClassification/DataClassificationStage.tsx b/client/src/components/dataClassification/DataClassificationStage.tsx new file mode 100644 index 0000000..7e0b91f --- /dev/null +++ b/client/src/components/dataClassification/DataClassificationStage.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { + allClassified, + SEGMENT_TABS, + segmentsForType, + unclassifiedCount, +} from './segments.ts'; +import { SegmentGrid } from './SegmentGrid.tsx'; +import { + chartStringSegmentsQueryOptions, + type ChartStringSegment, + useUpdateSegmentClassification, +} from '@/queries/chartStringSegments.ts'; +import { Link } from '@tanstack/react-router'; +import { useQuery } from '@tanstack/react-query'; + +export function DataClassificationStage() { + const { data: segments = [], isLoading } = useQuery( + chartStringSegmentsQueryOptions() + ); + const updateClassification = useUpdateSegmentClassification(); + const [activeType, setActiveType] = useState(SEGMENT_TABS[0].type); + + if (isLoading) { + return

Loading segments...

; + } + + const handleClassify = (segment: ChartStringSegment, include: boolean) => { + updateClassification.mutate({ + code: segment.code, + includeInReport: include, + segmentType: segment.segmentType, + }); + }; + + const gateOpen = allClassified(segments); + + return ( +
+

+ New chart-string segments need to be classified before they can be + included or excluded from the AD419 report. +

+ +
+ {SEGMENT_TABS.map((tab) => { + const count = unclassifiedCount(segments, tab.type); + + return ( + + ); + })} +
+ + + +
+ + {gateOpen + ? 'All segments classified.' + : 'Unclassified rows must be set before the next step.'} + + {gateOpen ? ( + + Continue to Expense Review + + ) : ( + + )} +
+
+ ); +} diff --git a/client/src/routes/(authenticated)/workflow.$stageId.tsx b/client/src/routes/(authenticated)/workflow.$stageId.tsx index 7dd1c62..100517d 100644 --- a/client/src/routes/(authenticated)/workflow.$stageId.tsx +++ b/client/src/routes/(authenticated)/workflow.$stageId.tsx @@ -3,6 +3,7 @@ import { getCurrentAvailableStageId, } from '@/mockData.ts'; import { workflowSnapshotQueryOptions } from '@/queries.ts'; +import { DataClassificationStage } from '@/components/dataClassification/DataClassificationStage.tsx'; import { SectionPanel } from '@/components/SectionPanel.tsx'; import { WorkflowShell } from '@/components/WorkflowShell.tsx'; import type { WorkflowStageId } from '@/types.ts'; @@ -45,9 +46,13 @@ function WorkflowStageRoute() { return (
- -

This step is coming soon.

-
+ {stage.id === 'data-classification' ? ( + + ) : ( + +

This step is coming soon.

+
+ )}
); diff --git a/client/src/test/routes/(authenticated)/dataClassification.test.tsx b/client/src/test/routes/(authenticated)/dataClassification.test.tsx new file mode 100644 index 0000000..d6143e5 --- /dev/null +++ b/client/src/test/routes/(authenticated)/dataClassification.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { server } from '@/test/mswUtils.ts'; +import { renderRoute } from '@/test/routerUtils.tsx'; + +const mockUser = { + email: 'shannon@example.edu', + id: 'user-1', + name: 'Shannon Taylor', + roles: ['User'], +}; + +const segments = [ + { code: '45530', description: 'AES', includeInReport: true, segmentType: 'Fund', sfn: '220' }, + { code: '70575', description: 'Berry', includeInReport: null, segmentType: 'Fund', sfn: '219' }, +]; + +function mockApi() { + let current = [...segments]; + server.use( + http.get('/api/user/me', () => HttpResponse.json(mockUser)), + http.get('/api/chartstringsegments', () => HttpResponse.json(current)), + http.patch('/api/chartstringsegments', async ({ request }) => { + const body = await request.json() as { code: string; includeInReport: boolean; segmentType: string }; + current = current.map((s) => + s.code === body.code ? { ...s, includeInReport: body.includeInReport } : s + ); + return new HttpResponse(null, { status: 204 }); + }) + ); +} + +describe('Data Classification stage', () => { + it('renders the classifier with tabs instead of the placeholder', async () => { + mockApi(); + const { cleanup } = renderRoute({ initialPath: '/workflow/data-classification' }); + + try { + expect(await screen.findByRole('tab', { name: /Fund/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Financial Dept/ })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Coming soon' })).not.toBeInTheDocument(); + } finally { + cleanup(); + } + }); + + it('blocks Continue until every segment is classified, then opens the gate', async () => { + mockApi(); + const { cleanup } = renderRoute({ initialPath: '/workflow/data-classification' }); + + try { + const fundTab = await screen.findByRole('tab', { name: /Fund/ }); + fireEvent.click(fundTab); + + await screen.findByText('70575'); + + expect( + screen.getByRole('button', { name: /Continue to Expense Review/ }) + ).toBeDisabled(); + + const includeButtons = screen.getAllByRole('button', { name: 'Include' }); + fireEvent.click(includeButtons.at(-1)!); + + await waitFor(() => { + expect( + screen.getByRole('link', { name: /Continue to Expense Review/ }) + ).toBeInTheDocument(); + }); + } finally { + cleanup(); + } + }); +}); From 73f2696d4c9ea77f5a8995c90265f9a035ad3e3f Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Wed, 1 Jul 2026 09:47:56 -0400 Subject: [PATCH 07/29] Widen Sfn column to 10 chars and clean fund seed Fix AuthorizedUserHandlerTests to pass required IHostEnvironment parameter to handler. Co-Authored-By: Claude Opus 4.8 (1M context) --- database/data/Tables/ChartStringSegments.sql | 2 +- server.core/Data/AppDbContext.cs | 2 +- server.core/Data/ChartStringSegmentSeed.cs | 2 +- .../ChartStringSegmentMappingTests.cs | 11 +++++++++++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/database/data/Tables/ChartStringSegments.sql b/database/data/Tables/ChartStringSegments.sql index 66a07d0..e1116d7 100644 --- a/database/data/Tables/ChartStringSegments.sql +++ b/database/data/Tables/ChartStringSegments.sql @@ -4,7 +4,7 @@ CREATE TABLE [data].[ChartStringSegments] [Code] NVARCHAR(50) NOT NULL, [Description] NVARCHAR(300) NULL, [IncludeInReport] BIT NULL, -- NULL = unclassified, needs review - [Sfn] NVARCHAR(3) NULL, -- only meaningful for Fund + [Sfn] NVARCHAR(10) NULL, -- SFN code (201..223) or 'Multiple'; only for Fund CONSTRAINT [PK_ChartStringSegments] PRIMARY KEY CLUSTERED ([SegmentType], [Code]), CONSTRAINT [CK_ChartStringSegments_Sfn] CHECK ([Sfn] IS NULL OR [SegmentType] = 'Fund') ); diff --git a/server.core/Data/AppDbContext.cs b/server.core/Data/AppDbContext.cs index 4f18101..5908365 100644 --- a/server.core/Data/AppDbContext.cs +++ b/server.core/Data/AppDbContext.cs @@ -27,7 +27,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(segment => segment.SegmentType).HasConversion().HasMaxLength(20); entity.Property(segment => segment.Code).HasMaxLength(50); entity.Property(segment => segment.Description).HasMaxLength(300); - entity.Property(segment => segment.Sfn).HasMaxLength(3); + entity.Property(segment => segment.Sfn).HasMaxLength(10); }); } } diff --git a/server.core/Data/ChartStringSegmentSeed.cs b/server.core/Data/ChartStringSegmentSeed.cs index 87ddb26..c180075 100644 --- a/server.core/Data/ChartStringSegmentSeed.cs +++ b/server.core/Data/ChartStringSegmentSeed.cs @@ -13,7 +13,7 @@ public static class ChartStringSegmentSeed new() { SegmentType = SegmentType.Account, Code = "500000", Description = "Supplies and Expense", IncludeInReport = null }, new() { SegmentType = SegmentType.Fund, Code = "45530", Description = "AES State Appropriations", IncludeInReport = true, Sfn = "220" }, new() { SegmentType = SegmentType.Fund, Code = "95981", Description = "USDA NIFA Hatch", IncludeInReport = true, Sfn = "201" }, - new() { SegmentType = SegmentType.Fund, Code = "70575", Description = "USDA NIFA SCRI Berry 2026", IncludeInReport = null, Sfn = "219" }, + new() { SegmentType = SegmentType.Fund, Code = "70575", Description = "USDA NIFA SCRI Berry 2026", IncludeInReport = null }, new() { SegmentType = SegmentType.Activity, Code = "44A100", Description = "Research", IncludeInReport = true }, new() { SegmentType = SegmentType.Activity, Code = "44A200", Description = "Extension", IncludeInReport = null }, ]; diff --git a/tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs b/tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs index f632b2b..7b3b1d9 100644 --- a/tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs +++ b/tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs @@ -31,4 +31,15 @@ public void Stores_segment_type_as_string() property!.GetProviderClrType().Should().Be(typeof(string)); } + + [Fact] + public void Sfn_column_allows_ten_characters() + { + using var db = TestDbContextFactory.CreateInMemory(); + + var property = db.Model.FindEntityType(typeof(ChartStringSegment))! + .FindProperty(nameof(ChartStringSegment.Sfn)); + + property!.GetMaxLength().Should().Be(10); + } } From f81c0b3eb7bb57562e9d25b8d62f2b4a7a10776f Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Wed, 1 Jul 2026 09:54:07 -0400 Subject: [PATCH 08/29] Validate and persist fund SFN on classification PATCH --- .../ChartStringSegmentsController.cs | 13 ++++ .../ChartStringSegmentDtos.cs | 3 +- server/Models/ChartStringSegments/FundSfns.cs | 12 ++++ .../ChartStringSegmentsControllerTests.cs | 69 ++++++++++++++++++- 4 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 server/Models/ChartStringSegments/FundSfns.cs diff --git a/server/Controllers/ChartStringSegmentsController.cs b/server/Controllers/ChartStringSegmentsController.cs index ce288d5..c0b6207 100644 --- a/server/Controllers/ChartStringSegmentsController.cs +++ b/server/Controllers/ChartStringSegmentsController.cs @@ -55,7 +55,20 @@ public async Task UpdateClassification( return NotFound(); } + var isFund = segmentType == SegmentType.Fund; + + if (!isFund && request.Sfn is not null) + { + return BadRequest("SFN is only valid for Fund segments."); + } + + if (isFund && request.IncludeInReport == true && !FundSfns.IsValidForInclusion(request.Sfn)) + { + return BadRequest($"Invalid SFN '{request.Sfn}' for an included fund."); + } + segment.IncludeInReport = request.IncludeInReport; + segment.Sfn = isFund && request.IncludeInReport == true ? request.Sfn : null; await _db.SaveChangesAsync(cancellationToken); return NoContent(); diff --git a/server/Models/ChartStringSegments/ChartStringSegmentDtos.cs b/server/Models/ChartStringSegments/ChartStringSegmentDtos.cs index 9af0cac..08af134 100644 --- a/server/Models/ChartStringSegments/ChartStringSegmentDtos.cs +++ b/server/Models/ChartStringSegments/ChartStringSegmentDtos.cs @@ -10,4 +10,5 @@ public sealed record ChartStringSegmentDto( public sealed record UpdateClassificationRequest( string SegmentType, string Code, - bool? IncludeInReport); + bool? IncludeInReport, + string? Sfn); diff --git a/server/Models/ChartStringSegments/FundSfns.cs b/server/Models/ChartStringSegments/FundSfns.cs new file mode 100644 index 0000000..7bdb94a --- /dev/null +++ b/server/Models/ChartStringSegments/FundSfns.cs @@ -0,0 +1,12 @@ +namespace Server.Models.ChartStringSegments; + +public static class FundSfns +{ + public const string MultipleMarker = "Multiple"; + + public static readonly IReadOnlySet Codes = + new HashSet { "201", "202", "203", "205", "220", "221", "223" }; + + public static bool IsValidForInclusion(string? sfn) => + sfn is not null && (Codes.Contains(sfn) || sfn == MultipleMarker); +} diff --git a/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs b/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs index 79baf34..035f1e7 100644 --- a/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs +++ b/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs @@ -34,11 +34,12 @@ public async Task Patch_updates_include_flag() var controller = new ChartStringSegmentsController(db); var result = await controller.UpdateClassification( - new UpdateClassificationRequest("Fund", "70575", true), CancellationToken.None); + new UpdateClassificationRequest("Fund", "70575", true, "201"), CancellationToken.None); result.Should().BeOfType(); var updated = await db.ChartStringSegments.FindAsync(SegmentType.Fund, "70575"); updated!.IncludeInReport.Should().BeTrue(); + updated.Sfn.Should().Be("201"); } [Fact] @@ -48,7 +49,7 @@ public async Task Patch_returns_not_found_for_missing_segment() var controller = new ChartStringSegmentsController(db); var result = await controller.UpdateClassification( - new UpdateClassificationRequest("Fund", "00000", false), CancellationToken.None); + new UpdateClassificationRequest("Fund", "00000", false, null), CancellationToken.None); result.Should().BeOfType(); } @@ -60,7 +61,69 @@ public async Task Patch_returns_bad_request_for_unknown_segment_type() var controller = new ChartStringSegmentsController(db); var result = await controller.UpdateClassification( - new UpdateClassificationRequest("Nonsense", "00000", false), CancellationToken.None); + new UpdateClassificationRequest("Nonsense", "00000", false, null), CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Patch_sets_sfn_for_included_fund() + { + using var db = TestDbContextFactory.CreateInMemory(); + db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null }); + await db.SaveChangesAsync(); + var controller = new ChartStringSegmentsController(db); + + var result = await controller.UpdateClassification( + new UpdateClassificationRequest("Fund", "70575", true, "220"), CancellationToken.None); + + result.Should().BeOfType(); + var updated = await db.ChartStringSegments.FindAsync(SegmentType.Fund, "70575"); + updated!.IncludeInReport.Should().BeTrue(); + updated.Sfn.Should().Be("220"); + } + + [Fact] + public async Task Patch_clears_sfn_when_fund_excluded() + { + using var db = TestDbContextFactory.CreateInMemory(); + db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "45530", IncludeInReport = true, Sfn = "220" }); + await db.SaveChangesAsync(); + var controller = new ChartStringSegmentsController(db); + + var result = await controller.UpdateClassification( + new UpdateClassificationRequest("Fund", "45530", false, null), CancellationToken.None); + + result.Should().BeOfType(); + var updated = await db.ChartStringSegments.FindAsync(SegmentType.Fund, "45530"); + updated!.IncludeInReport.Should().BeFalse(); + updated.Sfn.Should().BeNull(); + } + + [Fact] + public async Task Patch_rejects_invalid_sfn_for_included_fund() + { + using var db = TestDbContextFactory.CreateInMemory(); + db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null }); + await db.SaveChangesAsync(); + var controller = new ChartStringSegmentsController(db); + + var result = await controller.UpdateClassification( + new UpdateClassificationRequest("Fund", "70575", true, "999"), CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Patch_rejects_sfn_on_non_fund_segment() + { + using var db = TestDbContextFactory.CreateInMemory(); + db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Account, Code = "500000", IncludeInReport = null }); + await db.SaveChangesAsync(); + var controller = new ChartStringSegmentsController(db); + + var result = await controller.UpdateClassification( + new UpdateClassificationRequest("Account", "500000", true, "201"), CancellationToken.None); result.Should().BeOfType(); } From 9f85d793b8caf7af59e31b046ab77b8194b7d6a5 Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Wed, 1 Jul 2026 10:00:07 -0400 Subject: [PATCH 09/29] Add fund SFN dropdown to segment classification control --- .../SegmentClassificationControl.tsx | 48 ++++++++++++++-- client/src/queries/chartStringSegments.ts | 6 +- .../SegmentClassificationControl.test.tsx | 56 ++++++++++++++++--- 3 files changed, 96 insertions(+), 14 deletions(-) diff --git a/client/src/components/dataClassification/SegmentClassificationControl.tsx b/client/src/components/dataClassification/SegmentClassificationControl.tsx index b395442..fcd65ad 100644 --- a/client/src/components/dataClassification/SegmentClassificationControl.tsx +++ b/client/src/components/dataClassification/SegmentClassificationControl.tsx @@ -1,26 +1,66 @@ -import type { ChartStringSegment } from '@/queries/chartStringSegments.ts'; +import { + FUND_SFNS, + SFN_MULTIPLE, + type ChartStringSegment, +} from '@/queries/chartStringSegments.ts'; + +const EXCLUDED = 'Excluded'; export function SegmentClassificationControl({ onClassify, segment, }: { - onClassify: (include: boolean) => void; + onClassify: (includeInReport: boolean, sfn: string | null) => void; segment: ChartStringSegment; }) { + if (segment.segmentType === 'Fund') { + const value = + segment.includeInReport === false + ? EXCLUDED + : (segment.sfn ?? ''); + + const handleChange = (selected: string) => { + if (selected === EXCLUDED) { + onClassify(false, null); + } else if (selected !== '') { + onClassify(true, selected); + } + }; + + return ( + + ); + } + const status = segment.includeInReport; return (
+ {activeType === 'Ern' && ( +
+ + Note: ERN (earnings) code classification affects FTE calculations only — it + does not affect dollar-amount calculations. + +
+ )} + { cleanup(); } }); + + it('shows the FTE disclaimer only on the ERN tab', async () => { + mockApi(); + const { cleanup } = renderRoute({ initialPath: '/workflow/data-classification' }); + + try { + await screen.findByRole('tab', { name: /Fund/ }); + expect( + screen.queryByText(/affects fte calculations only/i) + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('tab', { name: /ERN/ })); + + expect( + await screen.findByText(/affects fte calculations only/i) + ).toBeInTheDocument(); + } finally { + cleanup(); + } + }); }); From b2501b89ba113c9283a31e997fbf4eabfd86d439 Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Wed, 1 Jul 2026 14:32:42 -0400 Subject: [PATCH 25/29] Remove em dash from ERN FTE disclaimer Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/dataClassification/DataClassificationStage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/dataClassification/DataClassificationStage.tsx b/client/src/components/dataClassification/DataClassificationStage.tsx index 3e1cd28..44697f9 100644 --- a/client/src/components/dataClassification/DataClassificationStage.tsx +++ b/client/src/components/dataClassification/DataClassificationStage.tsx @@ -74,7 +74,7 @@ export function DataClassificationStage() { {activeType === 'Ern' && (
- Note: ERN (earnings) code classification affects FTE calculations only — it + Note: ERN (earnings) code classification affects FTE calculations only. It does not affect dollar-amount calculations.
From 196d39355aad2175e1f98cd3e440cf8252dbd7ea Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Wed, 1 Jul 2026 14:38:01 -0400 Subject: [PATCH 26/29] Fix seeded included funds missing an SFN; keep page on data update Included funds are now seeded with a valid SFN so the Fund dropdown reflects a classified state instead of reading Unset. DataTable no longer resets to page 1 when the row data updates in place (autoResetPageIndex: false). Co-Authored-By: Claude Opus 4.8 (1M context) --- client/src/shared/dataTable.tsx | 1 + server.core/Data/ChartStringSegmentSeed.cs | 34 ++++++++++++++++------ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/client/src/shared/dataTable.tsx b/client/src/shared/dataTable.tsx index 1f9994a..627b375 100644 --- a/client/src/shared/dataTable.tsx +++ b/client/src/shared/dataTable.tsx @@ -36,6 +36,7 @@ export const DataTable = ({ // Keep this suppression until the library compatibility guidance changes. // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ + autoResetPageIndex: false, // keep the current page when data updates in place columns, data, getCoreRowModel: getCoreRowModel(), // basic rendering diff --git a/server.core/Data/ChartStringSegmentSeed.cs b/server.core/Data/ChartStringSegmentSeed.cs index d47ea50..381e09b 100644 --- a/server.core/Data/ChartStringSegmentSeed.cs +++ b/server.core/Data/ChartStringSegmentSeed.cs @@ -15,6 +15,12 @@ public static class ChartStringSegmentSeed // work to do; the rest are classified (include/exclude) below. private const int UnsetPerType = 10; + // Valid SFN values (mirrors the server's FundSfns / client FUND_SFNS list, plus the + // "Multiple" marker). Used to give seeded, included funds a real SFN so the Fund + // dropdown reflects a classified state rather than reading "Unset". + private static readonly string[] FundSfnPool = + ["201", "202", "203", "205", "220", "221", "223", "Multiple"]; + public static async Task EnsureSeededAsync(AppDbContext db, CancellationToken ct = default) { if (await db.ChartStringSegments.AnyAsync(ct)) @@ -64,16 +70,26 @@ private static async Task> BuildAsync( var rows = await hierarchy.ToListAsync(ct); return rows .OrderBy(row => row.Code, StringComparer.Ordinal) - .Select((row, index) => new ChartStringSegment + .Select((row, index) => { - SegmentType = type, - Code = row.Code, - Description = row.Description, - // First few stay unset; the rest are classified include/exclude with - // a stable pseudo-random split so dev data looks partially worked. - IncludeInReport = index < UnsetPerType - ? null - : (StableHash(row.Code) & 1) == 0, + // First few stay unset; the rest are classified include/exclude with a + // stable pseudo-random split so dev data looks partially worked. + bool? include = index < UnsetPerType ? null : (StableHash(row.Code) & 1) == 0; + + // An included fund must carry a valid SFN, otherwise the Fund dropdown + // has no matching option and shows "Unset" despite being classified. + var sfn = type == SegmentType.Fund && include == true + ? FundSfnPool[(StableHash(row.Code) & int.MaxValue) % FundSfnPool.Length] + : null; + + return new ChartStringSegment + { + SegmentType = type, + Code = row.Code, + Description = row.Description, + IncludeInReport = include, + Sfn = sfn, + }; }) .ToList(); } From dea030d95937163d384c4e5be3afcabd08858578 Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Wed, 1 Jul 2026 14:38:01 -0400 Subject: [PATCH 27/29] Test SFN Multiple round-trip and grid unset-first/frozen ordering Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dataClassification/SegmentGrid.test.tsx | 48 +++++++++++++++++++ .../ChartStringSegmentsControllerTests.cs | 17 +++++++ 2 files changed, 65 insertions(+) diff --git a/client/src/test/components/dataClassification/SegmentGrid.test.tsx b/client/src/test/components/dataClassification/SegmentGrid.test.tsx index 596cdee..adfd935 100644 --- a/client/src/test/components/dataClassification/SegmentGrid.test.tsx +++ b/client/src/test/components/dataClassification/SegmentGrid.test.tsx @@ -3,6 +3,20 @@ import { render, screen } from '@testing-library/react'; import type { ChartStringSegment } from '@/queries/chartStringSegments.ts'; import { SegmentGrid } from '@/components/dataClassification/SegmentGrid.tsx'; +function account( + code: string, + includeInReport: boolean | null +): ChartStringSegment { + return { code, description: `Name ${code}`, hierarchy: [], includeInReport, segmentType: 'Account', sfn: null }; +} + +// True when `first` appears before `second` in the DOM. +function isBefore(first: HTMLElement, second: HTMLElement): boolean { + return Boolean( + first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING + ); +} + const fundSegments: ChartStringSegment[] = [ { code: '45530', @@ -47,4 +61,38 @@ describe('SegmentGrid', () => { expect(screen.queryByRole('columnheader', { name: /^Level / })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Include' })).toBeInTheDocument(); }); + + it('sorts unclassified rows to the top by default', () => { + render( + + ); + + // BBB is unclassified, so it renders before the classified AAA. + expect(isBefore(screen.getByText('BBB'), screen.getByText('AAA'))).toBe(true); + }); + + it('keeps a row in place when it is classified in the same tab', () => { + const { rerender } = render( + + ); + expect(isBefore(screen.getByText('BBB'), screen.getByText('AAA'))).toBe(true); + + // BBB gets classified: order is frozen for the tab, so it stays above AAA. + rerender( + + ); + expect(isBefore(screen.getByText('BBB'), screen.getByText('AAA'))).toBe(true); + }); }); diff --git a/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs b/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs index 978304e..5046a72 100644 --- a/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs +++ b/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs @@ -215,4 +215,21 @@ public async Task Patch_rejects_sfn_on_non_fund_segment() result.Should().BeOfType(); } + + [Fact] + public async Task Patch_accepts_multiple_marker_for_included_fund() + { + using var db = TestDbContextFactory.CreateInMemory(); + db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null }); + await db.SaveChangesAsync(); + var controller = new ChartStringSegmentsController(db); + + var result = await controller.UpdateClassification( + new UpdateClassificationRequest("Fund", "70575", true, "Multiple"), CancellationToken.None); + + result.Should().BeOfType(); + var updated = await db.ChartStringSegments.FindAsync(SegmentType.Fund, "70575"); + updated!.IncludeInReport.Should().BeTrue(); + updated.Sfn.Should().Be("Multiple"); + } } From bb71338d39d86743c1cab3d14349d2ad875a18be Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Wed, 1 Jul 2026 15:40:31 -0400 Subject: [PATCH 28/29] Align hierarchy Code column with ChartStringSegments.Code (NVARCHAR(50)) The hierarchy tables' Code join key was VARCHAR(20) while ChartStringSegments.Code is NVARCHAR(50), risking implicit conversions in SQL joins and truncating codes longer than 20 chars. Widen Code to NVARCHAR(50) across the four hierarchy tables and match the EF max length; parent-level codes stay VARCHAR(20). Co-Authored-By: Claude Opus 4.8 (1M context) --- database/data/Tables/AccountHierarchy.sql | 2 +- database/data/Tables/ActivityHierarchy.sql | 2 +- database/data/Tables/DepartmentHierarchy.sql | 2 +- database/data/Tables/FundHierarchy.sql | 2 +- server.core/Data/AppDbContext.cs | 11 +++++++++-- .../ChartStringSegments/HierarchyMappingTests.cs | 3 ++- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/database/data/Tables/AccountHierarchy.sql b/database/data/Tables/AccountHierarchy.sql index ac5c4c2..cddaff6 100644 --- a/database/data/Tables/AccountHierarchy.sql +++ b/database/data/Tables/AccountHierarchy.sql @@ -1,6 +1,6 @@ CREATE TABLE [data].[AccountHierarchy] ( - [Code] VARCHAR(20) NOT NULL, + [Code] NVARCHAR(50) NOT NULL, [Description] NVARCHAR(1000) NULL, [ParentLevel0Code] VARCHAR(20) NULL, [ParentLevel0Name] NVARCHAR(1000) NULL, diff --git a/database/data/Tables/ActivityHierarchy.sql b/database/data/Tables/ActivityHierarchy.sql index 1510835..e7ff8bf 100644 --- a/database/data/Tables/ActivityHierarchy.sql +++ b/database/data/Tables/ActivityHierarchy.sql @@ -1,6 +1,6 @@ CREATE TABLE [data].[ActivityHierarchy] ( - [Code] VARCHAR(20) NOT NULL, + [Code] NVARCHAR(50) NOT NULL, [Description] NVARCHAR(1000) NULL, [ParentLevel0Code] VARCHAR(20) NULL, [ParentLevel0Name] NVARCHAR(1000) NULL, diff --git a/database/data/Tables/DepartmentHierarchy.sql b/database/data/Tables/DepartmentHierarchy.sql index c49f6d9..e5a584e 100644 --- a/database/data/Tables/DepartmentHierarchy.sql +++ b/database/data/Tables/DepartmentHierarchy.sql @@ -1,6 +1,6 @@ CREATE TABLE [data].[DepartmentHierarchy] ( - [Code] VARCHAR(20) NOT NULL, + [Code] NVARCHAR(50) NOT NULL, [Description] NVARCHAR(1000) NULL, [ParentLevelACode] VARCHAR(20) NULL, [ParentLevelAName] NVARCHAR(1000) NULL, diff --git a/database/data/Tables/FundHierarchy.sql b/database/data/Tables/FundHierarchy.sql index fca43a7..21812c8 100644 --- a/database/data/Tables/FundHierarchy.sql +++ b/database/data/Tables/FundHierarchy.sql @@ -1,6 +1,6 @@ CREATE TABLE [data].[FundHierarchy] ( - [Code] VARCHAR(20) NOT NULL, + [Code] NVARCHAR(50) NOT NULL, [Description] NVARCHAR(1000) NULL, [ParentLevel0Code] VARCHAR(20) NULL, [ParentLevel0Name] NVARCHAR(1000) NULL, diff --git a/server.core/Data/AppDbContext.cs b/server.core/Data/AppDbContext.cs index 7ca551a..e924657 100644 --- a/server.core/Data/AppDbContext.cs +++ b/server.core/Data/AppDbContext.cs @@ -52,8 +52,15 @@ private static void ConfigureHierarchy(ModelBuilder modelBuilder, string tabl foreach (var property in entity.Metadata.GetProperties() .Where(p => p.ClrType == typeof(string))) { - var isName = property.Name == "Description" || property.Name.EndsWith("Name"); - property.SetMaxLength(isName ? 1000 : 20); + var maxLength = property.Name switch + { + // Match ChartStringSegment.Code so joins/lookups on Code stay aligned. + nameof(ISegmentHierarchy.Code) => 50, + "Description" => 1000, + _ when property.Name.EndsWith("Name") => 1000, + _ => 20, + }; + property.SetMaxLength(maxLength); } }); } diff --git a/tests/server.tests/ChartStringSegments/HierarchyMappingTests.cs b/tests/server.tests/ChartStringSegments/HierarchyMappingTests.cs index b32c722..9e52b9d 100644 --- a/tests/server.tests/ChartStringSegments/HierarchyMappingTests.cs +++ b/tests/server.tests/ChartStringSegments/HierarchyMappingTests.cs @@ -48,6 +48,7 @@ public void Fund_level_properties_have_max_lengths() entity.FindProperty("ParentLevel0Code")!.GetMaxLength().Should().Be(20); entity.FindProperty("ParentLevel0Name")!.GetMaxLength().Should().Be(1000); entity.FindProperty("Description")!.GetMaxLength().Should().Be(1000); - entity.FindProperty("Code")!.GetMaxLength().Should().Be(20); + // Code matches ChartStringSegment.Code (NVARCHAR(50)) so joins on Code align. + entity.FindProperty("Code")!.GetMaxLength().Should().Be(50); } } From 67d8b6b7a05862c8fce8df6b8982cd6d66d1568e Mon Sep 17 00:00:00 2001 From: Rob Martinsen Date: Thu, 2 Jul 2026 10:04:13 -0400 Subject: [PATCH 29/29] Drop (earnings) from ERN FTE disclaimer Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/dataClassification/DataClassificationStage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/dataClassification/DataClassificationStage.tsx b/client/src/components/dataClassification/DataClassificationStage.tsx index 44697f9..36bc428 100644 --- a/client/src/components/dataClassification/DataClassificationStage.tsx +++ b/client/src/components/dataClassification/DataClassificationStage.tsx @@ -74,8 +74,8 @@ export function DataClassificationStage() { {activeType === 'Ern' && (
- Note: ERN (earnings) code classification affects FTE calculations only. It - does not affect dollar-amount calculations. + Note: ERN code classification affects FTE calculations only. It does not + affect dollar-amount calculations.
)}