Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import Accounts from "@/pages/Accounts";
import Reports from "@/pages/Reports";
import Integrations from "@/pages/Integrations";
import Allocations from "@/pages/Allocations";
import Classification from "@/pages/Classification";

// Lazy-load the Orbital Console (57KB + physics sim + canvas rendering)
const OrbitalConsole = lazy(() => import("@/pages/OrbitalConsole"));
Expand Down Expand Up @@ -59,6 +60,7 @@ function Router() {
<Route path="/integrations" component={Integrations} />
<Route path="/properties/:id" component={PropertyDetail} />
<Route path="/allocations" component={Allocations} />
<Route path="/classification" component={Classification} />
<Route path="/connections" component={Connections} />
<Route path="/admin" component={Admin} />
<Route path="/orbital">
Expand Down
3 changes: 2 additions & 1 deletion client/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
Settings, Plug, Building2, Shield,
ChevronDown, ChevronRight, ChevronsUpDown,
Menu, X, Activity, LayoutDashboard,
ArrowLeftRight, Wallet, BarChart3, Cable, Orbit, GitBranch
ArrowLeftRight, Wallet, BarChart3, Cable, Orbit, GitBranch, Tags
} from "lucide-react";
import { useState, useMemo } from "react";
import { useRole, type UserRole } from "@/contexts/RoleContext";
Expand All @@ -26,6 +26,7 @@ const NAV_ITEMS: NavItem[] = [
{ href: "/accounts", label: "Accounts", icon: Wallet, roles: ["cfo", "accountant", "bookkeeper"] },
{ href: "/orbital", label: "Orbital", icon: Orbit, roles: ["cfo", "accountant", "bookkeeper", "user"], badge: "NEW" },
{ href: "/allocations", label: "Allocations", icon: GitBranch, roles: ["cfo", "accountant"] },
{ href: "/classification", label: "Classification", icon: Tags, roles: ["cfo", "accountant", "bookkeeper"] },
{ href: "/reports", label: "Reports", icon: BarChart3, roles: ["cfo", "accountant"] },
{ href: "/integrations", label: "Integrations", icon: Cable, roles: ["cfo", "accountant"] },
{ href: "/connections", label: "Connections", icon: Plug, roles: ["cfo", "accountant"] },
Expand Down
159 changes: 159 additions & 0 deletions client/src/hooks/use-classification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiRequest } from '@/lib/queryClient';
import { useTenantId } from '@/contexts/TenantContext';

// Matches database/system.schema.ts chartOfAccounts
export interface ChartOfAccount {
id: string;
tenantId: string | null; // null = global default
code: string;
name: string;
type: 'asset' | 'liability' | 'equity' | 'income' | 'expense';
subtype: string | null;
description: string | null;
scheduleELine: string | null;
taxDeductible: boolean;
parentCode: string | null;
isActive: boolean;
}

// Matches database/system.schema.ts transactions (classification columns)
export interface UnclassifiedTransaction {
id: string;
tenantId: string;
accountId: string;
amount: string;
type: string;
category: string | null;
description: string;
date: string;
payee: string | null;
coaCode: string | null;
suggestedCoaCode: string | null;
classificationConfidence: string | null;
classifiedBy: string | null;
classifiedAt: string | null;
reconciled: boolean;
reconciledBy: string | null;
reconciledAt: string | null;
}

export interface ClassificationStats {
total: number | string;
classified: number | string;
reconciled: number | string;
suggested: number | string;
unclassified: number;
classifiedPct: number;
}

export interface ClassificationAuditEntry {
id: string;
transactionId: string;
tenantId: string;
previousCoaCode: string | null;
newCoaCode: string;
action: string; // 'suggest' | 're-suggest' | 'classify' | 'reclassify' | 'reconcile'
trustLevel: string;
actorId: string;
actorType: 'user' | 'agent' | 'system';
confidence: string | null;
reason: string | null;
createdAt: string;
}

// ── Queries ──

export function useChartOfAccounts() {
const tenantId = useTenantId();
return useQuery<ChartOfAccount[]>({
queryKey: ['/api/coa', tenantId],
enabled: !!tenantId,
});
}

export function useClassificationStats() {
const tenantId = useTenantId();
return useQuery<ClassificationStats>({
queryKey: ['/api/classification/stats', tenantId],
enabled: !!tenantId,
});
}

export function useUnclassifiedTransactions(limit = 50) {
const tenantId = useTenantId();
return useQuery<UnclassifiedTransaction[]>({
queryKey: ['/api/classification/unclassified', tenantId, limit],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include limit in unclassified fetch URL

useUnclassifiedTransactions(limit) never sends limit to the backend because the default query function only requests queryKey[0] as the URL. Here limit is only placed in the query key tuple, so calls like useUnclassifiedTransactions(100) still hit /api/classification/unclassified without ?limit=100 and silently fall back to the server default page size.

Useful? React with 👍 / 👎.

enabled: !!tenantId,
});
}

export function useClassificationAudit(transactionId: string | null) {
return useQuery<ClassificationAuditEntry[]>({
queryKey: ['/api/classification/audit', transactionId],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Build audit query URL with transaction ID

useClassificationAudit is wired to /api/classification/audit even though the route is /api/classification/audit/:transactionId. Because the shared query function only fetches queryKey[0], putting transactionId in the second key slot does not affect the request URL, so this hook will consistently request a non-existent endpoint when used.

Useful? React with 👍 / 👎.

enabled: !!transactionId,
Comment on lines +92 to +94
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useClassificationAudit's queryKey is not scoped by tenantId, while the rest of the classification hooks are. If a user switches tenants, React Query can serve cached audit data keyed only by transactionId. Include tenantId in the key (and gate enabled on both tenantId + transactionId) to keep caching behavior consistent with the stated tenant-scoping approach.

Suggested change
return useQuery<ClassificationAuditEntry[]>({
queryKey: ['/api/classification/audit', transactionId],
enabled: !!transactionId,
const tenantId = useTenantId();
return useQuery<ClassificationAuditEntry[]>({
queryKey: ['/api/classification/audit', tenantId, transactionId],
enabled: !!tenantId && !!transactionId,

Copilot uses AI. Check for mistakes.
});
}

// ── Mutations ──

function invalidateClassificationCache(qc: ReturnType<typeof useQueryClient>) {
qc.invalidateQueries({ queryKey: ['/api/classification/stats'] });
qc.invalidateQueries({ queryKey: ['/api/classification/unclassified'] });
}

/** L2 — set authoritative coa_code on a transaction */
export function useClassifyTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { transactionId: string; coaCode: string; reason?: string; confidence?: string }) =>
apiRequest('POST', '/api/classification/classify', data).then((r) => r.json()),
onSuccess: () => invalidateClassificationCache(qc),
});
}

/** L1 — write a suggestion (manual override of AI/keyword) */
export function useSuggestTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { transactionId: string; coaCode: string; reason?: string; confidence?: string }) =>
apiRequest('POST', '/api/classification/suggest', data).then((r) => r.json()),
onSuccess: () => invalidateClassificationCache(qc),
});
}

/** L3 — lock a classified transaction */
export function useReconcileTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { transactionId: string }) =>
apiRequest('POST', '/api/classification/reconcile', data).then((r) => r.json()),
onSuccess: () => invalidateClassificationCache(qc),
});
}

/** L1 — run keyword-match batch over unclassified queue */
export function useBatchSuggest() {
const qc = useQueryClient();
return useMutation({
mutationFn: (limit: number = 100) =>
apiRequest('POST', `/api/classification/batch-suggest?limit=${limit}`).then((r) => r.json()),
onSuccess: () => invalidateClassificationCache(qc),
});
}

/** L1 — run GPT-4o-mini AI batch over unclassified queue */
export function useAiSuggest() {
const qc = useQueryClient();
return useMutation({
mutationFn: (limit: number = 25) =>
apiRequest('POST', `/api/classification/ai-suggest?limit=${limit}`).then((r) => r.json()) as Promise<{
processed: number;
suggested: number;
aiCount: number;
keywordCount: number;
aiAvailable: boolean;
}>,
onSuccess: () => invalidateClassificationCache(qc),
});
}
Loading
Loading