Files
star-erp/resources/js/Pages/Production/Edit.tsx
sky121113 3ce96537b3
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m0s
feat: 標準化全系統數值輸入欄位與擴充商品價格功能
1. UI 標準化:
   - 針對全系統數值輸入欄位統一加上 step='any' 以支援小數點。
   - 表格形式 (Table) 的數值輸入欄位統一加上 text-right 靠右對齊。
   - 修正 Components 與 Pages 中所有涉及金額與數量的輸入框。

2. 功能擴充與修正:
   - 擴充 Product 模型與相關 Dialog 以支援多種價格設定。
   - 修正 Inventory/GoodsReceipt/Create.tsx 未使用的變數錯誤。
   - 優化庫存相關頁面的 UI 一致性。

3. 其他:
   - 更新相關的 Type 定義與 Controller 邏輯。
2026-02-05 11:45:08 +08:00

717 lines
34 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.
/**
* 編輯生產工單頁面
* 僅限草稿狀態可編輯
*/
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;
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;
quantity_used: string;
unit_id: string;
// 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;
ui_product_code?: string;
}
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) : ""
); // 產出倉庫
// 快取對照表product_id -> inventories
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
const [loadingProducts, setLoadingProducts] = useState<Record<string, boolean>>({});
// 獲取商品所有倉庫庫存的分佈
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 }));
}
};
// 初始化 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 輔助
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 的商品庫存資料
useEffect(() => {
initialBomItems.forEach(item => {
if (item.ui_product_id) {
fetchProductInventories(item.ui_product_id);
}
});
}, []);
// 當 inventoryOptions 載入後,更新現有 BOM items 的詳細資訊
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);
if (inv) {
return {
...item,
ui_warehouse_id: String(inv.warehouse_id), // 重要:還原倉庫 ID
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]);
// 同步 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 = "";
item.inventory_id = "";
item.quantity_used = "";
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) {
item.ui_product_name = prod.name;
item.ui_base_unit_name = prod.base_unit?.name || '';
}
fetchProductInventories(value);
}
}
// 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) 變更時 (第三層)
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_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"
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>
<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) }])
).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 }] : []);
// 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)
}));
// 備案
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}
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={displayWarehouseOptions 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={displayBatchOptions 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) 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"
value={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 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>
);
}