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"
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>
+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"
@@ -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,
}),
})
}