feat: 標準化全系統數值輸入欄位與擴充商品價格功能
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m0s

1. UI 標準化:
   - 針對全系統數值輸入欄位統一加上 step='any' 以支援小數點。
   - 表格形式 (Table) 的數值輸入欄位統一加上 text-right 靠右對齊。
   - 修正 Components 與 Pages 中所有涉及金額與數量的輸入框。

2. 功能擴充與修正:
   - 擴充 Product 模型與相關 Dialog 以支援多種價格設定。
   - 修正 Inventory/GoodsReceipt/Create.tsx 未使用的變數錯誤。
   - 優化庫存相關頁面的 UI 一致性。

3. 其他:
   - 更新相關的 Type 定義與 Controller 邏輯。
This commit is contained in:
2026-02-05 11:45:08 +08:00
parent 04f3891275
commit 3ce96537b3
40 changed files with 774 additions and 212 deletions

View File

@@ -23,14 +23,17 @@ 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 {
@@ -113,9 +116,101 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
});
}, [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: "個" };
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,
@@ -128,6 +223,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
selectedUnit: 'base',
batchMode: 'existing', // 預設選擇現有批號
originCountry: 'TW',
unit_cost: defaultProduct.costPrice || 0,
};
setItems([...items, newItem]);
};
@@ -162,6 +258,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
batchMode: 'existing',
inventoryId: undefined, // 清除已選擇的批號
expiryDate: undefined,
unit_cost: product.costPrice || 0,
});
}
};
@@ -224,7 +321,8 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
batchMode: item.batchMode,
inventoryId: item.inventoryId,
originCountry: item.originCountry,
expiryDate: item.expiryDate
expiryDate: item.expiryDate,
unit_cost: item.unit_cost
};
})
}, {
@@ -384,6 +482,12 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
</Button>
</div>
{/* 掃碼輸入區 */}
<ScannerInput
onScan={handleScan}
className="bg-gray-50/50"
/>
{errors.items && (
<p className="text-sm text-red-500">{errors.items}</p>
)}
@@ -399,12 +503,13 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
<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-[100px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
@@ -479,46 +584,90 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
)}
{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 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>
<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 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>
</div>
</>
)}
{item.batchMode === 'existing' && item.inventoryId && (
<div className="text-xs text-gray-500 px-2 font-mono">
: {item.expiryDate || '未設定'}
<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"
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`]}
@@ -544,48 +693,20 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
className="border-gray-300"
/>
) : (
<Input
value={item.baseUnit || "個"}
disabled
className="bg-gray-50 border-gray-200"
/>
<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>
<div className="flex items-center text-gray-700 font-medium bg-gray-50 px-3 py-2 rounded-md border border-gray-200">
<span>{convertedQuantity}</span>
<span className="ml-1 text-gray-500 text-sm">{item.baseUnit || "個"}</span>
</div>
</TableCell>
{/* 效期 */}
<TableCell>
<div className="relative">
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={item.expiryDate || ""}
onChange={(e) =>
handleUpdateItem(item.tempId, {
expiryDate: e.target.value,
})
}
disabled={item.batchMode === 'existing'}
className={`border-gray-300 pl-9 ${item.batchMode === 'existing' ? 'bg-gray-50' : ''}`}
/>
</div>
</TableCell>
{/* 刪除按鈕 */}
<TableCell>
<Button
type="button"
variant="ghost"
variant="outline"
size="icon"
onClick={() => handleRemoveItem(item.tempId)}
className="hover:bg-red-50 hover:text-red-600 h-8 w-8"
className="button-outlined-error h-8 w-8"
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -605,6 +726,6 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
</div>
</div>
</div>
</AuthenticatedLayout>
</AuthenticatedLayout >
);
}