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:
@@ -1,6 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
downloadMemberListPdf,
|
||||
downloadMonthlyReportCsv,
|
||||
downloadMonthlyReportPdf,
|
||||
downloadRecallReportPdf,
|
||||
useMemberListReportQuery,
|
||||
useMonthlyReportQuery,
|
||||
useRecallReportQuery,
|
||||
} from "@/services/reports"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
@@ -32,6 +41,7 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -56,20 +66,60 @@ export default function ReportsPage() {
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [previewType, setPreviewType] = useState<ReportType>("monthly")
|
||||
|
||||
const handleDownload = (reportType: ReportType, format: "pdf" | "csv") => {
|
||||
const names: Record<ReportType, string> = {
|
||||
monthly: t("monthly"),
|
||||
memberList: t("memberList"),
|
||||
recall: t("recall"),
|
||||
}
|
||||
const monthLabel = selectedMonth.replace("-", " ")
|
||||
const fileName = `${names[reportType]} ${monthLabel}.${format}`
|
||||
// React Query hooks
|
||||
const monthlyQuery = useMonthlyReportQuery(selectedMonth)
|
||||
const memberListQuery = useMemberListReportQuery(
|
||||
statusFilter === "all" ? undefined : statusFilter
|
||||
)
|
||||
const recallQuery = useRecallReportQuery(
|
||||
dateFrom && dateTo ? { from: dateFrom, to: dateTo } : undefined
|
||||
)
|
||||
|
||||
const handleDownload = async (
|
||||
reportType: ReportType,
|
||||
format: "pdf" | "csv"
|
||||
) => {
|
||||
toast.info(t("generating"))
|
||||
|
||||
setTimeout(() => {
|
||||
toast.success(t("downloaded", { name: fileName }))
|
||||
}, 1200)
|
||||
try {
|
||||
if (reportType === "monthly" && format === "pdf") {
|
||||
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) => {
|
||||
@@ -315,9 +365,15 @@ export default function ReportsPage() {
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6">
|
||||
{previewType === "monthly" && <MonthlyPreview t={t} />}
|
||||
{previewType === "memberList" && <MemberListPreview t={t} />}
|
||||
{previewType === "recall" && <RecallPreview t={t} />}
|
||||
{previewType === "monthly" && (
|
||||
<MonthlyPreview t={t} query={monthlyQuery} />
|
||||
)}
|
||||
{previewType === "memberList" && (
|
||||
<MemberListPreview t={t} query={memberListQuery} />
|
||||
)}
|
||||
{previewType === "recall" && (
|
||||
<RecallPreview t={t} query={recallQuery} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="mt-6">
|
||||
@@ -333,12 +389,45 @@ export default function ReportsPage() {
|
||||
|
||||
/* ─── 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({
|
||||
t,
|
||||
query,
|
||||
}: {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
@@ -387,10 +476,30 @@ function MonthlyPreview({
|
||||
|
||||
function MemberListPreview({
|
||||
t,
|
||||
query,
|
||||
}: {
|
||||
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) => {
|
||||
switch (status) {
|
||||
@@ -445,10 +554,35 @@ function MemberListPreview({
|
||||
|
||||
function RecallPreview({
|
||||
t,
|
||||
query,
|
||||
}: {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { usePortalDashboardQuery } from "@/services/portal"
|
||||
import { format } from "date-fns"
|
||||
import { de } from "date-fns/locale"
|
||||
import { useTranslations } from "next-intl"
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { PortalFooter } from "@/components/portal/portal-footer"
|
||||
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() {
|
||||
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 {
|
||||
dailyUsedGrams,
|
||||
dailyLimitGrams,
|
||||
monthlyUsedGrams,
|
||||
monthlyLimitGrams,
|
||||
} = mockPortalQuota
|
||||
} = quota
|
||||
const monthlyPercent = Math.round(
|
||||
(monthlyUsedGrams / monthlyLimitGrams) * 100
|
||||
)
|
||||
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 (
|
||||
<>
|
||||
@@ -120,16 +179,15 @@ export default function PortalDashboardPage() {
|
||||
{/* Welcome */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold sm:text-2xl">
|
||||
{t("welcome", { name: mockPortalUser.firstName })}
|
||||
{t("welcome", { name: user.firstName })}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{mockPortalUser.clubName} — {t("memberNumber")}:{" "}
|
||||
{mockPortalUser.memberNumber}
|
||||
{user.clubName} — {t("memberNumber")}: {user.memberNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<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>
|
||||
<p className="text-muted-foreground">{t("memberNumber")}</p>
|
||||
<p className="font-medium">{mockPortalUser.memberNumber}</p>
|
||||
<p className="font-medium">{user.memberNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("memberSince")}</p>
|
||||
<p className="font-medium">
|
||||
{format(new Date(mockPortalUser.joinedAt), "dd.MM.yyyy", {
|
||||
{format(new Date(user.joinedAt), "dd.MM.yyyy", {
|
||||
locale: de,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { usePortalHistoryQuery } from "@/services/portal"
|
||||
import { format } from "date-fns"
|
||||
import { de } from "date-fns/locale"
|
||||
import { useTranslations } from "next-intl"
|
||||
@@ -10,35 +11,73 @@ import type { PortalDistribution } from "@/data/mock/portal"
|
||||
|
||||
import { mockPortalHistory } from "@/data/mock/portal"
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { PortalFooter } from "@/components/portal/portal-footer"
|
||||
import { PortalNavbar } from "@/components/portal/portal-navbar"
|
||||
|
||||
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() {
|
||||
const t = useTranslations("portal")
|
||||
const [monthFilter, setMonthFilter] = useState<string>("all")
|
||||
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
|
||||
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))
|
||||
|
||||
// Filter by month
|
||||
const filtered: PortalDistribution[] =
|
||||
monthFilter === "all"
|
||||
// If using mock data, do client-side filtering/pagination
|
||||
const useClientPagination = !historyData
|
||||
|
||||
const filtered: PortalDistribution[] = useClientPagination
|
||||
? monthFilter === "all"
|
||||
? mockPortalHistory
|
||||
: mockPortalHistory.filter(
|
||||
(d) => format(new Date(d.date), "yyyy-MM") === monthFilter
|
||||
)
|
||||
: allHistory
|
||||
|
||||
// Paginate
|
||||
const totalPages = Math.ceil(filtered.length / ITEMS_PER_PAGE)
|
||||
const paginated = filtered.slice(
|
||||
(page - 1) * ITEMS_PER_PAGE,
|
||||
page * ITEMS_PER_PAGE
|
||||
)
|
||||
// Paginate (client-side for mock, server-side for real)
|
||||
const totalPages = useClientPagination
|
||||
? Math.ceil(filtered.length / ITEMS_PER_PAGE)
|
||||
: (historyData?.totalPages ?? 1)
|
||||
|
||||
const paginated = useClientPagination
|
||||
? filtered.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE)
|
||||
: filtered
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -66,87 +105,94 @@ export default function PortalHistoryPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && <TableSkeleton />}
|
||||
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden sm:block rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">
|
||||
{t("date")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium">
|
||||
{t("strain")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium">
|
||||
{t("amount")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium">
|
||||
{t("recordedBy")}
|
||||
</th>
|
||||
<th
|
||||
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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{paginated.map((dist) => (
|
||||
<tr key={dist.id} className="hover:bg-muted/30">
|
||||
<td className="px-4 py-3">
|
||||
{!isLoading && (
|
||||
<div className="hidden sm:block rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium">
|
||||
{t("date")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium">
|
||||
{t("strain")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium">
|
||||
{t("amount")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium">
|
||||
{t("recordedBy")}
|
||||
</th>
|
||||
<th
|
||||
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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{paginated.map((dist) => (
|
||||
<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", {
|
||||
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 */}
|
||||
<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>
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Lock className="h-3 w-3" />
|
||||
<span>{dist.recordedBy}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{filtered.length === 0 && (
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{t("noHistory")}
|
||||
</div>
|
||||
@@ -158,8 +204,19 @@ export default function PortalHistoryPage() {
|
||||
<span className="text-muted-foreground">
|
||||
{t("pagination", {
|
||||
from: String((page - 1) * ITEMS_PER_PAGE + 1),
|
||||
to: String(Math.min(page * ITEMS_PER_PAGE, filtered.length)),
|
||||
total: String(filtered.length),
|
||||
to: String(
|
||||
Math.min(
|
||||
page * ITEMS_PER_PAGE,
|
||||
useClientPagination
|
||||
? filtered.length
|
||||
: (historyData?.totalElements ?? 0)
|
||||
)
|
||||
),
|
||||
total: String(
|
||||
useClientPagination
|
||||
? filtered.length
|
||||
: (historyData?.totalElements ?? 0)
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -1,25 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
useChangePasswordMutation,
|
||||
usePortalProfileQuery,
|
||||
} from "@/services/portal"
|
||||
import { format } from "date-fns"
|
||||
import { de } from "date-fns/locale"
|
||||
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 { Skeleton } from "@/components/ui/skeleton"
|
||||
import { PortalFooter } from "@/components/portal/portal-footer"
|
||||
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() {
|
||||
const t = useTranslations("portal")
|
||||
const [passwordSuccess, setPasswordSuccess] = useState(false)
|
||||
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>) {
|
||||
e.preventDefault()
|
||||
const form = e.currentTarget
|
||||
const formData = new FormData(form)
|
||||
const currentPassword = formData.get("currentPassword") as string
|
||||
const newPass = formData.get("newPassword") as string
|
||||
const confirmPass = formData.get("confirmPassword") as string
|
||||
|
||||
@@ -31,10 +76,36 @@ export default function PortalProfilePage() {
|
||||
return
|
||||
}
|
||||
|
||||
// Mock success
|
||||
setPasswordSuccess(true)
|
||||
form.reset()
|
||||
setTimeout(() => setPasswordSuccess(false), 3000)
|
||||
changePassword.mutate(
|
||||
{ currentPassword, newPassword: newPass },
|
||||
{
|
||||
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 (
|
||||
@@ -60,27 +131,27 @@ export default function PortalProfilePage() {
|
||||
Name
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{mockPortalUser.firstName} {mockPortalUser.lastName}
|
||||
{user.firstName} {user.lastName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("email")}
|
||||
</p>
|
||||
<p className="font-medium">{mockPortalUser.email}</p>
|
||||
<p className="font-medium">{user.email}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("memberNumber")}
|
||||
</p>
|
||||
<p className="font-medium">{mockPortalUser.memberNumber}</p>
|
||||
<p className="font-medium">{user.memberNumber}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("memberSince")}
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{format(new Date(mockPortalUser.joinedAt), "dd.MM.yyyy", {
|
||||
{format(new Date(user.joinedAt), "dd.MM.yyyy", {
|
||||
locale: de,
|
||||
})}
|
||||
</p>
|
||||
@@ -89,7 +160,7 @@ export default function PortalProfilePage() {
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("club")}
|
||||
</p>
|
||||
<p className="font-medium">{mockPortalUser.clubName}</p>
|
||||
<p className="font-medium">{user.clubName}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,6 +200,7 @@ export default function PortalProfilePage() {
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
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"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
@@ -143,6 +215,7 @@ export default function PortalProfilePage() {
|
||||
type="password"
|
||||
required
|
||||
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"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
@@ -160,14 +233,19 @@ export default function PortalProfilePage() {
|
||||
type="password"
|
||||
required
|
||||
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"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
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")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
@@ -81,3 +81,20 @@ export function usePortalProfileQuery() {
|
||||
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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user