From ed1efccc902c60ed1e309d29c484d83c5f3be34d Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Fri, 12 Jun 2026 20:24:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(sprint-5):=20Phase=205=20=E2=80=94=20Wire?= =?UTF-8?q?=20reports=20+=20portal=20to=20React=20Query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../app/(dashboard-layout)/reports/page.tsx | 168 +++++++++++-- .../app/(portal)/portal/dashboard/page.tsx | 76 +++++- .../src/app/(portal)/portal/history/page.tsx | 227 +++++++++++------- .../src/app/(portal)/portal/profile/page.tsx | 100 +++++++- cannamanage-frontend/src/services/portal.ts | 19 +- 5 files changed, 467 insertions(+), 123 deletions(-) diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/reports/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/reports/page.tsx index fc874b0..ccdacbb 100644 --- a/cannamanage-frontend/src/app/(dashboard-layout)/reports/page.tsx +++ b/cannamanage-frontend/src/app/(dashboard-layout)/reports/page.tsx @@ -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("monthly") - const handleDownload = (reportType: ReportType, format: "pdf" | "csv") => { - const names: Record = { - 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 = { + 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 = { + 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() {
- {previewType === "monthly" && } - {previewType === "memberList" && } - {previewType === "recall" && } + {previewType === "monthly" && ( + + )} + {previewType === "memberList" && ( + + )} + {previewType === "recall" && ( + + )}
@@ -333,12 +389,45 @@ export default function ReportsPage() { /* ─── Preview Components ─── */ +function PreviewSkeleton() { + return ( +
+
+ + + + +
+ + +
+ ) +} + function MonthlyPreview({ t, + query, }: { t: ReturnType> + query: ReturnType }) { - const data = mockMonthlyReportPreview + if (query.isLoading) return + + // 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 (
@@ -387,10 +476,30 @@ function MonthlyPreview({ function MemberListPreview({ t, + query, }: { t: ReturnType> + query: ReturnType }) { - const data = mockMemberListPreview + if (query.isLoading) return + + // 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> + query: ReturnType }) { - const data = mockRecallReportPreview + if (query.isLoading) return + + // 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 (
diff --git a/cannamanage-frontend/src/app/(portal)/portal/dashboard/page.tsx b/cannamanage-frontend/src/app/(portal)/portal/dashboard/page.tsx index 268c0ed..6188285 100644 --- a/cannamanage-frontend/src/app/(portal)/portal/dashboard/page.tsx +++ b/cannamanage-frontend/src/app/(portal)/portal/dashboard/page.tsx @@ -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 ( +
+ +
+
+ + +
+
+ + +
+
+
+ ) +} + 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 ( + <> + +
+
+ + + + + +
+
+ + + ) + } return ( <> @@ -120,16 +179,15 @@ export default function PortalDashboardPage() { {/* Welcome */}

- {t("welcome", { name: mockPortalUser.firstName })} + {t("welcome", { name: user.firstName })}

- {mockPortalUser.clubName} — {t("memberNumber")}:{" "} - {mockPortalUser.memberNumber} + {user.clubName} — {t("memberNumber")}: {user.memberNumber}

{/* Under-21 notice */} - {mockPortalQuota.isUnder21 && ( + {quota.isUnder21 && (
{t("under21Notice")} @@ -212,19 +270,19 @@ export default function PortalDashboardPage() {

{t("memberNumber")}

-

{mockPortalUser.memberNumber}

+

{user.memberNumber}

{t("memberSince")}

- {format(new Date(mockPortalUser.joinedAt), "dd.MM.yyyy", { + {format(new Date(user.joinedAt), "dd.MM.yyyy", { locale: de, })}

{t("club")}

-

{mockPortalUser.clubName}

+

{user.clubName}

diff --git a/cannamanage-frontend/src/app/(portal)/portal/history/page.tsx b/cannamanage-frontend/src/app/(portal)/portal/history/page.tsx index a5e9ba5..19d16c9 100644 --- a/cannamanage-frontend/src/app/(portal)/portal/history/page.tsx +++ b/cannamanage-frontend/src/app/(portal)/portal/history/page.tsx @@ -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 ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) +} + export default function PortalHistoryPage() { const t = useTranslations("portal") const [monthFilter, setMonthFilter] = useState("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() {
+ {/* Loading state */} + {isLoading && } + {/* Desktop Table */} -
- - - - - - - - - - - - {paginated.map((dist) => ( - - - - - - - - ))} - -
- {t("date")} - - {t("strain")} - - {t("amount")} - - {t("recordedBy")} - - -
+ {!isLoading && ( +
+ + + + + + + + + + + + {paginated.map((dist) => ( + + + + + + + + ))} + +
+ {t("date")} + + {t("strain")} + + {t("amount")} + + {t("recordedBy")} + + +
+ {format(new Date(dist.date), "dd.MM.yyyy, HH:mm", { + locale: de, + })} + {dist.strain} + {dist.amountGrams} + {t("grams")} + + {dist.recordedBy} + + +
+
+ )} + + {/* Mobile Card Layout */} + {!isLoading && ( +
+ {paginated.map((dist) => ( +
+
+ {dist.strain} + + {dist.amountGrams} + {t("grams")} + +
+
+ {format(new Date(dist.date), "dd.MM.yyyy, HH:mm", { locale: de, })} -
{dist.strain} - {dist.amountGrams} - {t("grams")} - - {dist.recordedBy} - - -
-
- - {/* Mobile Card Layout */} -
- {paginated.map((dist) => ( -
-
- {dist.strain} - - {dist.amountGrams} - {t("grams")} - -
-
- - {format(new Date(dist.date), "dd.MM.yyyy, HH:mm", { - locale: de, - })} - -
- - {dist.recordedBy} + +
+ + {dist.recordedBy} +
-
- ))} -
+ ))} +
+ )} {/* Empty state */} - {filtered.length === 0 && ( + {!isLoading && filtered.length === 0 && (
{t("noHistory")}
@@ -158,8 +204,19 @@ export default function PortalHistoryPage() { {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) + ), })}
diff --git a/cannamanage-frontend/src/app/(portal)/portal/profile/page.tsx b/cannamanage-frontend/src/app/(portal)/portal/profile/page.tsx index 99e88bf..a203564 100644 --- a/cannamanage-frontend/src/app/(portal)/portal/profile/page.tsx +++ b/cannamanage-frontend/src/app/(portal)/portal/profile/page.tsx @@ -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 ( +
+ +
+
+ + +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ +
+ ) +} + export default function PortalProfilePage() { const t = useTranslations("portal") const [passwordSuccess, setPasswordSuccess] = useState(false) const [passwordError, setPasswordError] = useState(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) { 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 ( + <> + +
+
+ +
+
+ + + ) } return ( @@ -60,27 +131,27 @@ export default function PortalProfilePage() { Name

- {mockPortalUser.firstName} {mockPortalUser.lastName} + {user.firstName} {user.lastName}

{t("email")}

-

{mockPortalUser.email}

+

{user.email}

{t("memberNumber")}

-

{mockPortalUser.memberNumber}

+

{user.memberNumber}

{t("memberSince")}

- {format(new Date(mockPortalUser.joinedAt), "dd.MM.yyyy", { + {format(new Date(user.joinedAt), "dd.MM.yyyy", { locale: de, })}

@@ -89,7 +160,7 @@ export default function PortalProfilePage() {

{t("club")}

-

{mockPortalUser.clubName}

+

{user.clubName}

@@ -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="••••••••" /> diff --git a/cannamanage-frontend/src/services/portal.ts b/cannamanage-frontend/src/services/portal.ts index b76698c..efa411a 100644 --- a/cannamanage-frontend/src/services/portal.ts +++ b/cannamanage-frontend/src/services/portal.ts @@ -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("/portal/password", { + method: "PUT", + body: payload, + }), + }) +}