feat: 標準化全系統數值輸入欄位與擴充商品價格功能
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:
@@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function EditInventory({ warehouse, inventory, transactions = []
|
||||
id="quantity"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
step="any"
|
||||
value={data.quantity}
|
||||
onChange={(e) =>
|
||||
setData("quantity", parseFloat(e.target.value) || 0)
|
||||
|
||||
@@ -101,16 +101,7 @@ export default function WarehouseInventoryPage({
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdjust = (batchId: string, data: { operation: string; quantity: number; reason: string }) => {
|
||||
router.put(route("warehouses.inventory.update", { warehouse: warehouse.id, inventoryId: batchId }), data, {
|
||||
onSuccess: () => {
|
||||
toast.success("庫存已更新");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("庫存更新失敗");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getInventoryBreadcrumbs(warehouse.id, warehouse.name)}>
|
||||
@@ -195,7 +186,6 @@ export default function WarehouseInventoryPage({
|
||||
inventories={filteredInventories}
|
||||
onView={handleView}
|
||||
onDelete={confirmDelete}
|
||||
onAdjust={handleAdjust}
|
||||
onViewProduct={handleViewProduct}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user