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:
@@ -8,6 +8,7 @@ import { DashboardPage } from '@/pages/DashboardPage'
|
||||
import { CategoriesPage } from '@/pages/CategoriesPage'
|
||||
import { SettingsPage } from '@/pages/SettingsPage'
|
||||
import { TemplatePage } from '@/pages/TemplatePage'
|
||||
import { QuickAddPage } from '@/pages/QuickAddPage'
|
||||
import '@/i18n'
|
||||
|
||||
export default function App() {
|
||||
@@ -37,6 +38,7 @@ export default function App() {
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route path="/template" element={<TemplatePage />} />
|
||||
<Route path="/quick-add" element={<QuickAddPage />} />
|
||||
<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, FileText, Settings, LogOut } from 'lucide-react'
|
||||
import { LayoutDashboard, Tags, FileText, Settings, LogOut, Zap } from 'lucide-react'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -31,6 +31,7 @@ export function AppLayout({ auth, children }: Props) {
|
||||
{ path: '/', label: t('nav.dashboard'), icon: LayoutDashboard },
|
||||
{ path: '/categories', label: t('nav.categories'), icon: Tags },
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
|
||||
40
frontend/src/hooks/useQuickAdd.ts
Normal file
40
frontend/src/hooks/useQuickAdd.ts
Normal 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 }
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
"dashboard": "Dashboard",
|
||||
"categories": "Kategorien",
|
||||
"template": "Vorlage",
|
||||
"quickAdd": "Schnellzugriff",
|
||||
"settings": "Einstellungen",
|
||||
"logout": "Abmelden"
|
||||
},
|
||||
@@ -91,6 +92,21 @@
|
||||
"selectCategory": "Kategorie 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": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"dashboard": "Dashboard",
|
||||
"categories": "Categories",
|
||||
"template": "Template",
|
||||
"quickAdd": "Quick Add",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout"
|
||||
},
|
||||
@@ -91,6 +92,21 @@
|
||||
"selectCategory": "Select category",
|
||||
"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": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
|
||||
@@ -108,6 +108,16 @@ export interface TemplateDetail {
|
||||
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 {
|
||||
items: BudgetItem[]
|
||||
totals: {
|
||||
@@ -164,6 +174,17 @@ export const template = {
|
||||
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
|
||||
export const settings = {
|
||||
get: () => request<User>('/settings'),
|
||||
|
||||
203
frontend/src/pages/QuickAddPage.tsx
Normal file
203
frontend/src/pages/QuickAddPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user