first commit
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* 新增安全庫存對話框(兩步驟)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Search, ChevronRight, ChevronLeft } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "../ui/dialog";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import { SafetyStockSetting, Product } from "../../types/warehouse";
|
||||
import { generateId } from "../../utils/format";
|
||||
import { toast } from "sonner@2.0.3";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
interface AddSafetyStockDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
warehouseId: string;
|
||||
existingSettings: SafetyStockSetting[];
|
||||
availableProducts: Product[];
|
||||
onAdd: (settings: SafetyStockSetting[]) => void;
|
||||
}
|
||||
|
||||
interface ProductWithQuantity {
|
||||
product: Product;
|
||||
safetyStock: number;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
setSelectedProducts(newSet);
|
||||
};
|
||||
|
||||
// 更新商品安全庫存量
|
||||
const updateQuantity = (productId: string, value: number) => {
|
||||
const newQuantities = new Map(productQuantities);
|
||||
newQuantities.set(productId, value);
|
||||
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) || 0) <= 0
|
||||
);
|
||||
|
||||
if (missingQuantity) {
|
||||
toast.error("請為所有商品設定安全庫存量");
|
||||
return;
|
||||
}
|
||||
|
||||
// 創建安全庫存設定
|
||||
const newSettings: SafetyStockSetting[] = Array.from(selectedProducts).map((productId) => {
|
||||
const product = availableProducts.find((p) => p.id === productId)!;
|
||||
return {
|
||||
id: generateId(),
|
||||
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"
|
||||
/>
|
||||
</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">
|
||||
<Label className="w-24">安全庫存</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={quantity || ""}
|
||||
onChange={(e) =>
|
||||
updateQuantity(productId, parseInt(e.target.value) || 0)
|
||||
}
|
||||
placeholder="請輸入數量"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{step === 1 ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
取消
|
||||
</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)}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} className="button-filled-primary">
|
||||
確認新增
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 編輯安全庫存對話框
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "../ui/dialog";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { SafetyStockSetting } from "../../types/warehouse";
|
||||
import { toast } from "sonner@2.0.3";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
interface EditSafetyStockDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
setting: SafetyStockSetting;
|
||||
onSave: (setting: SafetyStockSetting) => void;
|
||||
}
|
||||
|
||||
export default function EditSafetyStockDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
setting,
|
||||
onSave,
|
||||
}: EditSafetyStockDialogProps) {
|
||||
const [safetyStock, setSafetyStock] = useState(setting.safetyStock);
|
||||
|
||||
useEffect(() => {
|
||||
setSafetyStock(setting.safetyStock);
|
||||
}, [setting]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (safetyStock <= 0) {
|
||||
toast.error("安全庫存量必須大於 0");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSetting: SafetyStockSetting = {
|
||||
...setting,
|
||||
safetyStock,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
onSave(updatedSetting);
|
||||
toast.success("安全庫存設定已更新");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>編輯安全庫存</DialogTitle>
|
||||
<DialogDescription>請輸入新的安全庫存量。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>商品名稱</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{setting.productName}</span>
|
||||
<Badge variant="outline">{setting.productType}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="safetyStock">
|
||||
安全庫存量 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="safetyStock"
|
||||
type="number"
|
||||
min="1"
|
||||
value={safetyStock}
|
||||
onChange={(e) => setSafetyStock(parseInt(e.target.value) || 0)}
|
||||
placeholder="請輸入安全庫存量"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} className="button-filled-primary">
|
||||
儲存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 安全庫存列表組件
|
||||
*/
|
||||
|
||||
import { Edit, Trash2, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table";
|
||||
import { Button } from "../ui/button";
|
||||
import { SafetyStockSetting, WarehouseInventory, SafetyStockStatus } from "../../types/warehouse";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
interface SafetyStockListProps {
|
||||
settings: SafetyStockSetting[];
|
||||
inventories: WarehouseInventory[];
|
||||
onEdit: (setting: SafetyStockSetting) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
// 計算安全庫存狀態
|
||||
function getSafetyStockStatus(
|
||||
currentStock: number,
|
||||
safetyStock: number
|
||||
): SafetyStockStatus {
|
||||
const ratio = currentStock / safetyStock;
|
||||
if (ratio >= 1.2) return "正常";
|
||||
if (ratio >= 1.0) return "接近";
|
||||
return "低於";
|
||||
}
|
||||
|
||||
// 獲取狀態徽章
|
||||
function getStatusBadge(status: SafetyStockStatus) {
|
||||
switch (status) {
|
||||
case "正常":
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-700 border-green-300">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
正常
|
||||
</Badge>
|
||||
);
|
||||
case "接近":
|
||||
return (
|
||||
<Badge className="bg-yellow-100 text-yellow-700 border-yellow-300">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
接近
|
||||
</Badge>
|
||||
);
|
||||
case "低於":
|
||||
return (
|
||||
<Badge className="bg-red-100 text-red-700 border-red-300">
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
低於
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function SafetyStockList({
|
||||
settings,
|
||||
inventories,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: SafetyStockListProps) {
|
||||
if (settings.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-12 text-center text-gray-400">
|
||||
<p>尚未設定任何安全庫存</p>
|
||||
<p className="text-sm mt-1">點擊「新增安全庫存」開始設定</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 計算每個商品的目前總庫存
|
||||
const getCurrentStock = (productId: string): number => {
|
||||
return inventories
|
||||
.filter((inv) => inv.productId === productId)
|
||||
.reduce((sum, inv) => sum + inv.quantity, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[5%]">#</TableHead>
|
||||
<TableHead className="w-[25%]">商品名稱</TableHead>
|
||||
<TableHead className="w-[12%]">商品類型</TableHead>
|
||||
<TableHead className="w-[12%]">目前庫存</TableHead>
|
||||
<TableHead className="w-[12%]">安全庫存</TableHead>
|
||||
<TableHead className="w-[15%]">狀態</TableHead>
|
||||
<TableHead className="w-[12%] text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{settings.map((setting, index) => {
|
||||
const currentStock = getCurrentStock(setting.productId);
|
||||
const status = getSafetyStockStatus(currentStock, setting.safetyStock);
|
||||
const isLowStock = status === "低於";
|
||||
|
||||
return (
|
||||
<TableRow key={setting.id} className={isLowStock ? "bg-red-50" : ""}>
|
||||
<TableCell className="text-grey-2">{index + 1}</TableCell>
|
||||
<TableCell className="font-medium">{setting.productName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{setting.productType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={isLowStock ? "text-red-600 font-medium" : ""}>
|
||||
{currentStock}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{setting.safetyStock}</TableCell>
|
||||
<TableCell>{getStatusBadge(status)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(setting)}
|
||||
className="hover:bg-primary/10 hover:text-primary"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
編輯
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(setting.id)}
|
||||
className="hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user