Files
star-erp/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx
sky121113 ac6a81b3d2
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 58s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
feat: 倉庫業務屬性、庫存成本追蹤與採購單功能更新
1. 倉庫管理:新增業務類型 (Owned/External/Customer) 與車牌資訊與司機欄位。
2. 庫存管理:實作成本追蹤 (unit_cost, total_value),更新列表與撥補單顯示。
3. 採購單:新增採購日期 (order_date),調整欄位名稱與順序。
4. 前端優化:更新相關 TS Type 定義與 UI 顯示。
2026-01-26 17:27:34 +08:00

320 lines
19 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 } from "react";
import { AlertTriangle, Edit, Trash2, Eye, ChevronDown, ChevronRight, CheckCircle, Package } 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 {
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";
import BatchAdjustmentModal from "./BatchAdjustmentModal";
interface InventoryTableProps {
inventories: GroupedInventory[];
onView: (id: string) => void;
onDelete: (id: string) => void;
onAdjust: (batchId: string, data: { operation: string; quantity: number; reason: string }) => void;
onViewProduct?: (productId: string) => void;
}
export default function InventoryTable({
inventories,
onView,
onDelete,
onAdjust,
onViewProduct,
}: InventoryTableProps) {
// 每個商品的展開/折疊狀態
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set());
// 調整彈窗狀態
const [adjustmentTarget, setAdjustmentTarget] = useState<{
id: string;
batchNumber: string;
currentQuantity: number;
productName: string;
} | null>(null);
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) => {
const newSet = new Set(prev);
if (newSet.has(productId)) {
newSet.delete(productId);
} else {
newSet.add(productId);
}
return newSet;
});
};
// 獲取狀態徽章
const getStatusBadge = (status: string) => {
switch (status) {
case "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300">
<CheckCircle className="mr-1 h-3 w-3" />
</Badge>
);
case "低於":
return (
<Badge className="bg-red-100 text-red-700 border-red-300">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
default:
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.has(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}</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">
<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>
</>
) : (
<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>
{/* 商品表格 - 可折疊內容 */}
<CollapsibleContent>
{hasInventory ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[5%]">#</TableHead>
<TableHead className="w-[12%]"></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>
<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 ? 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>
);
})}
<BatchAdjustmentModal
isOpen={!!adjustmentTarget}
onClose={() => setAdjustmentTarget(null)}
batch={adjustmentTarget || undefined}
onConfirm={(data) => {
if (adjustmentTarget) {
onAdjust(adjustmentTarget.id, data);
setAdjustmentTarget(null);
}
}}
/>
</div>
</TooltipProvider>
);
}