2025-12-30 15:03:19 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 新增安全庫存對話框(兩步驟)
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { useState } from "react";
|
|
|
|
|
|
import { Search, ChevronRight, ChevronLeft } from "lucide-react";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
DialogContent,
|
|
|
|
|
|
DialogHeader,
|
|
|
|
|
|
DialogTitle,
|
|
|
|
|
|
DialogFooter,
|
|
|
|
|
|
DialogDescription,
|
|
|
|
|
|
} from "@/Components/ui/dialog";
|
|
|
|
|
|
import { Button } from "@/Components/ui/button";
|
|
|
|
|
|
import { Input } from "@/Components/ui/input";
|
|
|
|
|
|
import { Label } from "@/Components/ui/label";
|
|
|
|
|
|
import { Checkbox } from "@/Components/ui/checkbox";
|
|
|
|
|
|
import { SafetyStockSetting, Product } from "@/types/warehouse";
|
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
|
import { Badge } from "@/Components/ui/badge";
|
|
|
|
|
|
|
|
|
|
|
|
interface AddSafetyStockDialogProps {
|
|
|
|
|
|
open: boolean;
|
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
|
warehouseId: string;
|
|
|
|
|
|
existingSettings: SafetyStockSetting[];
|
|
|
|
|
|
availableProducts: Product[];
|
|
|
|
|
|
onAdd: (settings: SafetyStockSetting[]) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function AddSafetyStockDialog({
|
|
|
|
|
|
open,
|
|
|
|
|
|
onOpenChange,
|
|
|
|
|
|
warehouseId,
|
|
|
|
|
|
existingSettings,
|
|
|
|
|
|
availableProducts,
|
|
|
|
|
|
onAdd,
|
|
|
|
|
|
}: AddSafetyStockDialogProps) {
|
|
|
|
|
|
const [step, setStep] = useState<1 | 2>(1);
|
|
|
|
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
|
|
|
|
const [selectedProducts, setSelectedProducts] = useState<Set<string>>(new Set());
|
|
|
|
|
|
const [productQuantities, setProductQuantities] = useState<Map<string, number>>(new Map());
|
|
|
|
|
|
|
|
|
|
|
|
// 重置對話框
|
|
|
|
|
|
const resetDialog = () => {
|
|
|
|
|
|
setStep(1);
|
|
|
|
|
|
setSearchTerm("");
|
|
|
|
|
|
setSelectedProducts(new Set());
|
|
|
|
|
|
setProductQuantities(new Map());
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 關閉對話框
|
|
|
|
|
|
const handleClose = () => {
|
|
|
|
|
|
resetDialog();
|
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 已設定的商品 ID
|
|
|
|
|
|
const existingProductIds = new Set(existingSettings.map((s) => s.productId));
|
|
|
|
|
|
|
|
|
|
|
|
// 可選擇的商品(排除已設定的)
|
|
|
|
|
|
const selectableProducts = availableProducts.filter(
|
|
|
|
|
|
(p) => !existingProductIds.has(p.id)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 篩選後的商品
|
|
|
|
|
|
const filteredProducts = selectableProducts.filter(
|
|
|
|
|
|
(p) =>
|
|
|
|
|
|
p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
|
|
|
|
p.type.includes(searchTerm)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 切換商品選擇
|
|
|
|
|
|
const toggleProduct = (productId: string) => {
|
|
|
|
|
|
const newSet = new Set(selectedProducts);
|
|
|
|
|
|
if (newSet.has(productId)) {
|
|
|
|
|
|
newSet.delete(productId);
|
|
|
|
|
|
// 同時移除數量設定
|
|
|
|
|
|
const newQuantities = new Map(productQuantities);
|
|
|
|
|
|
newQuantities.delete(productId);
|
|
|
|
|
|
setProductQuantities(newQuantities);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
newSet.add(productId);
|
|
|
|
|
|
// 預設數量
|
|
|
|
|
|
const newQuantities = new Map(productQuantities);
|
|
|
|
|
|
if (!newQuantities.has(productId)) {
|
|
|
|
|
|
newQuantities.set(productId, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
setProductQuantities(newQuantities);
|
|
|
|
|
|
}
|
|
|
|
|
|
setSelectedProducts(newSet);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 更新商品安全庫存量
|
|
|
|
|
|
const updateQuantity = (productId: string, value: number) => {
|
|
|
|
|
|
const newQuantities = new Map(productQuantities);
|
2026-01-26 14:59:24 +08:00
|
|
|
|
newQuantities.set(productId, value); // 允許為 0
|
2025-12-30 15:03:19 +08:00
|
|
|
|
setProductQuantities(newQuantities);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 前往步驟 2
|
|
|
|
|
|
const goToStep2 = () => {
|
|
|
|
|
|
if (selectedProducts.size === 0) {
|
|
|
|
|
|
toast.error("請至少選擇一個商品");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setStep(2);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 提交
|
|
|
|
|
|
const handleSubmit = () => {
|
|
|
|
|
|
// 驗證所有商品都已輸入數量
|
|
|
|
|
|
const missingQuantity = Array.from(selectedProducts).some(
|
|
|
|
|
|
(productId) => !productQuantities.has(productId) || (productQuantities.get(productId) ?? -1) < 0
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (missingQuantity) {
|
|
|
|
|
|
toast.error("請為所有商品設定安全庫存量");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 創建安全庫存設定
|
|
|
|
|
|
const newSettings: SafetyStockSetting[] = Array.from(selectedProducts).map((productId) => {
|
|
|
|
|
|
const product = availableProducts.find((p) => p.id === productId)!;
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: `ss-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
|
|
|
|
warehouseId,
|
|
|
|
|
|
productId,
|
|
|
|
|
|
productName: product.name,
|
|
|
|
|
|
productType: product.type,
|
|
|
|
|
|
safetyStock: productQuantities.get(productId) || 0,
|
|
|
|
|
|
createdAt: new Date().toISOString(),
|
|
|
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onAdd(newSettings);
|
|
|
|
|
|
// toast.success(`成功新增 ${newSettings.length} 項安全庫存設定`); // 父組件已處理
|
|
|
|
|
|
handleClose();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
|
|
|
|
<DialogContent className="max-w-2xl">
|
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle>
|
|
|
|
|
|
新增安全庫存 - 步驟 {step}/2
|
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
|
請選擇商品並設定安全庫存量。
|
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
{step === 1 ? (
|
|
|
|
|
|
// 步驟 1:選擇商品
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label>選擇商品(可多選)</Label>
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
placeholder="搜尋商品名稱或類型..."
|
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
|
|
|
|
className="pl-10 button-outlined-primary"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="border rounded-lg max-h-96 overflow-y-auto">
|
|
|
|
|
|
{filteredProducts.length === 0 ? (
|
|
|
|
|
|
<div className="p-8 text-center text-gray-400">
|
|
|
|
|
|
{selectableProducts.length === 0
|
|
|
|
|
|
? "所有商品都已設定安全庫存"
|
|
|
|
|
|
: "無符合條件的商品"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="divide-y">
|
|
|
|
|
|
{filteredProducts.map((product) => {
|
|
|
|
|
|
const isSelected = selectedProducts.has(product.id);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={product.id}
|
|
|
|
|
|
className={`p-4 flex items-center gap-3 hover:bg-gray-50 cursor-pointer transition-colors ${isSelected ? "bg-blue-50" : ""
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => toggleProduct(product.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={isSelected}
|
|
|
|
|
|
onCheckedChange={() => toggleProduct(product.id)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<div className="font-medium">{product.name}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Badge variant="outline">{product.type}</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="text-sm text-gray-600">
|
|
|
|
|
|
已選擇 {selectedProducts.size} 項商品
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
// 步驟 2:設定安全庫存量
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
<p className="text-sm text-gray-600">
|
|
|
|
|
|
為每個商品設定安全庫存量(共 {selectedProducts.size} 項)
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="border rounded-lg max-h-96 overflow-y-auto">
|
|
|
|
|
|
<div className="divide-y">
|
|
|
|
|
|
{Array.from(selectedProducts).map((productId) => {
|
|
|
|
|
|
const product = availableProducts.find((p) => p.id === productId)!;
|
|
|
|
|
|
const quantity = productQuantities.get(productId) || 0;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={productId} className="p-4 space-y-2">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="font-medium">{product.name}</span>
|
|
|
|
|
|
<Badge variant="outline">{product.type}</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<div className="flex-1 flex items-center gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
2026-02-05 11:45:08 +08:00
|
|
|
|
step="any"
|
2025-12-30 15:03:19 +08:00
|
|
|
|
value={quantity || ""}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
updateQuantity(productId, parseFloat(e.target.value) || 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
placeholder="請輸入數量"
|
|
|
|
|
|
className="flex-1 button-outlined-primary"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-sm text-gray-500 w-12">{product.unit || '個'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
|
{step === 1 ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Button variant="outline" onClick={handleClose} className="button-outlined-primary">
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
onClick={goToStep2}
|
|
|
|
|
|
disabled={selectedProducts.size === 0}
|
|
|
|
|
|
className="button-filled-primary"
|
|
|
|
|
|
>
|
|
|
|
|
|
下一步
|
|
|
|
|
|
<ChevronRight className="ml-2 h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Button variant="outline" onClick={() => setStep(1)} className="button-outlined-primary">
|
|
|
|
|
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
|
|
|
|
|
上一步
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button onClick={handleSubmit} className="button-filled-primary">
|
|
|
|
|
|
確認新增
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|