feat(06-01): add TemplatePage, routing, sidebar nav, and i18n translations

- Create TemplatePage with add form (category/tier/amount), items table, and empty state
- Add /template route to App.tsx with TemplatePage component
- Add Template nav item to sidebar (between Categories and Settings)
- Add template and nav.template i18n keys for EN and DE
- Fix unused import in useTemplate hook
This commit is contained in:
2026-03-12 13:04:52 +01:00
parent 0af9431435
commit 924e01c983
6 changed files with 260 additions and 2 deletions

View File

@@ -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() {
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/categories" element={<CategoriesPage />} />
<Route path="/template" element={<TemplatePage />} />
<Route path="/settings" element={<SettingsPage user={auth.user} onUpdate={auth.refetch} />} />
</Routes>
</AppLayout>

View File

@@ -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 },
]

View File

@@ -3,7 +3,6 @@ import {
template as templateApi,
categories as categoriesApi,
type TemplateDetail,
type TemplateItem,
type Category,
type ItemTier,
} from '@/lib/api'

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, string> = {
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<ItemTier | ''>('')
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 (
<div className="flex flex-col gap-6 p-6">
<Skeleton className="h-8 w-48" />
<Card>
<CardHeader className="bg-gradient-to-r from-violet-50 to-indigo-50">
<Skeleton className="h-6 w-40" />
</CardHeader>
<CardContent className="pt-4 flex flex-col gap-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
</div>
)
}
return (
<div className="flex flex-col gap-6 p-6">
<h1 className="text-2xl font-semibold">{t('template.title')}</h1>
<Card>
<CardHeader className="bg-gradient-to-r from-violet-50 to-indigo-50">
<CardTitle>{t('template.title')}</CardTitle>
</CardHeader>
<CardContent className="pt-4 flex flex-col gap-6">
{/* Add item form */}
<div className="flex flex-wrap items-end gap-3">
<div className="flex-1 min-w-[180px]">
<Select
value={selectedCategory}
onValueChange={setSelectedCategory}
>
<SelectTrigger>
<SelectValue placeholder={t('template.selectCategory')} />
</SelectTrigger>
<SelectContent>
{availableCategories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.icon ? `${cat.icon} ` : ''}{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-40">
<Select
value={selectedTier}
onValueChange={(v) => {
setSelectedTier(v as ItemTier)
if (v !== 'fixed') setAmount('')
}}
>
<SelectTrigger>
<SelectValue placeholder={t('template.selectTier')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="fixed">{t('template.fixed')}</SelectItem>
<SelectItem value="variable">{t('template.variable')}</SelectItem>
</SelectContent>
</Select>
</div>
{selectedTier === 'fixed' && (
<div className="w-36">
<Input
type="number"
placeholder={t('template.amount')}
value={amount}
onChange={(e) => setAmount(e.target.value)}
min="0"
step="0.01"
/>
</div>
)}
<Button onClick={handleAdd} disabled={isAddDisabled || adding}>
<Plus className="size-4 mr-1" />
{t('template.addItem')}
</Button>
</div>
{/* Template items table or empty state */}
{sortedItems.length === 0 ? (
<EmptyState
icon={FileText}
heading={t('template.noItems')}
subtext={t('template.noItemsHint')}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">{/* reorder */}</TableHead>
<TableHead>{t('template.category')}</TableHead>
<TableHead className="w-28">{t('template.tier')}</TableHead>
<TableHead className="w-28 text-right">{t('template.amount')}</TableHead>
<TableHead className="w-20 text-right">{t('template.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedItems.map((item, idx) => (
<TableRow key={item.id}>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
disabled={idx === 0}
onClick={() => moveItem(item.id, 'up')}
aria-label="Move up"
>
<ArrowUp className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={idx === sortedItems.length - 1}
onClick={() => moveItem(item.id, 'down')}
aria-label="Move down"
>
<ArrowDown className="size-4" />
</Button>
</div>
</TableCell>
<TableCell>
<span className="font-medium">
{item.category_icon ? `${item.category_icon} ` : ''}
{item.category_name}
</span>
</TableCell>
<TableCell>
<Badge className={TIER_COLORS[item.item_tier] ?? ''}>
{item.item_tier === 'fixed'
? t('template.fixed')
: t('template.variable')}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatAmount(item.budgeted_amount)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => removeItem(item.id)}
aria-label="Delete"
>
<Trash2 className="size-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}