feat(ts): type the shared Category API response (A4)
Adds Category (+ CategoryBillSummary) to @/types and wires categories/ createCategory/updateCategory. Category has no money field of its own (budgets live on the spending endpoints); the nested per-bill total_paid is a raw SQL sum, so it's deliberately left unbranded. With categories now typed, useSpending Categories drops its `any` cast and dead d.categories branch. Category, Bill, Payment, and the Tracker envelope are the cross-cutting types used by many components — worth defining upfront. The remaining single-consumer page responses (summary, analytics, spending, snowball, subscriptions, bank ledger) are typed alongside their page conversions in phase B, where the exact fields consumed are visible (more accurate than a speculative interface). typecheck 0, build green, 48 client tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
337ad95a3e
commit
4af738f947
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Bill, Payment, TrackerResponse } from '@/types';
|
import type { Bill, Category, Payment, TrackerResponse } from '@/types';
|
||||||
|
|
||||||
// Fetch CSRF token from the server once and cache in memory.
|
// Fetch CSRF token from the server once and cache in memory.
|
||||||
// The cookie is httpOnly so document.cookie cannot access it directly.
|
// The cookie is httpOnly so document.cookie cannot access it directly.
|
||||||
|
|
@ -332,10 +332,10 @@ export const api = {
|
||||||
abandonSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/abandon`, {}),
|
abandonSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/abandon`, {}),
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
categories: () => get('/categories'),
|
categories: () => get<Category[]>('/categories'),
|
||||||
createCategory: (data: Body) => post('/categories', data),
|
createCategory: (data: Body) => post<Category>('/categories', data),
|
||||||
reorderCategories: (order: Body) => put('/categories/reorder', order),
|
reorderCategories: (order: Body) => put('/categories/reorder', order),
|
||||||
updateCategory: (id: Id, data: Body) => put(`/categories/${id}`, data),
|
updateCategory: (id: Id, data: Body) => put<Category>(`/categories/${id}`, data),
|
||||||
toggleCategorySpending: (id: Id, val: unknown) => patch(`/categories/${id}/spending`, { spending_enabled: val }),
|
toggleCategorySpending: (id: Id, val: unknown) => patch(`/categories/${id}/spending`, { spending_enabled: val }),
|
||||||
deleteCategory: (id: Id) => del(`/categories/${id}`),
|
deleteCategory: (id: Id) => del(`/categories/${id}`),
|
||||||
restoreCategory: (id: Id) => post(`/categories/${id}/restore`),
|
restoreCategory: (id: Id) => post(`/categories/${id}/restore`),
|
||||||
|
|
|
||||||
|
|
@ -184,8 +184,8 @@ export function useSpendingCategories() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['spending-categories'],
|
queryKey: ['spending-categories'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const d = await api.categories() as any;
|
const cats = await api.categories();
|
||||||
return (d.categories || d || []).filter((c: any) => !c.deleted_at && c.spending_enabled);
|
return cats.filter(c => !c.deleted_at && c.spending_enabled);
|
||||||
},
|
},
|
||||||
staleTime: 1000 * 60 * 5,
|
staleTime: 1000 * 60 * 5,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,39 @@ export interface Payment {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A category's per-bill rollup (categories list endpoint). `total_paid` here is a
|
||||||
|
// raw SQL sum (not run through fromCents), so it is deliberately NOT branded.
|
||||||
|
export interface CategoryBillSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
payment_count: number;
|
||||||
|
total_paid: number;
|
||||||
|
last_paid_date: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A spending/bill category. Has no money field of its own (budgets live on the
|
||||||
|
// spending endpoints). The list endpoint adds bill rollups; create/update return
|
||||||
|
// the bare row — so the rollup fields are optional.
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
user_id?: number;
|
||||||
|
name: string;
|
||||||
|
sort_order?: number;
|
||||||
|
spending_enabled: boolean;
|
||||||
|
group_id: number | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
bill_count?: number;
|
||||||
|
active_bill_count?: number;
|
||||||
|
inactive_bill_count?: number;
|
||||||
|
payment_count?: number;
|
||||||
|
bill_names?: string[];
|
||||||
|
bills?: CategoryBillSummary[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
// A bill as serialized by the server (billsService.serializeBill spreads the DB
|
// A bill as serialized by the server (billsService.serializeBill spreads the DB
|
||||||
// row and converts the three money columns to dollars). SQLite booleans come back
|
// row and converts the three money columns to dollars). SQLite booleans come back
|
||||||
// as 0/1 integers. Extra joined columns (sparkline, has_merchant_rule, …) pass
|
// as 0/1 integers. Extra joined columns (sparkline, has_merchant_rule, …) pass
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue