Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
802118f
Add expense import tables, sprocs and project list (#31)
rmartinsen-ucd Jul 7, 2026
fae1a04
Merge remote-tracking branch 'origin/main' into rpm/expense-import
rmartinsen-ucd Jul 7, 2026
1e96c57
Emit letter hierarchy level keys for all segment types
rmartinsen-ucd Jul 8, 2026
4528a10
Null self-referencing hierarchy levels at seed load
rmartinsen-ucd Jul 8, 2026
cc4696a
Add SFN catalog with descriptions, validate funds against it
rmartinsen-ucd Jul 8, 2026
0a12545
Add Purpose segment type with hierarchy table and wiring
rmartinsen-ucd Jul 8, 2026
1d98bb3
Seed purpose hierarchy and 2025 default classifications
rmartinsen-ucd Jul 8, 2026
c20e2ff
Suppress sort-objects lint for flat-file-ordered test fixture
rmartinsen-ucd Jul 8, 2026
e1ab3bc
Add Purpose tab, per-tab headers and notes, SFN descriptions
rmartinsen-ucd Jul 8, 2026
e5dfcdd
Add per-tab Export All for classification segments
rmartinsen-ucd Jul 8, 2026
7b5be25
Move export button into the grid toolbar next to search
rmartinsen-ucd Jul 8, 2026
28fce6d
Merge origin/main: move Purpose wiring to DataDbContext
rmartinsen-ucd Jul 8, 2026
099f4ec
Rename ChartStringSegments to SegmentClassifications throughout
rmartinsen-ucd Jul 8, 2026
a74312f
Drop persisted purpose exclusion from the import pipeline
rmartinsen-ucd Jul 8, 2026
bfb7745
Require PGM match in Projects, store a single SFN
rmartinsen-ucd Jul 8, 2026
870b4d5
Tighten UcPathTransactions nullability, rename DosCode to ErnCode
rmartinsen-ucd Jul 8, 2026
480ac40
Require profiled-complete UcPathTransactions columns
rmartinsen-ucd Jul 8, 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
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@ import {
segmentsForType,
unclassifiedCount,
} from './segments.ts';
import { buildSegmentExport } from './exportSegments.ts';
import { SegmentGrid } from './SegmentGrid.tsx';
import { ExportDataButton } from '@/shared/exportDataButton.tsx';
import {
chartStringSegmentsQueryOptions,
type ChartStringSegment,
segmentClassificationsQueryOptions,
type SegmentClassification,
useUpdateSegmentClassification,
} from '@/queries/chartStringSegments.ts';
} from '@/queries/segmentClassifications.ts';
import { Link } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query';

export function DataClassificationStage() {
const { data: segments = [], isLoading } = useQuery(
chartStringSegmentsQueryOptions()
segmentClassificationsQueryOptions()
);
const updateClassification = useUpdateSegmentClassification();
const [activeType, setActiveType] = useState(SEGMENT_TABS[0].type);
Expand All @@ -26,7 +28,7 @@ export function DataClassificationStage() {
}

const handleClassify = (
segment: ChartStringSegment,
segment: SegmentClassification,
includeInReport: boolean,
sfn: string | null
) => {
Expand All @@ -39,6 +41,10 @@ export function DataClassificationStage() {
};

const gateOpen = allClassified(segments);
const activeTab =
SEGMENT_TABS.find((tab) => tab.type === activeType) ?? SEGMENT_TABS[0];
const tabSegments = segmentsForType(segments, activeType);
const exportData = buildSegmentExport(tabSegments, activeTab);

return (
<div className="space-y-4">
Expand Down Expand Up @@ -71,19 +77,25 @@ export function DataClassificationStage() {
})}
</div>

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

<SegmentGrid
classificationHeader={activeTab.classificationHeader}
onClassify={handleClassify}
segments={segmentsForType(segments, activeType)}
segments={tabSegments}
segmentType={activeType}
tableActions={
<ExportDataButton
columns={exportData.columns}
data={exportData.rows}
filename={exportData.filename}
label="Export"
/>
}
/>

<div className="flex items-center justify-between border-t pt-4">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {
FUND_SFNS,
SFN_CATALOG,
SFN_MULTIPLE,
type ChartStringSegment,
} from '@/queries/chartStringSegments.ts';
type SegmentClassification,
} from '@/queries/segmentClassifications.ts';

const EXCLUDED = 'Excluded';

Expand All @@ -11,7 +11,7 @@ export function SegmentClassificationControl({
segment,
}: {
onClassify: (includeInReport: boolean, sfn: string | null) => void;
segment: ChartStringSegment;
segment: SegmentClassification;
}) {
if (segment.segmentType === 'Fund') {
const value =
Expand All @@ -36,9 +36,9 @@ export function SegmentClassificationControl({
<option disabled value="">
Unset
</option>
{FUND_SFNS.map((sfn) => (
<option key={sfn} value={sfn}>
{sfn}
{SFN_CATALOG.map((entry) => (
<option key={entry.code} value={entry.code}>
{entry.code} - {entry.description}
</option>
))}
<option value={SFN_MULTIPLE}>Multiple</option>
Expand Down
25 changes: 15 additions & 10 deletions client/src/components/dataClassification/SegmentGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { useState } from 'react';
import { useState, type ReactNode } from 'react';
import { SegmentClassificationControl } from './SegmentClassificationControl.tsx';
import type {
ChartStringSegment,
SegmentClassification,
SegmentType,
} from '@/queries/chartStringSegments.ts';
} from '@/queries/segmentClassifications.ts';
import { DataTable } from '@/shared/dataTable.tsx';
import type { ColumnDef } from '@tanstack/react-table';

function classificationSortValue(segment: ChartStringSegment): string {
function classificationSortValue(segment: SegmentClassification): 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[] {
function unsetFirstCodes(segments: SegmentClassification[]): string[] {
return [...segments]
.sort(
(a, b) =>
Expand All @@ -26,17 +26,21 @@ function unsetFirstCodes(segments: ChartStringSegment[]): string[] {
}

export function SegmentGrid({
classificationHeader,
onClassify,
segments,
segmentType,
tableActions,
}: {
classificationHeader: string;
onClassify: (
segment: ChartStringSegment,
segment: SegmentClassification,
includeInReport: boolean,
sfn: string | null
) => void;
segments: ChartStringSegment[];
segments: SegmentClassification[];
segmentType: SegmentType;
tableActions?: ReactNode;
}) {
const levelKeys = [
...new Set(
Expand All @@ -46,7 +50,7 @@ export function SegmentGrid({
),
].sort();

const levelColumns: ColumnDef<ChartStringSegment>[] = levelKeys.map(
const levelColumns: ColumnDef<SegmentClassification>[] = levelKeys.map(
(levelKey) => ({
accessorFn: (segment) =>
segment.hierarchy.find((level) => level.level === levelKey)?.code ?? '',
Expand All @@ -71,7 +75,7 @@ export function SegmentGrid({
})
);

const columns: ColumnDef<ChartStringSegment>[] = [
const columns: ColumnDef<SegmentClassification>[] = [
{ accessorKey: 'code', header: 'Code' },
{ accessorKey: 'description', header: 'Name' },
...levelColumns,
Expand All @@ -85,7 +89,7 @@ export function SegmentGrid({
segment={row.original}
/>
),
header: 'Classification',
header: classificationHeader,
id: 'classification',
},
];
Expand Down Expand Up @@ -113,6 +117,7 @@ export function SegmentGrid({
globalFilter="right"
initialState={{ pagination: { pageSize: 25 } }}
key={segmentType}
tableActions={tableActions}
/>
);
}
79 changes: 79 additions & 0 deletions client/src/components/dataClassification/exportSegments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { SegmentTab } from './segments.ts';
import type { CsvColumn } from '@/lib/csv.ts';
import {
SFN_CATALOG,
type SegmentClassification,
} from '@/queries/segmentClassifications.ts';

type ExportRow = Record<string, string>;

function classificationLabel(segment: SegmentClassification): string {
if (segment.includeInReport === null) {
return 'Unset';
}
return segment.includeInReport ? 'Included' : 'Excluded';
}

// Exports every row of the tab's segment type (intentionally ignores the grid
// search filter). SFN code and description stay separate columns; description
// resolves through the catalog.
export function buildSegmentExport(
segments: SegmentClassification[],
tab: SegmentTab
): { columns: CsvColumn<ExportRow>[]; filename: string; rows: ExportRow[] } {
const levelKeys = [
...new Set(
segments.flatMap((segment) =>
segment.hierarchy.map((level) => level.level)
)
),
].sort();

const columns: CsvColumn<ExportRow>[] = [
{ header: 'Code', key: 'code' },
{ header: 'Name', key: 'name' },
...levelKeys.flatMap((levelKey): CsvColumn<ExportRow>[] => [
{ header: `Level ${levelKey} Code`, key: `level${levelKey}Code` },
{ header: `Level ${levelKey} Name`, key: `level${levelKey}Name` },
]),
{ header: 'Classification', key: 'classification' },
];

if (tab.type === 'Fund') {
columns.push(
{ header: 'SFN', key: 'sfn' },
{ header: 'SFN Description', key: 'sfnDescription' }
);
}

const rows = segments.map((segment) => {
const row: ExportRow = {
classification: classificationLabel(segment),
code: segment.code,
name: segment.description ?? '',
};

for (const levelKey of levelKeys) {
const level = segment.hierarchy.find(
(candidate) => candidate.level === levelKey
);
row[`level${levelKey}Code`] = level?.code ?? '';
row[`level${levelKey}Name`] = level?.name ?? '';
}

if (tab.type === 'Fund') {
row.sfn = segment.sfn ?? '';
row.sfnDescription =
SFN_CATALOG.find((entry) => entry.code === segment.sfn)?.description ??
'';
}

return row;
});

return {
columns,
filename: `ad419-${tab.slug}-classification.csv`,
rows,
};
}
69 changes: 57 additions & 12 deletions client/src/components/dataClassification/segments.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,77 @@
import type {
ChartStringSegment,
SegmentClassification,
SegmentType,
} from '@/queries/chartStringSegments.ts';
} from '@/queries/segmentClassifications.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 interface SegmentTab {
classificationHeader: string;
label: string;
note: string;
slug: string;
type: SegmentType;
}

export const SEGMENT_TABS: SegmentTab[] = [
{
classificationHeader: 'Is AES?',
label: 'Financial Dept',
note: 'Departments marked AES have their expenses considered for the AD419 report.',
slug: 'financial-dept',
type: 'FinancialDepartment',
},
{
classificationHeader: 'Include in AD419?',
label: 'Natural Account',
note: 'Whether expenses on this natural account are considered for the AD419 report.',
slug: 'natural-account',
type: 'Account',
},
{
classificationHeader: 'SFN',
label: 'Fund',
note: 'Included funds must be assigned an SFN. If a fund does not map to a single SFN, select Multiple.',
slug: 'fund',
type: 'Fund',
},
{
classificationHeader: 'Include in AD419?',
label: 'Activity',
note: 'Whether expenses on this activity are considered for the AD419 report.',
slug: 'activity',
type: 'Activity',
},
{
classificationHeader: 'Include in AD419?',
label: 'Purpose',
note: 'All purposes are included for transactions with fund 13U02, regardless of classification.',
slug: 'purpose',
type: 'Purpose',
},
{
classificationHeader: 'Include in FTE?',
label: 'ERN',
note: 'ERN code classification affects FTE calculations only. It does not affect dollar-amount calculations.',
slug: 'ern',
type: 'Ern',
},
];

export function segmentsForType(
segments: ChartStringSegment[],
segments: SegmentClassification[],
type: SegmentType
): ChartStringSegment[] {
): SegmentClassification[] {
return segments.filter((segment) => segment.segmentType === type);
}

export function unclassifiedCount(
segments: ChartStringSegment[],
segments: SegmentClassification[],
type: SegmentType
): number {
return segmentsForType(segments, type).filter(
(segment) => segment.includeInReport === null
).length;
}

export function allClassified(segments: ChartStringSegment[]): boolean {
export function allClassified(segments: SegmentClassification[]): boolean {
return segments.every((segment) => segment.includeInReport !== null);
}
Loading
Loading