Files
star-erp/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx
sky121113 f543b98d0f
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 57s
ERP-Deploy-Production / deploy-production (push) Successful in 1m14s
feat(inventory): 實作進貨單草稿編輯功能、價格雙向連動與批號 UI 優化
1. 實作進貨單編輯功能,支援草稿資料預填與 PUT 更新。
2. 修復進貨單儲存時 received_date 與 expiry_date 的日期格式錯誤 (Y-m-d)。
3. 實作非標準進貨類型(雜項、其他)的單價與小計雙向連動邏輯。
4. 優化品項批號 UI 為 SearchableSelect 整合模式,支援不使用批號 (NO-BATCH) 與建立新批號,與倉庫管理頁面風格統一。
2026-03-03 16:57:28 +08:00

987 lines
61 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.
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm, Link, router } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { SearchableSelect } from '@/Components/ui/searchable-select';
import React, { useState, useEffect } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/Components/ui/table';
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Plus,
Trash2,
Calendar as CalendarIcon,
Save,
ArrowLeft,
Package
} from 'lucide-react';
import axios from 'axios';
import { PurchaseOrderStatus } from '@/types/purchase-order';
import { STATUS_CONFIG } from '@/constants/purchase-order';
import { DuplicateWarningDialog } from './components/DuplicateWarningDialog';
// 待進貨採購單 Item 介面
interface PendingPOItem {
id: number;
product_id: number;
product_name: string;
product_code: string;
unit: string;
quantity: number;
received_quantity: number;
remaining: number;
unit_price: number;
batchMode?: 'existing' | 'new' | 'none';
originCountry?: string; // For new batch generation
}
// 待進貨採購單介面
interface PendingPO {
id: number;
code: string;
status: PurchaseOrderStatus;
vendor_id: number;
vendor_name: string;
warehouse_id: number | null;
order_date: string;
items: PendingPOItem[];
}
// 廠商介面
interface Vendor {
id: number;
name: string;
code: string;
}
// 編輯模式的進貨單資料
interface ReceiptData {
id: number;
code: string;
type: string;
warehouse_id: number;
vendor_id: number | null;
vendor: Vendor | null;
purchase_order_id: number | null;
purchase_order?: any;
received_date: string;
remarks: string;
items: any[];
}
interface Props {
warehouses: { id: number; name: string; type: string }[];
pendingPurchaseOrders: PendingPO[];
vendors: Vendor[];
receipt?: ReceiptData;
}
export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, vendors, receipt }: Props) {
const isEditMode = !!receipt;
const [selectedPO, setSelectedPO] = useState<PendingPO | null>(null);
const [selectedVendor, setSelectedVendor] = useState<Vendor | null>(null);
// 全商品清單(用於雜項入庫/其他類型的 SearchableSelect
const [allProducts, setAllProducts] = useState<any[]>([]);
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
// Duplicate Check States
const [warningOpen, setWarningOpen] = useState(false);
const [warnings, setWarnings] = useState<any[]>([]);
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
const { data, setData, processing, errors } = useForm({
type: receipt?.type || 'standard',
warehouse_id: receipt?.warehouse_id?.toString() || '',
purchase_order_id: receipt?.purchase_order_id?.toString() || '',
vendor_id: receipt?.vendor_id?.toString() || '',
received_date: receipt?.received_date || new Date().toISOString().split('T')[0],
remarks: receipt?.remarks || '',
items: (receipt?.items || []) as any[],
});
// 編輯模式下初始化 vendor 與 PO 狀態
useEffect(() => {
if (isEditMode) {
if (receipt?.vendor) {
setSelectedVendor(receipt.vendor);
}
if (receipt?.purchase_order) {
setSelectedPO(receipt.purchase_order);
}
}
}, []);
// 判斷是否為非標準採購類型
const isNonStandard = data.type !== 'standard';
// 載入所有商品(用於雜項入庫/其他類型的 SearchableSelect
const fetchAllProducts = async () => {
setIsLoadingProducts(true);
try {
const response = await axios.get(route('goods-receipts.search-products'), {
params: { query: '*' },
});
setAllProducts(response.data);
} catch (error) {
console.error('Failed to load products', error);
} finally {
setIsLoadingProducts(false);
}
};
// 當選擇非標準類型且已選供應商時,載入所有商品
useEffect(() => {
if (isNonStandard && selectedVendor) {
fetchAllProducts();
}
}, [isNonStandard, selectedVendor]);
// 選擇採購單
const handleSelectPO = (po: PendingPO) => {
setSelectedPO(po);
// 將採購單項目轉換為進貨單項目,預填剩餘可收貨量
const pendingItems = po.items.map((item) => ({
product_id: item.product_id,
purchase_order_item_id: item.id,
product_name: item.product_name,
product_code: item.product_code,
unit: item.unit,
quantity_ordered: item.quantity,
quantity_received_so_far: item.received_quantity,
quantity_received: item.remaining, // 預填剩餘量
unit_price: item.unit_price,
batch_number: '',
batchMode: 'new',
originCountry: 'TW',
expiry_date: '',
}));
setData((prev) => ({
...prev,
purchase_order_id: po.id.toString(),
vendor_id: po.vendor_id.toString(),
warehouse_id: po.warehouse_id ? po.warehouse_id.toString() : prev.warehouse_id,
items: pendingItems,
}));
};
// 選擇廠商(雜項入庫/其他)
const handleSelectVendor = (vendorId: string) => {
const vendor = vendors.find(v => v.id.toString() === vendorId);
if (vendor) {
setSelectedVendor(vendor);
setData('vendor_id', vendor.id.toString());
}
};
const handleAddEmptyItem = () => {
const newItem = {
product_id: '',
product_name: '',
product_code: '',
quantity_received: 0,
unit_price: 0,
subtotal: 0,
batch_number: '',
batchMode: 'new',
originCountry: 'TW',
expiry_date: '',
};
setData('items', [...data.items, newItem]);
};
// 選擇商品後填入該列(用於空白列的 SearchableSelect
const handleSelectProduct = (index: number, productId: string) => {
const product = allProducts.find(p => p.id.toString() === productId);
if (product) {
const newItems = [...data.items];
newItems[index] = {
...newItems[index],
product_id: product.id,
product_name: product.name,
product_code: product.code,
unit_price: product.price || 0,
};
setData('items', newItems);
}
};
const removeItem = (index: number) => {
const newItems = [...data.items];
newItems.splice(index, 1);
setData('items', newItems);
};
const updateItem = (index: number, field: string, value: any) => {
const newItems = [...data.items];
const item = { ...newItems[index], [field]: value };
const qty = parseFloat(item.quantity_received) || 0;
const price = parseFloat(item.unit_price) || 0;
const subtotal = parseFloat(item.subtotal) || 0;
if (field === 'quantity_received') {
// 修改數量 -> 更新小計 (保持單價)
item.subtotal = (qty * price).toString();
} else if (field === 'unit_price') {
// 修改單價 -> 更新小計 (價格 * 數量)
item.subtotal = (qty * price).toString();
} else if (field === 'subtotal') {
// 修改小計 -> 更新單價 (小計 / 數量)
if (qty > 0) {
item.unit_price = (subtotal / qty).toString();
}
}
newItems[index] = item;
setData('items', newItems);
};
// Generate batch preview (Added)
const getBatchPreview = (productId: number, productCode: string, country: string, dateStr: string) => {
if (!productCode || !productId) return "--";
try {
const datePart = dateStr.includes('T') ? dateStr.split('T')[0] : dateStr;
const [yyyy, mm, dd] = datePart.split('-');
const dateFormatted = `${yyyy}${mm}${dd}`;
const seqKey = `${productId}-${country}-${datePart}`;
// Handle sequence. Note: nextSequences values are numbers.
const seq = nextSequences[seqKey]?.toString().padStart(2, '0') || "01";
return `${productCode}-${country}-${dateFormatted}-${seq}`;
} catch (e) {
return "--";
}
};
// Batch management
const [nextSequences, setNextSequences] = useState<Record<string, number>>({});
// Fetch batches and sequence for a product
const fetchProductBatches = async (productId: number, country: string = 'TW', dateStr: string = '') => {
if (!data.warehouse_id) return;
// const cacheKey = `${productId}-${data.warehouse_id}`; // Unused
try {
const today = new Date().toISOString().split('T')[0];
const targetDate = dateStr || data.received_date || today;
// Adjust API endpoint to match AddInventory logic
// Assuming GoodsReceiptController or existing WarehouseController can handle this.
// Using the same endpoint as AddInventory: /api/warehouses/{id}/inventory/batches/{productId}
const response = await axios.get(
`/api/warehouses/${data.warehouse_id}/inventory/batches/${productId}`,
{
params: {
origin_country: country,
arrivalDate: targetDate
}
}
);
if (response.data) {
// Remove unused batch cache update
// Update next sequence for new batch generation
if (response.data.nextSequence !== undefined) {
const seqKey = `${productId}-${country}-${targetDate}`;
setNextSequences(prev => ({
...prev,
[seqKey]: parseInt(response.data.nextSequence)
}));
}
}
} catch (error) {
console.error("Failed to fetch batches", error);
}
};
// Trigger batch fetch when relevant fields change
useEffect(() => {
data.items.forEach(item => {
if (item.product_id && data.warehouse_id) {
const country = item.originCountry || 'TW';
const date = data.received_date;
fetchProductBatches(item.product_id, country, date);
}
});
}, [data.items.length, data.warehouse_id, data.received_date, JSON.stringify(data.items.map(i => i.originCountry))]);
useEffect(() => {
data.items.forEach((item, index) => {
if (item.batchMode === 'none') {
if (item.batch_number !== 'NO-BATCH') {
const newItems = [...data.items];
newItems[index].batch_number = 'NO-BATCH';
setData('items', newItems);
}
} else if (item.batchMode === 'new' && item.originCountry && data.received_date) {
const country = item.originCountry;
// Use date from form or today
const dateStr = data.received_date || new Date().toISOString().split('T')[0];
const seqKey = `${item.product_id}-${country}-${dateStr}`;
const seq = nextSequences[seqKey]?.toString().padStart(3, '0') || '001';
const datePart = dateStr.replace(/-/g, '');
const generatedBatch = `${item.product_code}-${country}-${datePart}-${seq}`;
if (item.batch_number !== generatedBatch) {
const newItems = [...data.items];
newItems[index].batch_number = generatedBatch;
setData('items', newItems);
}
}
});
}, [nextSequences, JSON.stringify(data.items.map(i => ({ m: i.batchMode, c: i.originCountry, s: i.product_code, p: i.product_id }))), data.received_date]);
const submit = async (e: React.FormEvent, force: boolean = false) => {
if (e) e.preventDefault();
// 格式化日期數據
const formattedItems = data.items.map(item => ({
...item,
expiry_date: item.expiry_date && item.expiry_date.includes('T')
? item.expiry_date.split('T')[0]
: item.expiry_date
}));
const formattedDate = data.received_date.includes('T')
? data.received_date.split('T')[0]
: data.received_date;
// 建立一個臨時的提交資料對象
const submitData = {
...data,
received_date: formattedDate,
items: formattedItems
};
// 編輯模式直接 PUT 更新
if (isEditMode) {
// 使用 router.put 因為 useForm 的 put 不支援傳入自定義 data 物件而不影響 state
// 或者先 setData 再 put但 setData 是非同步的
// 這裡採用在提交前手動傳遞格式化後的資料給 router
router.put(route('goods-receipts.update', receipt!.id), submitData, {
onSuccess: () => setWarningOpen(false),
});
return;
}
// 新增模式:先檢查重複
if (!force) {
setIsCheckingDuplicate(true);
try {
const response = await axios.post(route('goods-receipts.check-duplicate'), submitData);
if (response.data.has_warnings) {
setWarnings(response.data.warnings);
setWarningOpen(true);
return;
}
} catch (error) {
console.error("Duplicate check failed", error);
} finally {
setIsCheckingDuplicate(false);
}
}
router.post(route('goods-receipts.store'), submitData, {
onSuccess: () => setWarningOpen(false),
});
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '供應鏈管理', href: '#' },
{ label: '進貨單管理', href: route('goods-receipts.index') },
{ label: isEditMode ? `編輯進貨單: ${receipt!.code}` : '新增進貨單', href: isEditMode ? route('goods-receipts.edit', receipt!.id) : route('goods-receipts.create'), isPage: true },
]}
>
<Head title={isEditMode ? `編輯進貨單 - ${receipt!.code}` : '新增進貨單'} />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<div className="mb-6">
<Link href={route('goods-receipts.index')}>
<Button variant="outline" type="button" className="gap-2 mb-4 w-fit button-outlined-primary" onClick={(e) => {
if (isEditMode) {
e.preventDefault();
window.history.back();
}
}}>
<ArrowLeft className="h-4 w-4" />
{isEditMode ? '返回' : '返回進貨單'}
</Button>
</Link>
<div className="mb-4">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Package className="h-6 w-6 text-primary-main" />
{isEditMode ? `編輯進貨單: ${receipt!.code}` : '新增進貨單'}
</h1>
<p className="text-gray-500 mt-1">
{isEditMode ? '修改進貨單內容' : '建立新的進貨單並入庫'}
</p>
</div>
</div>
<div className="space-y-6">
{/* Step 0: Select Type */}
<div className="bg-white rounded-lg border shadow-sm p-6">
<Label className="text-sm font-bold mb-3 block"></Label>
<div className="flex gap-4">
{[
{ id: 'standard', label: '標準採購', desc: '從採購單帶入' },
{ id: 'miscellaneous', label: '雜項入庫', desc: '非採購之入庫' },
{ id: 'other', label: '其他', desc: '其他原因入庫' },
].map((t) => (
<button
key={t.id}
onClick={() => {
if (isEditMode) return; // 編輯模式禁止切換類型
setData((prev) => ({
...prev,
type: t.id,
purchase_order_id: '',
items: [],
vendor_id: t.id === 'standard' ? prev.vendor_id : '',
}));
setSelectedPO(null);
if (t.id !== 'standard') setSelectedVendor(null);
}}
disabled={isEditMode}
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all ${data.type === t.id
? 'border-primary-main bg-primary-main/5'
: 'border-gray-100 hover:border-gray-200'
} ${isEditMode ? 'cursor-not-allowed opacity-60' : ''}`}
>
<div className={`font-bold ${data.type === t.id ? 'text-primary-main' : 'text-gray-700'}`}>
{t.label}
</div>
<div className="text-xs text-gray-500">{t.desc}</div>
</button>
))}
</div>
</div>
{/* Step 1: Source Selection */}
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${(data.type === 'standard' ? !!selectedPO : !!selectedVendor)
? 'bg-green-500 text-white shadow-sm' : 'bg-primary-main text-white shadow-sm'}`}>
{(data.type === 'standard' ? selectedPO : selectedVendor) ? '✓' : '1'}
</div>
<h2 className="text-lg font-bold text-gray-800">
{data.type === 'standard' ? '選擇來源採購單' : '選擇供應商'}
</h2>
</div>
<div className="p-6">
{data.type === 'standard' ? (
!selectedPO ? (
<div className="space-y-4">
<Label className="text-sm font-medium text-gray-700"></Label>
{pendingPurchaseOrders.length === 0 ? (
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendingPurchaseOrders.map((po) => (
<TableRow key={po.id} className="hover:bg-gray-50/50">
<TableCell className="font-medium text-primary-main">{po.code}</TableCell>
<TableCell>{po.vendor_name}</TableCell>
<TableCell className="text-center">
<StatusBadge variant={STATUS_CONFIG[po.status]?.variant || 'neutral'}>
{STATUS_CONFIG[po.status]?.label || po.status}
</StatusBadge>
</TableCell>
<TableCell className="text-center text-gray-600">
{po.items.length}
</TableCell>
<TableCell className="text-center">
<Button size="sm" onClick={() => handleSelectPO(po)} className="button-filled-primary">
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
) : (
<div className="flex items-center justify-between bg-primary-main/5 p-4 rounded-xl border border-primary-main/20">
<div className="flex gap-8">
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-primary-main">{selectedPO.code}</span>
</div>
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-gray-800">{selectedPO.vendor_name}</span>
</div>
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-gray-800">{selectedPO.items.length} </span>
</div>
</div>
{!isEditMode && (
<Button variant="ghost" size="sm" onClick={() => setSelectedPO(null)} className="text-gray-500 hover:text-red-500">
</Button>
)}
</div>
)
) : (
!selectedVendor ? (
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<SearchableSelect
value=""
onValueChange={handleSelectVendor}
options={vendors.map(v => ({
label: `${v.name} (${v.code})`,
value: v.id.toString()
}))}
placeholder="選擇供應商..."
searchPlaceholder="搜尋供應商..."
className="h-9 w-full max-w-md"
/>
</div>
{vendors.length === 0 && (
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
</div>
)}
</div>
) : (
<div className="flex items-center justify-between bg-primary-main/5 p-4 rounded-xl border border-primary-main/20">
<div className="flex gap-8">
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-primary-main">{selectedVendor.name}</span>
</div>
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-gray-800">{selectedVendor.code}</span>
</div>
</div>
{!isEditMode && (
<Button variant="ghost" size="sm" onClick={() => setSelectedVendor(null)} className="text-gray-500 hover:text-red-500">
</Button>
)}
</div>
)
)}
</div>
</div>
{/* Step 2: Details & Items */}
{(isEditMode || (data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && (
<div className="bg-white rounded-lg border shadow-sm overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary-main text-white flex items-center justify-center font-bold text-sm shadow-sm">2</div>
<h2 className="text-lg font-bold text-gray-800"></h2>
</div>
<div className="p-6 space-y-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label htmlFor="warehouse_id"> <span className="text-red-500">*</span></Label>
<Select
value={data.warehouse_id}
onValueChange={(val) => setData('warehouse_id', val)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="選擇倉庫" />
</SelectTrigger>
<SelectContent>
{warehouses.map(w => (
<SelectItem key={w.id} value={w.id.toString()}>{w.name} ({w.type})</SelectItem>
))}
</SelectContent>
</Select>
{errors.warehouse_id && <p className="text-red-500 text-xs">{errors.warehouse_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="received_date"> <span className="text-red-500">*</span></Label>
<div className="relative">
<CalendarIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={data.received_date}
onChange={(e) => setData('received_date', e.target.value)}
className="pl-9 h-9 block w-full"
/>
</div>
{errors.received_date && <p className="text-red-500 text-xs">{errors.received_date}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="remarks"></Label>
<Input
value={data.remarks}
onChange={(e) => setData('remarks', e.target.value)}
className="h-9"
placeholder="選填..."
/>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-bold text-gray-700"></h3>
{isNonStandard && (
<Button
onClick={handleAddEmptyItem}
className="button-filled-primary h-10 gap-2"
>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
{/* Calculated Totals for usage in Table Footer or Summary */}
{(() => {
const subTotal = data.items.reduce((acc, item) => {
if (isNonStandard) {
// 非標準類型:使用手動輸入的小計
return acc + (parseFloat(item.subtotal) || 0);
}
// 標準類型:自動計算 qty × price
const qty = parseFloat(item.quantity_received) || 0;
const price = parseFloat(item.unit_price) || 0;
return acc + (qty * price);
}, 0);
const taxAmount = Math.round(subTotal * 0.05);
const grandTotal = subTotal + taxAmount;
return (
<>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50/50">
<TableRow>
<TableHead className={isNonStandard ? 'w-[220px]' : 'w-[180px]'}></TableHead>
{!isNonStandard && (
<>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
</>
)}
<TableHead className="w-[120px]">{isNonStandard ? '數量' : '本次收貨'} <span className="text-red-500">*</span></TableHead>
{isNonStandard && (
<TableHead className="w-[100px]"></TableHead>
)}
<TableHead className="w-[200px]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className={isNonStandard ? 'w-[120px]' : 'w-[80px] text-right'}>{isNonStandard && <> <span className="text-red-500">*</span></>}</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={isNonStandard ? 6 : 8} className="text-center py-8 text-gray-400 italic">
{isNonStandard ? '尚無明細,請點擊「新增品項」加入。' : '尚無明細,請搜尋商品加入。'}
</TableCell>
</TableRow>
) : (
data.items.map((item, index) => {
const errorKey = `items.${index}.quantity_received` as keyof typeof errors;
const itemTotal = (parseFloat(item.quantity_received || 0) * parseFloat(item.unit_price || 0));
return (
<TableRow key={index} className="hover:bg-gray-50/50 text-sm">
{/* Product Info */}
<TableCell>
{isNonStandard && !item.product_id ? (
<SearchableSelect
value=""
onValueChange={(val) => handleSelectProduct(index, val)}
options={allProducts.map(p => ({
label: `${p.name} (${p.code})`,
value: p.id.toString()
}))}
placeholder={isLoadingProducts ? '載入中...' : '選擇商品...'}
searchPlaceholder="搜尋商品名稱或代碼..."
className="w-full"
/>
) : (
<div className="flex flex-col">
<span className="font-medium text-gray-900">{item.product_name}</span>
<span className="text-xs text-gray-500">{item.product_code}</span>
</div>
)}
</TableCell>
{/* Total Quantity & Remaining - 僅標準採購顯示 */}
{!isNonStandard && (
<>
<TableCell className="text-center">
<span className="text-gray-500 text-sm">
{Math.round(item.quantity_ordered)}
</span>
</TableCell>
<TableCell className="text-center">
<span className="text-gray-900 font-medium text-sm">
{Math.round(item.quantity_ordered - item.quantity_received_so_far)}
</span>
</TableCell>
</>
)}
{/* Received Quantity */}
<TableCell>
<Input
type="number"
step="any"
min="0"
value={item.quantity_received}
onChange={(e) => updateItem(index, 'quantity_received', e.target.value)}
className={`w-full text-right ${errors && (errors as any)[errorKey] ? 'border-red-500' : ''}`}
/>
{(errors as any)[errorKey] && (
<p className="text-xs text-red-500 mt-1">{(errors as any)[errorKey]}</p>
)}
</TableCell>
{/* Unit Price - 僅非標準類型顯示 */}
{isNonStandard && (
<TableCell>
<Input
type="number"
step="any"
min="0"
value={item.unit_price || ''}
onChange={(e) => updateItem(index, 'unit_price', e.target.value)}
className="w-full text-right"
placeholder="0"
/>
</TableCell>
)}
{/* Batch Settings */}
<TableCell>
<div className="flex flex-col gap-2">
{/* 統一批號選擇器 */}
<SearchableSelect
value={item.batchMode === 'none' ? 'no_batch' : (item.batchMode === 'new' ? 'new_batch' : (item.inventory_id || ""))}
onValueChange={(value) => {
if (value === 'new_batch') {
const updatedItem = {
...item,
batchMode: 'new',
inventory_id: undefined,
originCountry: 'TW',
expiry_date: '',
};
const newItems = [...data.items];
newItems[index] = updatedItem;
setData('items', newItems);
} else if (value === 'no_batch') {
const updatedItem = {
...item,
batchMode: 'none',
batch_number: 'NO-BATCH',
inventory_id: undefined,
originCountry: 'TW',
expiry_date: '',
};
const newItems = [...data.items];
newItems[index] = updatedItem;
setData('items', newItems);
} else {
// 選擇現有批號 (如果有快照的話,目前架構下先保留 basic 選項)
updateItem(index, 'batchMode', 'existing');
updateItem(index, 'inventory_id', value);
}
}}
options={[
{ label: "📦 不使用批號 (自動累加)", value: "no_batch" },
{ label: "+ 建立新批號", value: "new_batch" },
// 若有現有批號列表可在此擴充,目前進貨單主要處理 new/none
]}
placeholder="選擇或建立批號"
className="border-gray-200"
/>
{/* 建立新批號時的附加欄位 */}
{item.batchMode === 'new' && (
<div className="flex items-center gap-2">
<Input
value={item.originCountry || 'TW'}
onChange={(e) => updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))}
placeholder="產地"
maxLength={2}
className="w-12 h-8 text-center px-1 text-xs"
/>
<div className="flex-1 text-[10px] font-mono px-2 py-1.5 rounded truncate bg-primary-50 text-primary-main border border-primary-100 flex items-center">
{getBatchPreview(item.product_id, item.product_code, item.originCountry || 'TW', data.received_date)}
</div>
</div>
)}
{/* 不使用批號時的提示 */}
{item.batchMode === 'none' && (
<p className="text-[10px] text-amber-600 bg-amber-50/50 px-2 py-1 rounded border border-amber-100/50">
</p>
)}
</div>
</TableCell>
{/* Expiry Date */}
<TableCell>
<div className="relative">
<CalendarIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={item.expiry_date}
onChange={(e) => updateItem(index, 'expiry_date', e.target.value)}
className={`pl-9 ${item.batchMode === 'existing' ? 'bg-gray-50' : ''}`}
disabled={item.batchMode === 'existing'}
/>
</div>
</TableCell>
{/* Subtotal */}
<TableCell className={isNonStandard ? '' : 'text-right font-medium'}>
{isNonStandard ? (
<Input
type="number"
step="any"
min="0"
value={item.subtotal || ''}
onChange={(e) => updateItem(index, 'subtotal', e.target.value)}
className="w-full text-right"
placeholder="0"
/>
) : (
<span>${itemTotal.toLocaleString()}</span>
)}
</TableCell>
{/* Actions */}
<TableCell>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeItem(index)}
className="hover:bg-red-50 hover:text-red-600 h-8 w-8"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<div className="mt-6 flex justify-end">
<div className="w-full max-w-sm bg-primary/5 px-6 py-4 rounded-xl border border-primary/10 flex flex-col gap-3">
<div className="flex justify-between items-center w-full">
<span className="text-sm text-gray-500 font-medium"></span>
<span className="text-lg font-bold text-gray-700">${subTotal.toLocaleString()}</span>
</div>
<div className="flex justify-between items-center w-full">
<span className="text-sm text-gray-500 font-medium"> (5%)</span>
<span className="text-lg font-bold text-gray-700">${taxAmount.toLocaleString()}</span>
</div>
<div className="h-px bg-primary/10 w-full my-1"></div>
<div className="flex justify-between items-end w-full">
<span className="text-sm text-gray-500 font-medium mb-1"></span>
<span className="text-2xl font-black text-primary">
${grandTotal.toLocaleString()}
</span>
</div>
</div>
</div>
</>
);
})()}
</div>
</div>
</div>
)}
</div>
{/* Bottom Action Bar */}
<div className="flex items-center justify-end gap-4 py-6 border-t border-gray-100 mt-6">
<Button variant="ghost" className="h-11 px-6 text-gray-500 hover:text-gray-700" onClick={() => window.history.back()}>
</Button>
<Button
size="lg"
className="button-filled-primary px-12 h-14 rounded-xl shadow-lg text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]"
onClick={submit}
disabled={processing || isCheckingDuplicate || (!isEditMode && (data.type === 'standard' ? !selectedPO : !selectedVendor))}
>
<Save className="mr-2 h-5 w-5" />
{processing || isCheckingDuplicate ? '處理中...' : (isEditMode ? '儲存變更' : '確認進貨')}
</Button>
</div>
</div>
<DuplicateWarningDialog
open={warningOpen}
onClose={() => setWarningOpen(false)}
onConfirm={() => submit(null as any, true)}
warnings={warnings}
processing={processing}
/>
</AuthenticatedLayout >
);
}