Files
star-erp/resources/js/Pages/Production/Create.tsx

443 lines
21 KiB
TypeScript
Raw Normal View History

/**
*
* 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>
);
}