Files
star-erp/resources/js/Pages/Production/Create.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

443 lines
21 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.
/**
* 建立生產工單頁面
* 動態 BOM 表單:選擇倉庫 → 選擇原物料 → 選擇批號 → 輸入用量
*/
import { useState, useEffect } from "react";
import { Factory, Plus, Trash2, ArrowLeft, Save, AlertTriangle, Calendar } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm } from "@inertiajs/react";
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea";
interface Product {
id: number;
name: string;
code: string;
base_unit?: { id: number; name: string } | null;
}
interface Warehouse {
id: number;
name: string;
}
interface Unit {
id: number;
name: string;
}
interface InventoryOption {
id: number;
product_id: number;
product_name: string;
product_code: string;
batch_number: string;
box_number: string | null;
quantity: number;
arrival_date: string | null;
expiry_date: string | null;
unit_name: string | null;
}
interface BomItem {
inventory_id: string;
quantity_used: string;
unit_id: string;
// 顯示用
product_name?: string;
batch_number?: string;
available_qty?: number;
}
interface Props {
products: Product[];
warehouses: Warehouse[];
units: Unit[];
}
export default function ProductionCreate({ products, warehouses, units }: Props) {
const [selectedWarehouse, setSelectedWarehouse] = useState<string>("");
const [inventoryOptions, setInventoryOptions] = useState<InventoryOption[]>([]);
const [isLoadingInventory, setIsLoadingInventory] = useState(false);
const [bomItems, setBomItems] = useState<BomItem[]>([]);
const { data, setData, processing, errors } = useForm({
product_id: "",
warehouse_id: "",
output_quantity: "",
output_batch_number: "",
output_box_count: "",
production_date: new Date().toISOString().split('T')[0],
expiry_date: "",
remark: "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
});
// 當選擇倉庫時,載入該倉庫的可用庫存
useEffect(() => {
if (selectedWarehouse) {
setIsLoadingInventory(true);
fetch(route('api.production.warehouses.inventories', selectedWarehouse))
.then(res => res.json())
.then((inventories: InventoryOption[]) => {
setInventoryOptions(inventories);
setIsLoadingInventory(false);
})
.catch(() => setIsLoadingInventory(false));
} else {
setInventoryOptions([]);
}
}, [selectedWarehouse]);
// 同步 warehouse_id 到 form data
useEffect(() => {
setData('warehouse_id', selectedWarehouse);
}, [selectedWarehouse]);
// 新增 BOM 項目
const addBomItem = () => {
setBomItems([...bomItems, {
inventory_id: "",
quantity_used: "",
unit_id: "",
}]);
};
// 移除 BOM 項目
const removeBomItem = (index: number) => {
setBomItems(bomItems.filter((_, i) => i !== index));
};
// 更新 BOM 項目
const updateBomItem = (index: number, field: keyof BomItem, value: string) => {
const updated = [...bomItems];
updated[index] = { ...updated[index], [field]: value };
// 如果選擇了庫存,自動填入顯示資訊
if (field === 'inventory_id' && value) {
const inv = inventoryOptions.find(i => String(i.id) === value);
if (inv) {
updated[index].product_name = inv.product_name;
updated[index].batch_number = inv.batch_number;
updated[index].available_qty = inv.quantity;
}
}
setBomItems(updated);
};
// 產生成品批號建議
const generateBatchNumber = () => {
if (!data.product_id) return;
const product = products.find(p => String(p.id) === data.product_id);
if (!product) return;
const date = data.production_date.replace(/-/g, '');
const suggested = `${product.code}-TW-${date}-01`;
setData('output_batch_number', suggested);
};
// 提交表單
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// 轉換 BOM items 格式
const formattedItems = bomItems
.filter(item => item.inventory_id && item.quantity_used)
.map(item => ({
inventory_id: parseInt(item.inventory_id),
quantity_used: parseFloat(item.quantity_used),
unit_id: item.unit_id ? parseInt(item.unit_id) : null,
}));
// 使用 router.post 提交完整資料
router.post(route('production-orders.store'), {
...data,
items: formattedItems,
});
};
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
<Head title="建立生產單" />
<div className="container mx-auto p-6 max-w-4xl">
<div className="flex items-center gap-4 mb-6">
<Button
variant="ghost"
onClick={() => router.get(route('production-orders.index'))}
className="p-2"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Factory className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
使
</p>
</div>
</div>
<form onSubmit={handleSubmit}>
{/* 成品資訊 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<SearchableSelect
value={data.product_id}
onValueChange={(v) => setData('product_id', v)}
options={products.map(p => ({
label: `${p.name} (${p.code})`,
value: String(p.id),
}))}
placeholder="選擇成品"
className="w-full h-9"
/>
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<Input
type="number"
step="0.01"
value={data.output_quantity}
onChange={(e) => setData('output_quantity', e.target.value)}
placeholder="例如: 50"
className="h-9"
/>
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<div className="flex gap-2">
<Input
value={data.output_batch_number}
onChange={(e) => setData('output_batch_number', e.target.value)}
placeholder="例如: AB-TW-20260121-01"
className="h-9 font-mono"
/>
<Button
type="button"
variant="outline"
onClick={generateBatchNumber}
disabled={!data.product_id}
className="h-9 button-outlined-primary shrink-0"
>
</Button>
</div>
{errors.output_batch_number && <p className="text-red-500 text-xs mt-1">{errors.output_batch_number}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Input
value={data.output_box_count}
onChange={(e) => setData('output_box_count', e.target.value)}
placeholder="例如: 10"
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={data.production_date}
onChange={(e) => setData('production_date', e.target.value)}
className="h-9 pl-9"
/>
</div>
{errors.production_date && <p className="text-red-500 text-xs mt-1">{errors.production_date}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={data.expiry_date}
onChange={(e) => setData('expiry_date', e.target.value)}
className="h-9 pl-9"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<SearchableSelect
value={selectedWarehouse}
onValueChange={setSelectedWarehouse}
options={warehouses.map(w => ({
label: w.name,
value: String(w.id),
}))}
placeholder="選擇倉庫"
className="w-full h-9"
/>
{errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
</div>
</div>
<div className="mt-4 space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Textarea
value={data.remark}
onChange={(e) => setData('remark', e.target.value)}
placeholder="生產備註..."
rows={2}
className="resize-none"
/>
</div>
</div>
{/* BOM 原物料明細 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">使 (BOM)</h2>
<Button
type="button"
variant="outline"
onClick={addBomItem}
disabled={!selectedWarehouse}
className="gap-2 button-filled-primary text-white"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{!selectedWarehouse && (
<div className="text-center py-8 text-gray-500">
<AlertTriangle className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
</div>
)}
{selectedWarehouse && isLoadingInventory && (
<div className="text-center py-8 text-gray-500">
...
</div>
)}
{selectedWarehouse && !isLoadingInventory && bomItems.length === 0 && (
<div className="text-center py-8 text-gray-500">
<Factory className="h-8 w-8 mx-auto mb-2 text-gray-300" />
BOM
</div>
)}
{bomItems.length > 0 && (
<div className="space-y-4">
{bomItems.map((item, index) => (
<div
key={index}
className="grid grid-cols-1 md:grid-cols-12 gap-3 items-end p-4 bg-gray-50/50 border border-gray-100 rounded-lg relative group"
>
<div className="md:col-span-5 space-y-1">
<Label className="text-xs font-medium text-grey-2"> ()</Label>
<SearchableSelect
value={item.inventory_id}
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
options={inventoryOptions.map(inv => ({
label: `${inv.product_name} - ${inv.batch_number} (庫存: ${inv.quantity})`,
value: String(inv.id),
}))}
placeholder="選擇原物料與批號"
className="w-full h-9"
/>
</div>
<div className="md:col-span-3 space-y-1">
<Label className="text-xs font-medium text-grey-2">使</Label>
<div className="relative">
<Input
type="number"
step="0.0001"
value={item.quantity_used}
onChange={(e) => updateBomItem(index, 'quantity_used', e.target.value)}
placeholder="0.00"
className="h-9 pr-12"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400 pointer-events-none">
</div>
</div>
{item.available_qty && (
<p className="text-xs text-gray-400 mt-1">: {item.available_qty.toLocaleString()}</p>
)}
</div>
<div className="md:col-span-3 space-y-1">
<Label className="text-xs font-medium text-grey-2">/</Label>
<SearchableSelect
value={item.unit_id}
onValueChange={(v) => updateBomItem(index, 'unit_id', v)}
options={units.map(u => ({
label: u.name,
value: String(u.id),
}))}
placeholder="選擇單位"
className="w-full h-9"
/>
</div>
<div className="md:col-span-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removeBomItem(index)}
className="button-outlined-error h-9 w-full md:w-9 p-0"
title="移除此項目"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
</div>
{/* 提交按鈕 */}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => router.get(route('production-orders.index'))}
className="h-10 px-6"
>
</Button>
<Button
type="submit"
disabled={processing || bomItems.length === 0}
className="gap-2 button-filled-primary h-10 px-8"
>
<Save className="h-4 w-4" />
{processing ? '處理中...' : '建立生產單'}
</Button>
</div>
</form>
</div>
</AuthenticatedLayout>
);
}