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

717 lines
34 KiB
TypeScript
Raw Normal View History

2026-01-22 15:39:35 +08:00
/**
*
* 稿
*/
import { useState, useEffect } from "react";
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm, Link } from "@inertiajs/react";
import toast, { Toaster } from 'react-hot-toast';
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 Unit {
id: number;
name: string;
}
interface InventoryOption {
id: number;
product_id: number;
product_name: string;
product_code: string;
warehouse_id: number;
warehouse_name: string;
2026-01-22 15:39:35 +08:00
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 {
// 後端必填
2026-01-22 15:39:35 +08:00
inventory_id: string;
quantity_used: string;
unit_id: string;
// UI 狀態
ui_warehouse_id: string; // 來源倉庫
2026-01-22 15:39:35 +08:00
ui_product_id: string;
ui_input_quantity: string;
ui_selected_unit: 'base' | 'large';
// UI 輔助 / 快取
2026-01-22 15:39:35 +08:00
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;
ui_product_code?: string;
2026-01-22 15:39:35 +08:00
}
interface ProductionOrderItem {
id: number;
production_order_id: number;
inventory_id: number;
quantity_used: number;
unit_id: number | null;
inventory?: {
product_id: number;
product?: {
name: string;
code: string;
base_unit?: { name: string };
};
batch_number: string;
quantity: number;
expiry_date?: string;
warehouse_id?: number;
};
unit?: {
name: string;
};
}
interface ProductionOrder {
id: number;
code: string;
product_id: number;
warehouse_id: number | null;
output_quantity: number;
output_batch_number: string;
output_box_count: string | null;
production_date: string;
expiry_date: string | null;
remark: string | null;
status: string;
items: ProductionOrderItem[];
product?: Product;
warehouse?: Warehouse;
}
interface Props {
productionOrder: ProductionOrder;
products: Product[];
warehouses: Warehouse[];
units: Unit[];
}
export default function ProductionEdit({ productionOrder, products, warehouses }: Props) {
// 日期格式轉換輔助函數
const formatDate = (dateValue: string | null | undefined): string => {
if (!dateValue) return '';
// 處理可能的 ISO 格式或 YYYY-MM-DD 格式
const date = new Date(dateValue);
if (isNaN(date.getTime())) return dateValue;
return date.toISOString().split('T')[0];
};
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
); // 產出倉庫
2026-01-22 15:39:35 +08:00
// 快取對照表product_id -> inventories
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
const [loadingProducts, setLoadingProducts] = useState<Record<string, boolean>>({});
2026-01-22 15:39:35 +08:00
// 獲取商品所有倉庫庫存的分佈
const fetchProductInventories = async (productId: string) => {
if (!productId) return;
if (loadingProducts[productId]) return;
2026-01-22 15:39:35 +08:00
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
2026-01-22 15:39:35 +08:00
try {
const res = await fetch(route('api.production.products.inventories', productId));
2026-01-22 15:39:35 +08:00
const data = await res.json();
setProductInventoryMap(prev => ({ ...prev, [productId]: data }));
2026-01-22 15:39:35 +08:00
} catch (e) {
console.error(e);
} finally {
setLoadingProducts(prev => ({ ...prev, [productId]: false }));
2026-01-22 15:39:35 +08:00
}
};
// 初始化 BOM items
const initialBomItems: BomItem[] = productionOrder.items.map(item => ({
inventory_id: String(item.inventory_id),
quantity_used: String(item.quantity_used),
unit_id: item.unit_id ? String(item.unit_id) : "",
// UI Initial State (復原)
ui_warehouse_id: item.inventory?.warehouse_id ? String(item.inventory.warehouse_id) : "",
ui_product_id: item.inventory ? String(item.inventory.product_id) : "",
ui_input_quantity: String(item.quantity_used), // 假設已存的資料是基本單位
ui_selected_unit: 'base',
// UI 輔助
2026-01-22 15:39:35 +08:00
ui_product_name: item.inventory?.product?.name,
ui_batch_number: item.inventory?.batch_number,
ui_available_qty: item.inventory?.quantity,
ui_expiry_date: item.inventory?.expiry_date,
}));
const [bomItems, setBomItems] = useState<BomItem[]>(initialBomItems);
const { data, setData, processing, errors } = useForm({
product_id: String(productionOrder.product_id),
warehouse_id: productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : "",
output_quantity: productionOrder.output_quantity ? String(productionOrder.output_quantity) : "",
output_batch_number: productionOrder.output_batch_number || "",
output_box_count: productionOrder.output_box_count || "",
production_date: formatDate(productionOrder.production_date) || new Date().toISOString().split('T')[0],
expiry_date: formatDate(productionOrder.expiry_date),
remark: productionOrder.remark || "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
});
// 初始化載入既有 BOM 的商品庫存資料
2026-01-22 15:39:35 +08:00
useEffect(() => {
initialBomItems.forEach(item => {
if (item.ui_product_id) {
fetchProductInventories(item.ui_product_id);
2026-01-22 15:39:35 +08:00
}
});
}, []);
// 當 inventoryOptions 載入後,更新現有 BOM items 的詳細資訊
2026-01-22 15:39:35 +08:00
useEffect(() => {
setBomItems(prevItems => prevItems.map(item => {
const currentOptions = productInventoryMap[item.ui_product_id] || [];
if (currentOptions.length > 0 && item.inventory_id && !item.ui_conversion_rate) {
const inv = currentOptions.find(i => String(i.id) === item.inventory_id);
2026-01-22 15:39:35 +08:00
if (inv) {
return {
...item,
ui_warehouse_id: String(inv.warehouse_id), // 重要:還原倉庫 ID
2026-01-22 15:39:35 +08:00
ui_product_name: inv.product_name,
ui_batch_number: inv.batch_number,
ui_available_qty: inv.quantity,
ui_expiry_date: inv.expiry_date || '',
ui_base_unit_name: inv.base_unit_name || inv.unit_name || '',
ui_large_unit_name: inv.large_unit_name || '',
ui_base_unit_id: inv.base_unit_id,
ui_large_unit_id: inv.large_unit_id,
ui_conversion_rate: inv.conversion_rate || 1,
};
}
}
return item;
}));
}, [productInventoryMap]);
2026-01-22 15:39:35 +08:00
// 同步 warehouse_id 到 form data
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 };
// 0. 當選擇商品變更時 (第一層)
if (field === 'ui_product_id') {
item.ui_warehouse_id = "";
2026-01-22 15:39:35 +08:00
item.inventory_id = "";
item.quantity_used = "";
item.unit_id = "";
item.ui_input_quantity = "";
item.ui_selected_unit = "base";
// 保留基本資訊
2026-01-22 15:39:35 +08:00
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);
2026-01-22 15:39:35 +08:00
}
}
// 1. 當選擇來源倉庫變更時 (第二層)
if (field === 'ui_warehouse_id') {
2026-01-22 15:39:35 +08:00
item.inventory_id = "";
item.quantity_used = "";
item.unit_id = "";
item.ui_input_quantity = "";
item.ui_selected_unit = "base";
}
// 2. 當選擇批號 (Inventory) 變更時 (第三層)
2026-01-22 15:39:35 +08:00
if (field === 'inventory_id' && value) {
const currentOptions = productInventoryMap[item.ui_product_id] || [];
2026-01-22 15:39:35 +08:00
const inv = currentOptions.find(i => String(i.id) === value);
if (inv) {
item.ui_warehouse_id = String(inv.warehouse_id);
2026-01-22 15:39:35 +08:00
item.ui_product_name = inv.product_name;
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.base_unit_name || inv.unit_name || '';
item.ui_large_unit_name = inv.large_unit_name || '';
item.ui_base_unit_id = inv.base_unit_id;
item.ui_large_unit_id = inv.large_unit_id;
item.ui_conversion_rate = inv.conversion_rate || 1;
item.ui_selected_unit = 'base';
item.unit_id = String(inv.base_unit_id || '');
}
}
// 3. 計算最終數量
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 submit = (status: 'draft' | 'completed') => {
// 驗證(簡單前端驗證)
if (status === 'completed') {
const missingFields = [];
if (!data.product_id) missingFields.push('成品商品');
if (!data.output_quantity) missingFields.push('生產數量');
if (!data.output_batch_number) missingFields.push('成品批號');
if (!data.production_date) missingFields.push('生產日期');
if (!selectedWarehouse) missingFields.push('入庫倉庫');
if (bomItems.length === 0) missingFields.push('原物料明細');
if (missingFields.length > 0) {
toast.error(
<div className="flex flex-col gap-1">
<span className="font-bold"></span>
<span className="text-sm">{missingFields.join('、')}</span>
</div>
);
return;
}
}
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.put(route('production-orders.update', productionOrder.id), {
...data,
items: formattedItems,
status: status,
}, {
onError: (errors) => {
const errorCount = Object.keys(errors).length;
toast.error(
<div className="flex flex-col gap-1">
<span className="font-bold"></span>
<span className="text-sm"> {errorCount} </span>
</div>
);
}
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
submit('completed');
};
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
<Head title={`編輯生產單 - ${productionOrder.code}`} />
<Toaster position="top-right" />
<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>
<div className="flex gap-2">
<Button
onClick={() => submit('draft')}
disabled={processing}
variant="outline"
className="button-outlined-primary"
>
<Save className="mr-2 h-4 w-4" />
稿
</Button>
<Button
onClick={() => submit('completed')}
disabled={processing}
className="button-filled-primary"
>
<Factory className="mr-2 h-4 w-4" />
</Button>
</div>
</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>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<Input
type="number"
step="any"
2026-01-22 15:39:35 +08:00
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>
<Input
value={data.output_batch_number}
onChange={(e) => setData('output_batch_number', e.target.value)}
placeholder="例如: AB-TW-20260122-01"
className="h-9 font-mono"
/>
{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}
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-[20%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead>
2026-01-22 15:39:35 +08:00
<TableHead className="w-[25%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"></TableHead>
<TableHead className="w-[5%]"></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) }])
2026-01-22 15:39:35 +08:00
).values());
const uniqueWarehouseOptions = filteredWarehouseOptions.length > 0
? filteredWarehouseOptions
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
// 備案 (初始載入時)
const displayWarehouseOptions = uniqueWarehouseOptions.length > 0
? uniqueWarehouseOptions
: (item.ui_warehouse_id ? [{ label: "載入中...", value: item.ui_warehouse_id }] : []);
2026-01-22 15:39:35 +08:00
// 3. 批號選項 (根據商品與倉庫過濾)
const batchOptions = currentInventories
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
.map((inv: InventoryOption) => ({
2026-01-22 15:39:35 +08:00
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
value: String(inv.id)
}));
// 備案
2026-01-22 15:39:35 +08:00
const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []);
return (
<TableRow key={index}>
{/* 1. 選擇商品 */}
2026-01-22 15:39:35 +08:00
<TableCell className="align-top">
<SearchableSelect
value={item.ui_product_id}
onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)}
options={productOptions}
placeholder="選擇商品"
2026-01-22 15:39:35 +08:00
className="w-full"
/>
</TableCell>
{/* 2. 選擇來源倉庫 */}
2026-01-22 15:39:35 +08:00
<TableCell className="align-top">
<SearchableSelect
value={item.ui_warehouse_id}
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
options={displayWarehouseOptions as any}
placeholder={item.ui_product_id
? (loadingProducts[item.ui_product_id]
? "載入庫存中..."
: (uniqueWarehouseOptions.length === 0 ? "該商品目前無庫存" : "選擇倉庫"))
: "請先選商品"}
2026-01-22 15:39:35 +08:00
className="w-full"
disabled={!item.ui_product_id || (loadingProducts[item.ui_product_id])}
2026-01-22 15:39:35 +08:00
/>
</TableCell>
{/* 3. 選擇批號 */}
2026-01-22 15:39:35 +08:00
<TableCell className="align-top">
<SearchableSelect
value={item.inventory_id}
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
options={displayBatchOptions as any}
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
2026-01-22 15:39:35 +08:00
className="w-full"
disabled={!item.ui_warehouse_id}
2026-01-22 15:39:35 +08:00
/>
{item.inventory_id && (() => {
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
2026-01-22 15:39:35 +08:00
if (selectedInv) return (
<div className="text-xs text-gray-500 mt-1">
: {selectedInv.expiry_date || '無'} | : {selectedInv.quantity}
</div>
);
return null;
})()}
</TableCell>
{/* 3. 輸入數量 */}
<TableCell className="align-top">
<Input
type="number"
step="any"
2026-01-22 15:39:35 +08:00
value={item.ui_input_quantity}
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
placeholder="0"
className="h-9 text-right"
2026-01-22 15:39:35 +08:00
disabled={!item.inventory_id}
/>
</TableCell>
{/* 4. 選擇單位 */}
<TableCell className="align-top pt-3">
<span className="text-sm">{item.ui_base_unit_name}</span>
</TableCell>
<TableCell className="align-top">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeBomItem(index)}
className="text-red-500 hover:text-red-700 hover:bg-red-50 p-2"
>
<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>
);
}