feat(sprint-5): Phase 2 — React Query API client layer

- @tanstack/react-query with QueryClientProvider in providers/index.tsx
- Typed api-client.ts fetch wrapper with ApiError class + apiDownload
- Service modules: members, distributions, stock, reports, dashboard, portal, staff
- Offline banner component (onlineManager subscription)
- API error boundary with retry button
- Loading skeleton components (card, table, chart, form, dashboard)
- i18n for error/loading states (de/en)
This commit is contained in:
Patrick Plate
2026-06-12 19:59:41 +02:00
parent 279f2f6de0
commit f42c166329
20 changed files with 2875 additions and 7 deletions
@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query"
import type { ClubStats, Distribution } from "@/types/api"
import { apiClient } from "@/lib/api-client"
// --- Query Hooks ---
export function useClubStatsQuery() {
return useQuery({
queryKey: ["dashboard", "stats"],
queryFn: () => apiClient<ClubStats>("/dashboard/stats"),
refetchInterval: 60 * 1000, // auto-refresh every 60s
})
}
export function useRecentDistributionsQuery(limit = 5) {
return useQuery({
queryKey: ["dashboard", "recent-distributions", limit],
queryFn: () =>
apiClient<Distribution[]>("/distributions/recent", {
params: { limit },
}),
})
}
@@ -0,0 +1,97 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type {
AvailableBatch,
Distribution,
DistributionRecord,
QuotaStatus,
} from "@/types/api"
import { apiClient } from "@/lib/api-client"
// --- Types ---
export interface DistributionsPage {
content: DistributionRecord[]
totalElements: number
totalPages: number
number: number
size: number
}
export interface CreateDistributionRequest {
memberId: string
batchId: string
amountGrams: number
}
// --- Query Hooks ---
export function useDistributionsQuery(params?: {
page?: number
size?: number
memberId?: string
from?: string
to?: string
}) {
return useQuery({
queryKey: ["distributions", params],
queryFn: () =>
apiClient<DistributionsPage>("/distributions", {
params: {
page: params?.page,
size: params?.size ?? 20,
memberId: params?.memberId || undefined,
from: params?.from || undefined,
to: params?.to || undefined,
},
}),
})
}
export function useRecentDistributionsQuery(limit = 5) {
return useQuery({
queryKey: ["distributions", "recent", limit],
queryFn: () =>
apiClient<Distribution[]>("/distributions/recent", {
params: { limit },
}),
})
}
export function useQuotaQuery(memberId: string) {
return useQuery({
queryKey: ["members", memberId, "quota"],
queryFn: () => apiClient<QuotaStatus>(`/members/${memberId}/quota`),
enabled: !!memberId,
})
}
export function useAvailableBatchesQuery() {
return useQuery({
queryKey: ["batches", "available"],
queryFn: () => apiClient<AvailableBatch[]>("/batches/available"),
})
}
// --- Mutation Hooks ---
export function useCreateDistributionMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateDistributionRequest) =>
apiClient<DistributionRecord>("/distributions", {
method: "POST",
body: data,
}),
onSuccess: (_data, variables) => {
// Invalidate distribution list + member quota
queryClient.invalidateQueries({ queryKey: ["distributions"] })
queryClient.invalidateQueries({
queryKey: ["members", variables.memberId, "quota"],
})
queryClient.invalidateQueries({ queryKey: ["batches"] })
queryClient.invalidateQueries({ queryKey: ["dashboard"] })
},
})
}
@@ -0,0 +1,102 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type { Member, QuotaStatus } from "@/types/api"
import { apiClient } from "@/lib/api-client"
// --- Types ---
export interface MembersPage {
content: Member[]
totalElements: number
totalPages: number
number: number
size: number
}
export interface CreateMemberRequest {
firstName: string
lastName: string
email: string
dateOfBirth: string
phone?: string
notes?: string
}
export interface UpdateMemberRequest extends Partial<CreateMemberRequest> {
status?: "ACTIVE" | "SUSPENDED" | "EXPELLED"
}
// --- Query Hooks ---
export function useMembersQuery(params?: {
page?: number
size?: number
search?: string
status?: string
}) {
return useQuery({
queryKey: ["members", params],
queryFn: () =>
apiClient<MembersPage>("/members", {
params: {
page: params?.page,
size: params?.size ?? 20,
search: params?.search || undefined,
status: params?.status || undefined,
},
}),
})
}
export function useMemberQuery(id: string) {
return useQuery({
queryKey: ["members", id],
queryFn: () => apiClient<Member>(`/members/${id}`),
enabled: !!id,
})
}
export function useMemberQuotaQuery(id: string) {
return useQuery({
queryKey: ["members", id, "quota"],
queryFn: () => apiClient<QuotaStatus>(`/members/${id}/quota`),
enabled: !!id,
})
}
// --- Mutation Hooks ---
export function useCreateMemberMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateMemberRequest) =>
apiClient<Member>("/members", { method: "POST", body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["members"] })
},
})
}
export function useUpdateMemberMutation(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: UpdateMemberRequest) =>
apiClient<Member>(`/members/${id}`, { method: "PUT", body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["members"] })
queryClient.invalidateQueries({ queryKey: ["members", id] })
},
})
}
export function useDeleteMemberMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiClient<void>(`/members/${id}`, { method: "DELETE" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["members"] })
},
})
}
@@ -0,0 +1,83 @@
import { useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api-client"
// --- Types ---
export interface PortalDashboardData {
memberName: string
memberNumber: string
quotaStatus: {
dailyUsedGrams: number
dailyLimitGrams: number
monthlyUsedGrams: number
monthlyLimitGrams: number
isUnder21: boolean
}
lastDistribution?: {
strainName: string
amountGrams: number
recordedAt: string
}
}
export interface PortalHistoryEntry {
id: string
strainName: string
amountGrams: number
recordedAt: string
}
export interface PortalHistoryPage {
content: PortalHistoryEntry[]
totalElements: number
totalPages: number
number: number
size: number
}
export interface PortalProfileData {
firstName: string
lastName: string
email: string
phone?: string
dateOfBirth: string
memberNumber: string
memberSince: string
status: "ACTIVE" | "SUSPENDED" | "EXPELLED"
}
// --- Query Hooks ---
export function usePortalDashboardQuery() {
return useQuery({
queryKey: ["portal", "dashboard"],
queryFn: () => apiClient<PortalDashboardData>("/portal/dashboard"),
})
}
export function usePortalHistoryQuery(params?: {
page?: number
size?: number
month?: string
}) {
return useQuery({
queryKey: ["portal", "history", params],
queryFn: () =>
apiClient<PortalHistoryPage>("/portal/history", {
params: {
page: params?.page,
size: params?.size ?? 20,
month: params?.month || undefined,
},
}),
})
}
export function usePortalProfileQuery() {
return useQuery({
queryKey: ["portal", "profile"],
queryFn: () => apiClient<PortalProfileData>("/portal/profile"),
staleTime: 5 * 60 * 1000, // profile rarely changes
})
}
@@ -0,0 +1,113 @@
import { useQuery } from "@tanstack/react-query"
import { apiClient, apiDownload } from "@/lib/api-client"
// --- Types ---
export interface MonthlyReportData {
month: string // YYYY-MM
totalDistributions: number
totalGrams: number
uniqueMembers: number
averagePerDistribution: number
topStrains: { name: string; grams: number }[]
}
export interface MemberListReportData {
totalMembers: number
activeMembers: number
suspendedMembers: number
expelledMembers: number
members: { id: string; name: string; status: string; joinedAt: string }[]
}
export interface RecallReportData {
totalRecalls: number
batches: {
id: string
strainName: string
recalledAt: string
reason: string
gramsAffected: number
}[]
}
// --- Query Hooks (preview data) ---
export function useMonthlyReportQuery(month?: string) {
return useQuery({
queryKey: ["reports", "monthly", month],
queryFn: () =>
apiClient<MonthlyReportData>("/reports/monthly", {
params: { month: month || undefined },
}),
enabled: !!month,
})
}
export function useMemberListReportQuery(status?: string) {
return useQuery({
queryKey: ["reports", "member-list", status],
queryFn: () =>
apiClient<MemberListReportData>("/reports/member-list", {
params: { status: status || undefined },
}),
})
}
export function useRecallReportQuery(dateRange?: { from: string; to: string }) {
return useQuery({
queryKey: ["reports", "recalls", dateRange],
queryFn: () =>
apiClient<RecallReportData>("/reports/recalls", {
params: {
from: dateRange?.from,
to: dateRange?.to,
},
}),
enabled: !!dateRange,
})
}
// --- Download Functions (imperative, not hooks) ---
export async function downloadMonthlyReportPdf(month: string) {
const { blob, filename } = await apiDownload("/reports/monthly/pdf", {
params: { month },
})
triggerDownload(blob, filename || `monthly-report-${month}.pdf`)
}
export async function downloadMonthlyReportCsv(month: string) {
const { blob, filename } = await apiDownload("/reports/monthly/csv", {
params: { month },
})
triggerDownload(blob, filename || `monthly-report-${month}.csv`)
}
export async function downloadMemberListPdf(status?: string) {
const { blob, filename } = await apiDownload("/reports/member-list/pdf", {
params: { status: status || undefined },
})
triggerDownload(blob, filename || "member-list.pdf")
}
export async function downloadRecallReportPdf(from: string, to: string) {
const { blob, filename } = await apiDownload("/reports/recalls/pdf", {
params: { from, to },
})
triggerDownload(blob, filename || `recall-report-${from}-${to}.pdf`)
}
// --- Helpers ---
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
@@ -0,0 +1,75 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api-client"
// --- Types ---
export interface StaffMember {
id: string
email: string
displayName: string
role: "ADMIN" | "MANAGER" | "STAFF"
permissions: string[]
status: "ACTIVE" | "INVITED" | "REVOKED"
lastLoginAt?: string
createdAt: string
}
export interface InviteStaffRequest {
email: string
displayName: string
role: "ADMIN" | "MANAGER" | "STAFF"
permissions: string[]
}
export interface UpdateStaffPermissionsRequest {
role?: "ADMIN" | "MANAGER" | "STAFF"
permissions?: string[]
}
// --- Query Hooks ---
export function useStaffListQuery() {
return useQuery({
queryKey: ["staff"],
queryFn: () => apiClient<StaffMember[]>("/staff"),
})
}
// --- Mutation Hooks ---
export function useInviteStaffMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: InviteStaffRequest) =>
apiClient<StaffMember>("/staff/invite", { method: "POST", body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["staff"] })
},
})
}
export function useUpdateStaffPermissionsMutation(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: UpdateStaffPermissionsRequest) =>
apiClient<StaffMember>(`/staff/${id}/permissions`, {
method: "PUT",
body: data,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["staff"] })
},
})
}
export function useRevokeStaffMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiClient<void>(`/staff/${id}/revoke`, { method: "POST" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["staff"] })
},
})
}
@@ -0,0 +1,95 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type { Batch, BatchSummary, Strain } from "@/types/api"
import { apiClient } from "@/lib/api-client"
// --- Types ---
export interface BatchesPage {
content: Batch[]
totalElements: number
totalPages: number
number: number
size: number
}
export interface CreateBatchRequest {
strainName: string
thcPercent: number
cbdPercent: number
totalGrams: number
supplier: string
harvestDate: string
notes?: string
}
// --- Query Hooks ---
export function useBatchesQuery(params?: {
page?: number
size?: number
status?: string
}) {
return useQuery({
queryKey: ["batches", params],
queryFn: () =>
apiClient<BatchesPage>("/batches", {
params: {
page: params?.page,
size: params?.size ?? 20,
status: params?.status || undefined,
},
}),
})
}
export function useBatchQuery(id: string) {
return useQuery({
queryKey: ["batches", id],
queryFn: () => apiClient<Batch>(`/batches/${id}`),
enabled: !!id,
})
}
export function useStrainsQuery() {
return useQuery({
queryKey: ["strains"],
queryFn: () => apiClient<Strain[]>("/strains"),
staleTime: 5 * 60 * 1000, // strains rarely change — 5 min stale
})
}
export function useStockSummaryQuery() {
return useQuery({
queryKey: ["batches", "summary"],
queryFn: () => apiClient<BatchSummary[]>("/batches/summary"),
})
}
// --- Mutation Hooks ---
export function useCreateBatchMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateBatchRequest) =>
apiClient<Batch>("/batches", { method: "POST", body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["batches"] })
queryClient.invalidateQueries({ queryKey: ["dashboard"] })
},
})
}
export function useRecallBatchMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) =>
apiClient<Batch>(`/batches/${id}/recall`, { method: "POST" }),
onSuccess: (_data, id) => {
queryClient.invalidateQueries({ queryKey: ["batches"] })
queryClient.invalidateQueries({ queryKey: ["batches", id] })
queryClient.invalidateQueries({ queryKey: ["dashboard"] })
},
})
}