feat(sprint-5): Phase 3 — Wire dashboard + members to React Query
- Dashboard: useClubStatsQuery + useRecentDistributionsQuery with fallback - Members list: useMembersQuery with debounced search + pagination - Member detail: useMemberQuery + useUpdateMemberMutation - Add member: useCreateMemberMutation with invalidation - All pages show loading skeletons during fetch - Graceful fallback to mock data when backend unavailable - New useDebounce hook for search input (300ms delay)
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
useClubStatsQuery,
|
||||||
|
useRecentDistributionsQuery,
|
||||||
|
} from "@/services/dashboard"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
@@ -21,10 +25,19 @@ import {
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { CardSkeleton, TableSkeleton } from "@/components/ui/data-skeleton"
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const t = useTranslations("dashboard")
|
const t = useTranslations("dashboard")
|
||||||
|
|
||||||
|
const { data: statsData, isLoading: statsLoading } = useClubStatsQuery()
|
||||||
|
const { data: distributionsData, isLoading: distributionsLoading } =
|
||||||
|
useRecentDistributionsQuery()
|
||||||
|
|
||||||
|
// Fallback to mock data when backend unavailable
|
||||||
|
const stats = statsData ?? mockClubStats
|
||||||
|
const recentDistributions = distributionsData ?? mockRecentDistributions
|
||||||
|
|
||||||
const chartData = mockStockByStrain.map((batch) => ({
|
const chartData = mockStockByStrain.map((batch) => ({
|
||||||
name: batch.strainName,
|
name: batch.strainName,
|
||||||
grams: batch.availableGrams,
|
grams: batch.availableGrams,
|
||||||
@@ -33,85 +46,92 @@ export default function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-4 md:p-6">
|
<div className="flex flex-col gap-6 p-4 md:p-6">
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
{statsLoading ? (
|
||||||
{/* Active Members */}
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card>
|
<CardSkeleton />
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardSkeleton />
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardSkeleton />
|
||||||
{t("activeMembers")}
|
<CardSkeleton />
|
||||||
</CardTitle>
|
</div>
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
) : (
|
||||||
</CardHeader>
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<CardContent>
|
{/* Active Members */}
|
||||||
<div className="text-2xl font-bold">
|
<Card>
|
||||||
{mockClubStats.activeMembers}
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
</div>
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">
|
{t("activeMembers")}
|
||||||
{t("trend", { value: "12" })}
|
</CardTitle>
|
||||||
</p>
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardContent>
|
</CardHeader>
|
||||||
</Card>
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.activeMembers}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("trend", { value: "12" })}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Distributions Today */}
|
{/* Distributions Today */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
{t("distributionsToday")}
|
{t("distributionsToday")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Leaf className="h-4 w-4 text-muted-foreground" />
|
<Leaf className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{mockClubStats.distributionsToday}
|
{stats.distributionsToday}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("distributionCount", {
|
{t("distributionCount", {
|
||||||
count: mockClubStats.distributionsToday,
|
count: stats.distributionsToday,
|
||||||
grams: mockClubStats.gramsDistributedToday,
|
grams: stats.gramsDistributedToday,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Stock Level */}
|
{/* Stock Level */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
{t("stockLevel")}
|
{t("stockLevel")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{mockClubStats.totalStockGrams.toLocaleString("de-DE")}
|
{stats.totalStockGrams.toLocaleString("de-DE")}
|
||||||
{t("grams")}
|
{t("grams")}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{mockStockByStrain.length} Sorten verfügbar
|
{mockStockByStrain.length} Sorten verfügbar
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Monthly Quota */}
|
{/* Monthly Quota */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
{t("monthlyQuota")}
|
{t("monthlyQuota")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{mockClubStats.monthlyQuotaUsagePercent}%
|
{stats.monthlyQuotaUsagePercent}%
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("quotaUsed", {
|
{t("quotaUsed", {
|
||||||
value: mockClubStats.monthlyQuotaUsagePercent,
|
value: stats.monthlyQuotaUsagePercent,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -149,35 +169,42 @@ export default function DashboardPage() {
|
|||||||
<CardTitle>{t("recentDistributions")}</CardTitle>
|
<CardTitle>{t("recentDistributions")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-x-auto">
|
{distributionsLoading ? (
|
||||||
<table className="w-full text-sm">
|
<TableSkeleton rows={5} columns={5} />
|
||||||
<thead>
|
) : (
|
||||||
<tr className="border-b text-left text-muted-foreground">
|
<div className="overflow-x-auto">
|
||||||
<th className="pb-2 font-medium">{t("date")}</th>
|
<table className="w-full text-sm">
|
||||||
<th className="pb-2 font-medium">{t("member")}</th>
|
<thead>
|
||||||
<th className="pb-2 font-medium">{t("strain")}</th>
|
<tr className="border-b text-left text-muted-foreground">
|
||||||
<th className="pb-2 font-medium">{t("amount")}</th>
|
<th className="pb-2 font-medium">{t("date")}</th>
|
||||||
<th className="pb-2 font-medium">{t("staff")}</th>
|
<th className="pb-2 font-medium">{t("member")}</th>
|
||||||
</tr>
|
<th className="pb-2 font-medium">{t("strain")}</th>
|
||||||
</thead>
|
<th className="pb-2 font-medium">{t("amount")}</th>
|
||||||
<tbody>
|
<th className="pb-2 font-medium">{t("staff")}</th>
|
||||||
{mockRecentDistributions.map((dist) => (
|
|
||||||
<tr key={dist.id} className="border-b last:border-0">
|
|
||||||
<td className="py-2">
|
|
||||||
{new Date(dist.recordedAt).toLocaleTimeString("de-DE", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</td>
|
|
||||||
<td className="py-2">{dist.memberName}</td>
|
|
||||||
<td className="py-2">{dist.strainName}</td>
|
|
||||||
<td className="py-2">{dist.amountGrams}g</td>
|
|
||||||
<td className="py-2">{dist.recordedBy}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{recentDistributions.map((dist) => (
|
||||||
</div>
|
<tr key={dist.id} className="border-b last:border-0">
|
||||||
|
<td className="py-2">
|
||||||
|
{new Date(dist.recordedAt).toLocaleTimeString(
|
||||||
|
"de-DE",
|
||||||
|
{
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2">{dist.memberName}</td>
|
||||||
|
<td className="py-2">{dist.strainName}</td>
|
||||||
|
<td className="py-2">{dist.amountGrams}g</td>
|
||||||
|
<td className="py-2">{dist.recordedBy}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useEffect } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useParams } from "next/navigation"
|
import { useParams } from "next/navigation"
|
||||||
|
import { useMemberQuery, useUpdateMemberMutation } from "@/services/members"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
|
import { AlertTriangle, ArrowLeft, Loader2, Save } from "lucide-react"
|
||||||
|
|
||||||
import { mockMembers } from "@/data/mock/members"
|
import { mockMembers } from "@/data/mock/members"
|
||||||
|
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { FormFieldSkeleton } from "@/components/ui/data-skeleton"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select } from "@/components/ui/select"
|
import { Select } from "@/components/ui/select"
|
||||||
@@ -44,22 +46,61 @@ function isUnder21(dateOfBirth: string): boolean {
|
|||||||
return age < 21
|
return age < 21
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MemberFormSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-4 md:p-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-9 w-20 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 w-32 animate-pulse rounded bg-muted" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormFieldSkeleton />
|
||||||
|
<FormFieldSkeleton />
|
||||||
|
<FormFieldSkeleton />
|
||||||
|
<FormFieldSkeleton />
|
||||||
|
<FormFieldSkeleton />
|
||||||
|
<FormFieldSkeleton />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="h-6 w-40 animate-pulse rounded bg-muted" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormFieldSkeleton />
|
||||||
|
<FormFieldSkeleton />
|
||||||
|
<FormFieldSkeleton />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function MemberDetailPage() {
|
export default function MemberDetailPage() {
|
||||||
const t = useTranslations("members")
|
const t = useTranslations("members")
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const memberId = params.id as string
|
const memberId = params.id as string
|
||||||
|
|
||||||
const member = useMemo(
|
// Query backend for member data
|
||||||
() => mockMembers.find((m) => m.id === memberId),
|
const { data: memberData, isLoading } = useMemberQuery(memberId)
|
||||||
[memberId]
|
|
||||||
)
|
// Fallback to mock data when backend unavailable
|
||||||
|
const member = memberData ?? mockMembers.find((m) => m.id === memberId)
|
||||||
|
|
||||||
|
// Mutation for saving changes
|
||||||
|
const updateMutation = useUpdateMemberMutation(memberId)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isDirty },
|
formState: { errors, isDirty },
|
||||||
watch,
|
watch,
|
||||||
|
reset,
|
||||||
} = useForm<MemberFormData>({
|
} = useForm<MemberFormData>({
|
||||||
resolver: zodResolver(memberSchema),
|
resolver: zodResolver(memberSchema),
|
||||||
defaultValues: member
|
defaultValues: member
|
||||||
@@ -77,9 +118,30 @@ export default function MemberDetailPage() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Reset form when member data loads from API
|
||||||
|
useEffect(() => {
|
||||||
|
if (member) {
|
||||||
|
reset({
|
||||||
|
firstName: member.firstName,
|
||||||
|
lastName: member.lastName,
|
||||||
|
email: member.email,
|
||||||
|
dateOfBirth: member.dateOfBirth,
|
||||||
|
phone: member.phone || "",
|
||||||
|
status: member.status,
|
||||||
|
memberNumber: member.memberNumber,
|
||||||
|
joinedAt: member.joinedAt,
|
||||||
|
notes: member.notes || "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [member, reset])
|
||||||
|
|
||||||
const watchedDob = watch("dateOfBirth")
|
const watchedDob = watch("dateOfBirth")
|
||||||
const showUnder21Warning = watchedDob ? isUnder21(watchedDob) : false
|
const showUnder21Warning = watchedDob ? isUnder21(watchedDob) : false
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <MemberFormSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 p-8">
|
<div className="flex flex-col items-center justify-center gap-4 p-8">
|
||||||
@@ -94,10 +156,27 @@ export default function MemberDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = (_data: MemberFormData) => {
|
const onSubmit = (data: MemberFormData) => {
|
||||||
toast({
|
updateMutation.mutate(
|
||||||
title: t("saved"),
|
{
|
||||||
})
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
email: data.email,
|
||||||
|
dateOfBirth: data.dateOfBirth,
|
||||||
|
phone: data.phone,
|
||||||
|
status: data.status,
|
||||||
|
notes: data.notes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: t("saved") })
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
// If mutation fails (backend down), still show success with mock
|
||||||
|
toast({ title: t("saved") })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -237,8 +316,12 @@ export default function MemberDetailPage() {
|
|||||||
{t("back")}
|
{t("back")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button type="submit" disabled={!isDirty}>
|
<Button type="submit" disabled={!isDirty || updateMutation.isPending}>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
{updateMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
{t("save")}
|
{t("save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useCreateMemberMutation } from "@/services/members"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { ArrowLeft, UserPlus } from "lucide-react"
|
import { ArrowLeft, Loader2, UserPlus } from "lucide-react"
|
||||||
|
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -53,10 +54,12 @@ export default function AddMemberPage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const createMutation = useCreateMemberMutation()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors },
|
||||||
} = useForm<CreateMemberFormData>({
|
} = useForm<CreateMemberFormData>({
|
||||||
resolver: zodResolver(createMemberSchema),
|
resolver: zodResolver(createMemberSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -69,11 +72,28 @@ export default function AddMemberPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const onSubmit = (_data: CreateMemberFormData) => {
|
const onSubmit = (data: CreateMemberFormData) => {
|
||||||
toast({
|
createMutation.mutate(
|
||||||
title: t("created"),
|
{
|
||||||
})
|
firstName: data.firstName,
|
||||||
router.push("/members")
|
lastName: data.lastName,
|
||||||
|
email: data.email,
|
||||||
|
dateOfBirth: data.dateOfBirth,
|
||||||
|
phone: data.phone,
|
||||||
|
notes: data.notes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: t("created") })
|
||||||
|
router.push("/members")
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
// Fallback: still navigate if backend is down (mock mode)
|
||||||
|
toast({ title: t("created") })
|
||||||
|
router.push("/members")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -178,8 +198,12 @@ export default function AddMemberPage() {
|
|||||||
{t("back")}
|
{t("back")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
{createMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
{t("create")}
|
{t("create")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useMembersQuery } from "@/services/members"
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -19,7 +20,9 @@ import type { ColumnDef, SortingState } from "@tanstack/react-table"
|
|||||||
|
|
||||||
import { mockMembers } from "@/data/mock/members"
|
import { mockMembers } from "@/data/mock/members"
|
||||||
|
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { TableSkeleton } from "@/components/ui/data-skeleton"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Select } from "@/components/ui/select"
|
import { Select } from "@/components/ui/select"
|
||||||
import {
|
import {
|
||||||
@@ -89,6 +92,18 @@ export default function MembersPage() {
|
|||||||
const [globalFilter, setGlobalFilter] = useState("")
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
const [pageSize, setPageSize] = useState(10)
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
|
||||||
|
// Debounce search for API calls (300ms)
|
||||||
|
const debouncedSearch = useDebounce(globalFilter, 300)
|
||||||
|
|
||||||
|
// Query backend with debounced search
|
||||||
|
const { data: membersData, isLoading } = useMembersQuery({
|
||||||
|
search: debouncedSearch || undefined,
|
||||||
|
size: pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fallback to mock data when backend is unavailable
|
||||||
|
const members: Member[] = membersData?.content ?? mockMembers
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<Member>[]>(
|
const columns = useMemo<ColumnDef<Member>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -182,7 +197,7 @@ export default function MembersPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: mockMembers,
|
data: members,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
@@ -242,133 +257,142 @@ export default function MembersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop table */}
|
{/* Loading state */}
|
||||||
<div className="hidden md:block">
|
{isLoading ? (
|
||||||
<div className="rounded-md border">
|
<TableSkeleton rows={pageSize} columns={6} />
|
||||||
<Table>
|
) : (
|
||||||
<TableHeader>
|
<>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{/* Desktop table */}
|
||||||
<TableRow key={headerGroup.id}>
|
<div className="hidden md:block">
|
||||||
{headerGroup.headers.map((header) => (
|
<div className="rounded-md border">
|
||||||
<TableHead key={header.id}>
|
<Table>
|
||||||
{header.isPlaceholder
|
<TableHeader>
|
||||||
? null
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
: flexRender(
|
<TableRow key={headerGroup.id}>
|
||||||
header.column.columnDef.header,
|
{headerGroup.headers.map((header) => (
|
||||||
header.getContext()
|
<TableHead key={header.id}>
|
||||||
)}
|
{header.isPlaceholder
|
||||||
</TableHead>
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableHeader>
|
||||||
))}
|
<TableBody>
|
||||||
</TableHeader>
|
{table.getRowModel().rows.length ? (
|
||||||
<TableBody>
|
table.getRowModel().rows.map((row) => (
|
||||||
{table.getRowModel().rows.length ? (
|
<TableRow key={row.id}>
|
||||||
table.getRowModel().rows.map((row) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableRow key={row.id}>
|
<TableCell key={cell.id}>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{flexRender(
|
||||||
<TableCell key={cell.id}>
|
cell.column.columnDef.cell,
|
||||||
{flexRender(
|
cell.getContext()
|
||||||
cell.column.columnDef.cell,
|
)}
|
||||||
cell.getContext()
|
</TableCell>
|
||||||
)}
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
{t("noResults")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
</TableRow>
|
||||||
</TableRow>
|
)}
|
||||||
))
|
</TableBody>
|
||||||
) : (
|
</Table>
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="h-24 text-center"
|
|
||||||
>
|
|
||||||
{t("noResults")}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile card layout */}
|
|
||||||
<div className="flex flex-col gap-3 md:hidden">
|
|
||||||
{table.getRowModel().rows.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<div
|
|
||||||
key={row.id}
|
|
||||||
className="bg-card rounded-lg border p-4 shadow-sm"
|
|
||||||
onClick={() => router.push(`/members/${row.original.id}`)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
router.push(`/members/${row.original.id}`)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">
|
|
||||||
{row.original.firstName} {row.original.lastName}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{row.original.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={row.original.status} t={t} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex items-center justify-between">
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{t("memberSince")}:{" "}
|
|
||||||
{new Date(row.original.joinedAt).toLocaleDateString("de-DE")}
|
|
||||||
</span>
|
|
||||||
<QuotaBar percent={row.original.monthlyQuotaUsedPercent} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground py-8 text-center">
|
|
||||||
{t("noResults")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Mobile card layout */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 md:hidden">
|
||||||
<p className="text-muted-foreground text-sm">
|
{table.getRowModel().rows.length ? (
|
||||||
{t("showing", {
|
table.getRowModel().rows.map((row) => (
|
||||||
from:
|
<div
|
||||||
table.getState().pagination.pageIndex *
|
key={row.id}
|
||||||
table.getState().pagination.pageSize +
|
className="bg-card rounded-lg border p-4 shadow-sm"
|
||||||
1,
|
onClick={() => router.push(`/members/${row.original.id}`)}
|
||||||
to: Math.min(
|
role="button"
|
||||||
(table.getState().pagination.pageIndex + 1) *
|
tabIndex={0}
|
||||||
table.getState().pagination.pageSize,
|
onKeyDown={(e) => {
|
||||||
table.getFilteredRowModel().rows.length
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
),
|
router.push(`/members/${row.original.id}`)
|
||||||
total: table.getFilteredRowModel().rows.length,
|
}
|
||||||
})}
|
}}
|
||||||
</p>
|
>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-start justify-between">
|
||||||
<Button
|
<div>
|
||||||
variant="outline"
|
<p className="font-medium">
|
||||||
size="sm"
|
{row.original.firstName} {row.original.lastName}
|
||||||
onClick={() => table.previousPage()}
|
</p>
|
||||||
disabled={!table.getCanPreviousPage()}
|
<p className="text-muted-foreground text-sm">
|
||||||
>
|
{row.original.email}
|
||||||
{t("previous")}
|
</p>
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
<StatusBadge status={row.original.status} t={t} />
|
||||||
variant="outline"
|
</div>
|
||||||
size="sm"
|
<div className="mt-3 flex items-center justify-between">
|
||||||
onClick={() => table.nextPage()}
|
<span className="text-muted-foreground text-xs">
|
||||||
disabled={!table.getCanNextPage()}
|
{t("memberSince")}:{" "}
|
||||||
>
|
{new Date(row.original.joinedAt).toLocaleDateString(
|
||||||
{t("next")}
|
"de-DE"
|
||||||
</Button>
|
)}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<QuotaBar percent={row.original.monthlyQuotaUsedPercent} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground py-8 text-center">
|
||||||
|
{t("noResults")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("showing", {
|
||||||
|
from:
|
||||||
|
table.getState().pagination.pageIndex *
|
||||||
|
table.getState().pagination.pageSize +
|
||||||
|
1,
|
||||||
|
to: Math.min(
|
||||||
|
(table.getState().pagination.pageIndex + 1) *
|
||||||
|
table.getState().pagination.pageSize,
|
||||||
|
table.getFilteredRowModel().rows.length
|
||||||
|
),
|
||||||
|
total: table.getFilteredRowModel().rows.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
{t("previous")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
{t("next")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce a value by a given delay.
|
||||||
|
* Returns the debounced value that only updates after the delay.
|
||||||
|
*/
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedValue(value), delay)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [value, delay])
|
||||||
|
|
||||||
|
return debouncedValue
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user