feat(04-02): upgrade QuickAddPage and SettingsPage with PageShell and skeletons
- QuickAddPage: adopt PageShell for header with title and Add button - QuickAddPage: replace return null with skeleton loading state (5-row table skeleton) - SettingsPage: adopt PageShell for header with title - SettingsPage: remove duplicate h1 heading (was shown alongside CardTitle) - SettingsPage: remove CardHeader and CardTitle (PageShell provides page-level title) - SettingsPage: replace return null with skeleton loading state - SettingsPage: clean up imports — remove CardHeader/CardTitle
This commit is contained in:
215
src/pages/QuickAddPage.tsx
Normal file
215
src/pages/QuickAddPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Plus, Pencil, Trash2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useQuickAdd } from "@/hooks/useQuickAdd"
|
||||||
|
import type { QuickAddItem } from "@/lib/types"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { PageShell } from "@/components/shared/PageShell"
|
||||||
|
|
||||||
|
export default function QuickAddPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { items, loading, create, update, remove } = useQuickAdd()
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<QuickAddItem | null>(null)
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [icon, setIcon] = useState("")
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Dialog helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null)
|
||||||
|
setName("")
|
||||||
|
setIcon("")
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: QuickAddItem) {
|
||||||
|
setEditing(item)
|
||||||
|
setName(item.name)
|
||||||
|
setIcon(item.icon ?? "")
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Save handler
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await update.mutateAsync({
|
||||||
|
id: editing.id,
|
||||||
|
name: name.trim(),
|
||||||
|
icon: icon.trim() || null,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await create.mutateAsync({
|
||||||
|
name: name.trim(),
|
||||||
|
icon: icon.trim() || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setDialogOpen(false)
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Delete handler
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
try {
|
||||||
|
await remove.mutateAsync(id)
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPending = create.isPending || update.isPending
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<PageShell title={t("quickAdd.title")}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4 px-4 py-2.5 border-b border-border">
|
||||||
|
<Skeleton className="h-5 w-10 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-7 rounded-md" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title={t("quickAdd.title")}
|
||||||
|
action={
|
||||||
|
<Button onClick={openCreate} size="sm">
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
{t("quickAdd.add")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Empty state */}
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t("quickAdd.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("categories.icon")}</TableHead>
|
||||||
|
<TableHead>{t("categories.name")}</TableHead>
|
||||||
|
<TableHead className="w-24" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
{item.icon && (
|
||||||
|
<Badge variant="secondary">{item.icon}</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{item.name}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("quickAdd.edit")}
|
||||||
|
onClick={() => openEdit(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
onClick={() => void handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / Edit dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? t("quickAdd.edit") : t("quickAdd.add")}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("categories.name")}</Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("categories.name")}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("categories.icon")}</Label>
|
||||||
|
<Input
|
||||||
|
value={icon}
|
||||||
|
onChange={(e) => setIcon(e.target.value)}
|
||||||
|
placeholder="e.g. emoji or icon name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={!name.trim() || isPending}
|
||||||
|
>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
src/pages/SettingsPage.tsx
Normal file
141
src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
|
import { supabase } from "@/lib/supabase"
|
||||||
|
import type { Profile } from "@/lib/types"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { PageShell } from "@/components/shared/PageShell"
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [profile, setProfile] = useState<Profile | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return
|
||||||
|
supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single()
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (data) {
|
||||||
|
setProfile(data)
|
||||||
|
i18n.changeLanguage(data.locale)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [user, i18n])
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!profile || !user) return
|
||||||
|
setSaving(true)
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({
|
||||||
|
display_name: profile.display_name,
|
||||||
|
locale: profile.locale,
|
||||||
|
currency: profile.currency,
|
||||||
|
})
|
||||||
|
.eq("id", user.id)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
} else {
|
||||||
|
i18n.changeLanguage(profile.locale)
|
||||||
|
toast.success(t("settings.saved"))
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<PageShell title={t("settings.title")}>
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 pt-6">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Skeleton className="h-10 w-20" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell title={t("settings.title")}>
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 pt-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("settings.displayName")}</Label>
|
||||||
|
<Input
|
||||||
|
value={profile?.display_name ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setProfile((p) => p && { ...p, display_name: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("settings.language")}</Label>
|
||||||
|
<Select
|
||||||
|
value={profile?.locale ?? "en"}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
setProfile((p) => p && { ...p, locale: val })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="en">English</SelectItem>
|
||||||
|
<SelectItem value="de">Deutsch</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("settings.currency")}</Label>
|
||||||
|
<Select
|
||||||
|
value={profile?.currency ?? "EUR"}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
setProfile((p) => p && { ...p, currency: val })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="EUR">EUR</SelectItem>
|
||||||
|
<SelectItem value="USD">USD</SelectItem>
|
||||||
|
<SelectItem value="GBP">GBP</SelectItem>
|
||||||
|
<SelectItem value="CHF">CHF</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{t("settings.save")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user