443 lines
21 KiB
TypeScript
443 lines
21 KiB
TypeScript
|
|
/**
|
|||
|
|
* 建立生產工單頁面
|
|||
|
|
* 動態 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>
|
|||
|
|
);
|
|||
|
|
}
|