diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/dashboard/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/dashboard/page.tsx
index 2f2185e..3f71147 100644
--- a/cannamanage-frontend/src/app/(dashboard-layout)/dashboard/page.tsx
+++ b/cannamanage-frontend/src/app/(dashboard-layout)/dashboard/page.tsx
@@ -1,6 +1,10 @@
"use client"
import Link from "next/link"
+import {
+ useClubStatsQuery,
+ useRecentDistributionsQuery,
+} from "@/services/dashboard"
import { useTranslations } from "next-intl"
import {
Bar,
@@ -21,10 +25,19 @@ import {
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { CardSkeleton, TableSkeleton } from "@/components/ui/data-skeleton"
export default function DashboardPage() {
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) => ({
name: batch.strainName,
grams: batch.availableGrams,
@@ -33,85 +46,92 @@ export default function DashboardPage() {
return (
{/* KPI Cards */}
-
- {/* Active Members */}
-
-
-
- {t("activeMembers")}
-
-
-
-
-
- {mockClubStats.activeMembers}
-
-
- {t("trend", { value: "12" })}
-
-
-
+ {statsLoading ? (
+
+
+
+
+
+
+ ) : (
+
+ {/* Active Members */}
+
+
+
+ {t("activeMembers")}
+
+
+
+
+ {stats.activeMembers}
+
+ {t("trend", { value: "12" })}
+
+
+
- {/* Distributions Today */}
-
-
-
- {t("distributionsToday")}
-
-
-
-
-
- {mockClubStats.distributionsToday}
-
-
- {t("distributionCount", {
- count: mockClubStats.distributionsToday,
- grams: mockClubStats.gramsDistributedToday,
- })}
-
-
-
+ {/* Distributions Today */}
+
+
+
+ {t("distributionsToday")}
+
+
+
+
+
+ {stats.distributionsToday}
+
+
+ {t("distributionCount", {
+ count: stats.distributionsToday,
+ grams: stats.gramsDistributedToday,
+ })}
+
+
+
- {/* Stock Level */}
-
-
-
- {t("stockLevel")}
-
-
-
-
-
- {mockClubStats.totalStockGrams.toLocaleString("de-DE")}
- {t("grams")}
-
-
- {mockStockByStrain.length} Sorten verfügbar
-
-
-
+ {/* Stock Level */}
+
+
+
+ {t("stockLevel")}
+
+
+
+
+
+ {stats.totalStockGrams.toLocaleString("de-DE")}
+ {t("grams")}
+
+
+ {mockStockByStrain.length} Sorten verfügbar
+
+
+
- {/* Monthly Quota */}
-
-
-
- {t("monthlyQuota")}
-
-
-
-
-
- {mockClubStats.monthlyQuotaUsagePercent}%
-
-
- {t("quotaUsed", {
- value: mockClubStats.monthlyQuotaUsagePercent,
- })}
-
-
-
-
+ {/* Monthly Quota */}
+
+
+
+ {t("monthlyQuota")}
+
+
+
+
+
+ {stats.monthlyQuotaUsagePercent}%
+
+
+ {t("quotaUsed", {
+ value: stats.monthlyQuotaUsagePercent,
+ })}
+
+
+
+
+ )}
{/* Quick Actions */}
@@ -149,35 +169,42 @@ export default function DashboardPage() {
{t("recentDistributions")}
-
-
-
-
- | {t("date")} |
- {t("member")} |
- {t("strain")} |
- {t("amount")} |
- {t("staff")} |
-
-
-
- {mockRecentDistributions.map((dist) => (
-
- |
- {new Date(dist.recordedAt).toLocaleTimeString("de-DE", {
- hour: "2-digit",
- minute: "2-digit",
- })}
- |
- {dist.memberName} |
- {dist.strainName} |
- {dist.amountGrams}g |
- {dist.recordedBy} |
+ {distributionsLoading ? (
+
+ ) : (
+
+
+
+
+ | {t("date")} |
+ {t("member")} |
+ {t("strain")} |
+ {t("amount")} |
+ {t("staff")} |
- ))}
-
-
-
+
+
+ {recentDistributions.map((dist) => (
+
+ |
+ {new Date(dist.recordedAt).toLocaleTimeString(
+ "de-DE",
+ {
+ hour: "2-digit",
+ minute: "2-digit",
+ }
+ )}
+ |
+ {dist.memberName} |
+ {dist.strainName} |
+ {dist.amountGrams}g |
+ {dist.recordedBy} |
+
+ ))}
+
+
+
+ )}
diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/members/[id]/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/members/[id]/page.tsx
index 96ecc26..a368fe0 100644
--- a/cannamanage-frontend/src/app/(dashboard-layout)/members/[id]/page.tsx
+++ b/cannamanage-frontend/src/app/(dashboard-layout)/members/[id]/page.tsx
@@ -1,19 +1,21 @@
"use client"
-import { useMemo } from "react"
+import { useEffect } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
+import { useMemberQuery, useUpdateMemberMutation } from "@/services/members"
import { zodResolver } from "@hookform/resolvers/zod"
import { useTranslations } from "next-intl"
import { useForm } from "react-hook-form"
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 { useToast } from "@/hooks/use-toast"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { FormFieldSkeleton } from "@/components/ui/data-skeleton"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
@@ -44,22 +46,61 @@ function isUnder21(dateOfBirth: string): boolean {
return age < 21
}
+function MemberFormSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
export default function MemberDetailPage() {
const t = useTranslations("members")
const params = useParams()
const { toast } = useToast()
const memberId = params.id as string
- const member = useMemo(
- () => mockMembers.find((m) => m.id === memberId),
- [memberId]
- )
+ // Query backend for member data
+ const { data: memberData, isLoading } = useMemberQuery(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 {
register,
handleSubmit,
formState: { errors, isDirty },
watch,
+ reset,
} = useForm
({
resolver: zodResolver(memberSchema),
defaultValues: member
@@ -77,9 +118,30 @@ export default function MemberDetailPage() {
: 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 showUnder21Warning = watchedDob ? isUnder21(watchedDob) : false
+ if (isLoading) {
+ return
+ }
+
if (!member) {
return (
@@ -94,10 +156,27 @@ export default function MemberDetailPage() {
)
}
- const onSubmit = (_data: MemberFormData) => {
- toast({
- title: t("saved"),
- })
+ const onSubmit = (data: MemberFormData) => {
+ updateMutation.mutate(
+ {
+ 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 (
@@ -237,8 +316,12 @@ export default function MemberDetailPage() {
{t("back")}
-
diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/members/new/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/members/new/page.tsx
index 78c494c..ba2d792 100644
--- a/cannamanage-frontend/src/app/(dashboard-layout)/members/new/page.tsx
+++ b/cannamanage-frontend/src/app/(dashboard-layout)/members/new/page.tsx
@@ -2,11 +2,12 @@
import Link from "next/link"
import { useRouter } from "next/navigation"
+import { useCreateMemberMutation } from "@/services/members"
import { zodResolver } from "@hookform/resolvers/zod"
import { useTranslations } from "next-intl"
import { useForm } from "react-hook-form"
import { z } from "zod"
-import { ArrowLeft, UserPlus } from "lucide-react"
+import { ArrowLeft, Loader2, UserPlus } from "lucide-react"
import { useToast } from "@/hooks/use-toast"
import { Button } from "@/components/ui/button"
@@ -53,10 +54,12 @@ export default function AddMemberPage() {
const router = useRouter()
const { toast } = useToast()
+ const createMutation = useCreateMemberMutation()
+
const {
register,
handleSubmit,
- formState: { errors, isSubmitting },
+ formState: { errors },
} = useForm({
resolver: zodResolver(createMemberSchema),
defaultValues: {
@@ -69,11 +72,28 @@ export default function AddMemberPage() {
},
})
- const onSubmit = (_data: CreateMemberFormData) => {
- toast({
- title: t("created"),
- })
- router.push("/members")
+ const onSubmit = (data: CreateMemberFormData) => {
+ createMutation.mutate(
+ {
+ firstName: data.firstName,
+ 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 (
@@ -178,8 +198,12 @@ export default function AddMemberPage() {
{t("back")}
-
-
+
+ {createMutation.isPending ? (
+
+ ) : (
+
+ )}
{t("create")}
diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/members/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/members/page.tsx
index 6aff66c..a0ffc89 100644
--- a/cannamanage-frontend/src/app/(dashboard-layout)/members/page.tsx
+++ b/cannamanage-frontend/src/app/(dashboard-layout)/members/page.tsx
@@ -3,6 +3,7 @@
import { useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
+import { useMembersQuery } from "@/services/members"
import {
flexRender,
getCoreRowModel,
@@ -19,7 +20,9 @@ import type { ColumnDef, SortingState } from "@tanstack/react-table"
import { mockMembers } from "@/data/mock/members"
+import { useDebounce } from "@/hooks/use-debounce"
import { Button } from "@/components/ui/button"
+import { TableSkeleton } from "@/components/ui/data-skeleton"
import { Input } from "@/components/ui/input"
import { Select } from "@/components/ui/select"
import {
@@ -89,6 +92,18 @@ export default function MembersPage() {
const [globalFilter, setGlobalFilter] = useState("")
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[]>(
() => [
{
@@ -182,7 +197,7 @@ export default function MembersPage() {
)
const table = useReactTable({
- data: mockMembers,
+ data: members,
columns,
state: {
sorting,
@@ -242,133 +257,142 @@ export default function MembersPage() {
- {/* Desktop table */}
-
-
-
-
- {table.getHeaderGroups().map((headerGroup) => (
-
- {headerGroup.headers.map((header) => (
-
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext()
- )}
-
+ {/* Loading state */}
+ {isLoading ? (
+
+ ) : (
+ <>
+ {/* Desktop table */}
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ ))}
+
))}
-
- ))}
-
-
- {table.getRowModel().rows.length ? (
- table.getRowModel().rows.map((row) => (
-
- {row.getVisibleCells().map((cell) => (
-
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext()
- )}
+
+
+ {table.getRowModel().rows.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ {t("noResults")}
- ))}
-
- ))
- ) : (
-
-
- {t("noResults")}
-
-
- )}
-
-
-
-
-
- {/* Mobile card layout */}
-
- {table.getRowModel().rows.length ? (
- table.getRowModel().rows.map((row) => (
-
router.push(`/members/${row.original.id}`)}
- role="button"
- tabIndex={0}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- router.push(`/members/${row.original.id}`)
- }
- }}
- >
-
-
-
- {row.original.firstName} {row.original.lastName}
-
-
- {row.original.email}
-
-
-
-
-
-
- {t("memberSince")}:{" "}
- {new Date(row.original.joinedAt).toLocaleDateString("de-DE")}
-
-
-
+
+ )}
+
+
- ))
- ) : (
-
- {t("noResults")}
-
- )}
-
+
- {/* Pagination */}
-
-
- {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,
- })}
-
-
- table.previousPage()}
- disabled={!table.getCanPreviousPage()}
- >
- {t("previous")}
-
- table.nextPage()}
- disabled={!table.getCanNextPage()}
- >
- {t("next")}
-
-
-
+ {/* Mobile card layout */}
+
+ {table.getRowModel().rows.length ? (
+ table.getRowModel().rows.map((row) => (
+
router.push(`/members/${row.original.id}`)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ router.push(`/members/${row.original.id}`)
+ }
+ }}
+ >
+
+
+
+ {row.original.firstName} {row.original.lastName}
+
+
+ {row.original.email}
+
+
+
+
+
+
+ {t("memberSince")}:{" "}
+ {new Date(row.original.joinedAt).toLocaleDateString(
+ "de-DE"
+ )}
+
+
+
+
+ ))
+ ) : (
+
+ {t("noResults")}
+
+ )}
+
+
+ {/* Pagination */}
+
+
+ {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,
+ })}
+
+
+ table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ {t("previous")}
+
+ table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ {t("next")}
+
+
+
+ >
+ )}
)
}
diff --git a/cannamanage-frontend/src/hooks/use-debounce.ts b/cannamanage-frontend/src/hooks/use-debounce.ts
new file mode 100644
index 0000000..a42c049
--- /dev/null
+++ b/cannamanage-frontend/src/hooks/use-debounce.ts
@@ -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(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value)
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedValue(value), delay)
+ return () => clearTimeout(timer)
+ }, [value, delay])
+
+ return debouncedValue
+}