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
+12
View File
@@ -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."
}
}
+12
View File
@@ -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."
}
}
+1
View File
@@ -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",
+18
View File
@@ -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>
)
}
+193
View File
@@ -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."
)
}
}
+28 -7
View File
@@ -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"] })
},
})
}