Files
star-erp/resources/js/Pages/Warehouse/AddInventory.tsx
sky121113 1ae21febb5
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
feat(生產/庫存): 實作生產管理模組與批號追溯功能
2026-01-21 17:19:36 +08:00

465 lines
24 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 } 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;
baseUnit: string;
largeUnit?: string;
conversionRate?: number;
}
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 handleAddItem = () => {
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", 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',
};
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',
});
}
};
// 驗證表單
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";
}
});
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,
batchNumber: item.batchNumber,
expiryDate: item.expiryDate
};
})
}, {
onSuccess: () => {
toast.success("庫存記錄已儲存");
router.get(`/warehouses/${warehouse.id}/inventory`);
},
onError: (err) => {
toast.error("儲存失敗,請檢查輸入內容");
console.error(err);
}
});
};
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-[280px]">
<span className="text-red-500">*</span>
</TableHead>
<TableHead className="w-[120px]">
<span className="text-red-500">*</span>
</TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[180px]"></TableHead>
<TableHead className="w-[220px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => {
// 計算轉換數量
const convertedQuantity = item.selectedUnit === 'large' && item.conversionRate
? item.quantity * item.conversionRate
: item.quantity;
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>
<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,
})
}
className="border-gray-300 pl-9"
/>
</div>
</TableCell>
{/* 批號 */}
<TableCell>
<Input
value={item.batchNumber || ""}
onChange={(e) =>
handleUpdateItem(item.tempId, {
batchNumber: e.target.value,
})
}
className="border-gray-300"
placeholder="系統自動生成"
/>
{errors[`item-${index}-batch`] && (
<p className="text-xs text-red-500 mt-1">
{errors[`item-${index}-batch`]}
</p>
)}
</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>
);
}