first commit

This commit is contained in:
2025-12-30 15:03:19 +08:00
commit c735c36009
902 changed files with 83591 additions and 0 deletions

View File

@@ -0,0 +1,283 @@
/**
* 新增安全庫存對話框(兩步驟)
*/
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);
newQuantities.set(productId, value); // Allow 0
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"
step="1"
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>
);
}

View File

@@ -0,0 +1,96 @@
/**
* 編輯安全庫存對話框
*/
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { SafetyStockSetting } from "@/types/warehouse";
interface EditSafetyStockDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
setting: SafetyStockSetting;
onSave: (updatedSetting: SafetyStockSetting) => void;
}
export default function EditSafetyStockDialog({
open,
onOpenChange,
setting,
onSave,
}: EditSafetyStockDialogProps) {
const [safetyStock, setSafetyStock] = useState<number>(setting.safetyStock);
useEffect(() => {
setSafetyStock(setting.safetyStock);
}, [setting]);
const handleSave = () => {
onSave({
...setting,
safetyStock,
updatedAt: new Date().toISOString(),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
<span className="font-semibold text-gray-900">{setting.productName}</span>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex flex-col gap-2">
<Label htmlFor="edit-safety" className="text-sm font-medium">
({setting.unit || '個'})
</Label>
<Input
id="edit-safety"
type="number"
min="0"
step="1"
value={safetyStock}
onChange={(e) => setSafetyStock(parseFloat(e.target.value) || 0)}
className="button-outlined-primary"
autoFocus
/>
<p className="text-xs text-gray-500">
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
>
</Button>
<Button
type="button"
onClick={handleSave}
className="button-filled-primary"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,153 @@
/**
* 安全庫存設定列表
*/
import { Trash2, Pencil, CheckCircle, Package, AlertTriangle } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { SafetyStockSetting, WarehouseInventory } from "@/types/warehouse";
import { calculateProductTotalStock, getSafetyStockStatus } from "@/utils/inventory";
interface SafetyStockListProps {
settings: SafetyStockSetting[];
inventories: WarehouseInventory[];
onEdit: (setting: SafetyStockSetting) => void;
onDelete: (id: string) => void;
}
export default function SafetyStockList({
settings,
inventories,
onEdit,
onDelete,
}: SafetyStockListProps) {
if (settings.length === 0) {
return (
<div className="bg-white rounded-lg border border-dashed p-12 text-center">
<div className="mx-auto w-12 h-12 bg-gray-50 rounded-full flex items-center justify-center mb-4">
<Package className="h-6 w-6 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900"></h3>
<p className="text-gray-500 mt-1 max-w-xs mx-auto">
</p>
</div>
);
}
// 按產品類型與名稱排序
const sortedSettings = [...settings].sort((a, b) => {
if (a.productType !== b.productType) {
return a.productType.localeCompare(b.productType, "zh-TW");
}
return a.productName.localeCompare(b.productName, "zh-TW");
});
// 獲取狀態徽章 (與 InventoryTable 保持一致)
const getStatusBadge = (quantity: number, safetyStock: number) => {
const status = getSafetyStockStatus(quantity, safetyStock);
switch (status) {
case "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300 hover:bg-green-100">
<CheckCircle className="mr-1 h-3 w-3" />
</Badge>
);
case "接近": // 數量 <= 安全庫存 * 1.2
return (
<Badge className="bg-yellow-100 text-yellow-700 border-yellow-300 hover:bg-yellow-100">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
case "低於": // 數量 < 安全庫存
return (
<Badge className="bg-orange-100 text-orange-700 border-orange-300 hover:bg-orange-100">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
default:
return null;
}
};
return (
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50/50">
<TableHead className="w-[50px]">#</TableHead>
<TableHead className="w-[250px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[150px] text-right"></TableHead>
<TableHead className="w-[150px] text-right"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedSettings.map((setting, index) => {
const currentStock = calculateProductTotalStock(inventories, setting.productId);
return (
<TableRow key={setting.id}>
<TableCell className="text-gray-500 font-medium">
{index + 1}
</TableCell>
<TableCell className="font-medium text-gray-900">
{setting.productName}
</TableCell>
<TableCell>
<Badge variant="outline" className="font-normal">
{setting.productType}
</Badge>
</TableCell>
<TableCell className="text-right font-semibold">
{setting.safetyStock} {setting.unit || '個'}
</TableCell>
<TableCell className="text-right">
<span className={currentStock < setting.safetyStock ? "text-orange-600 font-bold" : "text-gray-700"}>
{currentStock} {setting.unit || '個'}
</span>
</TableCell>
<TableCell>
{getStatusBadge(currentStock, setting.safetyStock)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onEdit(setting)}
className="button-outlined-primary"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onDelete(setting.id)}
className="button-outlined-error"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}