feat: 倉庫業務屬性、庫存成本追蹤與採購單功能更新
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 58s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

1. 倉庫管理:新增業務類型 (Owned/External/Customer) 與車牌資訊與司機欄位。
2. 庫存管理:實作成本追蹤 (unit_cost, total_value),更新列表與撥補單顯示。
3. 採購單:新增採購日期 (order_date),調整欄位名稱與順序。
4. 前端優化:更新相關 TS Type 定義與 UI 顯示。
This commit is contained in:
2026-01-26 17:27:34 +08:00
parent 106de4e945
commit ac6a81b3d2
24 changed files with 429 additions and 130 deletions

View File

@@ -41,11 +41,11 @@ export function PurchaseOrderItemsTable({
<TableHeader>
<TableRow className="bg-gray-50 hover:bg-gray-50">
<TableHead className="w-[20%] text-left"></TableHead>
<TableHead className="w-[10%] text-left"></TableHead>
<TableHead className="w-[10%] text-left"></TableHead>
<TableHead className="w-[12%] text-left"></TableHead>
<TableHead className="w-[12%] text-left"></TableHead>
<TableHead className="w-[15%] text-left"></TableHead>
<TableHead className="w-[15%] text-left"> / </TableHead>
<TableHead className="w-[15%] text-left"></TableHead>
{!isReadOnly && <TableHead className="w-[5%]"></TableHead>}
</TableRow>
</TableHeader>
@@ -146,7 +146,30 @@ export function PurchaseOrderItemsTable({
</div>
</TableCell>
{/* 總金額 (主要輸入欄位) */}
{/* 換算採購單價 / 基本單位 (顯示換算結果 - SWAPPED HERE) */}
<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>
</TableCell>
{/* 總金額 (主要輸入欄位 - SWAPPED HERE) */}
<TableCell className="text-left">
{isReadOnly ? (
<span className="font-bold text-primary">{formatCurrency(item.subtotal)}</span>
@@ -178,29 +201,6 @@ export function PurchaseOrderItemsTable({
)}
</TableCell>
{/* 換算採購單價 / 基本單位 (顯示換算結果) */}
<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>
</TableCell>
{/* 刪除按鈕 */}
{!isReadOnly && onRemoveItem && (
<TableCell className="text-center">

View File

@@ -147,14 +147,21 @@ export default function InventoryTable({
<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} </span>
<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} </span>
<span className="font-medium text-gray-900">{group.safetyStock} {group.baseUnit}</span>
</span>
</div>
<div>
@@ -193,11 +200,14 @@ export default function InventoryTable({
<TableRow>
<TableHead className="w-[5%]">#</TableHead>
<TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[15%]"></TableHead>
<TableHead className="w-[14%]"></TableHead>
<TableHead className="w-[14%]"></TableHead>
<TableHead className="w-[14%]"></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>
@@ -208,9 +218,12 @@ export default function InventoryTable({
<TableCell className="text-grey-2">{index + 1}</TableCell>
<TableCell>{batch.batchNumber || "-"}</TableCell>
<TableCell>
<span>{batch.quantity}</span>
<span>{batch.quantity} {batch.unit}</span>
</TableCell>
<TableCell>{batch.batchNumber || "-"}</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>

View File

@@ -23,6 +23,7 @@ import { Textarea } from "@/Components/ui/textarea";
import { toast } from "sonner";
import { Warehouse, TransferOrder, TransferOrderStatus } from "@/types/warehouse";
import { validateTransferOrder, validateTransferQuantity } from "@/utils/validation";
import { usePermission } from "@/hooks/usePermission";
export type { TransferOrder };
@@ -42,6 +43,8 @@ interface AvailableProduct {
availableQty: number;
unit: string;
expiryDate: string | null;
unitCost: number; // 新增
totalValue: number; // 新增
}
export default function TransferOrderDialog({
@@ -52,6 +55,9 @@ export default function TransferOrderDialog({
// inventories,
onSave,
}: TransferOrderDialogProps) {
const { can } = usePermission();
const canViewCost = can('inventory.view_cost');
const [formData, setFormData] = useState({
sourceWarehouseId: "",
targetWarehouseId: "",
@@ -106,7 +112,9 @@ export default function TransferOrderDialog({
batchNumber: item.batch_number,
availableQty: item.quantity,
unit: item.unit_name,
expiryDate: item.expiry_date
expiryDate: item.expiry_date,
unitCost: item.unit_cost, // 映射
totalValue: item.total_value, // 映射
}));
setAvailableProducts(mappedData);
})
@@ -249,7 +257,7 @@ export default function TransferOrderDialog({
onValueChange={handleProductChange}
disabled={!formData.sourceWarehouseId || !!order}
options={availableProducts.map((product) => ({
label: `${product.productName} | 批號: ${product.batchNumber || '-'} | 效期: ${product.expiryDate || '-'} (庫存: ${product.availableQty} ${product.unit})`,
label: `${product.productName} | 批號: ${product.batchNumber || '-'} | 效期: ${product.expiryDate || '-'} (庫存: ${product.availableQty} ${product.unit})${canViewCost ? ` | 成本: $${product.unitCost?.toLocaleString()}` : ''}`,
value: `${product.productId}|||${product.batchNumber}`,
}))}
placeholder="選擇商品與批號"

View File

@@ -32,6 +32,15 @@ interface WarehouseCardProps {
onEdit: (warehouse: Warehouse) => void;
}
const WAREHOUSE_TYPE_LABELS: Record<string, string> = {
standard: "標準倉",
production: "生產倉",
retail: "門市倉",
vending: "販賣機",
transit: "在途倉",
quarantine: "瑕疵倉",
};
export default function WarehouseCard({
warehouse,
stats,
@@ -71,6 +80,16 @@ export default function WarehouseCard({
<Info className="h-5 w-5" />
</button>
</div>
<div className="flex gap-2 mt-1">
<Badge variant="outline" className="text-xs font-normal">
{WAREHOUSE_TYPE_LABELS[warehouse.type || 'standard'] || '標準倉'}
</Badge>
{warehouse.type === 'transit' && warehouse.license_plate && (
<Badge variant="secondary" className="text-xs font-normal bg-yellow-100 text-yellow-800 border-yellow-200">
{warehouse.license_plate}
</Badge>
)}
</div>
</div>
</div>
@@ -107,6 +126,14 @@ export default function WarehouseCard({
)}
</div>
</div>
{/* 移動倉司機資訊 */}
{warehouse.type === 'transit' && warehouse.driver_name && (
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<span className="text-sm text-gray-500"></span>
<span className="text-sm font-medium text-gray-900">{warehouse.driver_name}</span>
</div>
)}
</div>
</div>

View File

@@ -1,6 +1,6 @@
/**
* 倉庫對話框元件
* 重構後:加入驗證邏輯
* 重構後:加入驗證邏輯與業務類型支援
*/
import { useEffect, useState } from "react";
@@ -27,9 +27,10 @@ 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 { Warehouse, WarehouseType } from "@/types/warehouse";
import { validateWarehouse } from "@/utils/validation";
import { toast } from "sonner";
import { SearchableSelect } from "@/Components/ui/searchable-select";
interface WarehouseDialogProps {
open: boolean;
@@ -39,6 +40,15 @@ interface WarehouseDialogProps {
onDelete?: (warehouseId: string) => void;
}
const WAREHOUSE_TYPE_OPTIONS: { label: string; value: WarehouseType }[] = [
{ label: "標準倉 (總倉)", value: "standard" },
{ label: "生產倉 (廚房/加工)", value: "production" },
{ label: "門市倉 (前台销售)", value: "retail" },
{ label: "販賣機 (IoT設備)", value: "vending" },
{ label: "在途倉 (物流車)", value: "transit" },
{ label: "瑕疵倉 (報廢/檢驗)", value: "quarantine" },
];
export default function WarehouseDialog({
open,
onOpenChange,
@@ -51,13 +61,19 @@ export default function WarehouseDialog({
name: string;
address: string;
description: string;
type: WarehouseType;
is_sellable: boolean;
license_plate: string;
driver_name: string;
}>({
code: "",
name: "",
address: "",
description: "",
type: "standard",
is_sellable: true,
license_plate: "",
driver_name: "",
});
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -69,7 +85,10 @@ export default function WarehouseDialog({
name: warehouse.name,
address: warehouse.address || "",
description: warehouse.description || "",
type: warehouse.type || "standard",
is_sellable: warehouse.is_sellable ?? true,
license_plate: warehouse.license_plate || "",
driver_name: warehouse.driver_name || "",
});
} else {
setFormData({
@@ -77,7 +96,10 @@ export default function WarehouseDialog({
name: "",
address: "",
description: "",
type: "standard",
is_sellable: true,
license_plate: "",
driver_name: "",
});
}
}, [warehouse, open]);
@@ -136,8 +158,21 @@ export default function WarehouseDialog({
/>
</div>
{/* 倉庫名稱 */}
{/* 倉庫類型 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<SearchableSelect
value={formData.type}
onValueChange={(val) => setFormData({ ...formData, type: val as WarehouseType })}
options={WAREHOUSE_TYPE_OPTIONS}
placeholder="選擇倉庫類型"
className="h-9"
showSearch={false}
/>
</div>
{/* 倉庫名稱 */}
<div className="space-y-2 col-span-2">
<Label htmlFor="name">
<span className="text-red-500">*</span>
</Label>
@@ -147,11 +182,43 @@ export default function WarehouseDialog({
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例:中央倉庫"
required
className="h-9"
/>
</div>
</div>
</div>
{/* 移動倉專屬資訊 */}
{formData.type === 'transit' && (
<div className="space-y-4 bg-yellow-50 p-4 rounded-lg border border-yellow-100">
<div className="border-b border-yellow-200 pb-2">
<h4 className="text-sm text-yellow-800 font-medium"> ()</h4>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="license_plate"></Label>
<Input
id="license_plate"
value={formData.license_plate}
onChange={(e) => setFormData({ ...formData, license_plate: e.target.value })}
placeholder="例ABC-1234"
className="h-9 bg-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="driver_name"></Label>
<Input
id="driver_name"
value={formData.driver_name}
onChange={(e) => setFormData({ ...formData, driver_name: e.target.value })}
placeholder="例:王小明"
className="h-9 bg-white"
/>
</div>
</div>
</div>
)}
{/* 銷售設定 */}
<div className="space-y-4">
<div className="border-b pb-2">
@@ -167,6 +234,9 @@ export default function WarehouseDialog({
/>
<Label htmlFor="is_sellable"></Label>
</div>
<p className="text-xs text-gray-500 ml-6">
POS
</p>
</div>
{/* 區塊 B位置 */}
@@ -186,6 +256,7 @@ export default function WarehouseDialog({
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="例台北市信義區信義路五段7號"
required
className="h-9"
/>
</div>