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 { DashboardPage } from '@/pages/DashboardPage'
|
||||||
import { CategoriesPage } from '@/pages/CategoriesPage'
|
import { CategoriesPage } from '@/pages/CategoriesPage'
|
||||||
import { SettingsPage } from '@/pages/SettingsPage'
|
import { SettingsPage } from '@/pages/SettingsPage'
|
||||||
|
import { TemplatePage } from '@/pages/TemplatePage'
|
||||||
import '@/i18n'
|
import '@/i18n'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -35,6 +36,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/categories" element={<CategoriesPage />} />
|
<Route path="/categories" element={<CategoriesPage />} />
|
||||||
|
<Route path="/template" element={<TemplatePage />} />
|
||||||
<Route path="/settings" element={<SettingsPage user={auth.user} onUpdate={auth.refetch} />} />
|
<Route path="/settings" element={<SettingsPage user={auth.user} onUpdate={auth.refetch} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
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 {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -30,6 +30,7 @@ export function AppLayout({ auth, children }: Props) {
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: t('nav.dashboard'), icon: LayoutDashboard },
|
{ path: '/', label: t('nav.dashboard'), icon: LayoutDashboard },
|
||||||
{ path: '/categories', label: t('nav.categories'), icon: Tags },
|
{ path: '/categories', label: t('nav.categories'), icon: Tags },
|
||||||
|
{ path: '/template', label: t('nav.template'), icon: FileText },
|
||||||
{ path: '/settings', label: t('nav.settings'), icon: Settings },
|
{ path: '/settings', label: t('nav.settings'), icon: Settings },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
template as templateApi,
|
template as templateApi,
|
||||||
categories as categoriesApi,
|
categories as categoriesApi,
|
||||||
type TemplateDetail,
|
type TemplateDetail,
|
||||||
type TemplateItem,
|
|
||||||
type Category,
|
type Category,
|
||||||
type ItemTier,
|
type ItemTier,
|
||||||
} from '@/lib/api'
|
} from '@/lib/api'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"categories": "Kategorien",
|
"categories": "Kategorien",
|
||||||
|
"template": "Vorlage",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"logout": "Abmelden"
|
"logout": "Abmelden"
|
||||||
},
|
},
|
||||||
@@ -74,6 +75,21 @@
|
|||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"save": "Speichern"
|
"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": {
|
"common": {
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
|
"template": "Template",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"logout": "Logout"
|
"logout": "Logout"
|
||||||
},
|
},
|
||||||
@@ -74,6 +75,21 @@
|
|||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"save": "Save"
|
"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": {
|
"common": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"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