Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5d5bf28
Add ChartStringSegment entity mapped to data schema
rmartinsen-ucd Jun 29, 2026
90fa7f8
Seed sample chart string segments in development
rmartinsen-ucd Jun 29, 2026
233f80b
Add chart string segments GET and PATCH API
rmartinsen-ucd Jun 29, 2026
086bdba
Add chart string segments client data layer and helpers
rmartinsen-ucd Jun 30, 2026
d53fc69
Add presentational segment classification control and grid
rmartinsen-ucd Jun 30, 2026
9088121
Render Data Classification step in the workflow
rmartinsen-ucd Jun 30, 2026
73f2696
Widen Sfn column to 10 chars and clean fund seed
rmartinsen-ucd Jul 1, 2026
f81c0b3
Validate and persist fund SFN on classification PATCH
rmartinsen-ucd Jul 1, 2026
9f85d79
Add fund SFN dropdown to segment classification control
rmartinsen-ucd Jul 1, 2026
e69b1f5
Wire SFN dropdown through grid and stage, drop SFN column
rmartinsen-ucd Jul 1, 2026
8d8f044
Add chart segment hierarchy tables and EF entities
rmartinsen-ucd Jul 1, 2026
6649cb3
Set max lengths on hierarchy level properties
rmartinsen-ucd Jul 1, 2026
5ccd773
Add dev seed for chart segment hierarchy tables
rmartinsen-ucd Jul 1, 2026
fda50d3
Return chart segment hierarchy from the segments API
rmartinsen-ucd Jul 1, 2026
f066677
Show chart segment hierarchy in the classification grid
rmartinsen-ucd Jul 1, 2026
2a7d060
Test hierarchy join reads each segment type's own table
rmartinsen-ucd Jul 1, 2026
e0d5ffc
Show hierarchy as a column per level, 25 rows per page, unset first
rmartinsen-ucd Jul 1, 2026
9bcf5fd
Seed hierarchy tables from real CSV data and derive segments
rmartinsen-ucd Jul 1, 2026
aeefcce
Freeze row order per tab, sortable columns, clear search on tab switch
rmartinsen-ucd Jul 1, 2026
9c750ea
Use daisyUI tooltip for instant hover on hierarchy codes
rmartinsen-ucd Jul 1, 2026
ab04489
Seed most segments as classified, leaving ~10 unset per type
rmartinsen-ucd Jul 1, 2026
bf502a5
Add ERN segment type and classification tab
rmartinsen-ucd Jul 1, 2026
84d2032
Seed ERN codes into chart string segments from CSV
rmartinsen-ucd Jul 1, 2026
9861656
Show FTE-only disclaimer on the ERN tab
rmartinsen-ucd Jul 1, 2026
b2501b8
Remove em dash from ERN FTE disclaimer
rmartinsen-ucd Jul 1, 2026
196d393
Fix seeded included funds missing an SFN; keep page on data update
rmartinsen-ucd Jul 1, 2026
dea030d
Test SFN Multiple round-trip and grid unset-first/frozen ordering
rmartinsen-ucd Jul 1, 2026
bb71338
Align hierarchy Code column with ChartStringSegments.Code (NVARCHAR(50))
rmartinsen-ucd Jul 1, 2026
67d8b6b
Drop (earnings) from ERN FTE disclaimer
rmartinsen-ucd Jul 2, 2026
3758df1
Merge branch 'main' into rpm/data-classification
rmartinsen-ucd Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions client/src/components/dataClassification/DataClassificationStage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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 <p>Loading segments...</p>;
}

const handleClassify = (
segment: ChartStringSegment,
includeInReport: boolean,
sfn: string | null
) => {
updateClassification.mutate({
code: segment.code,
includeInReport,
segmentType: segment.segmentType,
sfn,
});
};

const gateOpen = allClassified(segments);

return (
<div className="space-y-4">
<p>
New chart-string segments need to be classified before they can be
included or excluded from the AD419 report.
</p>

<div className="tabs tabs-bordered" role="tablist">
{SEGMENT_TABS.map((tab) => {
const count = unclassifiedCount(segments, tab.type);

return (
<button
aria-selected={activeType === tab.type}
className={`tab ${activeType === tab.type ? 'tab-active' : ''}`}
key={tab.type}
onClick={() => setActiveType(tab.type)}
role="tab"
type="button"
>
{tab.label}
{count > 0 && (
<span className="badge badge-warning badge-sm ml-2">
{count} new
</span>
)}
</button>
);
})}
</div>

{activeType === 'Ern' && (
<div className="alert alert-info" role="note">
<span>
Note: ERN code classification affects FTE calculations only. It does not
affect dollar-amount calculations.
</span>
</div>
)}

<SegmentGrid
onClassify={handleClassify}
segments={segmentsForType(segments, activeType)}
segmentType={activeType}
/>

<div className="flex items-center justify-between border-t pt-4">
<span className={gateOpen ? 'text-success' : 'text-warning'}>
{gateOpen
? 'All segments classified.'
: 'Unclassified rows must be set before the next step.'}
</span>
{gateOpen ? (
<Link
className="btn btn-primary"
params={{ stageId: 'expense-review' }}
to="/workflow/$stageId"
>
Continue to Expense Review
</Link>
) : (
<button className="btn btn-primary" disabled type="button">
Continue to Expense Review
</button>
)}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
FUND_SFNS,
SFN_MULTIPLE,
type ChartStringSegment,
} from '@/queries/chartStringSegments.ts';

const EXCLUDED = 'Excluded';

export function SegmentClassificationControl({
onClassify,
segment,
}: {
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 (
<select
className="select select-xs select-bordered"
onChange={(event) => handleChange(event.target.value)}
value={value}
>
<option disabled value="">
Unset
</option>
{FUND_SFNS.map((sfn) => (
<option key={sfn} value={sfn}>
{sfn}
</option>
))}
<option value={SFN_MULTIPLE}>Multiple</option>
<option value={EXCLUDED}>Excluded</option>
</select>
);
}

const status = segment.includeInReport;

return (
<div className="flex items-center gap-2">
<button
className={`btn btn-xs ${status === true ? 'btn-success' : 'btn-ghost'}`}
onClick={() => onClassify(true, null)}
type="button"
>
Include
</button>
<button
className={`btn btn-xs ${status === false ? 'btn-error' : 'btn-ghost'}`}
onClick={() => onClassify(false, null)}
type="button"
>
Exclude
</button>
{status === null && (
<span className="badge badge-warning badge-sm">Unset</span>
)}
</div>
);
}
118 changes: 118 additions & 0 deletions client/src/components/dataClassification/SegmentGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useState } from 'react';
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';

function classificationSortValue(segment: ChartStringSegment): string {
if (segment.includeInReport === null) {
return '';
}
return segment.includeInReport ? (segment.sfn ?? 'Included') : 'Excluded';
}

// Codes ordered with unclassified (unset) rows first, otherwise stable.
function unsetFirstCodes(segments: ChartStringSegment[]): string[] {
return [...segments]
.sort(
(a, b) =>
(a.includeInReport === null ? 0 : 1) -
(b.includeInReport === null ? 0 : 1)
)
.map((segment) => segment.code);
}

export function SegmentGrid({
onClassify,
segments,
segmentType,
}: {
onClassify: (
segment: ChartStringSegment,
includeInReport: boolean,
sfn: string | null
) => void;
segments: ChartStringSegment[];
segmentType: SegmentType;
}) {
const levelKeys = [
...new Set(
segments.flatMap((segment) =>
segment.hierarchy.map((level) => level.level)
)
),
].sort();

const levelColumns: ColumnDef<ChartStringSegment>[] = levelKeys.map(
(levelKey) => ({
accessorFn: (segment) =>
segment.hierarchy.find((level) => level.level === levelKey)?.code ?? '',
cell: ({ row }) => {
const level = row.original.hierarchy.find(
(candidate) => candidate.level === levelKey
);
if (!level) {
return <span className="text-base-content/40">—</span>;
}
return (
<span
className="tooltip tooltip-right underline decoration-dotted cursor-help"
data-tip={level.name ?? level.code}
>
{level.code}
</span>
);
},
header: `Level ${levelKey}`,
id: `level-${levelKey}`,
})
);

const columns: ColumnDef<ChartStringSegment>[] = [
{ accessorKey: 'code', header: 'Code' },
{ accessorKey: 'description', header: 'Name' },
...levelColumns,
{
accessorFn: classificationSortValue,
cell: ({ row }) => (
<SegmentClassificationControl
onClassify={(includeInReport, sfn) =>
onClassify(row.original, includeInReport, sfn)
}
segment={row.original}
/>
),
header: 'Classification',
id: 'classification',
},
];

// Freeze the default row order when the tab is entered: unclassified rows on top,
// otherwise stable. Recomputed only when the segment type changes (the "adjust
// state when a prop changes" pattern), so classifying a row in place does not
// immediately move it. Column sorting (below) overrides this when a header is
// clicked.
const [order, setOrder] = useState<{ codes: string[]; type: SegmentType }>(
() => ({ codes: unsetFirstCodes(segments), type: segmentType })
);
if (order.type !== segmentType) {
setOrder({ codes: unsetFirstCodes(segments), type: segmentType });
}
const orderIndex = new Map(order.codes.map((code, index) => [code, index]));
const orderedSegments = [...segments].sort(
(a, b) => (orderIndex.get(a.code) ?? 0) - (orderIndex.get(b.code) ?? 0)
);

return (
<DataTable
columns={columns}
data={orderedSegments}
globalFilter="right"
initialState={{ pagination: { pageSize: 25 } }}
key={segmentType}
/>
);
}
32 changes: 32 additions & 0 deletions client/src/components/dataClassification/segments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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' },
{ label: 'ERN', type: 'Ern' },
];

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);
}
Loading
Loading