feat(07-02): add QuickAdd API client, hook, management page, routing, and i18n

- Added QuickAddItem interface and quickAdd namespace to api.ts
- Created useQuickAdd hook with CRUD operations following useTemplate pattern
- Created QuickAddPage with amber/orange gradient header, add form, inline edit, and EmptyState
- Added /quick-add route to App.tsx with QuickAddPage import
- Added Zap nav item to AppLayout sidebar after template
- Added quickAdd i18n keys to en.json and de.json including picker keys
This commit is contained in:
2026-03-12 13:38:20 +01:00
parent bf0dac9bca
commit 411a986c14
7 changed files with 300 additions and 1 deletions

View File

@@ -8,6 +8,7 @@ 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 { TemplatePage } from '@/pages/TemplatePage'
import { QuickAddPage } from '@/pages/QuickAddPage'
import '@/i18n' import '@/i18n'
export default function App() { export default function App() {
@@ -37,6 +38,7 @@ export default function App() {
<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="/template" element={<TemplatePage />} />
<Route path="/quick-add" element={<QuickAddPage />} />
<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>

View File

@@ -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, FileText, Settings, LogOut } from 'lucide-react' import { LayoutDashboard, Tags, FileText, Settings, LogOut, Zap } from 'lucide-react'
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -31,6 +31,7 @@ export function AppLayout({ auth, children }: Props) {
{ 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: '/template', label: t('nav.template'), icon: FileText },
{ path: '/quick-add', label: t('nav.quickAdd'), icon: Zap },
{ path: '/settings', label: t('nav.settings'), icon: Settings }, { path: '/settings', label: t('nav.settings'), icon: Settings },
] ]

View File

@@ -0,0 +1,40 @@
import { useState, useEffect } from 'react'
import { quickAdd as quickAddApi, type QuickAddItem } from '@/lib/api'
export function useQuickAdd() {
const [items, setItems] = useState<QuickAddItem[]>([])
const [loading, setLoading] = useState(true)
const fetchItems = async () => {
const data = await quickAddApi.list()
setItems(data ?? [])
}
useEffect(() => {
const init = async () => {
try {
await fetchItems()
} finally {
setLoading(false)
}
}
init()
}, [])
const addItem = async (name: string, icon: string) => {
await quickAddApi.create({ name, icon })
await fetchItems()
}
const updateItem = async (id: string, name: string, icon: string, sortOrder: number) => {
await quickAddApi.update(id, { name, icon, sort_order: sortOrder })
await fetchItems()
}
const removeItem = async (id: string) => {
await quickAddApi.delete(id)
await fetchItems()
}
return { items, loading, addItem, updateItem, removeItem }
}

View File

@@ -6,6 +6,7 @@
"dashboard": "Dashboard", "dashboard": "Dashboard",
"categories": "Kategorien", "categories": "Kategorien",
"template": "Vorlage", "template": "Vorlage",
"quickAdd": "Schnellzugriff",
"settings": "Einstellungen", "settings": "Einstellungen",
"logout": "Abmelden" "logout": "Abmelden"
}, },
@@ -91,6 +92,21 @@
"selectCategory": "Kategorie auswaehlen", "selectCategory": "Kategorie auswaehlen",
"selectTier": "Typ auswaehlen" "selectTier": "Typ auswaehlen"
}, },
"quickAdd": {
"title": "Schnellzugriff-Bibliothek",
"name": "Name",
"icon": "Symbol",
"addItem": "Hinzufuegen",
"noItems": "Keine gespeicherten Eintraege",
"noItemsHint": "Speichere hier haeufig genutzte Einmal-Kategorien fuer schnellen Zugriff.",
"editItem": "Bearbeiten",
"deleteItem": "Entfernen",
"save": "Speichern",
"cancel": "Abbrechen",
"addOneOff": "Schnellzugriff",
"emptyPicker": "Keine gespeicherten Eintraege",
"goToLibrary": "Bibliothek verwalten"
},
"common": { "common": {
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",

View File

@@ -6,6 +6,7 @@
"dashboard": "Dashboard", "dashboard": "Dashboard",
"categories": "Categories", "categories": "Categories",
"template": "Template", "template": "Template",
"quickAdd": "Quick Add",
"settings": "Settings", "settings": "Settings",
"logout": "Logout" "logout": "Logout"
}, },
@@ -91,6 +92,21 @@
"selectCategory": "Select category", "selectCategory": "Select category",
"selectTier": "Select tier" "selectTier": "Select tier"
}, },
"quickAdd": {
"title": "Quick-Add Library",
"name": "Name",
"icon": "Icon",
"addItem": "Add Item",
"noItems": "No saved items",
"noItemsHint": "Save your frequently-used one-off categories here for quick access.",
"editItem": "Edit",
"deleteItem": "Remove",
"save": "Save",
"cancel": "Cancel",
"addOneOff": "Quick Add",
"emptyPicker": "No saved items",
"goToLibrary": "Manage library"
},
"common": { "common": {
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",

View File

@@ -108,6 +108,16 @@ export interface TemplateDetail {
items: TemplateItem[] items: TemplateItem[]
} }
export interface QuickAddItem {
id: string
user_id: string
name: string
icon: string
sort_order: number
created_at: string
updated_at: string
}
export interface BudgetDetail extends Budget { export interface BudgetDetail extends Budget {
items: BudgetItem[] items: BudgetItem[]
totals: { totals: {
@@ -164,6 +174,17 @@ export const template = {
request<void>('/template/items/reorder', { method: 'PUT', body: JSON.stringify({ items }) }), request<void>('/template/items/reorder', { method: 'PUT', body: JSON.stringify({ items }) }),
} }
// Quick Add Library
export const quickAdd = {
list: () => request<QuickAddItem[]>('/quick-add'),
create: (data: { name: string; icon: string }) =>
request<QuickAddItem>('/quick-add', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: { name: string; icon: string; sort_order: number }) =>
request<QuickAddItem>(`/quick-add/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: string) =>
request<void>(`/quick-add/${id}`, { method: 'DELETE' }),
}
// Settings // Settings
export const settings = { export const settings = {
get: () => request<User>('/settings'), get: () => request<User>('/settings'),

View File

@@ -0,0 +1,203 @@
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/EmptyState'
import { Zap, Pencil, Trash2, Check, X } from 'lucide-react'
import { useQuickAdd } from '@/hooks/useQuickAdd'
export function QuickAddPage() {
const { t } = useTranslation()
const { items, loading, addItem, updateItem, removeItem } = useQuickAdd()
const [newName, setNewName] = useState('')
const [newIcon, setNewIcon] = useState('')
const [adding, setAdding] = useState(false)
const [editId, setEditId] = useState<string | null>(null)
const [editName, setEditName] = useState('')
const [editIcon, setEditIcon] = useState('')
const [saving, setSaving] = useState(false)
const handleAdd = async () => {
if (!newName.trim()) return
setAdding(true)
try {
await addItem(newName.trim(), newIcon.trim())
setNewName('')
setNewIcon('')
} finally {
setAdding(false)
}
}
const startEdit = (id: string, name: string, icon: string) => {
setEditId(id)
setEditName(name)
setEditIcon(icon)
}
const cancelEdit = () => {
setEditId(null)
setEditName('')
setEditIcon('')
}
const handleSaveEdit = async (item: { id: string; sort_order: number }) => {
if (!editName.trim()) return
setSaving(true)
try {
await updateItem(item.id, editName.trim(), editIcon.trim(), item.sort_order)
setEditId(null)
setEditName('')
setEditIcon('')
} finally {
setSaving(false)
}
}
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-amber-50 to-orange-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('quickAdd.title')}</h1>
<Card>
<CardHeader className="bg-gradient-to-r from-amber-50 to-orange-50">
<CardTitle>{t('quickAdd.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]">
<Input
placeholder={t('quickAdd.name')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/>
</div>
<div className="w-32">
<Input
placeholder={t('quickAdd.icon')}
value={newIcon}
onChange={(e) => setNewIcon(e.target.value)}
/>
</div>
<Button onClick={handleAdd} disabled={!newName.trim() || adding}>
<Zap className="size-4 mr-1" />
{t('quickAdd.addItem')}
</Button>
</div>
{/* Items table or empty state */}
{items.length === 0 ? (
<EmptyState
icon={Zap}
heading={t('quickAdd.noItems')}
subtext={t('quickAdd.noItemsHint')}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('quickAdd.name')}</TableHead>
<TableHead className="w-28">{t('quickAdd.icon')}</TableHead>
<TableHead className="w-28 text-right">{/* actions */}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell>
{editId === item.id ? (
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-8"
/>
) : (
<span className="font-medium">{item.name}</span>
)}
</TableCell>
<TableCell>
{editId === item.id ? (
<Input
value={editIcon}
onChange={(e) => setEditIcon(e.target.value)}
className="h-8 w-20"
/>
) : (
<span>{item.icon}</span>
)}
</TableCell>
<TableCell className="text-right">
{editId === item.id ? (
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
disabled={!editName.trim() || saving}
onClick={() => handleSaveEdit(item)}
aria-label={t('quickAdd.save')}
>
<Check className="size-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={cancelEdit}
aria-label={t('quickAdd.cancel')}
>
<X className="size-4" />
</Button>
</div>
) : (
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => startEdit(item.id, item.name, item.icon)}
aria-label={t('quickAdd.editItem')}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeItem(item.id)}
aria-label={t('quickAdd.deleteItem')}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}