Files
star-erp/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx
sky121113 4fa87925a2
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m8s
UI優化: 全系統狀態標籤 (StatusBadge) 統一化重構完成 (Phase 3 & 4)
2026-02-13 13:16:05 +08:00

339 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 庫存表格元件 (Warehouse 版本)
* 顯示庫存項目列表(依商品分組並支援折疊)
*/
import { useState, useEffect } from "react";
import { Trash2, Eye, ChevronDown, ChevronRight, Package, AlertTriangle } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Collapsible,
CollapsibleContent,
} from "@/Components/ui/collapsible";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/Components/ui/tooltip";
import { GroupedInventory } from "@/types/warehouse";
import { formatDate } from "@/utils/format";
import { Can } from "@/Components/Permission/Can";
interface InventoryTableProps {
inventories: GroupedInventory[];
onView: (id: string) => void;
onDelete: (id: string) => void;
onViewProduct?: (productId: string) => void;
}
export default function InventoryTable({
inventories,
onView,
onDelete,
onViewProduct,
warehouse,
}: InventoryTableProps & { warehouse: any }) {
// 判斷是否為販賣機倉庫
const isVending = warehouse?.type === "vending";
// 每個商品的展開/折疊狀態 - 使用 sessionStorage 保留狀態 (改用 Array 以利序列化)
// 解決使用 Link 返回時 State 被重置的問題
const storageKey = `inventory_expanded_${warehouse.id}`;
const [expandedProducts, setExpandedProducts] = useState<string[]>(() => {
if (typeof window === 'undefined') return [];
try {
const saved = sessionStorage.getItem(storageKey);
return saved ? JSON.parse(saved) : [];
} catch (e) {
console.error("Failed to parse expanded state", e);
return [];
}
});
useEffect(() => {
try {
sessionStorage.setItem(storageKey, JSON.stringify(expandedProducts));
} catch (e) {
console.error("Failed to save expanded state", e);
}
}, [expandedProducts, storageKey]);
// console.log('InventoryTable Rendered', { warehouseId: warehouse.id, expandedProducts });
if (inventories.length === 0) {
return (
<div className="text-center py-12 text-gray-400">
<p></p>
<p className="text-sm mt-1">調</p>
</div>
);
}
// 按商品名稱排序
const sortedInventories = [...inventories].sort((a, b) =>
a.productName.localeCompare(b.productName, "zh-TW")
);
const toggleProduct = (productId: string) => {
setExpandedProducts((prev) => {
if (prev.includes(productId)) {
return prev.filter(id => id !== productId);
} else {
return [...prev, productId];
}
});
};
// 獲取狀態徽章
const getStatusBadge = (status: string) => {
if (status === '正常') {
return (
<StatusBadge variant="success">
</StatusBadge>
);
}
if (status === '低於') {
return (
<StatusBadge variant="destructive">
</StatusBadge>
);
}
return null;
};
return (
<TooltipProvider>
<div className="space-y-4 p-4">
{sortedInventories.map((group) => {
const totalQuantity = group.totalQuantity;
// 使用後端提供的狀態
const status = group.status;
const isLowStock = status === "低於";
const isExpanded = expandedProducts.includes(group.productId);
const hasInventory = group.batches.length > 0;
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}
{isVending && group.batches.length > 0 && (() => {
const locations = Array.from(new Set(group.batches.map(b => b.location).filter(Boolean)));
return locations.length > 0 ? (
<span className="ml-2 text-primary-main font-bold">
{locations.map(loc => `[${loc}]`).join('')}
</span>
) : null;
})()}
</h3>
<span className="text-sm text-gray-500">
{isVending ? '' : (hasInventory ? `${group.batches.length} 個批號` : '無庫存')}
</span>
{group.batches.some(b => b.expiryDate && new Date(b.expiryDate) < new Date()) && (
<StatusBadge variant="destructive">
</StatusBadge>
)}
</div>
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-gray-600">
<span className={`font-medium ${isLowStock ? "text-red-600" : "text-gray-900"}`}>{totalQuantity} {group.baseUnit}</span>
</span>
</div>
<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>
{group.safetyStock !== null ? (
<>
<div className="text-sm">
<span className="text-gray-600">
<span className="font-medium text-gray-900">{group.safetyStock} {group.baseUnit}</span>
</span>
</div>
<div>
{status && getStatusBadge(status)}
</div>
</>
) : (
<StatusBadge variant="neutral">
</StatusBadge>
)}
{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>
{/* 商品表格 - 可折疊內容 */}
<CollapsibleContent>
{hasInventory ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[5%]">#</TableHead>
<TableHead className="w-[15%]"></TableHead>
<TableHead className="w-[10%]">{isVending ? "貨道" : "儲位"}</TableHead>
<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>
<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 className="font-medium text-primary-main">{batch.location || "-"}</TableCell>
<TableCell>
<span>{batch.quantity} {batch.unit}</span>
</TableCell>
<Can permission="inventory.view_cost">
<TableCell>${batch.unit_cost?.toLocaleString()}</TableCell>
<TableCell>${batch.total_value?.toLocaleString()}</TableCell>
</Can>
<TableCell>
{batch.expiryDate ? (
<div className="flex items-center gap-2">
<span className={new Date(batch.expiryDate) < new Date() ? "text-red-600 font-medium" : ""}>
{formatDate(batch.expiryDate)}
</span>
{new Date(batch.expiryDate) < new Date() && (
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle className="h-4 w-4 text-red-500 cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
)}
</div>
) : "-"}
</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.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>
);
})}
</div >
</TooltipProvider >
);
}