diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ba32c1f..43592e1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { RegisterPage } from '@/pages/RegisterPage' import { DashboardPage } from '@/pages/DashboardPage' import { CategoriesPage } from '@/pages/CategoriesPage' import { SettingsPage } from '@/pages/SettingsPage' +import { TemplatePage } from '@/pages/TemplatePage' import '@/i18n' export default function App() { @@ -35,6 +36,7 @@ export default function App() { } /> } /> + } /> } /> diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index edb2416..28ea2a3 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next' import { Link, useLocation } from 'react-router-dom' -import { LayoutDashboard, Tags, Settings, LogOut } from 'lucide-react' +import { LayoutDashboard, Tags, FileText, Settings, LogOut } from 'lucide-react' import { Sidebar, SidebarContent, @@ -30,6 +30,7 @@ export function AppLayout({ auth, children }: Props) { const navItems = [ { path: '/', label: t('nav.dashboard'), icon: LayoutDashboard }, { path: '/categories', label: t('nav.categories'), icon: Tags }, + { path: '/template', label: t('nav.template'), icon: FileText }, { path: '/settings', label: t('nav.settings'), icon: Settings }, ] diff --git a/frontend/src/hooks/useTemplate.ts b/frontend/src/hooks/useTemplate.ts index d99fd24..021d88d 100644 --- a/frontend/src/hooks/useTemplate.ts +++ b/frontend/src/hooks/useTemplate.ts @@ -3,7 +3,6 @@ import { template as templateApi, categories as categoriesApi, type TemplateDetail, - type TemplateItem, type Category, type ItemTier, } from '@/lib/api' diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 91a8d8e..7df286d 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -5,6 +5,7 @@ "nav": { "dashboard": "Dashboard", "categories": "Kategorien", + "template": "Vorlage", "settings": "Einstellungen", "logout": "Abmelden" }, @@ -74,6 +75,21 @@ "profile": "Profil", "save": "Speichern" }, + "template": { + "title": "Monatliche Vorlage", + "addItem": "Eintrag hinzufuegen", + "category": "Kategorie", + "tier": "Typ", + "fixed": "Fest", + "variable": "Variabel", + "oneOff": "Einmalig", + "amount": "Betrag", + "actions": "Aktionen", + "noItems": "Noch keine Vorlageneintraege", + "noItemsHint": "Fuegen Sie feste und variable Eintraege hinzu, um Ihre monatliche Budgetvorlage zu definieren.", + "selectCategory": "Kategorie auswaehlen", + "selectTier": "Typ auswaehlen" + }, "common": { "save": "Speichern", "cancel": "Abbrechen", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index cab561c..0b9b3af 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -5,6 +5,7 @@ "nav": { "dashboard": "Dashboard", "categories": "Categories", + "template": "Template", "settings": "Settings", "logout": "Logout" }, @@ -74,6 +75,21 @@ "profile": "Profile", "save": "Save" }, + "template": { + "title": "Monthly Template", + "addItem": "Add Item", + "category": "Category", + "tier": "Tier", + "fixed": "Fixed", + "variable": "Variable", + "oneOff": "One-off", + "amount": "Amount", + "actions": "Actions", + "noItems": "No template items yet", + "noItemsHint": "Add fixed and variable items to define your monthly budget template.", + "selectCategory": "Select category", + "selectTier": "Select tier" + }, "common": { "save": "Save", "cancel": "Cancel", diff --git a/frontend/src/pages/TemplatePage.tsx b/frontend/src/pages/TemplatePage.tsx new file mode 100644 index 0000000..5cc7c4f --- /dev/null +++ b/frontend/src/pages/TemplatePage.tsx @@ -0,0 +1,224 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { EmptyState } from '@/components/EmptyState' +import { FileText, Plus, Trash2, ArrowUp, ArrowDown } from 'lucide-react' +import { useTemplate } from '@/hooks/useTemplate' +import type { ItemTier } from '@/lib/api' + +const TIER_COLORS: Record = { + fixed: 'bg-blue-100 text-blue-800', + variable: 'bg-amber-100 text-amber-800', +} + +export function TemplatePage() { + const { t } = useTranslation() + const { template, categories, loading, addItem, removeItem, moveItem } = useTemplate() + + const [selectedCategory, setSelectedCategory] = useState('') + const [selectedTier, setSelectedTier] = useState('') + const [amount, setAmount] = useState('') + const [adding, setAdding] = useState(false) + + const templateItemCategoryIds = new Set(template?.items.map((i) => i.category_id) ?? []) + const availableCategories = categories.filter((c) => !templateItemCategoryIds.has(c.id)) + + const sortedItems = [...(template?.items ?? [])].sort((a, b) => a.sort_order - b.sort_order) + + const isAddDisabled = + !selectedCategory || + !selectedTier || + (selectedTier === 'fixed' && amount.trim() === '') + + const handleAdd = async () => { + if (!selectedCategory || !selectedTier) return + setAdding(true) + try { + await addItem({ + category_id: selectedCategory, + item_tier: selectedTier as ItemTier, + ...(selectedTier === 'fixed' && amount.trim() !== '' + ? { budgeted_amount: parseFloat(amount) } + : {}), + }) + setSelectedCategory('') + setSelectedTier('') + setAmount('') + } finally { + setAdding(false) + } + } + + const formatAmount = (value: number | null) => { + if (value === null) return '—' + return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + } + + if (loading) { + return ( +
+ + + + + + + + + + +
+ ) + } + + return ( +
+

{t('template.title')}

+ + + + {t('template.title')} + + + {/* Add item form */} +
+
+ +
+ +
+ +
+ + {selectedTier === 'fixed' && ( +
+ setAmount(e.target.value)} + min="0" + step="0.01" + /> +
+ )} + + +
+ + {/* Template items table or empty state */} + {sortedItems.length === 0 ? ( + + ) : ( + + + + {/* reorder */} + {t('template.category')} + {t('template.tier')} + {t('template.amount')} + {t('template.actions')} + + + + {sortedItems.map((item, idx) => ( + + +
+ + +
+
+ + + {item.category_icon ? `${item.category_icon} ` : ''} + {item.category_name} + + + + + {item.item_tier === 'fixed' + ? t('template.fixed') + : t('template.variable')} + + + + {formatAmount(item.budgeted_amount)} + + + + +
+ ))} +
+
+ )} +
+
+
+ ) +}