feat(inventory): 實作進貨單草稿編輯功能、價格雙向連動與批號 UI 優化
1. 實作進貨單編輯功能,支援草稿資料預填與 PUT 更新。 2. 修復進貨單儲存時 received_date 與 expiry_date 的日期格式錯誤 (Y-m-d)。 3. 實作非標準進貨類型(雜項、其他)的單價與小計雙向連動邏輯。 4. 優化品項批號 UI 為 SearchableSelect 整合模式,支援不使用批號 (NO-BATCH) 與建立新批號,與倉庫管理頁面風格統一。
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, useForm, Link } from '@inertiajs/react';
|
||||
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';
|
||||
@@ -25,7 +25,7 @@ import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
|
||||
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Trash2,
|
||||
Calendar as CalendarIcon,
|
||||
Save,
|
||||
@@ -52,7 +52,7 @@ interface PendingPOItem {
|
||||
received_quantity: number;
|
||||
remaining: number;
|
||||
unit_price: number;
|
||||
batchMode?: 'existing' | 'new';
|
||||
batchMode?: 'existing' | 'new' | 'none';
|
||||
originCountry?: string; // For new batch generation
|
||||
}
|
||||
|
||||
@@ -75,52 +75,92 @@ interface Vendor {
|
||||
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 }: Props) {
|
||||
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);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Manual Product Search States
|
||||
const [productSearch, setProductSearch] = useState('');
|
||||
const [foundProducts, setFoundProducts] = useState<any[]>([]);
|
||||
|
||||
// 全商品清單(用於雜項入庫/其他類型的 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, post, processing, errors } = useForm({
|
||||
type: 'standard', // 'standard', 'miscellaneous', 'other'
|
||||
warehouse_id: '',
|
||||
purchase_order_id: '',
|
||||
vendor_id: '',
|
||||
received_date: new Date().toISOString().split('T')[0],
|
||||
remarks: '',
|
||||
items: [] as any[],
|
||||
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[],
|
||||
});
|
||||
|
||||
// 搜尋商品 API(用於雜項入庫/其他類型)
|
||||
const searchProducts = async () => {
|
||||
if (!productSearch) return;
|
||||
setIsSearching(true);
|
||||
// 編輯模式下初始化 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: productSearch },
|
||||
params: { query: '*' },
|
||||
});
|
||||
setFoundProducts(response.data);
|
||||
setAllProducts(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to search products', error);
|
||||
console.error('Failed to load products', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
setIsLoadingProducts(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 當選擇非標準類型且已選供應商時,載入所有商品
|
||||
useEffect(() => {
|
||||
if (isNonStandard && selectedVendor) {
|
||||
fetchAllProducts();
|
||||
}
|
||||
}, [isNonStandard, selectedVendor]);
|
||||
|
||||
// 選擇採購單
|
||||
const handleSelectPO = (po: PendingPO) => {
|
||||
setSelectedPO(po);
|
||||
@@ -159,21 +199,37 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddProduct = (product: any) => {
|
||||
|
||||
const handleAddEmptyItem = () => {
|
||||
const newItem = {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
product_code: product.code,
|
||||
product_id: '',
|
||||
product_name: '',
|
||||
product_code: '',
|
||||
quantity_received: 0,
|
||||
unit_price: product.price || 0,
|
||||
unit_price: 0,
|
||||
subtotal: 0,
|
||||
batch_number: '',
|
||||
batchMode: 'new',
|
||||
originCountry: 'TW',
|
||||
expiry_date: '',
|
||||
};
|
||||
setData('items', [...data.items, newItem]);
|
||||
setFoundProducts([]);
|
||||
setProductSearch('');
|
||||
};
|
||||
|
||||
// 選擇商品後填入該列(用於空白列的 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) => {
|
||||
@@ -184,7 +240,26 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
|
||||
const updateItem = (index: number, field: string, value: any) => {
|
||||
const newItems = [...data.items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -261,23 +336,23 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
|
||||
useEffect(() => {
|
||||
data.items.forEach((item, index) => {
|
||||
if (item.batchMode === 'new' && item.originCountry && data.received_date) {
|
||||
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';
|
||||
|
||||
// Only generate if we have a sequence (or default)
|
||||
// Note: fetch might not have returned yet, so seq might be default 001 until fetch updates nextSequences
|
||||
|
||||
const datePart = dateStr.replace(/-/g, '');
|
||||
const generatedBatch = `${item.product_code}-${country}-${datePart}-${seq}`;
|
||||
|
||||
if (item.batch_number !== generatedBatch) {
|
||||
// Update WITHOUT triggering re-render loop
|
||||
// Need a way to update item silently or check condition carefully
|
||||
// Using setBatchNumber might trigger this effect again but value will be same.
|
||||
const newItems = [...data.items];
|
||||
newItems[index].batch_number = generatedBatch;
|
||||
setData('items', newItems);
|
||||
@@ -289,25 +364,54 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
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'), data);
|
||||
const response = await axios.post(route('goods-receipts.check-duplicate'), submitData);
|
||||
if (response.data.has_warnings) {
|
||||
setWarnings(response.data.warnings);
|
||||
setWarningOpen(true);
|
||||
return; // 停止並顯示警告
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Duplicate check failed", error);
|
||||
// 檢查失敗則繼續,或視為阻擋?這裡選擇繼續
|
||||
} finally {
|
||||
setIsCheckingDuplicate(false);
|
||||
}
|
||||
}
|
||||
|
||||
post(route('goods-receipts.store'), {
|
||||
router.post(route('goods-receipts.store'), submitData, {
|
||||
onSuccess: () => setWarningOpen(false),
|
||||
});
|
||||
};
|
||||
@@ -319,28 +423,33 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
breadcrumbs={[
|
||||
{ label: '供應鏈管理', href: '#' },
|
||||
{ label: '進貨單管理', href: route('goods-receipts.index') },
|
||||
{ label: '新增進貨單', href: route('goods-receipts.create'), isPage: true },
|
||||
{ label: isEditMode ? `編輯進貨單: ${receipt!.code}` : '新增進貨單', href: isEditMode ? route('goods-receipts.edit', receipt!.id) : route('goods-receipts.create'), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title="新增進貨單" />
|
||||
<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">
|
||||
<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>
|
||||
@@ -358,6 +467,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => {
|
||||
if (isEditMode) return; // 編輯模式禁止切換類型
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
type: t.id,
|
||||
@@ -368,10 +478,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
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}
|
||||
@@ -457,9 +568,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<span className="font-bold text-gray-800">{selectedPO.items.length} 項</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPO(null)} className="text-gray-500 hover:text-red-500">
|
||||
重新選擇
|
||||
</Button>
|
||||
{!isEditMode && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPO(null)} className="text-gray-500 hover:text-red-500">
|
||||
重新選擇
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -497,9 +610,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<span className="font-bold text-gray-800">{selectedVendor.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedVendor(null)} className="text-gray-500 hover:text-red-500">
|
||||
重新選擇
|
||||
</Button>
|
||||
{!isEditMode && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedVendor(null)} className="text-gray-500 hover:text-red-500">
|
||||
重新選擇
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
@@ -507,7 +622,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
</div>
|
||||
|
||||
{/* Step 2: Details & Items */}
|
||||
{((data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && (
|
||||
{(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>
|
||||
@@ -560,42 +675,24 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-bold text-gray-700">商品明細</h3>
|
||||
{data.type !== 'standard' && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜尋商品加入..."
|
||||
value={productSearch}
|
||||
onChange={(e) => setProductSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && searchProducts()}
|
||||
className="h-9 w-64 pl-9"
|
||||
/>
|
||||
{foundProducts.length > 0 && (
|
||||
<div className="absolute top-10 left-0 w-full bg-white border rounded-lg shadow-xl z-50 max-h-60 overflow-y-auto">
|
||||
{foundProducts.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleAddProduct(p)}
|
||||
className="w-full text-left p-3 hover:bg-gray-50 border-b last:border-0 flex flex-col"
|
||||
>
|
||||
<span className="font-bold text-sm">{p.name}</span>
|
||||
<span className="text-xs text-gray-500">{p.code}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={searchProducts} disabled={isSearching} size="sm" className="button-filled-primary h-9">
|
||||
加入
|
||||
</Button>
|
||||
</div>
|
||||
{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);
|
||||
@@ -609,21 +706,28 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50/50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">商品資訊</TableHead>
|
||||
<TableHead className="w-[80px] text-center">總數量</TableHead>
|
||||
<TableHead className="w-[80px] text-center">待收貨</TableHead>
|
||||
<TableHead className="w-[120px]">本次收貨 <span className="text-red-500">*</span></TableHead>
|
||||
<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="w-[80px] text-right">小計</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={8} className="text-center py-8 text-gray-400 italic">
|
||||
尚無明細,請搜尋商品加入。
|
||||
<TableCell colSpan={isNonStandard ? 6 : 8} className="text-center py-8 text-gray-400 italic">
|
||||
{isNonStandard ? '尚無明細,請點擊「新增品項」加入。' : '尚無明細,請搜尋商品加入。'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -635,25 +739,41 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<TableRow key={index} className="hover:bg-gray-50/50 text-sm">
|
||||
{/* Product Info */}
|
||||
<TableCell>
|
||||
<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>
|
||||
{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 */}
|
||||
<TableCell className="text-center">
|
||||
<span className="text-gray-500 text-sm">
|
||||
{Math.round(item.quantity_ordered)}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Remaining */}
|
||||
<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>
|
||||
{/* 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>
|
||||
@@ -670,19 +790,88 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
)}
|
||||
</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 gap-2 items-center">
|
||||
<Input
|
||||
value={item.originCountry || 'TW'}
|
||||
onChange={(e) => updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))}
|
||||
placeholder="產地"
|
||||
maxLength={2}
|
||||
className="w-16 text-center px-1"
|
||||
<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"
|
||||
/>
|
||||
<div className="flex-1 text-sm font-mono bg-gray-50 px-3 py-2 rounded text-gray-600 truncate">
|
||||
{getBatchPreview(item.product_id, item.product_code, item.originCountry || 'TW', data.received_date)}
|
||||
</div>
|
||||
|
||||
{/* 建立新批號時的附加欄位 */}
|
||||
{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>
|
||||
|
||||
@@ -701,8 +890,20 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
</TableCell>
|
||||
|
||||
{/* Subtotal */}
|
||||
<TableCell className="text-right font-medium">
|
||||
${itemTotal.toLocaleString()}
|
||||
<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 */}
|
||||
@@ -765,10 +966,10 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
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 || (data.type === 'standard' ? !selectedPO : !selectedVendor)}
|
||||
disabled={processing || isCheckingDuplicate || (!isEditMode && (data.type === 'standard' ? !selectedPO : !selectedVendor))}
|
||||
>
|
||||
<Save className="mr-2 h-5 w-5" />
|
||||
{processing || isCheckingDuplicate ? '處理中...' : '確認進貨'}
|
||||
{processing || isCheckingDuplicate ? '處理中...' : (isEditMode ? '儲存變更' : '確認進貨')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user