生產工單BOM以及批號完善
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* 新增庫存頁面(手動入庫)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Trash2, Calendar, ArrowLeft, Save, Boxes } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
@@ -27,11 +27,21 @@ import { getInventoryBreadcrumbs } from "@/utils/breadcrumb";
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
baseUnit: string;
|
||||
largeUnit?: string;
|
||||
conversionRate?: number;
|
||||
}
|
||||
|
||||
interface Batch {
|
||||
inventoryId: string;
|
||||
batchNumber: string;
|
||||
originCountry: string;
|
||||
expiryDate: string | null;
|
||||
quantity: number;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
warehouse: Warehouse;
|
||||
products: Product[];
|
||||
@@ -51,10 +61,61 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
const [notes, setNotes] = useState("");
|
||||
const [items, setItems] = useState<InboundItem[]>([]);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [batchesCache, setBatchesCache] = useState<Record<string, { batches: Batch[], nextSequences: Record<string, string> }>>({});
|
||||
|
||||
// 取得商品批號與流水號
|
||||
const fetchProductBatches = async (productId: string, originCountry?: string, arrivalDate?: string) => {
|
||||
if (!productId) return;
|
||||
|
||||
const country = originCountry || 'TW';
|
||||
const date = arrivalDate || inboundDate.split('T')[0];
|
||||
const cacheKey = `${country}_${date}`;
|
||||
|
||||
// 如果該商品的批號列表尚未載入,強制載入
|
||||
const existingCache = batchesCache[productId];
|
||||
const hasBatches = existingCache && existingCache.batches.length >= 0 && existingCache.batches !== undefined;
|
||||
const hasThisSequence = existingCache?.nextSequences?.[cacheKey];
|
||||
|
||||
// 若 batches 尚未載入,或特定條件的 sequence 尚未載入,則呼叫 API
|
||||
if (!hasBatches || !hasThisSequence) {
|
||||
try {
|
||||
const response = await fetch(`/api/warehouses/${warehouse.id}/inventory/batches/${productId}?originCountry=${country}&arrivalDate=${date}`);
|
||||
const data = await response.json();
|
||||
|
||||
setBatchesCache(prev => {
|
||||
const existingProductCache = prev[productId] || { batches: [], nextSequences: {} };
|
||||
return {
|
||||
...prev,
|
||||
[productId]: {
|
||||
batches: data.batches,
|
||||
nextSequences: {
|
||||
...existingProductCache.nextSequences,
|
||||
[cacheKey]: data.nextSequence
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch batches", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 當 items 變動、日期變動時,確保資料同步
|
||||
useEffect(() => {
|
||||
items.forEach(item => {
|
||||
if (item.productId) {
|
||||
// 無論 batchMode 為何,都要載入批號列表
|
||||
// 若使用者切換到 new 模式,則額外傳入 originCountry 以取得正確流水號
|
||||
const country = item.batchMode === 'new' ? item.originCountry : undefined;
|
||||
fetchProductBatches(item.productId, country, inboundDate.split('T')[0]);
|
||||
}
|
||||
});
|
||||
}, [items, inboundDate]);
|
||||
|
||||
// 新增明細行
|
||||
const handleAddItem = () => {
|
||||
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", baseUnit: "個" };
|
||||
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", code: "", baseUnit: "個" };
|
||||
const newItem: InboundItem = {
|
||||
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
productId: defaultProduct.id,
|
||||
@@ -65,6 +126,8 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
largeUnit: defaultProduct.largeUnit,
|
||||
conversionRate: defaultProduct.conversionRate,
|
||||
selectedUnit: 'base',
|
||||
batchMode: 'existing', // 預設選擇現有批號
|
||||
originCountry: 'TW',
|
||||
};
|
||||
setItems([...items, newItem]);
|
||||
};
|
||||
@@ -96,6 +159,9 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
largeUnit: product.largeUnit,
|
||||
conversionRate: product.conversionRate,
|
||||
selectedUnit: 'base',
|
||||
batchMode: 'existing',
|
||||
inventoryId: undefined, // 清除已選擇的批號
|
||||
expiryDate: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -123,6 +189,12 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
if (item.quantity <= 0) {
|
||||
newErrors[`item-${index}-quantity`] = "數量必須大於 0";
|
||||
}
|
||||
if (item.batchMode === 'existing' && !item.inventoryId) {
|
||||
newErrors[`item-${index}-batch`] = "請選擇批號";
|
||||
}
|
||||
if (item.batchMode === 'new' && !item.originCountry) {
|
||||
newErrors[`item-${index}-country`] = "新批號必須輸入產地";
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
@@ -149,7 +221,9 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
return {
|
||||
productId: item.productId,
|
||||
quantity: finalQuantity,
|
||||
batchNumber: item.batchNumber,
|
||||
batchMode: item.batchMode,
|
||||
inventoryId: item.inventoryId,
|
||||
originCountry: item.originCountry,
|
||||
expiryDate: item.expiryDate
|
||||
};
|
||||
})
|
||||
@@ -165,6 +239,24 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
// 生成批號預覽
|
||||
const getBatchPreview = (productId: string | undefined, productCode: string | undefined, country: string, dateStr: string) => {
|
||||
if (!productCode || !productId) return "--";
|
||||
try {
|
||||
// 直接字串處理,避免時區問題且確保與 fetchProductBatches 的 key 一致
|
||||
const datePart = dateStr.includes('T') ? dateStr.split('T')[0] : dateStr;
|
||||
const [yyyy, mm, dd] = datePart.split('-');
|
||||
const dateFormatted = `${yyyy}${mm}${dd}`;
|
||||
|
||||
const cacheKey = `${country}_${datePart}`;
|
||||
const seq = batchesCache[productId]?.nextSequences?.[cacheKey] || "XX";
|
||||
|
||||
return `${productCode}-${country}-${dateFormatted}-${seq}`;
|
||||
} catch (e) {
|
||||
return "--";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getInventoryBreadcrumbs(warehouse.id, warehouse.name, "手動入庫")}>
|
||||
<Head title={`新增庫存 - ${warehouse.name}`} />
|
||||
@@ -301,17 +393,19 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50/50">
|
||||
<TableHead className="w-[280px]">
|
||||
<TableHead className="w-[180px]">
|
||||
商品 <span className="text-red-500">*</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
<TableHead className="w-[220px]">
|
||||
批號 <span className="text-red-500">*</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">
|
||||
數量 <span className="text-red-500">*</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">單位</TableHead>
|
||||
<TableHead className="w-[150px]">轉換數量</TableHead>
|
||||
<TableHead className="w-[180px]">效期</TableHead>
|
||||
<TableHead className="w-[220px]">批號</TableHead>
|
||||
<TableHead className="w-[60px]"></TableHead>
|
||||
<TableHead className="w-[90px]">單位</TableHead>
|
||||
<TableHead className="w-[100px]">轉換數量</TableHead>
|
||||
<TableHead className="w-[140px]">效期</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -321,6 +415,9 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
? item.quantity * item.conversionRate
|
||||
: item.quantity;
|
||||
|
||||
// Find product code
|
||||
const product = products.find(p => p.id === item.productId);
|
||||
|
||||
return (
|
||||
<TableRow key={item.tempId}>
|
||||
{/* 商品 */}
|
||||
@@ -342,6 +439,73 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* 批號與產地控制 */}
|
||||
<TableCell>
|
||||
<div className="space-y-2">
|
||||
<SearchableSelect
|
||||
value={item.batchMode === 'new' ? 'new_batch' : (item.inventoryId || "")}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'new_batch') {
|
||||
handleUpdateItem(item.tempId, {
|
||||
batchMode: 'new',
|
||||
inventoryId: undefined,
|
||||
originCountry: 'TW',
|
||||
expiryDate: undefined
|
||||
});
|
||||
} else {
|
||||
const selectedBatch = (batchesCache[item.productId]?.batches || []).find(b => b.inventoryId === value);
|
||||
handleUpdateItem(item.tempId, {
|
||||
batchMode: 'existing',
|
||||
inventoryId: value,
|
||||
originCountry: selectedBatch?.originCountry,
|
||||
expiryDate: selectedBatch?.expiryDate || undefined
|
||||
});
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: "+ 建立新批號", value: "new_batch" },
|
||||
...(batchesCache[item.productId]?.batches || []).map(b => ({
|
||||
label: `${b.batchNumber} - 庫存: ${b.quantity}`,
|
||||
value: b.inventoryId
|
||||
}))
|
||||
]}
|
||||
placeholder="選擇或新增批號"
|
||||
className="border-gray-300"
|
||||
/>
|
||||
{errors[`item-${index}-batch`] && (
|
||||
<p className="text-xs text-red-500">
|
||||
{errors[`item-${index}-batch`]}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.batchMode === 'new' && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={item.originCountry || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.toUpperCase().slice(0, 2);
|
||||
handleUpdateItem(item.tempId, { originCountry: val });
|
||||
}}
|
||||
maxLength={2}
|
||||
placeholder="產地"
|
||||
className="h-8 text-xs text-center border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-[3] text-xs bg-primary-50/50 text-primary-main px-2 py-1 rounded border border-primary-200/50 font-mono overflow-hidden whitespace-nowrap">
|
||||
{getBatchPreview(item.productId, product?.code, item.originCountry || 'TW', inboundDate)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.batchMode === 'existing' && item.inventoryId && (
|
||||
<div className="text-xs text-gray-500 px-2 font-mono">
|
||||
效期: {item.expiryDate || '未設定'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* 數量 */}
|
||||
<TableCell>
|
||||
<Input
|
||||
@@ -408,30 +572,12 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
expiryDate: e.target.value,
|
||||
})
|
||||
}
|
||||
className="border-gray-300 pl-9"
|
||||
disabled={item.batchMode === 'existing'}
|
||||
className={`border-gray-300 pl-9 ${item.batchMode === 'existing' ? 'bg-gray-50' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* 批號 */}
|
||||
<TableCell>
|
||||
<Input
|
||||
value={item.batchNumber || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateItem(item.tempId, {
|
||||
batchNumber: e.target.value,
|
||||
})
|
||||
}
|
||||
className="border-gray-300"
|
||||
placeholder="系統自動生成"
|
||||
/>
|
||||
{errors[`item-${index}-batch`] && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors[`item-${index}-batch`]}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* 刪除按鈕 */}
|
||||
<TableCell>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user