feat: 修正 BOM 單位顯示與完工入庫彈窗 UI 統一規範

This commit is contained in:
2026-02-12 16:30:34 +08:00
parent eb5ab58093
commit 5be4d49679
20 changed files with 1186 additions and 549 deletions

View File

@@ -4,10 +4,11 @@
*/
import { useState, useEffect } from "react";
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar } from 'lucide-react';
import { Trash2, Plus, ArrowLeft, Save, Factory } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { formatQuantity } from "@/lib/utils";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm, Link } from "@inertiajs/react";
import { router, useForm, Head, Link } from "@inertiajs/react";
import toast, { Toaster } from 'react-hot-toast';
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select";
@@ -107,10 +108,6 @@ interface ProductionOrder {
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[];
@@ -126,18 +123,9 @@ interface Props {
}
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) : ""
); // 產出倉庫
); // 預計入庫倉庫
// 快取對照表product_id -> inventories
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
@@ -169,7 +157,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
// 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_input_quantity: formatQuantity(item.quantity_used),
ui_selected_unit: 'base',
// UI 輔助
@@ -183,11 +171,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
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),
output_quantity: productionOrder.output_quantity ? formatQuantity(productionOrder.output_quantity) : "",
remark: productionOrder.remark || "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
});
@@ -210,7 +194,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
if (inv) {
return {
...item,
ui_warehouse_id: String(inv.warehouse_id), // 重要:還原倉庫 ID
ui_warehouse_id: String(inv.warehouse_id),
ui_product_name: inv.product_name,
ui_batch_number: inv.batch_number,
ui_available_qty: inv.quantity,
@@ -255,7 +239,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
const updated = [...bomItems];
const item = { ...updated[index], [field]: value };
// 0. 當選擇商品變更時 (第一層)
// 0. 當選擇商品變更時
if (field === 'ui_product_id') {
item.ui_warehouse_id = "";
item.inventory_id = "";
@@ -263,7 +247,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
item.unit_id = "";
item.ui_input_quantity = "";
item.ui_selected_unit = "base";
// 保留基本資訊
if (value) {
const prod = products.find(p => String(p.id) === value);
if (prod) {
@@ -274,16 +257,12 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
}
}
// 1. 當選擇來源倉庫變更時 (第二層)
// 1. 當選擇來源倉庫變更時
if (field === 'ui_warehouse_id') {
item.inventory_id = "";
item.quantity_used = "";
item.unit_id = "";
item.ui_input_quantity = "";
item.ui_selected_unit = "base";
}
// 2. 當選擇批號 (Inventory) 變更時 (第三層)
// 2. 當選擇批號 (Inventory) 變更時
if (field === 'inventory_id' && value) {
const currentOptions = productInventoryMap[item.ui_product_id] || [];
const inv = currentOptions.find(i => String(i.id) === value);
@@ -302,6 +281,10 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
item.ui_selected_unit = 'base';
item.unit_id = String(inv.base_unit_id || '');
if (!item.ui_input_quantity) {
item.ui_input_quantity = String(inv.quantity);
}
}
}
@@ -332,18 +315,13 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
})));
}, [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 (!selectedWarehouse) missingFields.push('預計入庫倉庫');
if (bomItems.length === 0) missingFields.push('原物料明細');
if (missingFields.length > 0) {
@@ -384,24 +362,22 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
submit('completed');
submit('draft');
};
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="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
<div className="mb-6">
<Link href={route('production-orders.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
className="gap-2 button-outlined-primary mb-4"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
@@ -409,38 +385,27 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Factory className="h-6 w-6 text-primary-main" />
{productionOrder.code}
</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"
className="gap-2 button-filled-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" />
<Save className="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">
@@ -463,64 +428,16 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
<Input
type="number"
step="any"
value={data.output_quantity}
value={Number(data.output_quantity) === 0 ? '' : formatQuantity(data.output_quantity)}
onChange={(e) => setData('output_quantity', e.target.value)}
placeholder="例如: 50"
className="h-9"
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>
<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>
<Label className="text-xs font-medium text-grey-2"> *</Label>
<SearchableSelect
value={selectedWarehouse}
onValueChange={setSelectedWarehouse}
@@ -547,7 +464,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
</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>
@@ -574,23 +490,21 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
<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>
<TableHead className="w-[25%]"> <span className="text-red-500">*</span></TableHead>
<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-[15%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"></TableHead>
<TableHead className="w-[5%]"></TableHead>
<TableHead className="w-[12%]"></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) }])
@@ -600,26 +514,22 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
? 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 }] : []);
// 3. 批號選項 (根據商品與倉庫過濾)
const batchOptions = currentInventories
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
.map((inv: InventoryOption) => ({
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
value: String(inv.id)
label: inv.batch_number,
value: String(inv.id),
sublabel: `(存:${inv.quantity} | 效:${inv.expiry_date || '無'})`
}));
// 備案
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. 選擇商品 */}
<TableCell className="align-top">
<SearchableSelect
value={item.ui_product_id}
@@ -630,7 +540,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
/>
</TableCell>
{/* 2. 選擇來源倉庫 */}
<TableCell className="align-top">
<SearchableSelect
value={item.ui_warehouse_id}
@@ -646,7 +555,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
/>
</TableCell>
{/* 3. 選擇批號 */}
<TableCell className="align-top">
<SearchableSelect
value={item.inventory_id}
@@ -658,21 +566,25 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
/>
{item.inventory_id && (() => {
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
if (selectedInv) return (
<div className="text-xs text-gray-500 mt-1">
: {selectedInv.expiry_date || '無'} | : {selectedInv.quantity}
</div>
);
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={item.ui_input_quantity}
value={formatQuantity(item.ui_input_quantity)}
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
placeholder="0"
className="h-9 text-right"
@@ -680,20 +592,20 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
/>
</TableCell>
{/* 4. 選擇單位 */}
<TableCell className="align-top pt-3">
<span className="text-sm">{item.ui_base_unit_name}</span>
<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="ghost"
variant="outline"
size="sm"
onClick={() => removeBomItem(index)}
className="text-red-500 hover:text-red-700 hover:bg-red-50 p-2"
className="button-outlined-error"
title="刪除"
>
<Trash2 className="h-4 w-4" />
</Button>