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:
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
template as templateApi,
|
||||
categories as categoriesApi,
|
||||
type TemplateDetail,
|
||||
type TemplateItem,
|
||||
type Category,
|
||||
type ItemTier,
|
||||
} from '@/lib/api'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
224
frontend/src/pages/TemplatePage.tsx
Normal file
224
frontend/src/pages/TemplatePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user