first commit
This commit is contained in:
@@ -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 "";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
300
resources/js/Components/Warehouse/Inventory/InventoryTable.tsx
Normal file
300
resources/js/Components/Warehouse/Inventory/InventoryTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
376
resources/js/Components/Warehouse/TransferOrderDialog.tsx
Normal file
376
resources/js/Components/Warehouse/TransferOrderDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
resources/js/Components/Warehouse/WarehouseCard.tsx
Normal file
161
resources/js/Components/Warehouse/WarehouseCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
237
resources/js/Components/Warehouse/WarehouseDialog.tsx
Normal file
237
resources/js/Components/Warehouse/WarehouseDialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
resources/js/Components/Warehouse/WarehouseEmptyState.tsx
Normal file
18
resources/js/Components/Warehouse/WarehouseEmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
51
resources/js/Components/Warehouse/WarehouseFilter.tsx
Normal file
51
resources/js/Components/Warehouse/WarehouseFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user