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,224 @@
import { useState, useEffect } from "react";
import { useForm } from "@inertiajs/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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { WarehouseInventory } from "@/types/warehouse";
import { toast } from "sonner";
import { Minus, Plus, Equal } from "lucide-react";
interface InventoryAdjustmentDialogProps {
warehouseId: string;
item: WarehouseInventory | null;
isOpen: boolean;
onClose: () => void;
}
type Operation = "add" | "subtract" | "set";
export default function InventoryAdjustmentDialog({
warehouseId,
item,
isOpen,
onClose,
}: InventoryAdjustmentDialogProps) {
const [operation, setOperation] = useState<Operation>("add");
const { data, setData, put, processing, reset, errors } = useForm({
quantity: 0,
reason: "盤點調整",
notes: "",
operation: "add" as Operation,
type: "adjustment", // 預設類型
});
// 重新開放時重置
useEffect(() => {
if (isOpen) {
reset();
setOperation("add");
}
}, [isOpen]);
useEffect(() => {
setData("operation", operation);
}, [operation]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!item) return;
put(route("warehouses.inventory.update", {
warehouse: warehouseId,
product: item.productId // 這裡後端接收 product (或可用 inventory ID 擴充)
}), {
onSuccess: () => {
toast.success("庫存調整成功");
onClose();
},
onError: () => {
toast.error("調整失敗,請檢查欄位資料");
}
});
};
if (!item) return null;
// 計算剩餘庫存預覽
const getResultQuantity = () => {
const inputQty = Number(data.quantity) || 0;
switch (operation) {
case "add": return item.quantity + inputQty;
case "subtract": return Math.max(0, item.quantity - inputQty);
case "set": return inputQty;
default: return item.quantity;
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>調</DialogTitle>
<DialogDescription>
調{item.productName} (: {item.batchNumber || "無"})
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6 py-4">
{/* 現有庫存 */}
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border">
<span className="text-gray-600"></span>
<span className="font-bold text-lg">{item.quantity}</span>
</div>
{/* 調整方式 */}
<div className="space-y-3">
<Label>調</Label>
<div className="grid grid-cols-3 gap-2">
<Button
type="button"
variant={operation === "add" ? "default" : "outline"}
className={`flex flex-col gap-1 h-auto py-2 ${operation === "add" ? "bg-primary text-white" : ""}`}
onClick={() => setOperation("add")}
>
<Plus className="h-4 w-4" />
<span className="text-xs"></span>
</Button>
<Button
type="button"
variant={operation === "subtract" ? "default" : "outline"}
className={`flex flex-col gap-1 h-auto py-2 ${operation === "subtract" ? "bg-primary text-white" : ""}`}
onClick={() => setOperation("subtract")}
>
<Minus className="h-4 w-4" />
<span className="text-xs"></span>
</Button>
<Button
type="button"
variant={operation === "set" ? "default" : "outline"}
className={`flex flex-col gap-1 h-auto py-2 ${operation === "set" ? "bg-primary text-white" : ""}`}
onClick={() => setOperation("set")}
>
<Equal className="h-4 w-4" />
<span className="text-xs"></span>
</Button>
</div>
</div>
{/* 調整數量 */}
<div className="space-y-2">
<Label htmlFor="quantity">調</Label>
<Input
id="quantity"
type="number"
step="0.01"
value={data.quantity === 0 ? "" : data.quantity}
onChange={e => setData("quantity", Number(e.target.value))}
placeholder="請輸入數量"
className={errors.quantity ? "border-red-500" : ""}
/>
{errors.quantity && <p className="text-xs text-red-500">{errors.quantity}</p>}
</div>
{/* 預計剩餘庫存 */}
<div className="flex items-center justify-between p-3 bg-primary/5 rounded-lg border border-primary/20">
<span className="text-gray-600">調</span>
<span className="font-bold text-lg text-primary">{getResultQuantity()}</span>
</div>
{/* 調整原因 */}
<div className="space-y-2">
<Label htmlFor="reason">調</Label>
<Select
value={data.reason}
onValueChange={val => setData("reason", val)}
>
<SelectTrigger>
<SelectValue placeholder="選擇原因" />
</SelectTrigger>
<SelectContent>
<SelectItem value="盤點調整">調</SelectItem>
<SelectItem value="損耗"></SelectItem>
<SelectItem value="其他"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 備註 */}
<div className="space-y-2">
<Label htmlFor="notes"></Label>
<Textarea
id="notes"
value={data.notes}
onChange={e => setData("notes", e.target.value)}
placeholder="輸入調整細節..."
rows={2}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={processing}
>
</Button>
<Button
type="submit"
disabled={processing || data.quantity <= 0 && operation !== "set"}
>
調
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// Helper: 假如沒有 route 函式 (Ziggy),則需要手動處理 URL
function route(name: string, params: any) {
if (name === "warehouses.inventory.update") {
return `/warehouses/${params.warehouse}/inventory/${params.product}`;
}
return "";
}

View File

@@ -0,0 +1,32 @@
/**
* 庫存統計資訊元件
*/
interface InventoryStatsProps {
totalItems: number;
totalQuantity: number;
lowStockItems: number;
}
export default function InventoryStats({
totalItems,
totalQuantity,
lowStockItems,
}: InventoryStatsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white p-4 rounded-lg border shadow-sm">
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-bold">{totalItems} </p>
</div>
<div className="bg-white p-4 rounded-lg border shadow-sm">
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-bold">{totalQuantity.toLocaleString()} </p>
</div>
<div className="bg-white p-4 rounded-lg border shadow-sm">
<p className="text-sm text-gray-500 font-medium text-red-600"></p>
<p className="text-2xl font-bold text-red-600">{lowStockItems} </p>
</div>
</div>
);
}

View File

@@ -0,0 +1,300 @@
/**
* 庫存表格元件 (扁平化列表版)
* 顯示庫存項目列表,不進行折疊分組
*/
import { useState, useMemo } from "react";
import {
AlertTriangle,
Trash2,
Eye,
CheckCircle,
Package,
ArrowUpDown,
ArrowUp,
ArrowDown
} 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 { WarehouseInventory } from "@/types/warehouse";
import { getSafetyStockStatus } from "@/utils/inventory";
import { formatDate } from "@/utils/format";
interface InventoryTableProps {
inventories: WarehouseInventory[];
onView: (id: string) => void;
onDelete: (id: string) => void;
}
type SortField = "productName" | "quantity" | "lastInboundDate" | "lastOutboundDate" | "safetyStock" | "status";
type SortDirection = "asc" | "desc" | null;
export default function InventoryTable({
inventories,
onView,
onDelete,
}: InventoryTableProps) {
const [sortField, setSortField] = useState<SortField | null>("status");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc"); // "asc" for status means Priority High (Low Stock) first
// 處理排序
const handleSort = (field: SortField) => {
if (sortField === field) {
if (sortDirection === "asc") {
setSortDirection("desc");
} else if (sortDirection === "desc") {
setSortDirection(null);
setSortField(null);
} else {
setSortDirection("asc");
}
} else {
setSortField(field);
setSortDirection("asc");
}
};
// 排序後的列表
const sortedInventories = useMemo(() => {
if (!sortField || !sortDirection) {
return inventories;
}
return [...inventories].sort((a, b) => {
let aValue: string | number;
let bValue: string | number;
// Status Priority map for sorting: Low > Near > Normal
const statusPriority: Record<string, number> = {
"低於": 1,
"接近": 2,
"正常": 3
};
switch (sortField) {
case "productName":
aValue = a.productName;
bValue = b.productName;
break;
case "quantity":
aValue = a.quantity;
bValue = b.quantity;
break;
case "lastInboundDate":
aValue = a.lastInboundDate || "";
bValue = b.lastInboundDate || "";
break;
case "lastOutboundDate":
aValue = a.lastOutboundDate || "";
bValue = b.lastOutboundDate || "";
break;
case "safetyStock":
aValue = a.safetyStock ?? -1; // null as -1 or Infinity depending on desired order
bValue = b.safetyStock ?? -1;
break;
case "status":
const aStatus = (a.safetyStock !== null && a.safetyStock !== undefined) ? getSafetyStockStatus(a.quantity, a.safetyStock) : "正常";
const bStatus = (b.safetyStock !== null && b.safetyStock !== undefined) ? getSafetyStockStatus(b.quantity, b.safetyStock) : "正常";
aValue = statusPriority[aStatus] || 3;
bValue = statusPriority[bStatus] || 3;
break;
default:
return 0;
}
if (typeof aValue === "string" && typeof bValue === "string") {
return sortDirection === "asc"
? aValue.localeCompare(bValue, "zh-TW")
: bValue.localeCompare(aValue, "zh-TW");
} else {
return sortDirection === "asc"
? (aValue as number) - (bValue as number)
: (bValue as number) - (aValue as number);
}
});
}, [inventories, sortField, sortDirection]);
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) {
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
}
if (sortDirection === "asc") {
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
}
if (sortDirection === "desc") {
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
}
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
};
if (inventories.length === 0) {
return (
<div className="text-center py-12 text-gray-400">
<Package className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p></p>
<p className="text-sm mt-1">調</p>
</div>
);
}
// 獲取狀態徽章
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="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-50/50">
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead className="w-[25%]">
<button onClick={() => handleSort("productName")} className="flex items-center hover:text-gray-900 font-semibold">
<SortIcon field="productName" />
</button>
</TableHead>
<TableHead className="w-[10%] text-right">
<div className="flex justify-end">
<button onClick={() => handleSort("quantity")} className="flex items-center hover:text-gray-900 font-semibold">
<SortIcon field="quantity" />
</button>
</div>
</TableHead>
<TableHead className="w-[12%]">
<button onClick={() => handleSort("lastInboundDate")} className="flex items-center hover:text-gray-900 font-semibold">
<SortIcon field="lastInboundDate" />
</button>
</TableHead>
<TableHead className="w-[12%]">
<button onClick={() => handleSort("lastOutboundDate")} className="flex items-center hover:text-gray-900 font-semibold">
<SortIcon field="lastOutboundDate" />
</button>
</TableHead>
<TableHead className="w-[10%] text-right">
<div className="flex justify-end">
<button onClick={() => handleSort("safetyStock")} className="flex items-center hover:text-gray-900 font-semibold">
<SortIcon field="safetyStock" />
</button>
</div>
</TableHead>
<TableHead className="w-[10%] text-center">
<div className="flex justify-center">
<button onClick={() => handleSort("status")} className="flex items-center hover:text-gray-900 font-semibold">
<SortIcon field="status" />
</button>
</div>
</TableHead>
<TableHead className="w-[10%] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedInventories.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-gray-500 font-medium text-center">
{index + 1}
</TableCell>
{/* 商品資訊 */}
<TableCell>
<div className="flex flex-col">
<div className="font-medium text-gray-900">{item.productName}</div>
<div className="text-xs text-gray-500">{item.productCode}</div>
</div>
</TableCell>
{/* 庫存數量 */}
<TableCell className="text-right">
<span className="font-medium text-gray-900">{item.quantity}</span>
<span className="text-xs text-gray-500 ml-1">{item.unit}</span>
</TableCell>
{/* 最新入庫 */}
<TableCell className="text-gray-600">
{item.lastInboundDate ? formatDate(item.lastInboundDate) : "-"}
</TableCell>
{/* 最新出庫 */}
<TableCell className="text-gray-600">
{item.lastOutboundDate ? formatDate(item.lastOutboundDate) : "-"}
</TableCell>
{/* 安全庫存 */}
<TableCell className="text-right">
{item.safetyStock !== null && item.safetyStock >= 0 ? (
<span className="font-medium text-gray-900">
{item.safetyStock} <span className="text-xs text-gray-500 font-normal">{item.unit}</span>
</span>
) : (
<span className="text-gray-400 text-xs"></span>
)}
</TableCell>
{/* 狀態 */}
<TableCell className="text-center">
{(item.safetyStock !== null && item.safetyStock !== undefined) ? getStatusBadge(item.quantity, item.safetyStock) : (
<Badge variant="outline" className="text-gray-400 border-dashed"></Badge>
)}
</TableCell>
{/* 操作 */}
<TableCell className="text-center">
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onView(item.id)}
title="查看庫存流水帳"
className="button-outlined-primary"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onDelete(item.id)}
title="刪除"
className="button-outlined-error"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,60 @@
/**
* 庫存篩選工具列
* 包含搜尋框和產品類型篩選
*/
import { Search, Filter } from "lucide-react";
import { Input } from "@/Components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/Components/ui/select";
interface InventoryToolbarProps {
searchTerm: string;
onSearchChange: (value: string) => void;
typeFilter: string;
onTypeFilterChange: (value: string) => void;
}
export default function InventoryToolbar({
searchTerm,
onSearchChange,
typeFilter,
onTypeFilterChange,
}: InventoryToolbarProps) {
return (
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between p-4 bg-white rounded-lg border shadow-sm">
<div className="relative w-full md:max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="搜尋商品名稱或批號..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-3 w-full md:w-auto">
<div className="flex items-center gap-2 text-sm text-gray-500 whitespace-nowrap">
<Filter className="h-4 w-4" />
<span></span>
</div>
<Select value={typeFilter} onValueChange={onTypeFilterChange}>
<SelectTrigger className="w-full md:w-[180px]">
<SelectValue placeholder="所有類型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="原物料"></SelectItem>
<SelectItem value="半成品"></SelectItem>
<SelectItem value="成品"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
export interface Transaction {
id: string;
type: string;
quantity: number;
balanceAfter: number;
reason: string | null;
userName: string;
actualTime: string;
}
interface TransactionTableProps {
transactions: Transaction[];
}
export default function TransactionTable({ transactions }: TransactionTableProps) {
if (transactions.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-gray-700 uppercase bg-gray-50">
<tr>
<th className="px-4 py-3 w-[50px]">#</th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3 text-right"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3">/</th>
</tr>
</thead>
<tbody>
{transactions.map((tx, index) => (
<tr key={tx.id} className="border-b hover:bg-gray-50">
<td className="px-4 py-3 text-center text-gray-500 font-medium">{index + 1}</td>
<td className="px-4 py-3 whitespace-nowrap">{tx.actualTime}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs ${tx.quantity > 0
? 'bg-green-100 text-green-800'
: tx.quantity < 0
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}>
{tx.type}
</span>
</td>
<td className={`px-4 py-3 text-right font-medium ${tx.quantity > 0 ? 'text-green-600' : tx.quantity < 0 ? 'text-red-600' : ''
}`}>
{tx.quantity > 0 ? '+' : ''}{tx.quantity}
</td>
<td className="px-4 py-3 text-right">{tx.balanceAfter}</td>
<td className="px-4 py-3">{tx.userName}</td>
<td className="px-4 py-3 text-gray-500">{tx.reason || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,376 @@
/**
* 撥補單對話框元件
* 重構後:加入驗證邏輯模組化
*/
import { useState, useEffect } from "react";
import { getCurrentDateTime, generateOrderNumber } from "@/utils/format";
import axios from "axios";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { Label } from "@/Components/ui/label";
import { Input } from "@/Components/ui/input";
import { Button } from "@/Components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Textarea } from "@/Components/ui/textarea";
import { toast } from "sonner";
import { Warehouse, TransferOrder, TransferOrderStatus } from "@/types/warehouse";
import { validateTransferOrder, validateTransferQuantity } from "@/utils/validation";
export type { TransferOrder };
interface TransferOrderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
order: TransferOrder | null;
warehouses: Warehouse[];
// inventories: WarehouseInventory[]; // Removed as we fetch from API
onSave: (order: Omit<TransferOrder, "id" | "createdAt" | "orderNumber">) => void;
}
interface AvailableProduct {
productId: string;
productName: string;
batchNumber: string;
availableQty: number;
}
export default function TransferOrderDialog({
open,
onOpenChange,
order,
warehouses,
// inventories,
onSave,
}: TransferOrderDialogProps) {
const [formData, setFormData] = useState({
sourceWarehouseId: "",
targetWarehouseId: "",
productId: "",
productName: "",
batchNumber: "",
quantity: 0,
transferDate: getCurrentDateTime(),
status: "待處理" as TransferOrderStatus,
notes: "",
});
const [availableProducts, setAvailableProducts] = useState<AvailableProduct[]>([]);
// 當對話框開啟或訂單變更時,重置表單
useEffect(() => {
if (order) {
setFormData({
sourceWarehouseId: order.sourceWarehouseId,
targetWarehouseId: order.targetWarehouseId,
productId: order.productId,
productName: order.productName,
batchNumber: order.batchNumber,
quantity: order.quantity,
transferDate: order.transferDate,
status: order.status,
notes: order.notes || "",
});
} else {
setFormData({
sourceWarehouseId: "",
targetWarehouseId: "",
productId: "",
productName: "",
batchNumber: "",
quantity: 0,
transferDate: getCurrentDateTime(),
status: "待處理",
notes: "",
});
}
}, [order, open]);
// 當來源倉庫變更時,從 API 更新可用商品列表
useEffect(() => {
if (formData.sourceWarehouseId) {
axios.get(route('api.warehouses.inventories', formData.sourceWarehouseId))
.then(response => {
setAvailableProducts(response.data);
})
.catch(error => {
console.error("Failed to fetch inventories:", error);
toast.error("無法取得倉庫庫存資訊");
setAvailableProducts([]);
});
} else {
setAvailableProducts([]);
}
}, [formData.sourceWarehouseId]);
const handleSubmit = () => {
// 基本驗證
const validation = validateTransferOrder(formData);
if (!validation.isValid) {
toast.error(validation.error);
return;
}
// 檢查可用數量
const selectedProduct = availableProducts.find(
(p) => p.productId === formData.productId && p.batchNumber === formData.batchNumber
);
if (selectedProduct) {
const quantityValidation = validateTransferQuantity(
formData.quantity,
selectedProduct.availableQty
);
if (!quantityValidation.isValid) {
toast.error(quantityValidation.error);
return;
}
}
const sourceWarehouse = warehouses.find((w) => w.id === formData.sourceWarehouseId);
const targetWarehouse = warehouses.find((w) => w.id === formData.targetWarehouseId);
onSave({
sourceWarehouseId: formData.sourceWarehouseId,
sourceWarehouseName: sourceWarehouse?.name || "",
targetWarehouseId: formData.targetWarehouseId,
targetWarehouseName: targetWarehouse?.name || "",
productId: formData.productId,
productName: formData.productName,
batchNumber: formData.batchNumber,
quantity: formData.quantity,
transferDate: formData.transferDate,
status: formData.status,
notes: formData.notes,
});
};
const handleProductChange = (productKey: string) => {
const [productId, batchNumber] = productKey.split("|||");
const product = availableProducts.find(
(p) => p.productId === productId && p.batchNumber === batchNumber
);
if (product) {
setFormData({
...formData,
productId: product.productId,
productName: product.productName,
batchNumber: product.batchNumber,
quantity: 0,
});
}
};
const selectedProduct = availableProducts.find(
(p) => p.productId === formData.productId && p.batchNumber === formData.batchNumber
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{order ? "編輯撥補單" : "新增撥補單"}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 來源倉庫和目標倉庫 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sourceWarehouse">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.sourceWarehouseId}
onValueChange={(value) =>
setFormData({
...formData,
sourceWarehouseId: value,
productId: "",
productName: "",
batchNumber: "",
quantity: 0,
})
}
disabled={!!order}
>
<SelectTrigger id="sourceWarehouse">
<SelectValue placeholder="選擇來源倉庫" />
</SelectTrigger>
<SelectContent>
{warehouses.map((warehouse) => (
<SelectItem key={warehouse.id} value={warehouse.id}>
{warehouse.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="targetWarehouse">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.targetWarehouseId}
onValueChange={(value) =>
setFormData({ ...formData, targetWarehouseId: value })
}
disabled={!!order}
>
<SelectTrigger id="targetWarehouse">
<SelectValue placeholder="選擇目標倉庫" />
</SelectTrigger>
<SelectContent>
{warehouses
.filter((w) => w.id !== formData.sourceWarehouseId)
.map((warehouse) => (
<SelectItem key={warehouse.id} value={warehouse.id}>
{warehouse.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 商品選擇 */}
<div className="space-y-2">
<Label htmlFor="product">
<span className="text-red-500">*</span>
</Label>
<Select
value={
formData.productId && formData.batchNumber
? `${formData.productId}||| ${formData.batchNumber} `
: ""
}
onValueChange={handleProductChange}
disabled={!formData.sourceWarehouseId || !!order}
>
<SelectTrigger id="product">
<SelectValue placeholder="選擇商品與批號" />
</SelectTrigger>
<SelectContent>
{availableProducts.length === 0 ? (
<div className="p-2 text-sm text-gray-500 text-center">
{formData.sourceWarehouseId ? "該倉庫無可用庫存" : "請先選擇來源倉庫"}
</div>
) : (
availableProducts.map((product) => (
<SelectItem
key={`${product.productId}||| ${product.batchNumber} `}
value={`${product.productId}||| ${product.batchNumber} `}
>
{product.productName} - : {product.batchNumber} (:{" "}
{product.availableQty})
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* 數量和日期 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="quantity">
<span className="text-red-500">*</span>
</Label>
<Input
id="quantity"
type="number"
min="0"
max={selectedProduct?.availableQty || 0}
value={formData.quantity}
onChange={(e) =>
setFormData({ ...formData, quantity: Number(e.target.value) })
}
/>
{selectedProduct && (
<p className="text-sm text-gray-500">
: {selectedProduct.availableQty}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="transferDate">
<span className="text-red-500">*</span>
</Label>
<Input
id="transferDate"
type="datetime-local"
value={formData.transferDate}
onChange={(e) =>
setFormData({ ...formData, transferDate: e.target.value })
}
/>
</div>
</div>
{/* 狀態(僅編輯時顯示) */}
{order && (
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={(value) =>
setFormData({ ...formData, status: value as TransferOrderStatus })
}
>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="待處理"></SelectItem>
<SelectItem value="處理中"></SelectItem>
<SelectItem value="已完成"></SelectItem>
<SelectItem value="已取消"></SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 備註 */}
<div className="space-y-2">
<Label htmlFor="notes"></Label>
<Textarea
id="notes"
placeholder="請輸入備註(選填)"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="button-outlined-primary"
>
</Button>
<Button onClick={handleSubmit} className="button-filled-primary">
{order ? "更新" : "新增"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,161 @@
/**
* 倉庫卡片元件
* 顯示單個倉庫的資訊和統計
*/
import { useState } from "react";
import {
Package,
AlertTriangle,
MapPin,
Edit,
Info,
FileText,
} from "lucide-react";
import { Warehouse, WarehouseStats } from "@/types/warehouse";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { Card, CardContent } from "@/Components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
interface WarehouseCardProps {
warehouse: Warehouse;
stats: WarehouseStats;
hasWarning: boolean;
onViewInventory: (warehouseId: string) => void;
onEdit: (warehouse: Warehouse) => void;
}
export default function WarehouseCard({
warehouse,
stats,
hasWarning,
onViewInventory,
onEdit,
}: WarehouseCardProps) {
const [showInfoDialog, setShowInfoDialog] = useState(false);
return (
<Card
className={`relative overflow-hidden transition-all hover:shadow-lg flex flex-col ${hasWarning
? "border-orange-400 border-2 bg-orange-50/50"
: "border-gray-200"
}`}
>
{/* 警告橫幅 */}
{hasWarning && (
<div className="absolute top-0 left-0 right-0 bg-orange-500 text-white px-4 py-1 flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" />
<span></span>
</div>
)}
<CardContent className={`p-6 flex flex-col flex-1 ${hasWarning ? "pt-12" : "pt-6"}`}>
{/* 上半部:資訊區域 */}
<div className="flex-1">
{/* 標題區塊 */}
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="text-2xl font-bold">{warehouse.name}</h3>
<button
onClick={() => setShowInfoDialog(true)}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<Info className="h-5 w-5" />
</button>
</div>
</div>
</div>
<div className="text-sm text-gray-600 mb-4 line-clamp-2 min-h-[40px]">
{warehouse.description || "無描述"}
</div>
{/* 統計區塊 - 庫存警告 */}
<div className="space-y-3">
{/* 低庫存警告狀態 */}
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
<div className="flex items-center gap-2 text-gray-600">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm"></span>
</div>
<div>
{hasWarning ? (
<Badge className="bg-orange-500 text-white hover:bg-orange-600 border-none px-2 py-0.5">
{stats.lowStockCount}
</Badge>
) : (
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-100 border-green-200">
</Badge>
)}
</div>
</div>
</div>
</div>
{/* 下半部:操作按鈕 */}
<div className="mt-5 pt-3 border-t border-gray-200">
<div className="flex gap-2">
<Button
onClick={() => onViewInventory(warehouse.id)}
className="flex-1 button-filled-primary"
size="sm"
>
<Package className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onEdit(warehouse)}
className="button-outlined-primary"
>
<Edit className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
{/* 倉庫資訊對話框 */}
<Dialog open={showInfoDialog} onOpenChange={setShowInfoDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{warehouse.name}</DialogTitle>
<DialogDescription>
{warehouse.code}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<div className="flex items-start gap-2 text-gray-600">
<MapPin className="h-5 w-5 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-gray-500 mb-1"></p>
<p className="text-gray-900">{warehouse.address || "-"}</p>
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-start gap-2 text-gray-600">
<FileText className="h-5 w-5 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-gray-500 mb-1"></p>
<p className="text-gray-900 whitespace-pre-wrap">{warehouse.description || "-"}</p>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,237 @@
/**
* 倉庫對話框元件
* 重構後:加入驗證邏輯
*/
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea";
import { Button } from "@/Components/ui/button";
import { Trash2 } from "lucide-react";
import { Warehouse } from "@/types/warehouse";
import { validateWarehouse } from "@/utils/validation";
import { toast } from "sonner";
interface WarehouseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
warehouse: Warehouse | null;
onSave: (warehouse: Omit<Warehouse, "id" | "createdAt" | "updatedAt">) => void;
onDelete?: (warehouseId: string) => void;
}
export default function WarehouseDialog({
open,
onOpenChange,
warehouse,
onSave,
onDelete,
}: WarehouseDialogProps) {
const [formData, setFormData] = useState<{
code: string;
name: string;
address: string;
description: string;
}>({
code: "",
name: "",
address: "",
description: "",
});
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
useEffect(() => {
if (warehouse) {
setFormData({
code: warehouse.code,
name: warehouse.name,
address: warehouse.address || "",
description: warehouse.description || "",
});
} else {
setFormData({
code: "",
name: "",
address: "",
description: "",
});
}
}, [warehouse, open]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const validation = validateWarehouse(formData);
if (!validation.isValid) {
toast.error(validation.error);
return;
}
onSave(formData);
};
const handleDelete = () => {
if (warehouse && onDelete) {
onDelete(warehouse.id);
setShowDeleteDialog(false);
onOpenChange(false);
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{warehouse ? "編輯倉庫" : "新增倉庫"}</DialogTitle>
<DialogDescription>
{warehouse ? "修改倉庫資訊" : "建立新的倉庫資訊"}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-6 py-4">
{/* 區塊 A基本資訊 */}
<div className="space-y-4">
<div className="border-b pb-2">
<h4 className="text-sm text-gray-700"></h4>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 倉庫編號 */}
<div className="space-y-2">
<Label htmlFor="code">
</Label>
<Input
id="code"
value={warehouse ? formData.code : ""}
disabled={true}
placeholder={warehouse ? "" : "系統自動產生"}
className="bg-gray-100"
/>
</div>
{/* 倉庫名稱 */}
<div className="space-y-2">
<Label htmlFor="name">
<span className="text-red-500">*</span>
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例:中央倉庫"
required
/>
</div>
</div>
</div>
{/* 區塊 B位置 */}
<div className="space-y-4">
<div className="border-b pb-2">
<h4 className="text-sm text-gray-700"></h4>
</div>
{/* 倉庫地址 */}
<div className="space-y-2">
<Label htmlFor="address">
<span className="text-red-500">*</span>
</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="例台北市信義區信義路五段7號"
required
/>
</div>
{/* 備註說明 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="其他說明"
rows={2}
className="resize-none"
/>
</div>
</div>
</div>
<DialogFooter className="gap-2">
{warehouse && onDelete && (
<Button
type="button"
onClick={() => setShowDeleteDialog(true)}
variant="outline"
className="group mr-auto border-2 border-red-600 text-red-600 hover:bg-red-600 hover:text-white"
>
<Trash2 className="mr-2 h-4 w-4 group-hover:text-white" />
</Button>
)}
<Button
type="button"
onClick={() => onOpenChange(false)}
className="button-outlined-primary"
>
</Button>
<Button type="submit" className="button-filled-primary">
{warehouse ? "更新" : "新增"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* 刪除確認對話框 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{warehouse?.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,18 @@
/**
* 倉庫空狀態元件
* 當沒有倉庫資料時顯示
*/
import { Package } from "lucide-react";
export default function WarehouseEmptyState() {
return (
<div className="bg-white rounded-lg shadow-sm border p-12">
<div className="flex flex-col items-center justify-center text-gray-400">
<Package className="h-16 w-16 mb-4" />
<p className="text-lg"></p>
<p className="text-sm mt-2"></p>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
/**
* 倉庫類別篩選器元件
*/
import { Filter } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { WarehouseType } from "@/types/warehouse";
interface WarehouseFilterProps {
value: string;
onChange: (value: string) => void;
className?: string;
}
const WAREHOUSE_TYPES: Array<{ value: string; label: string }> = [
{ value: "all", label: "全部類別" },
{ value: "中央倉庫", label: "中央倉庫" },
{ value: "門市", label: "門市" },
];
export default function WarehouseFilter({
value,
onChange,
className = "",
}: WarehouseFilterProps) {
return (
<div className={className}>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-gray-400" />
<SelectValue placeholder="選擇類別" />
</div>
</SelectTrigger>
<SelectContent>
{WAREHOUSE_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}