Files
star-erp/resources/js/Components/Product/ProductDialog.tsx
sky121113 bb78a432f5
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m1s
fix(product): 修復條碼掃描自動送出問題並優化手動輸入體驗
2026-02-02 09:06:06 +08:00

306 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect } from "react";
import { Wand2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { useForm } from "@inertiajs/react";
import { toast } from "sonner";
import type { Product, Category } from "@/Pages/Product/Index";
import type { Unit } from "@/Components/Unit/UnitManagerDialog";
interface ProductDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
product: Product | null;
categories: Category[];
units: Unit[];
onSave?: (product: any) => void; // Legacy prop, can be removed if fully switching to Inertia submit within dialog
}
export default function ProductDialog({
open,
onOpenChange,
product,
categories,
units,
}: ProductDialogProps) {
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
code: "",
barcode: "",
name: "",
category_id: "",
brand: "",
specification: "",
base_unit_id: "",
large_unit_id: "",
conversion_rate: "",
purchase_unit_id: "",
});
useEffect(() => {
if (open) {
clearErrors();
if (product) {
setData({
code: product.code,
barcode: product.barcode || "",
name: product.name,
category_id: product.categoryId.toString(),
brand: product.brand || "",
specification: product.specification || "",
base_unit_id: product.baseUnitId?.toString() || "",
large_unit_id: product.largeUnitId?.toString() || "",
conversion_rate: product.conversionRate ? product.conversionRate.toString() : "",
purchase_unit_id: product.purchaseUnitId?.toString() || "",
});
} else {
reset();
// Set default category if available
if (categories.length > 0) {
setData("category_id", categories[0].id.toString());
}
}
}
}, [open, product, categories]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (product) {
put(route("products.update", product.id), {
onSuccess: () => {
toast.success("商品已更新");
onOpenChange(false);
reset();
},
onError: () => {
toast.error("更新失敗,請檢查輸入資料");
}
});
} else {
post(route("products.store"), {
onSuccess: () => {
toast.success("商品已新增");
onOpenChange(false);
reset();
},
onError: () => {
toast.error("新增失敗,請檢查輸入資料");
}
});
}
};
const generateRandomBarcode = () => {
const randomDigits = Math.floor(Math.random() * 9000000000) + 1000000000;
setData("barcode", randomDigits.toString());
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{product ? "編輯商品" : "新增商品"}</DialogTitle>
<DialogDescription>
{product ? "修改商品資料" : "建立新的商品資料"}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6 py-4">
{/* 基本資訊區塊 */}
<div className="space-y-4">
<h3 className="text-lg font-medium border-b pb-2"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="category_id">
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={data.category_id}
onValueChange={(value) => setData("category_id", value)}
options={categories.map((c) => ({ label: c.name, value: c.id.toString() }))}
placeholder="選擇分類"
searchPlaceholder="搜尋分類..."
className={errors.category_id ? "border-red-500" : ""}
/>
{errors.category_id && <p className="text-sm text-red-500">{errors.category_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="name">
<span className="text-red-500">*</span>
</Label>
<Input
id="name"
value={data.name}
onChange={(e) => setData("name", e.target.value)}
placeholder="例:法國麵粉"
className={errors.name ? "border-red-500" : ""}
/>
{errors.name && <p className="text-sm text-red-500">{errors.name}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="code">
<span className="text-red-500">*</span>
</Label>
<Input
id="code"
value={data.code}
onChange={(e) => setData("code", e.target.value)}
placeholder="例A1 (最多2碼)"
maxLength={2}
className={errors.code ? "border-red-500" : ""}
/>
{errors.code && <p className="text-sm text-red-500">{errors.code}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="barcode">
<span className="text-red-500">*</span>
</Label>
<div className="flex gap-2">
<Input
id="barcode"
value={data.barcode}
onChange={(e) => setData("barcode", e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
// 掃描後自動跳轉到下一個欄位(品牌)
document.getElementById('brand')?.focus();
}
}}
placeholder="輸入條碼或自動生成"
className={`flex-1 ${errors.barcode ? "border-red-500" : ""}`}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={generateRandomBarcode}
title="隨機生成條碼"
className="shrink-0 button-outlined-primary"
>
<Wand2 className="h-4 w-4" />
</Button>
</div>
{errors.barcode && <p className="text-sm text-red-500">{errors.barcode}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="brand"></Label>
<Input
id="brand"
value={data.brand}
onChange={(e) => setData("brand", e.target.value)}
placeholder="例:鳥越製粉"
/>
{errors.brand && <p className="text-sm text-red-500">{errors.brand}</p>}
</div>
<div className="space-y-2 col-span-2">
<Label htmlFor="specification"></Label>
<Textarea
id="specification"
value={data.specification}
onChange={(e) => setData("specification", e.target.value)}
placeholder="例25kg/袋灰分0.45%"
className="resize-none"
/>
{errors.specification && <p className="text-sm text-red-500">{errors.specification}</p>}
</div>
</div>
</div>
{/* 單位設定區塊 */}
<div className="space-y-4">
<h3 className="text-lg font-medium border-b pb-2"></h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="base_unit_id">
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={data.base_unit_id}
onValueChange={(value) => setData("base_unit_id", value)}
options={units.map((u) => ({ label: u.name, value: u.id.toString() }))}
placeholder="選擇單位"
searchPlaceholder="搜尋單位..."
className={errors.base_unit_id ? "border-red-500" : ""}
/>
{errors.base_unit_id && <p className="text-sm text-red-500">{errors.base_unit_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="large_unit_id"></Label>
<SearchableSelect
value={data.large_unit_id}
onValueChange={(value) => setData("large_unit_id", value)}
options={[
{ label: "無", value: "none" },
...units.map((u) => ({ label: u.name, value: u.id.toString() }))
]}
placeholder="無"
searchPlaceholder="搜尋單位..."
className={errors.large_unit_id ? "border-red-500" : ""}
/>
{errors.large_unit_id && <p className="text-sm text-red-500">{errors.large_unit_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="conversion_rate">
{data.large_unit_id && <span className="text-red-500">*</span>}
</Label>
<Input
id="conversion_rate"
type="number"
step="0.0001"
value={data.conversion_rate}
onChange={(e) => setData("conversion_rate", e.target.value)}
placeholder={data.large_unit_id && data.base_unit_id ? `1 ${units.find(u => u.id.toString() === data.large_unit_id)?.name} = ? ${units.find(u => u.id.toString() === data.base_unit_id)?.name}` : ""}
disabled={!data.large_unit_id}
/>
{errors.conversion_rate && <p className="text-sm text-red-500">{errors.conversion_rate}</p>}
</div>
</div>
{data.large_unit_id && data.base_unit_id && data.conversion_rate && (
<div className="bg-blue-50 p-3 rounded text-sm text-blue-700">
1 {units.find(u => u.id.toString() === data.large_unit_id)?.name} = {data.conversion_rate} {units.find(u => u.id.toString() === data.base_unit_id)?.name}
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="button-outlined-primary"
>
</Button>
<Button type="submit" className="button-filled-primary" disabled={processing}>
{processing ? "儲存... " : (product ? "儲存變更" : "新增")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog >
);
}