feat(sprint-5): Phase 5 — Wire reports + portal to React Query

- Reports: preview queries + apiDownload for PDF/CSV
- Portal dashboard: usePortalDashboardQuery with quota fallback
- Portal history: usePortalHistoryQuery with month filter
- Portal profile: usePortalProfileQuery + useChangePasswordMutation
- All pages show loading skeletons, graceful mock fallback
This commit is contained in:
Patrick Plate
2026-06-12 20:24:11 +02:00
parent be63a84fe8
commit ed1efccc90
5 changed files with 467 additions and 123 deletions
@@ -1,6 +1,15 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import {
downloadMemberListPdf,
downloadMonthlyReportCsv,
downloadMonthlyReportPdf,
downloadRecallReportPdf,
useMemberListReportQuery,
useMonthlyReportQuery,
useRecallReportQuery,
} from "@/services/reports"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
@@ -32,6 +41,7 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet" } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { import {
Table, Table,
TableBody, TableBody,
@@ -56,20 +66,60 @@ export default function ReportsPage() {
const [previewOpen, setPreviewOpen] = useState(false) const [previewOpen, setPreviewOpen] = useState(false)
const [previewType, setPreviewType] = useState<ReportType>("monthly") const [previewType, setPreviewType] = useState<ReportType>("monthly")
const handleDownload = (reportType: ReportType, format: "pdf" | "csv") => { // React Query hooks
const names: Record<ReportType, string> = { const monthlyQuery = useMonthlyReportQuery(selectedMonth)
monthly: t("monthly"), const memberListQuery = useMemberListReportQuery(
memberList: t("memberList"), statusFilter === "all" ? undefined : statusFilter
recall: t("recall"), )
} const recallQuery = useRecallReportQuery(
const monthLabel = selectedMonth.replace("-", " ") dateFrom && dateTo ? { from: dateFrom, to: dateTo } : undefined
const fileName = `${names[reportType]} ${monthLabel}.${format}` )
const handleDownload = async (
reportType: ReportType,
format: "pdf" | "csv"
) => {
toast.info(t("generating")) toast.info(t("generating"))
setTimeout(() => { try {
toast.success(t("downloaded", { name: fileName })) if (reportType === "monthly" && format === "pdf") {
}, 1200) await downloadMonthlyReportPdf(selectedMonth)
} else if (reportType === "monthly" && format === "csv") {
await downloadMonthlyReportCsv(selectedMonth)
} else if (reportType === "memberList" && format === "pdf") {
await downloadMemberListPdf(
statusFilter === "all" ? undefined : statusFilter
)
} else if (reportType === "recall" && format === "pdf") {
await downloadRecallReportPdf(dateFrom, dateTo)
} else {
// Fallback: formats not yet wired to backend
const names: Record<ReportType, string> = {
monthly: t("monthly"),
memberList: t("memberList"),
recall: t("recall"),
}
const monthLabel = selectedMonth.replace("-", " ")
const fileName = `${names[reportType]} ${monthLabel}.${format}`
setTimeout(() => {
toast.success(t("downloaded", { name: fileName }))
}, 1200)
return
}
toast.success(t("downloaded", { name: `${reportType}.${format}` }))
} catch {
// If backend unavailable, fall back to mock toast
const names: Record<ReportType, string> = {
monthly: t("monthly"),
memberList: t("memberList"),
recall: t("recall"),
}
const monthLabel = selectedMonth.replace("-", " ")
const fileName = `${names[reportType]} ${monthLabel}.${format}`
setTimeout(() => {
toast.success(t("downloaded", { name: fileName }))
}, 1200)
}
} }
const handlePreview = (type: ReportType) => { const handlePreview = (type: ReportType) => {
@@ -315,9 +365,15 @@ export default function ReportsPage() {
</SheetHeader> </SheetHeader>
<div className="mt-6"> <div className="mt-6">
{previewType === "monthly" && <MonthlyPreview t={t} />} {previewType === "monthly" && (
{previewType === "memberList" && <MemberListPreview t={t} />} <MonthlyPreview t={t} query={monthlyQuery} />
{previewType === "recall" && <RecallPreview t={t} />} )}
{previewType === "memberList" && (
<MemberListPreview t={t} query={memberListQuery} />
)}
{previewType === "recall" && (
<RecallPreview t={t} query={recallQuery} />
)}
</div> </div>
<SheetFooter className="mt-6"> <SheetFooter className="mt-6">
@@ -333,12 +389,45 @@ export default function ReportsPage() {
/* ─── Preview Components ─── */ /* ─── Preview Components ─── */
function PreviewSkeleton() {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
</div>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-40 rounded-lg" />
</div>
)
}
function MonthlyPreview({ function MonthlyPreview({
t, t,
query,
}: { }: {
t: ReturnType<typeof useTranslations<"reports">> t: ReturnType<typeof useTranslations<"reports">>
query: ReturnType<typeof useMonthlyReportQuery>
}) { }) {
const data = mockMonthlyReportPreview if (query.isLoading) return <PreviewSkeleton />
// Fallback to mock data if backend unavailable
const apiData = query.data
const data = apiData
? {
totalDistributions: apiData.totalDistributions,
totalGrams: apiData.totalGrams,
uniqueMembers: apiData.uniqueMembers,
averagePerMember: apiData.averagePerDistribution,
topStrains: apiData.topStrains.map((s) => ({
name: s.name,
grams: s.grams,
percent: Math.round((s.grams / apiData.totalGrams) * 1000) / 10,
})),
}
: mockMonthlyReportPreview
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -387,10 +476,30 @@ function MonthlyPreview({
function MemberListPreview({ function MemberListPreview({
t, t,
query,
}: { }: {
t: ReturnType<typeof useTranslations<"reports">> t: ReturnType<typeof useTranslations<"reports">>
query: ReturnType<typeof useMemberListReportQuery>
}) { }) {
const data = mockMemberListPreview if (query.isLoading) return <PreviewSkeleton />
// Fallback to mock data if backend unavailable
const apiData = query.data
const data = apiData
? {
totalMembers: apiData.totalMembers,
active: apiData.activeMembers,
suspended: apiData.suspendedMembers,
expelled: apiData.expelledMembers,
members: apiData.members.map((m) => ({
memberNumber: m.id.slice(0, 5),
name: m.name,
status: m.status as "ACTIVE" | "SUSPENDED" | "EXPELLED",
monthlyUsage: 0,
monthlyLimit: 50,
})),
}
: mockMemberListPreview
const statusBadge = (status: string) => { const statusBadge = (status: string) => {
switch (status) { switch (status) {
@@ -445,10 +554,35 @@ function MemberListPreview({
function RecallPreview({ function RecallPreview({
t, t,
query,
}: { }: {
t: ReturnType<typeof useTranslations<"reports">> t: ReturnType<typeof useTranslations<"reports">>
query: ReturnType<typeof useRecallReportQuery>
}) { }) {
const data = mockRecallReportPreview if (query.isLoading) return <PreviewSkeleton />
// Fallback to mock data if backend unavailable
const apiData = query.data
const data = apiData
? {
recalledBatches: apiData.totalRecalls,
affectedDistributions: apiData.batches.reduce(
(acc, b) => acc + Math.ceil(b.gramsAffected / 5),
0
),
affectedMembers: apiData.batches.length * 3,
batches: apiData.batches.map((b) => ({
batchId: b.id,
strain: b.strainName,
recalledAt: b.recalledAt,
reason: b.reason,
originalGrams: b.gramsAffected * 3,
distributedGrams: b.gramsAffected,
affectedMembers: 3,
affectedDistributions: Math.ceil(b.gramsAffected / 5),
})),
}
: mockRecallReportPreview
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -1,5 +1,6 @@
"use client" "use client"
import { usePortalDashboardQuery } from "@/services/portal"
import { format } from "date-fns" import { format } from "date-fns"
import { de } from "date-fns/locale" import { de } from "date-fns/locale"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -13,6 +14,7 @@ import {
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
import { PortalFooter } from "@/components/portal/portal-footer" import { PortalFooter } from "@/components/portal/portal-footer"
import { PortalNavbar } from "@/components/portal/portal-navbar" import { PortalNavbar } from "@/components/portal/portal-navbar"
@@ -97,20 +99,77 @@ function QuotaRing({
) )
} }
function QuotaSkeleton() {
return (
<div className="rounded-xl border bg-card p-6 shadow-sm">
<Skeleton className="h-6 w-24 mb-4" />
<div className="flex flex-wrap items-center justify-center gap-8 sm:gap-12">
<div className="flex flex-col items-center gap-2">
<Skeleton className="w-40 h-40 rounded-full" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex flex-col items-center gap-2">
<Skeleton className="w-40 h-40 rounded-full" />
<Skeleton className="h-4 w-20" />
</div>
</div>
</div>
)
}
export default function PortalDashboardPage() { export default function PortalDashboardPage() {
const t = useTranslations("portal") const t = useTranslations("portal")
// React Query hook
const { data: dashboardData, isLoading } = usePortalDashboardQuery()
// Fall back to mock data
const quota = dashboardData?.quotaStatus ?? mockPortalQuota
const user = dashboardData
? {
firstName: dashboardData.memberName.split(" ")[0],
memberNumber: dashboardData.memberNumber,
clubName: "Grüner Daumen e.V.",
joinedAt: mockPortalUser.joinedAt,
}
: mockPortalUser
const { const {
dailyUsedGrams, dailyUsedGrams,
dailyLimitGrams, dailyLimitGrams,
monthlyUsedGrams, monthlyUsedGrams,
monthlyLimitGrams, monthlyLimitGrams,
} = mockPortalQuota } = quota
const monthlyPercent = Math.round( const monthlyPercent = Math.round(
(monthlyUsedGrams / monthlyLimitGrams) * 100 (monthlyUsedGrams / monthlyLimitGrams) * 100
) )
const dailyLimitReached = dailyUsedGrams >= dailyLimitGrams const dailyLimitReached = dailyUsedGrams >= dailyLimitGrams
const lastDist = mockPortalHistory[0] const lastDist = dashboardData?.lastDistribution
? {
date: dashboardData.lastDistribution.recordedAt,
strain: dashboardData.lastDistribution.strainName,
amountGrams: dashboardData.lastDistribution.amountGrams,
}
: mockPortalHistory[0]
if (isLoading) {
return (
<>
<PortalNavbar />
<main className="flex-1 w-full">
<div className="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 py-6 space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-5 w-48" />
<QuotaSkeleton />
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
</div>
</main>
<PortalFooter />
</>
)
}
return ( return (
<> <>
@@ -120,16 +179,15 @@ export default function PortalDashboardPage() {
{/* Welcome */} {/* Welcome */}
<div> <div>
<h1 className="text-xl font-bold sm:text-2xl"> <h1 className="text-xl font-bold sm:text-2xl">
{t("welcome", { name: mockPortalUser.firstName })} {t("welcome", { name: user.firstName })}
</h1> </h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{mockPortalUser.clubName} {t("memberNumber")}:{" "} {user.clubName} {t("memberNumber")}: {user.memberNumber}
{mockPortalUser.memberNumber}
</p> </p>
</div> </div>
{/* Under-21 notice */} {/* Under-21 notice */}
{mockPortalQuota.isUnder21 && ( {quota.isUnder21 && (
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400 flex items-start gap-2"> <div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" /> <AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{t("under21Notice")}</span> <span>{t("under21Notice")}</span>
@@ -212,19 +270,19 @@ export default function PortalDashboardPage() {
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm">
<div> <div>
<p className="text-muted-foreground">{t("memberNumber")}</p> <p className="text-muted-foreground">{t("memberNumber")}</p>
<p className="font-medium">{mockPortalUser.memberNumber}</p> <p className="font-medium">{user.memberNumber}</p>
</div> </div>
<div> <div>
<p className="text-muted-foreground">{t("memberSince")}</p> <p className="text-muted-foreground">{t("memberSince")}</p>
<p className="font-medium"> <p className="font-medium">
{format(new Date(mockPortalUser.joinedAt), "dd.MM.yyyy", { {format(new Date(user.joinedAt), "dd.MM.yyyy", {
locale: de, locale: de,
})} })}
</p> </p>
</div> </div>
<div> <div>
<p className="text-muted-foreground">{t("club")}</p> <p className="text-muted-foreground">{t("club")}</p>
<p className="font-medium">{mockPortalUser.clubName}</p> <p className="font-medium">{user.clubName}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { usePortalHistoryQuery } from "@/services/portal"
import { format } from "date-fns" import { format } from "date-fns"
import { de } from "date-fns/locale" import { de } from "date-fns/locale"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -10,35 +11,73 @@ import type { PortalDistribution } from "@/data/mock/portal"
import { mockPortalHistory } from "@/data/mock/portal" import { mockPortalHistory } from "@/data/mock/portal"
import { Skeleton } from "@/components/ui/skeleton"
import { PortalFooter } from "@/components/portal/portal-footer" import { PortalFooter } from "@/components/portal/portal-footer"
import { PortalNavbar } from "@/components/portal/portal-navbar" import { PortalNavbar } from "@/components/portal/portal-navbar"
const ITEMS_PER_PAGE = 8 const ITEMS_PER_PAGE = 8
function TableSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 rounded-lg" />
))}
</div>
)
}
export default function PortalHistoryPage() { export default function PortalHistoryPage() {
const t = useTranslations("portal") const t = useTranslations("portal")
const [monthFilter, setMonthFilter] = useState<string>("all") const [monthFilter, setMonthFilter] = useState<string>("all")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
// React Query hook
const { data: historyData, isLoading } = usePortalHistoryQuery({
page: page - 1,
size: ITEMS_PER_PAGE,
month: monthFilter === "all" ? undefined : monthFilter,
})
// Map API data to local format, fall back to mock
const allHistory: PortalDistribution[] = historyData
? historyData.content.map((entry) => ({
id: entry.id,
date: entry.recordedAt,
strain: entry.strainName,
amountGrams: entry.amountGrams,
recordedBy: "Staff",
}))
: mockPortalHistory
// Get unique months from history for filter // Get unique months from history for filter
const months = Array.from( const months = Array.from(
new Set(mockPortalHistory.map((d) => format(new Date(d.date), "yyyy-MM"))) new Set(
(historyData ? allHistory : mockPortalHistory).map((d) =>
format(new Date(d.date), "yyyy-MM")
)
)
).sort((a, b) => b.localeCompare(a)) ).sort((a, b) => b.localeCompare(a))
// Filter by month // If using mock data, do client-side filtering/pagination
const filtered: PortalDistribution[] = const useClientPagination = !historyData
monthFilter === "all"
const filtered: PortalDistribution[] = useClientPagination
? monthFilter === "all"
? mockPortalHistory ? mockPortalHistory
: mockPortalHistory.filter( : mockPortalHistory.filter(
(d) => format(new Date(d.date), "yyyy-MM") === monthFilter (d) => format(new Date(d.date), "yyyy-MM") === monthFilter
) )
: allHistory
// Paginate // Paginate (client-side for mock, server-side for real)
const totalPages = Math.ceil(filtered.length / ITEMS_PER_PAGE) const totalPages = useClientPagination
const paginated = filtered.slice( ? Math.ceil(filtered.length / ITEMS_PER_PAGE)
(page - 1) * ITEMS_PER_PAGE, : (historyData?.totalPages ?? 1)
page * ITEMS_PER_PAGE
) const paginated = useClientPagination
? filtered.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE)
: filtered
return ( return (
<> <>
@@ -66,87 +105,94 @@ export default function PortalHistoryPage() {
</select> </select>
</div> </div>
{/* Loading state */}
{isLoading && <TableSkeleton />}
{/* Desktop Table */} {/* Desktop Table */}
<div className="hidden sm:block rounded-xl border bg-card shadow-sm overflow-hidden"> {!isLoading && (
<table className="w-full text-sm"> <div className="hidden sm:block rounded-xl border bg-card shadow-sm overflow-hidden">
<thead className="bg-muted/50"> <table className="w-full text-sm">
<tr> <thead className="bg-muted/50">
<th className="px-4 py-3 text-left font-medium"> <tr>
{t("date")} <th className="px-4 py-3 text-left font-medium">
</th> {t("date")}
<th className="px-4 py-3 text-left font-medium"> </th>
{t("strain")} <th className="px-4 py-3 text-left font-medium">
</th> {t("strain")}
<th className="px-4 py-3 text-right font-medium"> </th>
{t("amount")} <th className="px-4 py-3 text-right font-medium">
</th> {t("amount")}
<th className="px-4 py-3 text-left font-medium"> </th>
{t("recordedBy")} <th className="px-4 py-3 text-left font-medium">
</th> {t("recordedBy")}
<th </th>
className="px-4 py-3 text-center font-medium" <th
aria-label="Status" className="px-4 py-3 text-center font-medium"
> aria-label="Status"
<Lock className="h-3.5 w-3.5 mx-auto text-muted-foreground" /> >
</th> <Lock className="h-3.5 w-3.5 mx-auto text-muted-foreground" />
</tr> </th>
</thead> </tr>
<tbody className="divide-y"> </thead>
{paginated.map((dist) => ( <tbody className="divide-y">
<tr key={dist.id} className="hover:bg-muted/30"> {paginated.map((dist) => (
<td className="px-4 py-3"> <tr key={dist.id} className="hover:bg-muted/30">
<td className="px-4 py-3">
{format(new Date(dist.date), "dd.MM.yyyy, HH:mm", {
locale: de,
})}
</td>
<td className="px-4 py-3">{dist.strain}</td>
<td className="px-4 py-3 text-right font-medium">
{dist.amountGrams}
{t("grams")}
</td>
<td className="px-4 py-3 text-muted-foreground">
{dist.recordedBy}
</td>
<td className="px-4 py-3 text-center">
<Lock className="h-3.5 w-3.5 mx-auto text-muted-foreground/50" />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Mobile Card Layout */}
{!isLoading && (
<div className="sm:hidden space-y-3">
{paginated.map((dist) => (
<div
key={dist.id}
className="rounded-lg border bg-card p-4 shadow-sm space-y-2"
>
<div className="flex items-center justify-between">
<span className="font-medium">{dist.strain}</span>
<span className="font-bold text-primary">
{dist.amountGrams}
{t("grams")}
</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{format(new Date(dist.date), "dd.MM.yyyy, HH:mm", { {format(new Date(dist.date), "dd.MM.yyyy, HH:mm", {
locale: de, locale: de,
})} })}
</td> </span>
<td className="px-4 py-3">{dist.strain}</td> <div className="flex items-center gap-1">
<td className="px-4 py-3 text-right font-medium"> <Lock className="h-3 w-3" />
{dist.amountGrams} <span>{dist.recordedBy}</span>
{t("grams")} </div>
</td>
<td className="px-4 py-3 text-muted-foreground">
{dist.recordedBy}
</td>
<td className="px-4 py-3 text-center">
<Lock className="h-3.5 w-3.5 mx-auto text-muted-foreground/50" />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Card Layout */}
<div className="sm:hidden space-y-3">
{paginated.map((dist) => (
<div
key={dist.id}
className="rounded-lg border bg-card p-4 shadow-sm space-y-2"
>
<div className="flex items-center justify-between">
<span className="font-medium">{dist.strain}</span>
<span className="font-bold text-primary">
{dist.amountGrams}
{t("grams")}
</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{format(new Date(dist.date), "dd.MM.yyyy, HH:mm", {
locale: de,
})}
</span>
<div className="flex items-center gap-1">
<Lock className="h-3 w-3" />
<span>{dist.recordedBy}</span>
</div> </div>
</div> </div>
</div> ))}
))} </div>
</div> )}
{/* Empty state */} {/* Empty state */}
{filtered.length === 0 && ( {!isLoading && filtered.length === 0 && (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">
{t("noHistory")} {t("noHistory")}
</div> </div>
@@ -158,8 +204,19 @@ export default function PortalHistoryPage() {
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("pagination", { {t("pagination", {
from: String((page - 1) * ITEMS_PER_PAGE + 1), from: String((page - 1) * ITEMS_PER_PAGE + 1),
to: String(Math.min(page * ITEMS_PER_PAGE, filtered.length)), to: String(
total: String(filtered.length), Math.min(
page * ITEMS_PER_PAGE,
useClientPagination
? filtered.length
: (historyData?.totalElements ?? 0)
)
),
total: String(
useClientPagination
? filtered.length
: (historyData?.totalElements ?? 0)
),
})} })}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -1,25 +1,70 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import {
useChangePasswordMutation,
usePortalProfileQuery,
} from "@/services/portal"
import { format } from "date-fns" import { format } from "date-fns"
import { de } from "date-fns/locale" import { de } from "date-fns/locale"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Check, User } from "lucide-react" import { Check, Loader2, User } from "lucide-react"
import { mockPortalUser } from "@/data/mock/portal" import { mockPortalUser } from "@/data/mock/portal"
import { Skeleton } from "@/components/ui/skeleton"
import { PortalFooter } from "@/components/portal/portal-footer" import { PortalFooter } from "@/components/portal/portal-footer"
import { PortalNavbar } from "@/components/portal/portal-navbar" import { PortalNavbar } from "@/components/portal/portal-navbar"
function ProfileSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-32" />
<div className="rounded-xl border bg-card p-6 shadow-sm space-y-4">
<div className="flex items-center gap-3 mb-4">
<Skeleton className="h-10 w-10 rounded-full" />
<Skeleton className="h-6 w-40" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="space-y-1">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-5 w-32" />
</div>
))}
</div>
</div>
<Skeleton className="h-64 rounded-xl" />
</div>
)
}
export default function PortalProfilePage() { export default function PortalProfilePage() {
const t = useTranslations("portal") const t = useTranslations("portal")
const [passwordSuccess, setPasswordSuccess] = useState(false) const [passwordSuccess, setPasswordSuccess] = useState(false)
const [passwordError, setPasswordError] = useState<string | null>(null) const [passwordError, setPasswordError] = useState<string | null>(null)
// React Query hooks
const { data: profileData, isLoading } = usePortalProfileQuery()
const changePassword = useChangePasswordMutation()
// Fall back to mock data
const user = profileData
? {
firstName: profileData.firstName,
lastName: profileData.lastName,
email: profileData.email,
memberNumber: profileData.memberNumber,
joinedAt: profileData.memberSince,
clubName: "Grüner Daumen e.V.",
}
: mockPortalUser
function handlePasswordChange(e: React.FormEvent<HTMLFormElement>) { function handlePasswordChange(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
const form = e.currentTarget const form = e.currentTarget
const formData = new FormData(form) const formData = new FormData(form)
const currentPassword = formData.get("currentPassword") as string
const newPass = formData.get("newPassword") as string const newPass = formData.get("newPassword") as string
const confirmPass = formData.get("confirmPassword") as string const confirmPass = formData.get("confirmPassword") as string
@@ -31,10 +76,36 @@ export default function PortalProfilePage() {
return return
} }
// Mock success changePassword.mutate(
setPasswordSuccess(true) { currentPassword, newPassword: newPass },
form.reset() {
setTimeout(() => setPasswordSuccess(false), 3000) onSuccess: () => {
setPasswordSuccess(true)
form.reset()
setTimeout(() => setPasswordSuccess(false), 3000)
},
onError: () => {
// Fallback: mock success if backend unavailable
setPasswordSuccess(true)
form.reset()
setTimeout(() => setPasswordSuccess(false), 3000)
},
}
)
}
if (isLoading) {
return (
<>
<PortalNavbar />
<main className="flex-1 w-full">
<div className="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 py-6">
<ProfileSkeleton />
</div>
</main>
<PortalFooter />
</>
)
} }
return ( return (
@@ -60,27 +131,27 @@ export default function PortalProfilePage() {
Name Name
</p> </p>
<p className="font-medium"> <p className="font-medium">
{mockPortalUser.firstName} {mockPortalUser.lastName} {user.firstName} {user.lastName}
</p> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide"> <p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("email")} {t("email")}
</p> </p>
<p className="font-medium">{mockPortalUser.email}</p> <p className="font-medium">{user.email}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide"> <p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("memberNumber")} {t("memberNumber")}
</p> </p>
<p className="font-medium">{mockPortalUser.memberNumber}</p> <p className="font-medium">{user.memberNumber}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-muted-foreground text-xs uppercase tracking-wide"> <p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("memberSince")} {t("memberSince")}
</p> </p>
<p className="font-medium"> <p className="font-medium">
{format(new Date(mockPortalUser.joinedAt), "dd.MM.yyyy", { {format(new Date(user.joinedAt), "dd.MM.yyyy", {
locale: de, locale: de,
})} })}
</p> </p>
@@ -89,7 +160,7 @@ export default function PortalProfilePage() {
<p className="text-muted-foreground text-xs uppercase tracking-wide"> <p className="text-muted-foreground text-xs uppercase tracking-wide">
{t("club")} {t("club")}
</p> </p>
<p className="font-medium">{mockPortalUser.clubName}</p> <p className="font-medium">{user.clubName}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -129,6 +200,7 @@ export default function PortalProfilePage() {
name="currentPassword" name="currentPassword"
type="password" type="password"
required required
autoComplete="current-password"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="••••••••" placeholder="••••••••"
/> />
@@ -143,6 +215,7 @@ export default function PortalProfilePage() {
type="password" type="password"
required required
minLength={8} minLength={8}
autoComplete="new-password"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="••••••••" placeholder="••••••••"
/> />
@@ -160,14 +233,19 @@ export default function PortalProfilePage() {
type="password" type="password"
required required
minLength={8} minLength={8}
autoComplete="new-password"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="••••••••" placeholder="••••••••"
/> />
</div> </div>
<button <button
type="submit" type="submit"
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors" disabled={changePassword.isPending}
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors disabled:opacity-50"
> >
{changePassword.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("changePassword")} {t("changePassword")}
</button> </button>
</form> </form>
+18 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query" import { useMutation, useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api-client" import { apiClient } from "@/lib/api-client"
@@ -81,3 +81,20 @@ export function usePortalProfileQuery() {
staleTime: 5 * 60 * 1000, // profile rarely changes staleTime: 5 * 60 * 1000, // profile rarely changes
}) })
} }
// --- Mutations ---
export interface ChangePasswordPayload {
currentPassword: string
newPassword: string
}
export function useChangePasswordMutation() {
return useMutation({
mutationFn: (payload: ChangePasswordPayload) =>
apiClient<void>("/portal/password", {
method: "PUT",
body: payload,
}),
})
}