2025-12-30 15:03:19 +08:00
|
|
|
|
/**
|
2026-01-22 15:39:35 +08:00
|
|
|
|
* 庫存表格元件 (Warehouse 版本)
|
|
|
|
|
|
* 顯示庫存項目列表(依商品分組並支援折疊)
|
2025-12-30 15:03:19 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
import { useState } from "react";
|
|
|
|
|
|
import { AlertTriangle, Edit, Trash2, Eye, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react";
|
2025-12-30 15:03:19 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Table,
|
|
|
|
|
|
TableBody,
|
|
|
|
|
|
TableCell,
|
|
|
|
|
|
TableHead,
|
|
|
|
|
|
TableHeader,
|
|
|
|
|
|
TableRow,
|
|
|
|
|
|
} from "@/Components/ui/table";
|
|
|
|
|
|
import { Button } from "@/Components/ui/button";
|
|
|
|
|
|
import { Badge } from "@/Components/ui/badge";
|
2026-01-22 15:39:35 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Collapsible,
|
|
|
|
|
|
CollapsibleContent,
|
|
|
|
|
|
} from "@/Components/ui/collapsible";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Tooltip,
|
|
|
|
|
|
TooltipContent,
|
|
|
|
|
|
TooltipProvider,
|
|
|
|
|
|
TooltipTrigger,
|
|
|
|
|
|
} from "@/Components/ui/tooltip";
|
|
|
|
|
|
import { GroupedInventory } from "@/types/warehouse";
|
2025-12-30 15:03:19 +08:00
|
|
|
|
import { formatDate } from "@/utils/format";
|
2026-01-13 17:00:58 +08:00
|
|
|
|
import { Can } from "@/Components/Permission/Can";
|
2026-01-22 15:39:35 +08:00
|
|
|
|
import BatchAdjustmentModal from "./BatchAdjustmentModal";
|
2025-12-30 15:03:19 +08:00
|
|
|
|
|
|
|
|
|
|
interface InventoryTableProps {
|
2026-01-22 15:39:35 +08:00
|
|
|
|
inventories: GroupedInventory[];
|
2025-12-30 15:03:19 +08:00
|
|
|
|
onView: (id: string) => void;
|
|
|
|
|
|
onDelete: (id: string) => void;
|
2026-01-22 15:39:35 +08:00
|
|
|
|
onAdjust: (batchId: string, data: { operation: string; quantity: number; reason: string }) => void;
|
|
|
|
|
|
onViewProduct?: (productId: string) => void;
|
2025-12-30 15:03:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function InventoryTable({
|
|
|
|
|
|
inventories,
|
|
|
|
|
|
onView,
|
|
|
|
|
|
onDelete,
|
2026-01-22 15:39:35 +08:00
|
|
|
|
onAdjust,
|
|
|
|
|
|
onViewProduct,
|
2025-12-30 15:03:19 +08:00
|
|
|
|
}: InventoryTableProps) {
|
2026-01-22 15:39:35 +08:00
|
|
|
|
// 每個商品的展開/折疊狀態
|
|
|
|
|
|
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set());
|
2025-12-30 15:03:19 +08:00
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
// 調整彈窗狀態
|
|
|
|
|
|
const [adjustmentTarget, setAdjustmentTarget] = useState<{
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
batchNumber: string;
|
|
|
|
|
|
currentQuantity: number;
|
|
|
|
|
|
productName: string;
|
|
|
|
|
|
} | null>(null);
|
2025-12-30 15:03:19 +08:00
|
|
|
|
|
|
|
|
|
|
if (inventories.length === 0) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="text-center py-12 text-gray-400">
|
|
|
|
|
|
<p>無符合條件的品項</p>
|
|
|
|
|
|
<p className="text-sm mt-1">請調整搜尋或篩選條件</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
// 按商品名稱排序
|
|
|
|
|
|
const sortedInventories = [...inventories].sort((a, b) =>
|
|
|
|
|
|
a.productName.localeCompare(b.productName, "zh-TW")
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const toggleProduct = (productId: string) => {
|
|
|
|
|
|
setExpandedProducts((prev) => {
|
|
|
|
|
|
const newSet = new Set(prev);
|
|
|
|
|
|
if (newSet.has(productId)) {
|
|
|
|
|
|
newSet.delete(productId);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
newSet.add(productId);
|
|
|
|
|
|
}
|
|
|
|
|
|
return newSet;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-30 15:03:19 +08:00
|
|
|
|
// 獲取狀態徽章
|
2026-01-22 15:39:35 +08:00
|
|
|
|
const getStatusBadge = (status: string) => {
|
2025-12-30 15:03:19 +08:00
|
|
|
|
switch (status) {
|
|
|
|
|
|
case "正常":
|
|
|
|
|
|
return (
|
2026-01-22 15:39:35 +08:00
|
|
|
|
<Badge className="bg-green-100 text-green-700 border-green-300">
|
2025-12-30 15:03:19 +08:00
|
|
|
|
<CheckCircle className="mr-1 h-3 w-3" />
|
|
|
|
|
|
正常
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
);
|
2026-01-22 15:39:35 +08:00
|
|
|
|
|
|
|
|
|
|
case "低於":
|
2025-12-30 15:03:19 +08:00
|
|
|
|
return (
|
2026-01-22 15:39:35 +08:00
|
|
|
|
<Badge className="bg-red-100 text-red-700 border-red-300">
|
2025-12-30 15:03:19 +08:00
|
|
|
|
<AlertTriangle className="mr-1 h-3 w-3" />
|
|
|
|
|
|
低於
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
);
|
|
|
|
|
|
default:
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-22 15:39:35 +08:00
|
|
|
|
<TooltipProvider>
|
|
|
|
|
|
<div className="space-y-4 p-4">
|
|
|
|
|
|
{sortedInventories.map((group) => {
|
|
|
|
|
|
const totalQuantity = group.totalQuantity;
|
2025-12-30 15:03:19 +08:00
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
// 使用後端提供的狀態
|
|
|
|
|
|
const status = group.status;
|
2025-12-30 15:03:19 +08:00
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
const isLowStock = status === "低於";
|
|
|
|
|
|
const isExpanded = expandedProducts.has(group.productId);
|
|
|
|
|
|
const hasInventory = group.batches.length > 0;
|
2025-12-30 15:03:19 +08:00
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<Collapsible
|
|
|
|
|
|
key={group.productId}
|
|
|
|
|
|
open={isExpanded}
|
|
|
|
|
|
onOpenChange={() => toggleProduct(group.productId)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="border rounded-lg overflow-hidden">
|
|
|
|
|
|
{/* 商品標題 - 可點擊折疊 */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
onClick={() => toggleProduct(group.productId)}
|
|
|
|
|
|
className={`px-4 py-3 border-b cursor-pointer hover:bg-gray-100 transition-colors ${isLowStock ? "bg-red-50" : "bg-gray-50"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
{/* 折疊圖示 */}
|
|
|
|
|
|
{isExpanded ? (
|
|
|
|
|
|
<ChevronDown className="h-5 w-5 text-gray-600" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ChevronRight className="h-5 w-5 text-gray-600" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<h3 className="font-semibold text-gray-900">{group.productName}</h3>
|
|
|
|
|
|
<span className="text-sm text-gray-500">
|
|
|
|
|
|
{hasInventory ? `${group.batches.length} 個批號` : '無庫存'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
|
<div className="text-sm">
|
|
|
|
|
|
<span className="text-gray-600">
|
2026-01-26 17:27:34 +08:00
|
|
|
|
總庫存:<span className={`font-medium ${isLowStock ? "text-red-600" : "text-gray-900"}`}>{totalQuantity} {group.baseUnit}</span>
|
2026-01-22 15:39:35 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-01-26 17:27:34 +08:00
|
|
|
|
<Can permission="inventory.view_cost">
|
|
|
|
|
|
<div className="text-sm">
|
|
|
|
|
|
<span className="text-gray-600">
|
|
|
|
|
|
總價值:<span className="font-medium text-gray-900">${group.totalValue?.toLocaleString()}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Can>
|
2026-01-22 15:39:35 +08:00
|
|
|
|
{group.safetyStock !== null ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="text-sm">
|
|
|
|
|
|
<span className="text-gray-600">
|
2026-01-26 17:27:34 +08:00
|
|
|
|
安全庫存:<span className="font-medium text-gray-900">{group.safetyStock} {group.baseUnit}</span>
|
2026-01-22 15:39:35 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{status && getStatusBadge(status)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Badge variant="outline" className="text-gray-500">
|
|
|
|
|
|
未設定
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{onViewProduct && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onViewProduct(group.productId);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="button-outlined-primary"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Eye className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-30 15:03:19 +08:00
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
{/* 商品表格 - 可折疊內容 */}
|
|
|
|
|
|
<CollapsibleContent>
|
|
|
|
|
|
{hasInventory ? (
|
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
<TableHead className="w-[5%]">#</TableHead>
|
|
|
|
|
|
<TableHead className="w-[12%]">批號</TableHead>
|
2026-01-26 17:27:34 +08:00
|
|
|
|
<TableHead className="w-[10%]">庫存數量</TableHead>
|
|
|
|
|
|
<Can permission="inventory.view_cost">
|
|
|
|
|
|
<TableHead className="w-[10%]">單位成本</TableHead>
|
|
|
|
|
|
<TableHead className="w-[10%]">總價值</TableHead>
|
|
|
|
|
|
</Can>
|
|
|
|
|
|
<TableHead className="w-[12%]">保存期限</TableHead>
|
|
|
|
|
|
<TableHead className="w-[12%]">最新入庫</TableHead>
|
|
|
|
|
|
<TableHead className="w-[12%]">最新出庫</TableHead>
|
2026-01-22 15:39:35 +08:00
|
|
|
|
<TableHead className="w-[8%] text-right">操作</TableHead>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
{group.batches.map((batch, index) => {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TableRow key={batch.id}>
|
|
|
|
|
|
<TableCell className="text-grey-2">{index + 1}</TableCell>
|
|
|
|
|
|
<TableCell>{batch.batchNumber || "-"}</TableCell>
|
|
|
|
|
|
<TableCell>
|
2026-01-26 17:27:34 +08:00
|
|
|
|
<span>{batch.quantity} {batch.unit}</span>
|
2026-01-22 15:39:35 +08:00
|
|
|
|
</TableCell>
|
2026-01-26 17:27:34 +08:00
|
|
|
|
<Can permission="inventory.view_cost">
|
|
|
|
|
|
<TableCell>${batch.unit_cost?.toLocaleString()}</TableCell>
|
|
|
|
|
|
<TableCell>${batch.total_value?.toLocaleString()}</TableCell>
|
|
|
|
|
|
</Can>
|
2026-01-22 15:39:35 +08:00
|
|
|
|
<TableCell>
|
|
|
|
|
|
{batch.expiryDate ? formatDate(batch.expiryDate) : "-"}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
{batch.lastInboundDate ? formatDate(batch.lastInboundDate) : "-"}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
{batch.lastOutboundDate ? formatDate(batch.lastOutboundDate) : "-"}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell className="text-right">
|
|
|
|
|
|
<div className="flex justify-end gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => onView(batch.id)}
|
|
|
|
|
|
className="button-outlined-primary"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Eye className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Can permission="inventory.adjust">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setAdjustmentTarget({
|
|
|
|
|
|
id: batch.id,
|
|
|
|
|
|
batchNumber: batch.batchNumber,
|
|
|
|
|
|
currentQuantity: batch.quantity,
|
|
|
|
|
|
productName: group.productName
|
|
|
|
|
|
})}
|
|
|
|
|
|
className="button-outlined-primary"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Edit className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Can>
|
|
|
|
|
|
<Can permission="inventory.delete">
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<div className="inline-block">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => onDelete(batch.id)}
|
|
|
|
|
|
className={batch.quantity > 0 ? "opacity-50 cursor-not-allowed border-gray-200 text-gray-400 hover:bg-transparent" : "button-outlined-error"}
|
|
|
|
|
|
disabled={batch.quantity > 0}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
|
<p className="text-xs">{batch.quantity > 0 ? "庫存須為 0 才可刪除" : "刪除"}</p>
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</Can>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="px-4 py-8 text-center text-gray-400 bg-gray-50">
|
|
|
|
|
|
<Package className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
|
|
|
|
|
<p className="text-sm">此商品尚無庫存批號</p>
|
|
|
|
|
|
<p className="text-xs mt-1">請點擊「新增庫存」進行入庫</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CollapsibleContent>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Collapsible>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2025-12-30 15:03:19 +08:00
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
<BatchAdjustmentModal
|
|
|
|
|
|
isOpen={!!adjustmentTarget}
|
|
|
|
|
|
onClose={() => setAdjustmentTarget(null)}
|
|
|
|
|
|
batch={adjustmentTarget || undefined}
|
|
|
|
|
|
onConfirm={(data) => {
|
|
|
|
|
|
if (adjustmentTarget) {
|
|
|
|
|
|
onAdjust(adjustmentTarget.id, data);
|
|
|
|
|
|
setAdjustmentTarget(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TooltipProvider>
|
2025-12-30 15:03:19 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|