Files
star-erp/resources/js/Pages/Warehouse/AddInventory.tsx

747 lines
41 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 { 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;
}
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
};
})
}, {
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-[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
});
}
}}
options={[
{ label: "📦 不使用批號 (自動累加)", value: "no_batch" },
{ label: "+ 建立新批號", value: "new_batch" },
...(batchesCache[item.productId]?.batches || []).map(b => ({
label: `${b.batchNumber === 'NO-BATCH' ? '(無批號紀錄)' : b.batchNumber} - 庫存: ${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>
<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 >
);
}