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:
@@ -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"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user