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

669 lines
32 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 { Trash2, Plus, ArrowLeft, Save, Factory } from "lucide-react";
import { formatQuantity } from "@/lib/utils";
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { router, useForm, Head, Link } from "@inertiajs/react";
import { toast } from "sonner";
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";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
interface Product {
id: number;
name: string;
code: string;
base_unit?: { id: number; name: string } | null;
}
interface Warehouse {
id: number;
name: string;
}
interface InventoryOption {
id: number;
product_id: number;
product_name: string;
product_code: string;
warehouse_id: number;
warehouse_name: string;
batch_number: string;
box_number: string | null;
quantity: number;
arrival_date: string | null;
expiry_date: string | null;
unit_name: string | null;
base_unit_id?: number;
base_unit_name?: string;
large_unit_id?: number;
large_unit_name?: string;
conversion_rate?: number;
}
interface BomItem {
// 後端必填
inventory_id: string; // 所選庫存記錄 ID特定批號
quantity_used: string; // 轉換後的最終數量(基本單位)
unit_id: string; // 單位 ID通常為基本單位 ID
// UI 狀態
ui_warehouse_id: string; // 來源倉庫
ui_product_id: string; // 批號列表篩選
ui_input_quantity: string; // 使用者輸入數量
ui_selected_unit: 'base' | 'large'; // 使用者選擇單位
// UI 輔助 / 快取
ui_product_name?: string;
ui_batch_number?: string;
ui_available_qty?: number;
ui_expiry_date?: string;
ui_conversion_rate?: number;
ui_base_unit_name?: string;
ui_large_unit_name?: string;
ui_base_unit_id?: number;
ui_large_unit_id?: number;
}
interface Props {
products: Product[];
warehouses: Warehouse[];
}
export default function Create({ products, warehouses }: Props) {
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫
// 快取對照表product_id -> inventories across warehouses
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
const [loadingProducts, setLoadingProducts] = useState<Record<string, boolean>>({});
const [bomItems, setBomItems] = useState<BomItem[]>([]);
// 多配方支援
const [recipes, setRecipes] = useState<any[]>([]);
const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
const { data, setData, processing, errors } = useForm({
product_id: "",
warehouse_id: "",
output_quantity: "",
// 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄
remark: "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
});
// 獲取特定商品在各倉庫的庫存分佈
const fetchProductInventories = async (productId: string) => {
if (!productId) return;
// 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
if (loadingProducts[productId]) return;
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
try {
const res = await fetch(route('api.production.products.inventories', productId));
const data = await res.json();
setProductInventoryMap(prev => ({ ...prev, [productId]: data }));
} catch (e) {
console.error(e);
} finally {
setLoadingProducts(prev => ({ ...prev, [productId]: false }));
}
};
// 同步 warehouse_id 到 form data (Output)
useEffect(() => {
setData('warehouse_id', selectedWarehouse);
}, [selectedWarehouse]);
// 新增 BOM 項目
const addBomItem = () => {
setBomItems([...bomItems, {
inventory_id: "",
quantity_used: "",
unit_id: "",
ui_warehouse_id: "",
ui_product_id: "",
ui_input_quantity: "",
ui_selected_unit: 'base',
}]);
};
// 移除 BOM 項目
const removeBomItem = (index: number) => {
setBomItems(bomItems.filter((_, i) => i !== index));
};
// 更新 BOM 項目邏輯
const updateBomItem = (index: number, field: keyof BomItem, value: any) => {
const updated = [...bomItems];
const item = { ...updated[index], [field]: value };
// 1. 當選擇商品變更時 -> 載入庫存分佈並重置後續欄位
if (field === 'ui_product_id') {
item.ui_warehouse_id = "";
item.inventory_id = "";
item.quantity_used = "";
item.unit_id = "";
item.ui_input_quantity = "";
item.ui_selected_unit = "base";
// 清除 cache 資訊
delete item.ui_product_name;
delete item.ui_batch_number;
delete item.ui_available_qty;
delete item.ui_expiry_date;
delete item.ui_conversion_rate;
delete item.ui_base_unit_name;
delete item.ui_large_unit_name;
delete item.ui_base_unit_id;
delete item.ui_large_unit_id;
if (value) {
const prod = products.find(p => String(p.id) === value);
if (prod) {
item.ui_product_name = prod.name;
item.ui_base_unit_name = prod.base_unit?.name || '';
}
fetchProductInventories(value);
}
}
// 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量)
if (field === 'ui_warehouse_id') {
item.inventory_id = "";
// 不重置數量
// item.quantity_used = "";
// item.ui_input_quantity = "";
// item.ui_selected_unit = "base";
// 清除某些 cache
delete item.ui_batch_number;
delete item.ui_available_qty;
delete item.ui_expiry_date;
}
// 3. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
if (field === 'inventory_id' && value) {
const currentOptions = productInventoryMap[item.ui_product_id] || [];
const inv = currentOptions.find(i => String(i.id) === value);
if (inv) {
item.ui_warehouse_id = String(inv.warehouse_id);
item.ui_batch_number = inv.batch_number;
item.ui_available_qty = inv.quantity;
item.ui_expiry_date = inv.expiry_date || '';
// 單位與轉換率
item.ui_base_unit_name = inv.unit_name || '';
item.ui_base_unit_id = inv.base_unit_id;
item.ui_conversion_rate = inv.conversion_rate || 1;
// 預設單位
item.ui_selected_unit = 'base';
item.unit_id = String(inv.base_unit_id || '');
// 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方)
if (!item.ui_input_quantity) {
item.ui_input_quantity = formatQuantity(inv.quantity);
}
}
}
// 4. 計算最終數量 (Base Quantity)
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
const inputQty = parseFloat(item.ui_input_quantity || '0');
const rate = item.ui_conversion_rate || 1;
if (item.ui_selected_unit === 'large') {
item.quantity_used = String(inputQty * rate);
item.unit_id = String(item.ui_base_unit_id || '');
} else {
item.quantity_used = String(inputQty);
item.unit_id = String(item.ui_base_unit_id || '');
}
}
updated[index] = item;
setBomItems(updated);
};
// 同步 BOM items 到表單 data
useEffect(() => {
setData('items', bomItems.map(item => ({
inventory_id: Number(item.inventory_id),
quantity_used: Number(item.quantity_used),
unit_id: item.unit_id ? Number(item.unit_id) : null
})));
}, [bomItems]);
// 應用配方到表單 (獨立函式)
const applyRecipe = (recipe: any) => {
if (!recipe || !recipe.items) return;
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
// 自動帶入配方標準產量
setData('output_quantity', String(yieldQty));
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
const baseQty = parseFloat(item.quantity || "0");
const calculatedQty = baseQty; // 保持精度
// 若有配方商品,預先載入庫存分佈
if (item.product_id) {
fetchProductInventories(String(item.product_id));
}
return {
inventory_id: "",
quantity_used: String(calculatedQty),
unit_id: String(item.unit_id),
ui_warehouse_id: "",
ui_product_id: String(item.product_id),
ui_product_name: item.product_name,
ui_batch_number: "",
ui_available_qty: 0,
ui_input_quantity: String(calculatedQty),
ui_selected_unit: 'base',
ui_base_unit_name: item.unit_name,
ui_base_unit_id: item.unit_id,
ui_conversion_rate: 1,
};
});
setBomItems(newBomItems);
toast.success(`已自動載入配方: ${recipe.name}`, {
description: `標準產量: ${yieldQty}`
});
};
// 當手動切換配方時
useEffect(() => {
if (!selectedRecipeId) return;
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
if (targetRecipe) {
applyRecipe(targetRecipe);
}
}, [selectedRecipeId]);
// 自動產生成品批號與載入配方
useEffect(() => {
if (!data.product_id) return;
// 2. 自動載入配方列表
const fetchRecipes = async () => {
try {
// 改為抓取所有配方
const res = await fetch(route('api.production.recipes.by-product', data.product_id));
const recipesData = await res.json();
if (Array.isArray(recipesData) && recipesData.length > 0) {
setRecipes(recipesData);
// 預設選取最新的 (第一個)
const latest = recipesData[0];
setSelectedRecipeId(String(latest.id));
} else {
// 若無配方
setRecipes([]);
setSelectedRecipeId("");
setBomItems([]); // 清空 BOM
}
} catch (e) {
console.error("Failed to fetch recipes", e);
setRecipes([]);
setBomItems([]);
}
};
fetchRecipes();
}, [data.product_id]);
// 當生產數量變動時,如果是從配方載入的,則按比例更新用量
useEffect(() => {
if (bomItems.length > 0 && data.output_quantity) {
// 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號
// 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾
// 但如果是剛載入inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性
}
}, [data.output_quantity]);
// 提交表單
const submit = (status: 'draft' | 'completed') => {
// 驗證(簡單前端驗證,完整驗證在後端)
if (status === 'completed') {
const missingFields = [];
if (!data.product_id) missingFields.push('成品商品');
if (!data.output_quantity) missingFields.push('生產數量');
if (!selectedWarehouse) missingFields.push('預計入庫倉庫');
if (bomItems.length === 0) missingFields.push('原物料明細');
if (missingFields.length > 0) {
toast.error("請填寫必要欄位", {
description: `缺漏:${missingFields.join('、')}`
});
return;
}
}
// 轉換 BOM items 格式
const formattedItems = bomItems
.filter(item => status === 'draft' || (item.inventory_id && item.quantity_used))
.map(item => ({
inventory_id: item.inventory_id ? parseInt(item.inventory_id) : null,
quantity_used: item.quantity_used ? parseFloat(item.quantity_used) : 0,
unit_id: item.unit_id ? parseInt(item.unit_id) : null,
}));
// 使用 router.post 提交完整資料
router.post(route('production-orders.store'), {
...data,
warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
items: formattedItems,
status: status,
}, {
onError: (errors) => {
const errorCount = Object.keys(errors).length;
toast.error("建立失敗,請檢查表單", {
description: `共有 ${errorCount} 個欄位有誤,請修正後再試`
});
}
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
submit('completed');
};
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
<Head title="建立生產單" />
<div className="container mx-auto p-6 max-w-7xl">
<div className="mb-6">
<Link href={route('production-orders.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex items-center justify-between">
<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>
<Button
onClick={() => submit('draft')}
disabled={processing}
className="gap-2 button-filled-primary"
>
<Save className="h-4 w-4" />
(稿)
</Button>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* 成品資訊 */}
<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>}
{/* 配方選擇 (放在成品商品底下) */}
{recipes.length > 0 && (
<div className="pt-2">
<div className="flex justify-between items-center mb-1">
<Label className="text-xs font-medium text-grey-2">使</Label>
<span className="text-[10px] text-blue-500">
</span>
</div>
<SearchableSelect
value={selectedRecipeId}
onValueChange={setSelectedRecipeId}
options={recipes.map(r => ({
label: `${r.name} (${r.code})`,
value: String(r.id),
}))}
placeholder="選擇配方"
className="w-full h-9"
/>
</div>
)}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<Input
type="number"
step="any"
value={Number(data.output_quantity) === 0 ? '' : formatQuantity(data.output_quantity)}
onChange={(e) => setData('output_quantity', e.target.value)}
placeholder="例如: 50"
className="h-9 font-mono"
/>
{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>
<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}
className="gap-2 button-filled-primary text-white"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{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="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50/50">
<TableHead className="w-[18%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[30%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[12%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[10%]"></TableHead>
<TableHead className="w-[10%]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bomItems.map((item, index) => {
// 1. 商品選項
const productOptions = products.map(p => ({
label: `${p.name} (${p.code})`,
value: String(p.id)
}));
// 2. 來源倉庫選項 (根據商品庫存過濾)
const currentInventories = productInventoryMap[item.ui_product_id] || [];
const filteredWarehouseOptions = Array.from(new Map(
currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }])
).values());
// 如果篩選後沒有倉庫(即該商品無庫存),則顯示所有倉庫以供選取(或顯示無庫存提示)
const uniqueWarehouseOptions = filteredWarehouseOptions.length > 0
? filteredWarehouseOptions
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
// 3. 批號選項 (利用 sublabel 顯示詳細資訊,保持選中後簡潔)
const batchOptions = currentInventories
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
.map((inv: InventoryOption) => ({
label: inv.batch_number,
value: String(inv.id),
sublabel: `(存:${inv.quantity} | 效:${inv.expiry_date || '無'})`
}));
return (
<TableRow key={index}>
{/* 1. 選擇商品 */}
<TableCell className="align-top">
<SearchableSelect
value={item.ui_product_id}
onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)}
options={productOptions}
placeholder="選擇商品"
className="w-full"
/>
</TableCell>
{/* 2. 選擇來源倉庫 */}
<TableCell className="align-top">
<SearchableSelect
value={item.ui_warehouse_id}
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
options={uniqueWarehouseOptions as any}
placeholder={item.ui_product_id
? (loadingProducts[item.ui_product_id]
? "載入庫存中..."
: (uniqueWarehouseOptions.length === 0 ? "該商品目前無庫存" : "選擇倉庫"))
: "請先選商品"}
className="w-full"
disabled={!item.ui_product_id || (loadingProducts[item.ui_product_id])}
/>
</TableCell>
{/* 3. 選擇批號 */}
<TableCell className="align-top">
<SearchableSelect
value={item.inventory_id}
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
options={batchOptions as any}
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
className="w-full"
disabled={!item.ui_warehouse_id}
/>
{item.inventory_id && (() => {
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
if (selectedInv) {
const isInsufficient = selectedInv.quantity < parseFloat(item.ui_input_quantity || '0');
return (
<div className={`text-xs mt-1 ${isInsufficient ? 'text-red-500 font-bold animate-pulse' : 'text-gray-500'}`}>
: {selectedInv.expiry_date || '無'} |
: {formatQuantity(selectedInv.quantity)}
{isInsufficient && ' (庫存不足!)'}
</div>
);
}
return null;
})()}
</TableCell>
{/* 3. 輸入數量 */}
<TableCell className="align-top">
<Input
type="number"
step="any"
value={formatQuantity(item.ui_input_quantity)}
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
placeholder="0"
className="h-9 text-right"
disabled={!item.inventory_id}
/>
</TableCell>
{/* 4. 單位 */}
<TableCell className="align-top">
<div className="h-9 flex items-center px-1 text-sm text-gray-600 font-medium">
{item.ui_base_unit_name || '-'}
</div>
</TableCell>
<TableCell className="align-top">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => removeBomItem(index)}
className="button-outlined-error"
title="刪除"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
</div>
</form>
</div>
</AuthenticatedLayout>
);
}