From 0af94314356b42a841248f936c94c5b89d80762e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 12 Mar 2026 13:03:23 +0100 Subject: [PATCH] feat(06-01): add template API client functions and useTemplate hook - Add ItemTier type and TemplateItem/TemplateDetail interfaces to api.ts - Add item_tier field to BudgetItem interface - Add template API object with get/updateName/addItem/updateItem/deleteItem/reorder - Add generate function to budgets API object - Create useTemplate hook with CRUD operations and reorder logic --- frontend/src/hooks/useTemplate.ts | 98 +++++++++++++++++++++++++++++++ frontend/src/lib/api.ts | 38 ++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 frontend/src/hooks/useTemplate.ts diff --git a/frontend/src/hooks/useTemplate.ts b/frontend/src/hooks/useTemplate.ts new file mode 100644 index 0000000..d99fd24 --- /dev/null +++ b/frontend/src/hooks/useTemplate.ts @@ -0,0 +1,98 @@ +import { useState, useEffect } from 'react' +import { + template as templateApi, + categories as categoriesApi, + type TemplateDetail, + type TemplateItem, + type Category, + type ItemTier, +} from '@/lib/api' + +export function useTemplate() { + const [templateDetail, setTemplateDetail] = useState(null) + const [cats, setCats] = useState([]) + const [loading, setLoading] = useState(true) + + const fetchTemplate = async () => { + const data = await templateApi.get() + setTemplateDetail(data) + } + + const fetchCategories = async () => { + const data = await categoriesApi.list() + setCats(data) + } + + useEffect(() => { + const init = async () => { + try { + await Promise.all([fetchTemplate(), fetchCategories()]) + } finally { + setLoading(false) + } + } + init() + }, []) + + const addItem = async (data: { + category_id: string + item_tier: ItemTier + budgeted_amount?: number + }) => { + await templateApi.addItem(data) + await fetchTemplate() + } + + const removeItem = async (itemId: string) => { + await templateApi.deleteItem(itemId) + await fetchTemplate() + } + + const moveItem = async (itemId: string, direction: 'up' | 'down') => { + if (!templateDetail) return + const items = [...templateDetail.items].sort((a, b) => a.sort_order - b.sort_order) + const idx = items.findIndex((i) => i.id === itemId) + if (idx === -1) return + const swapIdx = direction === 'up' ? idx - 1 : idx + 1 + if (swapIdx < 0 || swapIdx >= items.length) return + + const current = items[idx] + const swap = items[swapIdx] + const reordered: { id: string; sort_order: number }[] = items.map((item) => { + if (item.id === current.id) return { id: item.id, sort_order: swap.sort_order } + if (item.id === swap.id) return { id: item.id, sort_order: current.sort_order } + return { id: item.id, sort_order: item.sort_order } + }) + + await templateApi.reorder(reordered) + await fetchTemplate() + } + + const updateItem = async ( + itemId: string, + data: { item_tier?: ItemTier; budgeted_amount?: number } + ) => { + await templateApi.updateItem(itemId, data) + await fetchTemplate() + } + + return { + template: templateDetail, + categories: cats, + loading, + addItem, + removeItem, + moveItem, + updateItem, + refetch: fetchTemplate, + } as { + template: TemplateDetail | null + categories: Category[] + loading: boolean + addItem: (data: { category_id: string; item_tier: ItemTier; budgeted_amount?: number }) => Promise + removeItem: (itemId: string) => Promise + moveItem: (itemId: string, direction: 'up' | 'down') => Promise + updateItem: (itemId: string, data: { item_tier?: ItemTier; budgeted_amount?: number }) => Promise + refetch: () => Promise + } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 21b49c6..a30f2a4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -76,17 +76,38 @@ export interface Budget { carryover_amount: number } +export type ItemTier = 'fixed' | 'variable' | 'one_off' + export interface BudgetItem { id: string budget_id: string category_id: string category_name: string category_type: CategoryType + item_tier: ItemTier budgeted_amount: number actual_amount: number notes: string } +export interface TemplateItem { + id: string + template_id: string + category_id: string + category_name: string + category_type: CategoryType + category_icon: string + item_tier: ItemTier + budgeted_amount: number | null + sort_order: number +} + +export interface TemplateDetail { + id: string | null + name: string + items: TemplateItem[] +} + export interface BudgetDetail extends Budget { items: BudgetItem[] totals: { @@ -116,6 +137,8 @@ export const budgets = { delete: (id: string) => request(`/budgets/${id}`, { method: 'DELETE' }), copyFrom: (id: string, srcId: string) => request(`/budgets/${id}/copy-from/${srcId}`, { method: 'POST' }), + generate: (data: { month: string; currency: string }) => + request('/budgets/generate', { method: 'POST', body: JSON.stringify(data) }), } // Budget Items @@ -128,6 +151,21 @@ export const budgetItems = { request(`/budgets/${budgetId}/items/${itemId}`, { method: 'DELETE' }), } +// Template +export const template = { + get: () => request('/template'), + updateName: (name: string) => + request('/template', { method: 'PUT', body: JSON.stringify({ name }) }), + addItem: (data: { category_id: string; item_tier: ItemTier; budgeted_amount?: number }) => + request('/template/items', { method: 'POST', body: JSON.stringify(data) }), + updateItem: (itemId: string, data: { item_tier?: ItemTier; budgeted_amount?: number }) => + request(`/template/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteItem: (itemId: string) => + request(`/template/items/${itemId}`, { method: 'DELETE' }), + reorder: (items: { id: string; sort_order: number }[]) => + request('/template/items/reorder', { method: 'PUT', body: JSON.stringify({ items }) }), +} + // Settings export const settings = { get: () => request('/settings'),