be63a84fe8
- 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
258 lines
7.9 KiB
TypeScript
258 lines
7.9 KiB
TypeScript
"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, Loader2 } from "lucide-react"
|
|
|
|
import { mockStrains } from "@/data/mock/stock"
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
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({
|
|
strainName: z.string().min(1, "Strain name is required"),
|
|
amount: z.coerce.number().positive("Amount must be greater than 0"),
|
|
thcPercent: z.coerce
|
|
.number()
|
|
.min(0, "THC must be at least 0%")
|
|
.max(30, "THC cannot exceed 30%"),
|
|
cbdPercent: z.coerce
|
|
.number()
|
|
.min(0, "CBD must be at least 0%")
|
|
.max(30, "CBD cannot exceed 30%"),
|
|
supplier: z.string().min(1, "Supplier is required"),
|
|
harvestDate: z.string().min(1, "Harvest date is required"),
|
|
notes: z.string().optional(),
|
|
})
|
|
|
|
type BatchFormValues = z.infer<typeof batchSchema>
|
|
|
|
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 },
|
|
} = useForm<BatchFormValues>({
|
|
resolver: zodResolver(batchSchema),
|
|
defaultValues: {
|
|
strainName: "",
|
|
amount: undefined,
|
|
thcPercent: undefined,
|
|
cbdPercent: undefined,
|
|
supplier: "",
|
|
harvestDate: "",
|
|
notes: "",
|
|
},
|
|
})
|
|
|
|
function handleStrainChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
|
const strainName = e.target.value
|
|
setValue("strainName", strainName)
|
|
const strain = strains.find((s) => s.name === strainName)
|
|
if (strain) {
|
|
setValue("thcPercent", strain.defaultThcPercent)
|
|
setValue("cbdPercent", strain.defaultCbdPercent)
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<div className="mx-auto max-w-2xl space-y-6 p-4 md:p-6">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-4">
|
|
<Button variant="ghost" size="sm" onClick={() => router.push("/stock")}>
|
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
|
{t("title")}
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t("addBatch")}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
{/* 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>
|
|
{strains.map((strain) => (
|
|
<option key={strain.id} value={strain.name}>
|
|
{strain.name}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
)}
|
|
{errors.strainName && (
|
|
<p className="text-destructive text-sm">
|
|
{errors.strainName.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Amount */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="amount">{t("amount")}</Label>
|
|
<Input
|
|
id="amount"
|
|
type="number"
|
|
step="1"
|
|
min="1"
|
|
placeholder="500"
|
|
{...register("amount")}
|
|
/>
|
|
{errors.amount && (
|
|
<p className="text-destructive text-sm">
|
|
{errors.amount.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* THC and CBD side by side */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="thcPercent">{t("thc")}</Label>
|
|
<Input
|
|
id="thcPercent"
|
|
type="number"
|
|
step="0.1"
|
|
min="0"
|
|
max="30"
|
|
placeholder="20.0"
|
|
{...register("thcPercent")}
|
|
/>
|
|
{errors.thcPercent && (
|
|
<p className="text-destructive text-sm">
|
|
{errors.thcPercent.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="cbdPercent">{t("cbd")}</Label>
|
|
<Input
|
|
id="cbdPercent"
|
|
type="number"
|
|
step="0.1"
|
|
min="0"
|
|
max="30"
|
|
placeholder="2.0"
|
|
{...register("cbdPercent")}
|
|
/>
|
|
{errors.cbdPercent && (
|
|
<p className="text-destructive text-sm">
|
|
{errors.cbdPercent.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Supplier */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="supplier">{t("supplier")}</Label>
|
|
<Input
|
|
id="supplier"
|
|
placeholder="GreenGrow GmbH"
|
|
{...register("supplier")}
|
|
/>
|
|
{errors.supplier && (
|
|
<p className="text-destructive text-sm">
|
|
{errors.supplier.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Harvest Date */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="harvestDate">{t("harvestDate")}</Label>
|
|
<Input
|
|
id="harvestDate"
|
|
type="date"
|
|
{...register("harvestDate")}
|
|
/>
|
|
{errors.harvestDate && (
|
|
<p className="text-destructive text-sm">
|
|
{errors.harvestDate.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="notes">{t("notes")}</Label>
|
|
<Textarea
|
|
id="notes"
|
|
placeholder={t("notesPlaceholder")}
|
|
rows={3}
|
|
{...register("notes")}
|
|
/>
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<div className="flex justify-end pt-4">
|
|
<Button type="submit" disabled={createMutation.isPending}>
|
|
{createMutation.isPending && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
)}
|
|
{t("addBatch")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|