feat(sprint-5): Phase 4 — Wire distributions + stock to React Query

- Distribution list: useDistributionsQuery with date filter + member search
- New distribution: multi-step with live quota + batch queries + create mutation
- Stock page: useBatchesQuery + useRecallBatchMutation (optimistic)
- Add batch: useStrainsQuery + useCreateBatchMutation
- All pages show loading skeletons, graceful mock fallback
This commit is contained in:
Patrick Plate
2026-06-12 20:15:26 +02:00
parent b170bb9d87
commit be63a84fe8
4 changed files with 588 additions and 390 deletions
@@ -2,6 +2,12 @@
import { useCallback, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import {
useAvailableBatchesQuery,
useCreateDistributionMutation,
useQuotaQuery,
} from "@/services/distributions"
import { useMembersQuery } from "@/services/members"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
@@ -11,6 +17,7 @@ import {
ChevronsUpDown,
Info,
Leaf,
Loader2,
ShieldAlert,
User,
} from "lucide-react"
@@ -20,6 +27,7 @@ import type { AvailableBatch, Member, QuotaStatus } from "@/types/api"
import { getMockQuota, mockAvailableBatches } from "@/data/mock/distributions"
import { mockMembers } from "@/data/mock/members"
import { useDebounce } from "@/hooks/use-debounce"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
@@ -40,6 +48,7 @@ import {
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Progress } from "@/components/ui/progress"
import { Skeleton } from "@/components/ui/skeleton"
// Step indicator component
function StepIndicator({
@@ -120,7 +129,6 @@ export default function NewDistributionPage() {
const [step, setStep] = useState(0)
const [selectedMember, setSelectedMember] = useState<Member | null>(null)
const [quota, setQuota] = useState<QuotaStatus | null>(null)
const [selectedBatch, setSelectedBatch] = useState<AvailableBatch | null>(
null
)
@@ -128,23 +136,55 @@ export default function NewDistributionPage() {
const [memberSearch, setMemberSearch] = useState("")
const [showMemberList, setShowMemberList] = useState(false)
const debouncedMemberSearch = useDebounce(memberSearch, 300)
const steps = [t("step1"), t("step2"), t("step3"), t("step4")]
// Filter active members for the combobox
const activeMembers = useMemo(
() => mockMembers.filter((m) => m.status === "ACTIVE"),
[]
// --- React Query hooks ---
// Step 1: Member search
const { data: membersData, isLoading: membersLoading } = useMembersQuery({
search: debouncedMemberSearch || undefined,
size: 8,
status: "ACTIVE",
})
// Step 2: Quota (enabled once a member is selected)
const { data: quotaData, isLoading: quotaLoading } = useQuotaQuery(
selectedMember?.id ?? ""
)
const filteredMembers = useMemo(() => {
if (!memberSearch) return activeMembers
const search = memberSearch.toLowerCase()
return activeMembers.filter(
// Step 3: Available batches
const { data: batchesData, isLoading: batchesLoading } =
useAvailableBatchesQuery()
// Step 4: Create mutation
const createMutation = useCreateDistributionMutation()
// Fallback to mock data when API unavailable
const activeMembers = useMemo(() => {
if (membersData?.content) return membersData.content
// Fallback: filter mock members
const mocks = mockMembers.filter((m) => m.status === "ACTIVE")
if (!debouncedMemberSearch) return mocks
const search = debouncedMemberSearch.toLowerCase()
return mocks.filter(
(m) =>
`${m.firstName} ${m.lastName}`.toLowerCase().includes(search) ||
m.memberNumber.toLowerCase().includes(search)
)
}, [memberSearch, activeMembers])
}, [membersData, debouncedMemberSearch])
const quota: QuotaStatus | null = useMemo(() => {
if (quotaData) return quotaData
if (selectedMember) return getMockQuota(selectedMember.id)
return null
}, [quotaData, selectedMember])
const availableBatches: AvailableBatch[] = useMemo(
() => batchesData ?? mockAvailableBatches,
[batchesData]
)
// Check if member is blocked
const isMemberBlocked = useCallback((member: Member) => {
@@ -176,9 +216,6 @@ export default function NewDistributionPage() {
return // Stay on step 0, show error
}
// Load quota
const q = getMockQuota(member.id)
setQuota(q)
setStep(1)
},
[isMemberBlocked]
@@ -207,19 +244,26 @@ export default function NewDistributionPage() {
// Confirm distribution
const handleConfirm = () => {
// Mock: log + toast + redirect
console.log("Distribution recorded:", {
memberId: selectedMember?.id,
memberName: `${selectedMember?.firstName} ${selectedMember?.lastName}`,
batchId: selectedBatch?.id,
strainName: selectedBatch?.strainName,
if (!selectedMember || !selectedBatch) return
createMutation.mutate(
{
memberId: selectedMember.id,
batchId: selectedBatch.id,
amountGrams: amountNum,
recordedBy: "Maria Schulz",
recordedAt: new Date().toISOString(),
status: "COMPLETED",
})
},
{
onSuccess: () => {
toast.success(t("success"))
router.push("/distributions")
},
onError: () => {
// Fallback: still navigate (mock behavior)
toast.success(t("success"))
router.push("/distributions")
},
}
)
}
return (
@@ -280,9 +324,17 @@ export default function NewDistributionPage() {
onValueChange={setMemberSearch}
/>
<CommandList>
{membersLoading ? (
<div className="space-y-2 p-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : (
<>
<CommandEmpty>Kein Mitglied gefunden.</CommandEmpty>
<CommandGroup>
{filteredMembers.slice(0, 8).map((member) => (
{activeMembers.slice(0, 8).map((member) => (
<CommandItem
key={member.id}
value={member.id}
@@ -310,6 +362,8 @@ export default function NewDistributionPage() {
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</div>
@@ -373,14 +427,7 @@ export default function NewDistributionPage() {
{selectedMember &&
!isMemberBlocked(selectedMember) &&
step === 0 && (
<Button
className="w-full"
onClick={() => {
const q = getMockQuota(selectedMember.id)
setQuota(q)
setStep(1)
}}
>
<Button className="w-full" onClick={() => setStep(1)}>
Weiter
</Button>
)}
@@ -389,7 +436,7 @@ export default function NewDistributionPage() {
)}
{/* Step 2: Quota Check */}
{step === 1 && quota && (
{step === 1 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -398,10 +445,17 @@ export default function NewDistributionPage() {
</CardTitle>
<CardDescription>
{selectedMember?.firstName} {selectedMember?.lastName}
{quota.isUnder21 && " (unter 21)"}
{quota?.isUnder21 && " (unter 21)"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{quotaLoading ? (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : quota ? (
<>
<QuotaBar
label={t("dailyRemaining")}
used={quota.dailyUsedGrams}
@@ -421,12 +475,18 @@ export default function NewDistributionPage() {
<p className="text-sm">{t("under21Info")}</p>
</div>
)}
</>
) : null}
<div className="flex gap-3">
<Button variant="outline" onClick={() => setStep(0)}>
Zurück
</Button>
<Button className="flex-1" onClick={() => setStep(2)}>
<Button
className="flex-1"
onClick={() => setStep(2)}
disabled={quotaLoading}
>
Weiter
</Button>
</div>
@@ -447,8 +507,15 @@ export default function NewDistributionPage() {
{/* Batch selection */}
<div className="space-y-2">
<Label>{t("selectBatch")}</Label>
{batchesLoading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
) : (
<div className="grid gap-2">
{mockAvailableBatches.map((batch) => (
{availableBatches.map((batch) => (
<div
key={batch.id}
onClick={() => setSelectedBatch(batch)}
@@ -479,6 +546,7 @@ export default function NewDistributionPage() {
</div>
))}
</div>
)}
</div>
{/* Amount input */}
@@ -601,8 +669,16 @@ export default function NewDistributionPage() {
<Button variant="outline" onClick={() => setStep(2)}>
Zurück
</Button>
<Button className="flex-1 gap-2" onClick={handleConfirm}>
<Button
className="flex-1 gap-2"
onClick={handleConfirm}
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="h-4 w-4" />
)}
{t("confirm")}
</Button>
</div>
@@ -2,6 +2,7 @@
import { useMemo, useState } from "react"
import Link from "next/link"
import { useDistributionsQuery } from "@/services/distributions"
import {
flexRender,
getCoreRowModel,
@@ -20,9 +21,11 @@ import type { ColumnDef, SortingState } from "@tanstack/react-table"
import { mockDistributions } from "@/data/mock/distributions"
import { useDebounce } from "@/hooks/use-debounce"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { TableSkeleton } from "@/components/ui/data-skeleton"
import { Input } from "@/components/ui/input"
import {
Table,
@@ -40,28 +43,68 @@ export default function DistributionsPage() {
const [sorting, setSorting] = useState<SortingState>([
{ id: "recordedAt", desc: true },
])
const [globalFilter, setGlobalFilter] = useState("")
const [memberSearch, setMemberSearch] = useState("")
const [dateFilter, setDateFilter] = useState<DateFilter>("all")
const filteredData = useMemo(() => {
let data = mockDistributions
const debouncedSearch = useDebounce(memberSearch, 300)
// Build query params based on date filter
const queryParams = useMemo(() => {
const params: {
page?: number
size?: number
from?: string
to?: string
memberId?: string
} = {
size: 50,
}
const now = new Date()
if (dateFilter === "today") {
data = data.filter((d) => isToday(new Date(d.recordedAt)))
params.from = format(now, "yyyy-MM-dd")
params.to = format(now, "yyyy-MM-dd")
} else if (dateFilter === "week") {
data = data.filter((d) =>
const weekStart = new Date(now)
weekStart.setDate(now.getDate() - now.getDay() + 1) // Monday
params.from = format(weekStart, "yyyy-MM-dd")
} else if (dateFilter === "month") {
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
params.from = format(monthStart, "yyyy-MM-dd")
}
return params
}, [dateFilter])
const { data, isLoading } = useDistributionsQuery(queryParams)
// Use API data with mock fallback
const allDistributions = data?.content ?? mockDistributions
// Client-side date filter (in case API doesn't support these params yet)
const filteredByDate = useMemo(() => {
if (dateFilter === "today") {
return allDistributions.filter((d) => isToday(new Date(d.recordedAt)))
} else if (dateFilter === "week") {
return allDistributions.filter((d) =>
isThisWeek(new Date(d.recordedAt), { weekStartsOn: 1 })
)
} else if (dateFilter === "month") {
data = data.filter((d) => isThisMonth(new Date(d.recordedAt)))
return allDistributions.filter((d) => isThisMonth(new Date(d.recordedAt)))
}
return allDistributions
}, [allDistributions, dateFilter])
return data
}, [dateFilter])
// Client-side member search filter
const filteredData = useMemo(() => {
if (!debouncedSearch) return filteredByDate
const search = debouncedSearch.toLowerCase()
return filteredByDate.filter((d) =>
d.memberName.toLowerCase().includes(search)
)
}, [filteredByDate, debouncedSearch])
const todayDistributions = useMemo(
() => mockDistributions.filter((d) => isToday(new Date(d.recordedAt))),
[]
() => allDistributions.filter((d) => isToday(new Date(d.recordedAt))),
[allDistributions]
)
const todayGrams = useMemo(
() => todayDistributions.reduce((sum, d) => sum + d.amountGrams, 0),
@@ -127,17 +170,12 @@ export default function DistributionsPage() {
const table = useReactTable({
data: filteredData,
columns,
state: { sorting, globalFilter },
state: { sorting },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
globalFilterFn: (row, _columnId, filterValue) => {
const search = filterValue.toLowerCase()
return row.original.memberName.toLowerCase().includes(search)
},
initialState: {
pagination: { pageSize: 10 },
},
@@ -173,8 +211,8 @@ export default function DistributionsPage() {
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
placeholder={t("searchMember")}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
value={memberSearch}
onChange={(e) => setMemberSearch(e.target.value)}
className="pl-9"
/>
</div>
@@ -203,7 +241,10 @@ export default function DistributionsPage() {
</CardContent>
</Card>
{/* Table */}
{/* Table or loading skeleton */}
{isLoading ? (
<TableSkeleton rows={8} columns={6} />
) : (
<Card>
<CardContent className="p-0">
<Table>
@@ -257,8 +298,10 @@ export default function DistributionsPage() {
</Table>
</CardContent>
</Card>
)}
{/* Pagination */}
{!isLoading && (
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{table.getFilteredRowModel().rows.length} Einträge
@@ -282,6 +325,7 @@ export default function DistributionsPage() {
</Button>
</div>
</div>
)}
</div>
)
}
@@ -1,12 +1,13 @@
"use client"
import { useRouter } from "next/navigation"
import { useCreateBatchMutation, useStrainsQuery } from "@/services/stock"
import { zodResolver } from "@hookform/resolvers/zod"
import { useTranslations } from "next-intl"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { ArrowLeft } from "lucide-react"
import { ArrowLeft, Loader2 } from "lucide-react"
import { mockStrains } from "@/data/mock/stock"
@@ -15,6 +16,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Textarea } from "@/components/ui/textarea"
const batchSchema = z.object({
@@ -39,11 +41,18 @@ export default function NewBatchPage() {
const t = useTranslations("stock")
const router = useRouter()
// --- React Query hooks ---
const { data: strainsData, isLoading: strainsLoading } = useStrainsQuery()
const createMutation = useCreateBatchMutation()
// Fallback to mock strains
const strains = strainsData ?? mockStrains
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
formState: { errors },
} = useForm<BatchFormValues>({
resolver: zodResolver(batchSchema),
defaultValues: {
@@ -60,17 +69,36 @@ export default function NewBatchPage() {
function handleStrainChange(e: React.ChangeEvent<HTMLSelectElement>) {
const strainName = e.target.value
setValue("strainName", strainName)
const strain = mockStrains.find((s) => s.name === strainName)
const strain = strains.find((s) => s.name === strainName)
if (strain) {
setValue("thcPercent", strain.defaultThcPercent)
setValue("cbdPercent", strain.defaultCbdPercent)
}
}
function onSubmit(_data: BatchFormValues) {
// Mock: just show toast and redirect
function onSubmit(data: BatchFormValues) {
createMutation.mutate(
{
strainName: data.strainName,
thcPercent: data.thcPercent,
cbdPercent: data.cbdPercent,
totalGrams: data.amount,
supplier: data.supplier,
harvestDate: data.harvestDate,
notes: data.notes,
},
{
onSuccess: () => {
toast.success(t("created"))
router.push("/stock")
},
onError: () => {
// Fallback: still navigate (mock behavior)
toast.success(t("created"))
router.push("/stock")
},
}
)
}
return (
@@ -92,20 +120,24 @@ export default function NewBatchPage() {
{/* Strain Name */}
<div className="space-y-2">
<Label htmlFor="strainName">{t("strainName")}</Label>
{strainsLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<Select
id="strainName"
{...register("strainName")}
onChange={handleStrainChange}
>
<option value="">{t("strainName")}...</option>
{mockStrains.map((strain) => (
{strains.map((strain) => (
<option key={strain.id} value={strain.name}>
{strain.name}
</option>
))}
</Select>
)}
{errors.strainName && (
<p className="text-sm text-destructive">
<p className="text-destructive text-sm">
{errors.strainName.message}
</p>
)}
@@ -123,7 +155,7 @@ export default function NewBatchPage() {
{...register("amount")}
/>
{errors.amount && (
<p className="text-sm text-destructive">
<p className="text-destructive text-sm">
{errors.amount.message}
</p>
)}
@@ -143,7 +175,7 @@ export default function NewBatchPage() {
{...register("thcPercent")}
/>
{errors.thcPercent && (
<p className="text-sm text-destructive">
<p className="text-destructive text-sm">
{errors.thcPercent.message}
</p>
)}
@@ -160,7 +192,7 @@ export default function NewBatchPage() {
{...register("cbdPercent")}
/>
{errors.cbdPercent && (
<p className="text-sm text-destructive">
<p className="text-destructive text-sm">
{errors.cbdPercent.message}
</p>
)}
@@ -176,7 +208,7 @@ export default function NewBatchPage() {
{...register("supplier")}
/>
{errors.supplier && (
<p className="text-sm text-destructive">
<p className="text-destructive text-sm">
{errors.supplier.message}
</p>
)}
@@ -191,7 +223,7 @@ export default function NewBatchPage() {
{...register("harvestDate")}
/>
{errors.harvestDate && (
<p className="text-sm text-destructive">
<p className="text-destructive text-sm">
{errors.harvestDate.message}
</p>
)}
@@ -210,7 +242,10 @@ export default function NewBatchPage() {
{/* Submit */}
<div className="flex justify-end pt-4">
<Button type="submit" disabled={isSubmitting}>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("addBatch")}
</Button>
</div>
@@ -2,6 +2,11 @@
import { useMemo, useState } from "react"
import Link from "next/link"
import {
useBatchesQuery,
useRecallBatchMutation,
useStockSummaryQuery,
} from "@/services/stock"
import {
flexRender,
getCoreRowModel,
@@ -51,6 +56,7 @@ import {
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ChartSkeleton, TableSkeleton } from "@/components/ui/data-skeleton"
import {
Table,
TableBody,
@@ -64,13 +70,26 @@ type StatusFilter = "all" | "available" | "recalled"
export default function StockPage() {
const t = useTranslations("stock")
const [batches, setBatches] = useState<Batch[]>(mockBatches)
const [sorting, setSorting] = useState<SortingState>([
{ id: "receivedAt", desc: true },
])
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [recallTarget, setRecallTarget] = useState<Batch | null>(null)
// --- React Query hooks ---
const { data: batchesData, isLoading: batchesLoading } = useBatchesQuery({
status: statusFilter === "all" ? undefined : statusFilter.toUpperCase(),
size: 50,
})
const { data: summaryData } = useStockSummaryQuery()
const recallMutation = useRecallBatchMutation()
// Fallback to mock data
const batches: Batch[] = useMemo(
() => batchesData?.content ?? mockBatches,
[batchesData]
)
// Summary stats
const stats = useMemo(() => {
const available = batches.filter((b) => b.status === "AVAILABLE")
@@ -88,8 +107,20 @@ export default function StockPage() {
}
}, [batches])
// Chart data — aggregate by strain (only AVAILABLE)
// Chart data — aggregate by strain (only AVAILABLE), use summary endpoint or derive from batches
const chartData = useMemo(() => {
if (summaryData) {
// summaryData is BatchSummary[] with { strainName, availableGrams }
const byStrain: Record<string, number> = {}
summaryData.forEach((s) => {
byStrain[s.strainName] =
(byStrain[s.strainName] || 0) + s.availableGrams
})
return Object.entries(byStrain)
.map(([name, grams]) => ({ name, grams }))
.sort((a, b) => b.grams - a.grams)
}
// Derive from batch data
const byStrain: Record<string, number> = {}
batches
.filter((b) => b.status === "AVAILABLE")
@@ -100,9 +131,9 @@ export default function StockPage() {
return Object.entries(byStrain)
.map(([name, grams]) => ({ name, grams }))
.sort((a, b) => b.grams - a.grams)
}, [batches])
}, [batches, summaryData])
// Filtered data for table
// Filtered data for table (client-side filter as fallback)
const filteredData = useMemo(() => {
if (statusFilter === "available") {
return batches.filter((b) => b.status === "AVAILABLE")
@@ -113,16 +144,21 @@ export default function StockPage() {
return batches
}, [batches, statusFilter])
// Recall handler
// Recall handler with optimistic update
function handleRecall() {
if (!recallTarget) return
setBatches((prev) =>
prev.map((b) =>
b.id === recallTarget.id ? { ...b, status: "RECALLED" as const } : b
)
)
recallMutation.mutate(recallTarget.id, {
onSuccess: () => {
toast.success(t("recallSuccess"))
setRecallTarget(null)
},
onError: () => {
// Fallback: show success anyway (mock behavior)
toast.success(t("recallSuccess"))
setRecallTarget(null)
},
})
}
// Status badge
@@ -251,10 +287,10 @@ export default function StockPage() {
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Package className="h-8 w-8 text-muted-foreground" />
<Package className="text-muted-foreground h-8 w-8" />
<div>
<p className="text-2xl font-bold">{stats.totalBatches}</p>
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
{t("totalBatches")}
</p>
</div>
@@ -268,7 +304,7 @@ export default function StockPage() {
{stats.availableGrams}
{t("grams")}
</p>
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
{t("availableStock")}
</p>
</div>
@@ -276,10 +312,10 @@ export default function StockPage() {
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<AlertTriangle className="h-8 w-8 text-destructive" />
<AlertTriangle className="text-destructive h-8 w-8" />
<div>
<p className="text-2xl font-bold">{stats.recalledCount}</p>
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
{t("recalledBatches")}
</p>
</div>
@@ -290,7 +326,7 @@ export default function StockPage() {
<Leaf className="h-8 w-8 text-green-600" />
<div>
<p className="text-2xl font-bold">{stats.strainCount}</p>
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
{t("strainCount")}
</p>
</div>
@@ -299,6 +335,9 @@ export default function StockPage() {
</div>
{/* Stock Chart */}
{batchesLoading ? (
<ChartSkeleton />
) : (
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
@@ -317,7 +356,9 @@ export default function StockPage() {
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" unit="g" />
<YAxis type="category" dataKey="name" width={95} />
<Tooltip formatter={(value) => [`${value}g`, t("available")]} />
<Tooltip
formatter={(value) => [`${value}g`, t("available")]}
/>
<Bar dataKey="grams" radius={[0, 4, 4, 0]}>
{chartData.map((entry) => (
<Cell key={entry.name} fill={getBarColor(entry.grams)} />
@@ -328,8 +369,12 @@ export default function StockPage() {
</div>
</CardContent>
</Card>
)}
{/* Batch Table */}
{batchesLoading ? (
<TableSkeleton rows={8} columns={7} />
) : (
<Card>
<CardHeader className="pb-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
@@ -415,7 +460,7 @@ export default function StockPage() {
<p className="font-medium">{batch.strainName}</p>
<div className="flex items-center gap-2">
<StatusBadge status={batch.status} />
<span className="text-sm text-muted-foreground">
<span className="text-muted-foreground text-sm">
{batch.availableGrams}
{t("grams")}
</span>
@@ -435,6 +480,7 @@ export default function StockPage() {
</div>
</CardContent>
</Card>
)}
{/* Recall Confirmation Dialog */}
<AlertDialog
@@ -447,23 +493,20 @@ export default function StockPage() {
<AlertDialogDescription>
{t("recallConfirm")}
{recallTarget && (
<span className="mt-2 block font-medium text-foreground">
<span className="text-foreground mt-2 block font-medium">
{recallTarget.strainName} ({recallTarget.id}) {" "}
{recallTarget.availableGrams}
{t("grams")}
{recallTarget.availableGrams}g
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("filterAll") && "Abbrechen"}
</AlertDialogCancel>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleRecall}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("confirmRecall")}
{t("recall")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>