770 lines
43 KiB
TypeScript
770 lines
43 KiB
TypeScript
/**
|
||
* 新增庫存頁面(手動入庫)
|
||
*/
|
||
|
||
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";
|
||
import { Label } from "@/Components/ui/label";
|
||
import { Textarea } from "@/Components/ui/textarea";
|
||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/Components/ui/table";
|
||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||
import { Head, Link, router } from "@inertiajs/react";
|
||
import { Warehouse, InboundItem, InboundReason } from "@/types/warehouse";
|
||
import { getCurrentDateTime } from "@/utils/format";
|
||
import { toast } from "sonner";
|
||
import { getInventoryBreadcrumbs } from "@/utils/breadcrumb";
|
||
import ScannerInput from "@/Components/Inventory/ScannerInput";
|
||
|
||
interface Product {
|
||
id: string;
|
||
name: string;
|
||
code: string;
|
||
barcode?: string;
|
||
baseUnit: string;
|
||
largeUnit?: string;
|
||
conversionRate?: number;
|
||
costPrice?: number;
|
||
}
|
||
|
||
interface Batch {
|
||
inventoryId: string;
|
||
batchNumber: string;
|
||
originCountry: string;
|
||
expiryDate: string | null;
|
||
quantity: number;
|
||
isDeleted?: boolean;
|
||
location?: string;
|
||
}
|
||
|
||
interface Props {
|
||
warehouse: Warehouse;
|
||
products: Product[];
|
||
}
|
||
|
||
const INBOUND_REASONS: InboundReason[] = [
|
||
"期初建檔",
|
||
"盤點調整",
|
||
"實際入庫未走採購流程",
|
||
"生產加工成品入庫",
|
||
"其他",
|
||
];
|
||
|
||
export default function AddInventoryPage({ warehouse, products }: Props) {
|
||
const [inboundDate, setInboundDate] = useState(getCurrentDateTime());
|
||
const [reason, setReason] = useState<InboundReason>("期初建檔");
|
||
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 handleScan = async (code: string, mode: 'continuous' | 'single') => {
|
||
const cleanCode = code.trim();
|
||
// 1. 搜尋商品 (優先比對 Code, Barcode, ID)
|
||
let product = products.find(p => p.code === cleanCode || p.barcode === cleanCode || p.id === cleanCode);
|
||
|
||
// 如果前端找不到,嘗試 API 搜尋 (Fallback)
|
||
if (!product) {
|
||
try {
|
||
// 這裡假設有 API 可以搜尋商品,若沒有則會失敗
|
||
// 使用 Product/Index 的搜尋邏輯 (Inertia Props 比較難已 AJAX 取得)
|
||
// 替代方案:直接請求 /products?search=CLEAN_CODE&per_page=1
|
||
// 加上 header 確認是 JSON 請求
|
||
const response = await fetch(`/products?search=${encodeURIComponent(cleanCode)}&per_page=1`, {
|
||
headers: {
|
||
'X-Requested-With': 'XMLHttpRequest', // 強制 AJAX 識別
|
||
}
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
// Inertia 回傳的是 component props 結構,或 partial props
|
||
// 根據 ProductController::index,回傳 props.products.data
|
||
if (data.props && data.props.products && data.props.products.data && data.props.products.data.length > 0) {
|
||
const foundProduct = data.props.products.data[0];
|
||
// 轉換格式以符合 AddInventory 的 Product 介面
|
||
product = {
|
||
id: foundProduct.id,
|
||
name: foundProduct.name,
|
||
code: foundProduct.code,
|
||
barcode: foundProduct.barcode,
|
||
baseUnit: foundProduct.baseUnit?.name || '個',
|
||
largeUnit: foundProduct.largeUnit?.name,
|
||
conversionRate: foundProduct.conversionRate,
|
||
costPrice: foundProduct.costPrice,
|
||
};
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("API Search failed", err);
|
||
}
|
||
}
|
||
|
||
if (!product) {
|
||
toast.error(`找不到商品: ${code}`);
|
||
return;
|
||
}
|
||
|
||
// 2. 連續模式:尋找最近一筆相同商品並 +1
|
||
if (mode === 'continuous') {
|
||
let foundIndex = -1;
|
||
// 從後往前搜尋,找到最近加入的那一筆
|
||
for (let i = items.length - 1; i >= 0; i--) {
|
||
if (items[i].productId === product.id) {
|
||
foundIndex = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (foundIndex !== -1) {
|
||
// 更新數量
|
||
const newItems = [...items];
|
||
const currentQty = newItems[foundIndex].quantity || 0;
|
||
newItems[foundIndex] = {
|
||
...newItems[foundIndex],
|
||
quantity: currentQty + 1
|
||
};
|
||
setItems(newItems);
|
||
toast.success(`${product.name} 數量 +1 (總數: ${currentQty + 1})`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 3. 單筆模式 或 連續模式但尚未加入過:新增一筆
|
||
const newItem: InboundItem = {
|
||
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||
productId: product.id,
|
||
productName: product.name,
|
||
quantity: 1,
|
||
unit: product.baseUnit, // 僅用於顯示當前選擇單位的名稱
|
||
baseUnit: product.baseUnit,
|
||
largeUnit: product.largeUnit,
|
||
conversionRate: product.conversionRate,
|
||
selectedUnit: 'base',
|
||
batchMode: 'existing', // 預設選擇現有批號 (需要使用者確認/輸入)
|
||
originCountry: 'TW',
|
||
unit_cost: product.costPrice || 0,
|
||
};
|
||
setItems(prev => [...prev, newItem]);
|
||
toast.success(`已加入 ${product.name}`);
|
||
};
|
||
|
||
// 新增明細行
|
||
const handleAddItem = () => {
|
||
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", code: "", baseUnit: "個", costPrice: 0 };
|
||
const newItem: InboundItem = {
|
||
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||
productId: defaultProduct.id,
|
||
productName: defaultProduct.name,
|
||
quantity: 0,
|
||
unit: defaultProduct.baseUnit, // 僅用於顯示當前選擇單位的名稱
|
||
baseUnit: defaultProduct.baseUnit,
|
||
largeUnit: defaultProduct.largeUnit,
|
||
conversionRate: defaultProduct.conversionRate,
|
||
selectedUnit: 'base',
|
||
batchMode: 'existing', // 預設選擇現有批號
|
||
originCountry: 'TW',
|
||
unit_cost: defaultProduct.costPrice || 0,
|
||
};
|
||
setItems([...items, newItem]);
|
||
};
|
||
|
||
// 刪除明細行
|
||
const handleRemoveItem = (tempId: string) => {
|
||
setItems(items.filter((item) => item.tempId !== tempId));
|
||
};
|
||
|
||
// 更新明細行
|
||
const handleUpdateItem = (tempId: string, updates: Partial<InboundItem>) => {
|
||
setItems(
|
||
items.map((item) =>
|
||
item.tempId === tempId ? { ...item, ...updates } : item
|
||
)
|
||
);
|
||
};
|
||
|
||
// 處理商品變更
|
||
const handleProductChange = (tempId: string, productId: string) => {
|
||
const product = products.find((p) => p.id === productId);
|
||
|
||
if (product) {
|
||
handleUpdateItem(tempId, {
|
||
productId,
|
||
productName: product.name,
|
||
unit: product.baseUnit,
|
||
baseUnit: product.baseUnit,
|
||
largeUnit: product.largeUnit,
|
||
conversionRate: product.conversionRate,
|
||
selectedUnit: 'base',
|
||
batchMode: 'existing',
|
||
inventoryId: undefined, // 清除已選擇的批號
|
||
expiryDate: undefined,
|
||
unit_cost: product.costPrice || 0,
|
||
});
|
||
}
|
||
};
|
||
|
||
// 驗證表單
|
||
const validateForm = (): boolean => {
|
||
const newErrors: Record<string, string> = {};
|
||
|
||
if (!reason) {
|
||
newErrors.reason = "請選擇入庫原因";
|
||
}
|
||
|
||
if (reason === "其他" && !notes.trim()) {
|
||
newErrors.notes = "原因為「其他」時,備註為必填";
|
||
}
|
||
|
||
if (items.length === 0) {
|
||
newErrors.items = "請至少新增一筆庫存明細";
|
||
}
|
||
|
||
items.forEach((item, index) => {
|
||
if (!item.productId) {
|
||
newErrors[`item-${index}-product`] = "請選擇商品";
|
||
}
|
||
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);
|
||
return Object.keys(newErrors).length === 0;
|
||
};
|
||
|
||
// 處理儲存
|
||
const handleSave = () => {
|
||
if (!validateForm()) {
|
||
toast.error("請檢查表單內容");
|
||
return;
|
||
}
|
||
|
||
router.post(`/warehouses/${warehouse.id}/inventory`, {
|
||
inboundDate,
|
||
reason,
|
||
notes,
|
||
items: items.map(item => {
|
||
// 如果選擇大單位,則換算為基本單位數量
|
||
const finalQuantity = item.selectedUnit === 'large' && item.conversionRate
|
||
? item.quantity * item.conversionRate
|
||
: item.quantity;
|
||
|
||
return {
|
||
productId: item.productId,
|
||
quantity: finalQuantity,
|
||
batchMode: item.batchMode,
|
||
inventoryId: item.inventoryId,
|
||
originCountry: item.originCountry,
|
||
expiryDate: item.expiryDate,
|
||
unit_cost: item.unit_cost,
|
||
location: item.location,
|
||
};
|
||
})
|
||
}, {
|
||
onSuccess: () => {
|
||
toast.success("庫存記錄已儲存");
|
||
router.get(`/warehouses/${warehouse.id}/inventory`);
|
||
},
|
||
onError: (err) => {
|
||
toast.error("儲存失敗,請檢查輸入內容");
|
||
console.error(err);
|
||
}
|
||
});
|
||
};
|
||
|
||
// 生成批號預覽
|
||
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}`} />
|
||
<div className="container mx-auto p-6 max-w-7xl">
|
||
{/* 頁面標題與導航 - 已於先前任務優化 */}
|
||
<div className="mb-6">
|
||
<div className="mb-6">
|
||
<Link href={`/warehouses/${warehouse.id}/inventory`}>
|
||
<Button
|
||
variant="outline"
|
||
className="gap-2 button-outlined-primary"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
返回庫存管理
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||
<Boxes className="h-6 w-6 text-primary-main" />
|
||
新增庫存(手動入庫)
|
||
</h1>
|
||
<p className="text-gray-500 mt-1">
|
||
為 <span className="font-semibold text-gray-900">{warehouse.name}</span> 新增庫存記錄
|
||
</p>
|
||
</div>
|
||
<Button
|
||
onClick={handleSave}
|
||
className="button-filled-primary"
|
||
>
|
||
<Save className="mr-2 h-4 w-4" />
|
||
儲存
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 表單內容 */}
|
||
<div className="space-y-6">
|
||
{/* 基本資訊區塊 */}
|
||
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||
<h3 className="font-semibold text-lg border-b pb-2">基本資訊</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
{/* 倉庫 */}
|
||
<div className="space-y-2">
|
||
<Label className="text-gray-700">倉庫</Label>
|
||
<Input
|
||
value={warehouse.name}
|
||
disabled
|
||
className="bg-gray-50 border-gray-200"
|
||
/>
|
||
</div>
|
||
|
||
{/* 入庫日期 */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="inbound-date" className="text-gray-700">
|
||
入庫日期 <span className="text-red-500">*</span>
|
||
</Label>
|
||
<div className="relative">
|
||
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||
<Input
|
||
id="inbound-date"
|
||
type="datetime-local"
|
||
value={inboundDate}
|
||
onChange={(e) => setInboundDate(e.target.value)}
|
||
className="border-gray-300 pl-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 入庫原因 */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="reason" className="text-gray-700">
|
||
入庫原因 <span className="text-red-500">*</span>
|
||
</Label>
|
||
<SearchableSelect
|
||
value={reason}
|
||
onValueChange={(value) => setReason(value as InboundReason)}
|
||
options={INBOUND_REASONS.map((r) => ({ label: r, value: r }))}
|
||
placeholder="選擇入庫原因"
|
||
className="border-gray-300"
|
||
/>
|
||
{errors.reason && (
|
||
<p className="text-sm text-red-500">{errors.reason}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* 備註 */}
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label htmlFor="notes" className="text-gray-700">
|
||
備註 {reason === "其他" && <span className="text-red-500">*</span>}
|
||
</Label>
|
||
<Textarea
|
||
id="notes"
|
||
value={notes}
|
||
onChange={(e) => setNotes(e.target.value)}
|
||
placeholder="請輸入備註說明..."
|
||
className="border-gray-300 resize-none min-h-[100px]"
|
||
/>
|
||
{errors.notes && (
|
||
<p className="text-sm text-red-500">{errors.notes}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 庫存明細區塊 */}
|
||
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="font-semibold text-lg">庫存明細</h3>
|
||
<p className="text-sm text-gray-500">
|
||
請新增要入庫的商品明細
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
onClick={handleAddItem}
|
||
variant="outline"
|
||
className="button-outlined-primary"
|
||
>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
新增明細
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 掃碼輸入區 */}
|
||
<ScannerInput
|
||
onScan={handleScan}
|
||
className="bg-gray-50/50"
|
||
/>
|
||
|
||
{errors.items && (
|
||
<p className="text-sm text-red-500">{errors.items}</p>
|
||
)}
|
||
|
||
{items.length > 0 ? (
|
||
<div className="border rounded-lg overflow-hidden">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="bg-gray-50/50">
|
||
<TableHead className="w-[180px]">
|
||
商品 <span className="text-red-500">*</span>
|
||
</TableHead>
|
||
<TableHead className="w-[220px]">
|
||
批號 <span className="text-red-500">*</span>
|
||
</TableHead>
|
||
<TableHead className="w-[100px]">
|
||
單價
|
||
</TableHead>
|
||
<TableHead className="w-[100px]">
|
||
數量 <span className="text-red-500">*</span>
|
||
</TableHead>
|
||
<TableHead className="w-[90px]">單位</TableHead>
|
||
<TableHead className="w-[120px]">
|
||
{warehouse.type === 'vending' ? '貨道' : '儲位'}
|
||
</TableHead>
|
||
<TableHead className="w-[50px]"></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{items.map((item, index) => {
|
||
// 計算轉換數量
|
||
const convertedQuantity = item.selectedUnit === 'large' && item.conversionRate
|
||
? item.quantity * item.conversionRate
|
||
: item.quantity;
|
||
|
||
// Find product code
|
||
const product = products.find(p => p.id === item.productId);
|
||
|
||
return (
|
||
<TableRow key={item.tempId}>
|
||
{/* 商品 */}
|
||
<TableCell>
|
||
<SearchableSelect
|
||
value={item.productId}
|
||
onValueChange={(value) =>
|
||
handleProductChange(item.tempId, value)
|
||
}
|
||
options={products.map((p) => ({ label: p.name, value: p.id }))}
|
||
placeholder="選擇商品"
|
||
searchPlaceholder="搜尋商品..."
|
||
className="border-gray-300"
|
||
/>
|
||
{errors[`item-${index}-product`] && (
|
||
<p className="text-xs text-red-500 mt-1">
|
||
{errors[`item-${index}-product`]}
|
||
</p>
|
||
)}
|
||
</TableCell>
|
||
|
||
{/* 批號與產地控制 */}
|
||
<TableCell>
|
||
<div className="space-y-2">
|
||
<SearchableSelect
|
||
value={item.batchMode === 'none' ? 'no_batch' : (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 if (value === 'no_batch') {
|
||
// 嘗試匹配現有的 NO-BATCH 紀錄
|
||
const existingNoBatch = (batchesCache[item.productId]?.batches || []).find(b => b.batchNumber === 'NO-BATCH');
|
||
handleUpdateItem(item.tempId, {
|
||
batchMode: 'none',
|
||
inventoryId: existingNoBatch?.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,
|
||
location: selectedBatch?.location || item.location,
|
||
});
|
||
}
|
||
}}
|
||
options={[
|
||
{ label: "📦 不使用批號 (自動累加)", value: "no_batch" },
|
||
{ label: "+ 建立新批號", value: "new_batch" },
|
||
...(batchesCache[item.productId]?.batches || []).map(b => {
|
||
const isNoBatch = b.batchNumber === 'NO-BATCH';
|
||
const showLocation = isNoBatch || warehouse.type === 'vending';
|
||
const locationInfo = (showLocation && b.location) ? ` [${b.location}]` : '';
|
||
const batchLabel = isNoBatch ? '(無批號紀錄)' : b.batchNumber;
|
||
|
||
return {
|
||
label: `${batchLabel}${locationInfo} - 庫存: ${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 === 'new' && (
|
||
<div className="mt-2 flex items-center gap-2">
|
||
<span className="text-xs text-gray-500 whitespace-nowrap">效期:</span>
|
||
<div className="relative flex-1">
|
||
<Calendar className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-gray-400 pointer-events-none" />
|
||
<Input
|
||
type="date"
|
||
value={item.expiryDate || ""}
|
||
onChange={(e) =>
|
||
handleUpdateItem(item.tempId, {
|
||
expiryDate: e.target.value,
|
||
})
|
||
}
|
||
className="h-8 pl-8 text-xs border-gray-300 w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{item.batchMode === 'none' && (
|
||
<div className="mt-1 px-2 py-1 bg-amber-50 text-amber-700 text-[10px] rounded border border-amber-100 flex items-center gap-1">
|
||
<span className="shrink-0 font-bold">INFO</span>
|
||
系統將自動累加至該商品的通用庫存紀錄
|
||
</div>
|
||
)}
|
||
|
||
{item.batchMode === 'existing' && item.inventoryId && (
|
||
<div className="flex flax-col gap-1 mt-1">
|
||
<div className="text-xs text-gray-500 font-mono">
|
||
效期: {item.expiryDate || '無效期紀錄'}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
|
||
{/* 單價 */}
|
||
<TableCell>
|
||
<Input
|
||
type="number"
|
||
min="0"
|
||
step="any"
|
||
value={item.unit_cost || 0}
|
||
onChange={(e) =>
|
||
handleUpdateItem(item.tempId, {
|
||
unit_cost: parseFloat(e.target.value) || 0,
|
||
})
|
||
}
|
||
className="border-gray-300 bg-gray-50 text-right"
|
||
placeholder="0"
|
||
/>
|
||
</TableCell>
|
||
|
||
{/* 數量 */}
|
||
<TableCell>
|
||
<Input
|
||
type="number"
|
||
min="1"
|
||
step="any"
|
||
value={item.quantity || ""}
|
||
onChange={(e) =>
|
||
handleUpdateItem(item.tempId, {
|
||
quantity: parseFloat(e.target.value) || 0,
|
||
})
|
||
}
|
||
className="border-gray-300 text-right"
|
||
/>
|
||
{item.selectedUnit === 'large' && item.conversionRate && (
|
||
<div className="text-xs text-gray-500 mt-1">
|
||
轉換: {convertedQuantity} {item.baseUnit || "個"}
|
||
</div>
|
||
)}
|
||
{errors[`item-${index}-quantity`] && (
|
||
<p className="text-xs text-red-500 mt-1">
|
||
{errors[`item-${index}-quantity`]}
|
||
</p>
|
||
)}
|
||
</TableCell>
|
||
|
||
{/* 單位 */}
|
||
<TableCell>
|
||
{item.largeUnit ? (
|
||
<SearchableSelect
|
||
value={item.selectedUnit || ""}
|
||
onValueChange={(value) =>
|
||
handleUpdateItem(item.tempId, {
|
||
selectedUnit: value as 'base' | 'large',
|
||
unit: value === 'base' ? item.baseUnit : item.largeUnit
|
||
})
|
||
}
|
||
options={[
|
||
{ label: item.baseUnit || "個", value: "base" },
|
||
{ label: item.largeUnit || "", value: "large" }
|
||
]}
|
||
className="border-gray-300"
|
||
/>
|
||
) : (
|
||
<div className="text-sm text-gray-700 font-medium px-3 py-2 bg-gray-50 border border-gray-200 rounded-md">
|
||
{item.baseUnit || "個"}
|
||
</div>
|
||
)}
|
||
</TableCell>
|
||
|
||
{/* 儲位/貨道 */}
|
||
<TableCell>
|
||
<Input
|
||
value={item.location || ""}
|
||
onChange={(e) => handleUpdateItem(item.tempId, { location: e.target.value })}
|
||
className="border-gray-300"
|
||
placeholder={warehouse.type === 'vending' ? "貨道 (如: A1)" : "儲位 (選填)"}
|
||
/>
|
||
</TableCell>
|
||
|
||
{/* 刪除按鈕 */}
|
||
<TableCell>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={() => handleRemoveItem(item.tempId)}
|
||
className="button-outlined-error h-8 w-8"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
) : (
|
||
<div className="border border-dashed rounded-lg p-12 text-center text-gray-500 bg-gray-50/30">
|
||
<p className="text-base font-medium">尚無明細</p>
|
||
<p className="text-sm mt-1">請點擊右上方「新增明細」按鈕加入商品</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</AuthenticatedLayout >
|
||
);
|
||
}
|