2025-12-30 15:03:19 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 採購單商品表格元件
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { Trash2 } from "lucide-react";
|
|
|
|
|
|
import { Button } from "@/Components/ui/button";
|
|
|
|
|
|
import { Input } from "@/Components/ui/input";
|
2026-01-09 10:18:52 +08:00
|
|
|
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
2025-12-30 15:03:19 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Table,
|
|
|
|
|
|
TableBody,
|
|
|
|
|
|
TableCell,
|
|
|
|
|
|
TableHead,
|
|
|
|
|
|
TableHeader,
|
|
|
|
|
|
TableRow,
|
|
|
|
|
|
} from "@/Components/ui/table";
|
|
|
|
|
|
import type { PurchaseOrderItem, Supplier } from "@/types/purchase-order";
|
2026-01-08 16:32:10 +08:00
|
|
|
|
import { formatCurrency } from "@/utils/purchase-order";
|
2025-12-30 15:03:19 +08:00
|
|
|
|
|
|
|
|
|
|
interface PurchaseOrderItemsTableProps {
|
|
|
|
|
|
items: PurchaseOrderItem[];
|
|
|
|
|
|
supplier?: Supplier;
|
|
|
|
|
|
isReadOnly?: boolean;
|
|
|
|
|
|
isDisabled?: boolean;
|
|
|
|
|
|
onAddItem?: () => void;
|
|
|
|
|
|
onRemoveItem?: (index: number) => void;
|
|
|
|
|
|
onItemChange?: (index: number, field: keyof PurchaseOrderItem, value: string | number) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function PurchaseOrderItemsTable({
|
|
|
|
|
|
items,
|
|
|
|
|
|
supplier,
|
|
|
|
|
|
isReadOnly = false,
|
|
|
|
|
|
isDisabled = false,
|
|
|
|
|
|
onRemoveItem,
|
|
|
|
|
|
onItemChange,
|
|
|
|
|
|
}: PurchaseOrderItemsTableProps) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={`border rounded-lg overflow-hidden ${isDisabled ? "opacity-50 pointer-events-none grayscale" : ""}`}>
|
|
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
2026-01-08 16:32:10 +08:00
|
|
|
|
<TableHead className="w-[20%] text-left">商品名稱</TableHead>
|
2026-01-06 15:45:13 +08:00
|
|
|
|
<TableHead className="w-[10%] text-left">數量</TableHead>
|
2026-01-08 16:32:10 +08:00
|
|
|
|
<TableHead className="w-[12%] text-left">單位</TableHead>
|
|
|
|
|
|
<TableHead className="w-[12%] text-left">換算基本單位</TableHead>
|
2026-01-19 15:32:41 +08:00
|
|
|
|
<TableHead className="w-[15%] text-left">小計</TableHead>
|
2026-01-08 16:32:10 +08:00
|
|
|
|
<TableHead className="w-[15%] text-left">單價 / 基本單位</TableHead>
|
2025-12-30 15:03:19 +08:00
|
|
|
|
{!isReadOnly && <TableHead className="w-[5%]"></TableHead>}
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
{items.length === 0 ? (
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
<TableCell
|
2026-01-08 17:51:06 +08:00
|
|
|
|
colSpan={isReadOnly ? 6 : 7}
|
2025-12-30 15:03:19 +08:00
|
|
|
|
className="text-center text-gray-400 py-12 italic"
|
|
|
|
|
|
>
|
|
|
|
|
|
{isDisabled ? "請先選擇供應商後才能新增商品" : "尚未新增任何商品項"}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
) : (
|
2026-01-08 16:32:10 +08:00
|
|
|
|
items.map((item, index) => {
|
|
|
|
|
|
// 計算換算後的單價 (基本單位單價)
|
2026-01-08 17:51:06 +08:00
|
|
|
|
// unitPrice is derived from subtotal / quantity
|
|
|
|
|
|
const currentUnitPrice = item.unitPrice;
|
|
|
|
|
|
|
2026-01-08 16:32:10 +08:00
|
|
|
|
const convertedUnitPrice = item.selectedUnit === 'large' && item.conversion_rate
|
2026-01-08 17:51:06 +08:00
|
|
|
|
? currentUnitPrice / item.conversion_rate
|
|
|
|
|
|
: currentUnitPrice;
|
2025-12-30 15:03:19 +08:00
|
|
|
|
|
2026-01-08 16:32:10 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<TableRow key={index}>
|
|
|
|
|
|
{/* 商品選擇 */}
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
{isReadOnly ? (
|
|
|
|
|
|
<span className="font-medium">{item.productName}</span>
|
|
|
|
|
|
) : (
|
2026-01-09 10:18:52 +08:00
|
|
|
|
<SearchableSelect
|
2026-01-08 16:32:10 +08:00
|
|
|
|
value={item.productId}
|
|
|
|
|
|
onValueChange={(value) =>
|
|
|
|
|
|
onItemChange?.(index, "productId", value)
|
|
|
|
|
|
}
|
|
|
|
|
|
disabled={isDisabled}
|
2026-01-09 10:18:52 +08:00
|
|
|
|
options={supplier?.commonProducts.map((p) => ({ label: p.productName, value: p.productId })) || []}
|
|
|
|
|
|
placeholder="選擇商品"
|
|
|
|
|
|
searchPlaceholder="搜尋商品..."
|
|
|
|
|
|
emptyText="無可用商品"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
/>
|
2026-01-08 16:32:10 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</TableCell>
|
2026-01-06 15:45:13 +08:00
|
|
|
|
|
2026-01-08 16:32:10 +08:00
|
|
|
|
{/* 數量 */}
|
|
|
|
|
|
<TableCell className="text-left">
|
|
|
|
|
|
{isReadOnly ? (
|
|
|
|
|
|
<span>{Math.floor(item.quantity)}</span>
|
|
|
|
|
|
) : (
|
2025-12-30 15:03:19 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
2026-01-08 16:32:10 +08:00
|
|
|
|
step="1"
|
|
|
|
|
|
value={item.quantity === 0 ? "" : Math.floor(item.quantity)}
|
2025-12-30 15:03:19 +08:00
|
|
|
|
onChange={(e) =>
|
2026-01-08 16:32:10 +08:00
|
|
|
|
onItemChange?.(index, "quantity", Math.floor(Number(e.target.value)))
|
2025-12-30 15:03:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
disabled={isDisabled}
|
2026-01-08 16:32:10 +08:00
|
|
|
|
className="h-10 text-left border-gray-200 w-24"
|
2025-12-30 15:03:19 +08:00
|
|
|
|
/>
|
2026-01-08 16:32:10 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 單位選擇 */}
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
{!isReadOnly && item.large_unit_id ? (
|
2026-01-09 10:18:52 +08:00
|
|
|
|
<SearchableSelect
|
2026-01-08 16:32:10 +08:00
|
|
|
|
value={item.selectedUnit || 'base'}
|
|
|
|
|
|
onValueChange={(value) =>
|
|
|
|
|
|
onItemChange?.(index, "selectedUnit", value)
|
|
|
|
|
|
}
|
|
|
|
|
|
disabled={isDisabled}
|
2026-01-09 10:18:52 +08:00
|
|
|
|
options={[
|
|
|
|
|
|
{ label: item.base_unit_name || "個", value: "base" },
|
|
|
|
|
|
{ label: item.large_unit_name || "", value: "large" }
|
|
|
|
|
|
]}
|
|
|
|
|
|
className="w-24"
|
|
|
|
|
|
/>
|
2026-01-08 16:32:10 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-gray-500 font-medium">
|
|
|
|
|
|
{item.selectedUnit === 'large' && item.large_unit_name
|
|
|
|
|
|
? item.large_unit_name
|
|
|
|
|
|
: (item.base_unit_name || "個")}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 換算基本單位 */}
|
|
|
|
|
|
<TableCell>
|
|
|
|
|
|
<div className="text-gray-700 font-medium">
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{item.selectedUnit === 'large' && item.conversion_rate
|
|
|
|
|
|
? item.quantity * item.conversion_rate
|
|
|
|
|
|
: item.quantity}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="ml-1 text-gray-500 text-sm">{item.base_unit_name || "個"}</span>
|
2025-12-30 15:03:19 +08:00
|
|
|
|
</div>
|
2026-01-08 16:32:10 +08:00
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
2026-01-08 17:51:06 +08:00
|
|
|
|
{/* 總金額 (主要輸入欄位) */}
|
2026-01-08 16:32:10 +08:00
|
|
|
|
<TableCell className="text-left">
|
|
|
|
|
|
{isReadOnly ? (
|
2026-01-08 17:51:06 +08:00
|
|
|
|
<span className="font-bold text-primary">{formatCurrency(item.subtotal)}</span>
|
2026-01-08 16:32:10 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
2026-01-08 17:51:06 +08:00
|
|
|
|
step="1"
|
|
|
|
|
|
value={item.subtotal || ""}
|
2026-01-08 16:32:10 +08:00
|
|
|
|
onChange={(e) =>
|
2026-01-08 17:51:06 +08:00
|
|
|
|
onItemChange?.(index, "subtotal", Number(e.target.value))
|
2026-01-08 16:32:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
disabled={isDisabled}
|
|
|
|
|
|
className={`h-10 text-left w-32 ${
|
2026-01-08 17:51:06 +08:00
|
|
|
|
// 如果有數量但沒有金額,顯示錯誤樣式
|
|
|
|
|
|
item.quantity > 0 && (!item.subtotal || item.subtotal <= 0)
|
2026-01-08 16:32:10 +08:00
|
|
|
|
? "border-red-400 bg-red-50 focus-visible:ring-red-500"
|
|
|
|
|
|
: "border-gray-200"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
/>
|
2026-01-08 17:51:06 +08:00
|
|
|
|
{/* 錯誤提示 */}
|
|
|
|
|
|
{item.quantity > 0 && (!item.subtotal || item.subtotal <= 0) && (
|
2026-01-08 16:32:10 +08:00
|
|
|
|
<p className="text-[10px] text-red-600 font-medium">
|
2026-01-08 17:51:06 +08:00
|
|
|
|
❌ 請填寫總金額
|
2026-01-08 16:32:10 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TableCell>
|
2025-12-30 15:03:19 +08:00
|
|
|
|
|
2026-01-08 17:51:06 +08:00
|
|
|
|
{/* 換算採購單價 / 基本單位 (顯示換算結果) */}
|
2026-01-08 16:32:10 +08:00
|
|
|
|
<TableCell className="text-left">
|
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
|
<div className="text-gray-500 font-medium text-sm">
|
|
|
|
|
|
{formatCurrency(convertedUnitPrice)} / {item.base_unit_name || "個"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{convertedUnitPrice > 0 && item.previousPrice && item.previousPrice > 0 && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{convertedUnitPrice > item.previousPrice && (
|
|
|
|
|
|
<p className="text-[10px] text-amber-600 font-medium animate-pulse">
|
|
|
|
|
|
⚠️ 高於上次: {formatCurrency(item.previousPrice)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{convertedUnitPrice < item.previousPrice && (
|
|
|
|
|
|
<p className="text-[10px] text-green-600 font-medium">
|
|
|
|
|
|
📉 低於上次: {formatCurrency(item.previousPrice)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-12-30 15:03:19 +08:00
|
|
|
|
</TableCell>
|
2026-01-08 16:32:10 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 刪除按鈕 */}
|
|
|
|
|
|
{!isReadOnly && onRemoveItem && (
|
|
|
|
|
|
<TableCell className="text-center">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
onClick={() => onRemoveItem(index)}
|
|
|
|
|
|
className="h-8 w-8 text-gray-300 hover:text-red-500 hover:bg-red-50 transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
2025-12-30 15:03:19 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|