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:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -292,5 +292,17 @@
|
||||
"allMonths": "Alle Monate",
|
||||
"footerText": "Cannabis-Anbauverein — Sichere Mitgliederverwaltung",
|
||||
"adminLogin": "Zum Admin-Login"
|
||||
},
|
||||
"api": {
|
||||
"loading": "Wird geladen...",
|
||||
"error": "Fehler beim Laden der Daten.",
|
||||
"retry": "Erneut versuchen",
|
||||
"offline": "Keine Verbindung zum Server — Daten könnten veraltet sein.",
|
||||
"networkError": "Netzwerkfehler. Bitte prüfe deine Verbindung.",
|
||||
"unauthorized": "Sitzung abgelaufen. Bitte erneut anmelden.",
|
||||
"forbidden": "Keine Berechtigung für diese Aktion.",
|
||||
"notFound": "Ressource nicht gefunden.",
|
||||
"quotaExceeded": "Kontingent überschritten.",
|
||||
"serverError": "Serverfehler. Bitte versuche es später erneut."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,5 +292,17 @@
|
||||
"allMonths": "All months",
|
||||
"footerText": "Cannabis cultivation club — Secure member management",
|
||||
"adminLogin": "Go to Admin Login"
|
||||
},
|
||||
"api": {
|
||||
"loading": "Loading...",
|
||||
"error": "Failed to load data.",
|
||||
"retry": "Try again",
|
||||
"offline": "No connection to server — data may be outdated.",
|
||||
"networkError": "Network error. Please check your connection.",
|
||||
"unauthorized": "Session expired. Please sign in again.",
|
||||
"forbidden": "You do not have permission for this action.",
|
||||
"notFound": "Resource not found.",
|
||||
"quotaExceeded": "Quota exceeded.",
|
||||
"serverError": "Server error. Please try again later."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-toast": "1.2.1",
|
||||
"@radix-ui/react-tooltip": "1.1.5",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
|
||||
Generated
+18
@@ -53,6 +53,9 @@ importers:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: 1.1.5
|
||||
version: 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.101.0
|
||||
version: 5.101.0(react@19.1.3)
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(react-dom@19.1.3(react@19.1.3))(react@19.1.3)
|
||||
@@ -1642,6 +1645,14 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20'
|
||||
|
||||
'@tanstack/query-core@5.101.0':
|
||||
resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==}
|
||||
|
||||
'@tanstack/react-query@5.101.0':
|
||||
resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-table@8.21.3':
|
||||
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4918,6 +4929,13 @@ snapshots:
|
||||
postcss-selector-parser: 6.0.10
|
||||
tailwindcss: 4.1.3
|
||||
|
||||
'@tanstack/query-core@5.101.0': {}
|
||||
|
||||
'@tanstack/react-query@5.101.0(react@19.1.3)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.101.0
|
||||
react: 19.1.3
|
||||
|
||||
'@tanstack/react-table@8.21.3(react-dom@19.1.3(react@19.1.3))(react@19.1.3)':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.3
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import { Component } from "react"
|
||||
import { AlertCircle, RefreshCw } from "lucide-react"
|
||||
|
||||
import type { ErrorInfo, ReactNode } from "react"
|
||||
|
||||
import { ApiError } from "@/lib/api-client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
|
||||
interface ApiErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
fallbackMessage?: string
|
||||
}
|
||||
|
||||
interface ApiErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ApiErrorBoundary extends Component<
|
||||
ApiErrorBoundaryProps,
|
||||
ApiErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ApiErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ApiErrorBoundaryState {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("[ApiErrorBoundary]", error, errorInfo)
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
const error = this.state.error
|
||||
const isApiError = error instanceof ApiError
|
||||
const message = isApiError
|
||||
? error.message
|
||||
: this.props.fallbackMessage || "An unexpected error occurred."
|
||||
|
||||
return (
|
||||
<Card className="mx-auto mt-8 max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<AlertCircle className="text-destructive h-10 w-10" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{message}</p>
|
||||
{isApiError && error.code !== "NETWORK_ERROR" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Code: {error.code} (HTTP {error.status})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={this.handleRetry}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { onlineManager } from "@tanstack/react-query"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { WifiOff } from "lucide-react"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
export function OfflineBanner() {
|
||||
const t = useTranslations("api")
|
||||
const [isOnline, setIsOnline] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Subscribe to online manager state changes
|
||||
const unsubscribe = onlineManager.subscribe((online) => {
|
||||
setIsOnline(online)
|
||||
})
|
||||
// Set initial state
|
||||
setIsOnline(onlineManager.isOnline())
|
||||
return () => unsubscribe()
|
||||
}, [])
|
||||
|
||||
if (isOnline) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="bg-destructive/10 border-destructive/30 text-destructive flex items-center gap-2 border-b px-4 py-2 text-sm"
|
||||
>
|
||||
<WifiOff className="h-4 w-4 shrink-0" />
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that prevents hydration mismatch for online/offline state.
|
||||
*/
|
||||
export function OfflineBannerWrapper({ children }: { children: ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
if (!mounted) return null
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
/**
|
||||
* Skeleton for KPI stat cards on the dashboard.
|
||||
*/
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border p-6">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for a data table (header + rows).
|
||||
*/
|
||||
export function TableSkeleton({
|
||||
rows = 5,
|
||||
columns = 4,
|
||||
}: {
|
||||
rows?: number
|
||||
columns?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full space-y-2">
|
||||
{/* Header */}
|
||||
<div className="flex gap-4 border-b pb-2">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<Skeleton key={`header-${i}`} className="h-4 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{Array.from({ length: rows }).map((_, rowIdx) => (
|
||||
<div key={`row-${rowIdx}`} className="flex gap-4 py-2">
|
||||
{Array.from({ length: columns }).map((_, colIdx) => (
|
||||
<Skeleton key={`cell-${rowIdx}-${colIdx}`} className="h-4 flex-1" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for chart / Recharts areas.
|
||||
*/
|
||||
export function ChartSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border p-6">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton for a single form field.
|
||||
*/
|
||||
export function FormFieldSkeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid of card skeletons for the dashboard.
|
||||
*/
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Typed fetch wrapper for the CannaManage API.
|
||||
*
|
||||
* Routes through the Next.js rewrite proxy:
|
||||
* /api/backend/... → BACKEND_URL/api/v1/...
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = "ApiError"
|
||||
}
|
||||
|
||||
/** True for 401/403 — session likely expired or insufficient permissions */
|
||||
get isAuthError(): boolean {
|
||||
return this.status === 401 || this.status === 403
|
||||
}
|
||||
|
||||
/** True for 5xx — server-side failure */
|
||||
get isServerError(): boolean {
|
||||
return this.status >= 500
|
||||
}
|
||||
|
||||
/** True for network failures (status 0) */
|
||||
get isNetworkError(): boolean {
|
||||
return this.status === 0
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiClientOptions extends Omit<RequestInit, "body"> {
|
||||
token?: string
|
||||
body?: unknown
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* Core fetch wrapper that targets the Next.js rewrite proxy.
|
||||
*
|
||||
* @param endpoint - API path without the prefix, e.g. `/members` or `/members/123`
|
||||
* @param options - Fetch options + optional token and typed body
|
||||
* @returns Parsed JSON response of type T
|
||||
* @throws ApiError on non-2xx responses
|
||||
*/
|
||||
export async function apiClient<T>(
|
||||
endpoint: string,
|
||||
options: ApiClientOptions = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
token,
|
||||
body,
|
||||
params,
|
||||
headers: extraHeaders,
|
||||
...fetchOptions
|
||||
} = options
|
||||
|
||||
// Build URL with optional query params
|
||||
let url = `/api/backend${endpoint}`
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined) {
|
||||
searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
const qs = searchParams.toString()
|
||||
if (qs) url += `?${qs}`
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
...(extraHeaders as Record<string, string>),
|
||||
}
|
||||
|
||||
// Don't set Content-Type for GET/HEAD (no body)
|
||||
const method = (fetchOptions.method || "GET").toUpperCase()
|
||||
if (method === "GET" || method === "HEAD") {
|
||||
delete headers["Content-Type"]
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
...fetchOptions,
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
// Try to parse application/problem+json or standard error body
|
||||
const errorBody = await res.json().catch(() => ({
|
||||
code: `HTTP_${res.status}`,
|
||||
message: res.statusText || "Request failed",
|
||||
}))
|
||||
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
errorBody.code || `HTTP_${res.status}`,
|
||||
errorBody.message || errorBody.detail || res.statusText
|
||||
)
|
||||
}
|
||||
|
||||
// 204 No Content — return undefined as T
|
||||
if (res.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error
|
||||
}
|
||||
// Network errors (fetch throws TypeError for network failures)
|
||||
throw new ApiError(
|
||||
0,
|
||||
"NETWORK_ERROR",
|
||||
"Network error — unable to reach the server."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file (PDF, CSV) from the API as a Blob.
|
||||
* Used for report downloads where we don't parse JSON.
|
||||
*/
|
||||
export async function apiDownload(
|
||||
endpoint: string,
|
||||
options: ApiClientOptions = {}
|
||||
): Promise<{ blob: Blob; filename: string }> {
|
||||
const {
|
||||
token,
|
||||
body,
|
||||
params,
|
||||
headers: extraHeaders,
|
||||
...fetchOptions
|
||||
} = options
|
||||
|
||||
let url = `/api/backend${endpoint}`
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined) {
|
||||
searchParams.set(key, String(value))
|
||||
}
|
||||
}
|
||||
const qs = searchParams.toString()
|
||||
if (qs) url += `?${qs}`
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/octet-stream",
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
...(extraHeaders as Record<string, string>),
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json().catch(() => ({
|
||||
code: `HTTP_${res.status}`,
|
||||
message: res.statusText,
|
||||
}))
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
errorBody.code || `HTTP_${res.status}`,
|
||||
errorBody.message || res.statusText
|
||||
)
|
||||
}
|
||||
|
||||
const blob = await res.blob()
|
||||
const disposition = res.headers.get("Content-Disposition") || ""
|
||||
const filenameMatch = disposition.match(/filename="?([^";\n]+)"?/)
|
||||
const filename = filenameMatch?.[1] || "download"
|
||||
|
||||
return { blob, filename }
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) throw error
|
||||
throw new ApiError(
|
||||
0,
|
||||
"NETWORK_ERROR",
|
||||
"Network error — unable to reach the server."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { SessionProvider } from "next-auth/react"
|
||||
|
||||
import type { LocaleType } from "@/types"
|
||||
@@ -17,15 +19,34 @@ export function Providers({
|
||||
locale: LocaleType
|
||||
children: ReactNode
|
||||
}>) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<SessionProvider>
|
||||
<SettingsProvider locale={locale}>
|
||||
<ModeProvider>
|
||||
<ThemeProvider>
|
||||
<SidebarProvider>{children}</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</ModeProvider>
|
||||
</SettingsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SettingsProvider locale={locale}>
|
||||
<ModeProvider>
|
||||
<ThemeProvider>
|
||||
<SidebarProvider>{children}</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</ModeProvider>
|
||||
</SettingsProvider>
|
||||
</QueryClientProvider>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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