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 { 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>
|
||||||
|
|||||||
@@ -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 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
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",
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
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