Files
star-erp/resources/js/Pages/Warehouse/AddInventory.tsx
sky121113 1d134c9ad8
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 57s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
生產工單BOM以及批號完善
2026-01-22 15:39:35 +08:00

611 lines
33 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";
interface Product {
id: string;
name: string;
code: string;
baseUnit: string;
largeUnit?: string;
conversionRate?: 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 handleAddItem = () => {
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", code: "", baseUnit: "個" };
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',
};
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,
});
}
};
// 驗證表單
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
};
})
}, {
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>
{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]">
<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>
<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 === 'new' ? 'new_batch' : (item.inventoryId || "")}
onValueChange={(value) => {
if (value === 'new_batch') {
handleUpdateItem(item.tempId, {
batchMode: 'new',
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: "new_batch" },
...(batchesCache[item.productId]?.batches || []).map(b => ({
label: `${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 === 'existing' && item.inventoryId && (
<div className="text-xs text-gray-500 px-2 font-mono">
: {item.expiryDate || '未設定'}
</div>
)}
</div>
</TableCell>
{/* 數量 */}
<TableCell>
<Input
type="number"
min="1"
value={item.quantity || ""}
onChange={(e) =>
handleUpdateItem(item.tempId, {
quantity: parseFloat(e.target.value) || 0,
})
}
className="border-gray-300"
/>
{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"
/>
) : (
<Input
value={item.baseUnit || "個"}
disabled
className="bg-gray-50 border-gray-200"
/>
)}
</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"
size="icon"
onClick={() => handleRemoveItem(item.tempId)}
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="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>
);
}