2026-01-21 17:19:36 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 建立生產工單頁面
|
|
|
|
|
|
* 動態 BOM 表單:選擇倉庫 → 選擇原物料 → 選擇批號 → 輸入用量
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect } from "react";
|
2026-01-22 15:39:35 +08:00
|
|
|
|
import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar, AlertCircle } from 'lucide-react';
|
2026-01-21 17:19:36 +08:00
|
|
|
|
import { Button } from "@/Components/ui/button";
|
|
|
|
|
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
|
|
|
|
|
import { Head, router, useForm } from "@inertiajs/react";
|
2026-01-22 15:39:35 +08:00
|
|
|
|
import toast, { Toaster } from 'react-hot-toast';
|
2026-01-21 17:19:36 +08:00
|
|
|
|
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";
|
2026-01-22 15:39:35 +08:00
|
|
|
|
import { Link } from "@inertiajs/react";
|
|
|
|
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
2026-01-21 17:19:36 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-01-22 15:39:35 +08:00
|
|
|
|
base_unit_id?: number;
|
|
|
|
|
|
base_unit_name?: string;
|
|
|
|
|
|
large_unit_id?: number;
|
|
|
|
|
|
large_unit_name?: string;
|
|
|
|
|
|
conversion_rate?: number;
|
2026-01-21 17:19:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface BomItem {
|
2026-01-26 14:59:24 +08:00
|
|
|
|
// 後端必填
|
|
|
|
|
|
inventory_id: string; // 所選庫存記錄 ID(特定批號)
|
|
|
|
|
|
quantity_used: string; // 轉換後的最終數量(基本單位)
|
|
|
|
|
|
unit_id: string; // 單位 ID(通常為基本單位 ID)
|
2026-01-22 15:39:35 +08:00
|
|
|
|
|
2026-01-26 14:59:24 +08:00
|
|
|
|
// UI 狀態
|
|
|
|
|
|
ui_warehouse_id: string; // 來源倉庫
|
|
|
|
|
|
ui_product_id: string; // 批號列表篩選
|
|
|
|
|
|
ui_input_quantity: string; // 使用者輸入數量
|
|
|
|
|
|
ui_selected_unit: 'base' | 'large'; // 使用者選擇單位
|
2026-01-22 15:39:35 +08:00
|
|
|
|
|
2026-01-26 14:59:24 +08:00
|
|
|
|
// 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;
|
2026-01-21 17:19:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
products: Product[];
|
|
|
|
|
|
warehouses: Warehouse[];
|
|
|
|
|
|
units: Unit[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
export default function ProductionCreate({ products, warehouses }: Props) {
|
2026-01-26 14:59:24 +08:00
|
|
|
|
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫
|
|
|
|
|
|
// 快取對照表:warehouse_id -> inventories
|
2026-01-22 15:39:35 +08:00
|
|
|
|
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
|
|
|
|
|
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
|
|
|
|
|
|
|
2026-01-21 17:19:36 +08:00
|
|
|
|
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 }[],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-26 14:59:24 +08:00
|
|
|
|
// 獲取倉庫資料的輔助函式
|
2026-01-22 15:39:35 +08:00
|
|
|
|
const fetchWarehouseInventory = async (warehouseId: string) => {
|
|
|
|
|
|
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
|
|
|
|
|
|
|
|
|
|
|
|
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: true }));
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(route('api.production.warehouses.inventories', warehouseId));
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
setInventoryMap(prev => ({ ...prev, [warehouseId]: data }));
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: false }));
|
2026-01-21 17:19:36 +08:00
|
|
|
|
}
|
2026-01-22 15:39:35 +08:00
|
|
|
|
};
|
2026-01-21 17:19:36 +08:00
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
// 同步 warehouse_id 到 form data (Output)
|
2026-01-21 17:19:36 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setData('warehouse_id', selectedWarehouse);
|
|
|
|
|
|
}, [selectedWarehouse]);
|
|
|
|
|
|
|
|
|
|
|
|
// 新增 BOM 項目
|
|
|
|
|
|
const addBomItem = () => {
|
|
|
|
|
|
setBomItems([...bomItems, {
|
|
|
|
|
|
inventory_id: "",
|
|
|
|
|
|
quantity_used: "",
|
|
|
|
|
|
unit_id: "",
|
2026-01-22 15:39:35 +08:00
|
|
|
|
ui_warehouse_id: "",
|
|
|
|
|
|
ui_product_id: "",
|
|
|
|
|
|
ui_input_quantity: "",
|
|
|
|
|
|
ui_selected_unit: 'base',
|
2026-01-21 17:19:36 +08:00
|
|
|
|
}]);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 移除 BOM 項目
|
|
|
|
|
|
const removeBomItem = (index: number) => {
|
|
|
|
|
|
setBomItems(bomItems.filter((_, i) => i !== index));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
// 更新 BOM 項目邏輯
|
|
|
|
|
|
const updateBomItem = (index: number, field: keyof BomItem, value: any) => {
|
2026-01-21 17:19:36 +08:00
|
|
|
|
const updated = [...bomItems];
|
2026-01-22 15:39:35 +08:00
|
|
|
|
const item = { ...updated[index], [field]: value };
|
|
|
|
|
|
|
|
|
|
|
|
// 0. 當選擇來源倉庫變更時
|
|
|
|
|
|
if (field === 'ui_warehouse_id') {
|
|
|
|
|
|
// 重置後續欄位
|
|
|
|
|
|
item.ui_product_id = "";
|
|
|
|
|
|
item.inventory_id = "";
|
|
|
|
|
|
item.quantity_used = "";
|
|
|
|
|
|
item.unit_id = "";
|
|
|
|
|
|
item.ui_input_quantity = "";
|
|
|
|
|
|
item.ui_selected_unit = "base";
|
|
|
|
|
|
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) {
|
|
|
|
|
|
fetchWarehouseInventory(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 當選擇商品變更時 -> 清空批號與相關資訊
|
|
|
|
|
|
if (field === 'ui_product_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;
|
|
|
|
|
|
}
|
2026-01-21 17:19:36 +08:00
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
// 2. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
|
2026-01-21 17:19:36 +08:00
|
|
|
|
if (field === 'inventory_id' && value) {
|
2026-01-22 15:39:35 +08:00
|
|
|
|
const currentOptions = inventoryMap[item.ui_warehouse_id] || [];
|
|
|
|
|
|
const inv = currentOptions.find(i => String(i.id) === value);
|
2026-01-21 17:19:36 +08:00
|
|
|
|
if (inv) {
|
2026-01-22 15:39:35 +08:00
|
|
|
|
item.ui_product_id = String(inv.product_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 || '');
|
2026-01-21 17:19:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
// 3. 計算最終數量 (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);
|
|
|
|
|
|
// 注意:後端需要的是 Base Unit ID? 這裡我們都送 Base Unit ID,因為 quantity_used 是 Base Unit
|
|
|
|
|
|
// 但為了保留 User 的選擇,我們可能可以在 remark 註記? 目前先從簡
|
|
|
|
|
|
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;
|
2026-01-21 17:19:36 +08:00
|
|
|
|
setBomItems(updated);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
// 同步 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]);
|
|
|
|
|
|
|
|
|
|
|
|
// 自動產生成品批號(當選擇商品或日期變動時)
|
|
|
|
|
|
useEffect(() => {
|
2026-01-21 17:19:36 +08:00
|
|
|
|
if (!data.product_id) return;
|
2026-01-22 15:39:35 +08:00
|
|
|
|
|
2026-01-21 17:19:36 +08:00
|
|
|
|
const product = products.find(p => String(p.id) === data.product_id);
|
|
|
|
|
|
if (!product) return;
|
|
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
const datePart = data.production_date; // YYYY-MM-DD
|
|
|
|
|
|
const dateFormatted = datePart.replace(/-/g, '');
|
|
|
|
|
|
const originCountry = 'TW';
|
|
|
|
|
|
|
|
|
|
|
|
// 呼叫 API 取得下一組流水號
|
|
|
|
|
|
// 複用庫存批號 API,但這裡可能沒有選 warehouse,所以用第一個預設
|
|
|
|
|
|
const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1');
|
|
|
|
|
|
|
|
|
|
|
|
fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`)
|
|
|
|
|
|
.then(res => res.json())
|
|
|
|
|
|
.then(result => {
|
|
|
|
|
|
const seq = result.nextSequence || '01';
|
|
|
|
|
|
const suggested = `${product.code}-${originCountry}-${dateFormatted}-${seq}`;
|
|
|
|
|
|
setData('output_batch_number', suggested);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
// Fallback:若 API 失敗,使用預設 01
|
|
|
|
|
|
const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`;
|
|
|
|
|
|
setData('output_batch_number', suggested);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [data.product_id, data.production_date]);
|
2026-01-21 17:19:36 +08:00
|
|
|
|
|
|
|
|
|
|
// 提交表單
|
2026-01-22 15:39:35 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-21 17:19:36 +08:00
|
|
|
|
|
|
|
|
|
|
// 轉換 BOM items 格式
|
|
|
|
|
|
const formattedItems = bomItems
|
2026-01-22 15:39:35 +08:00
|
|
|
|
.filter(item => status === 'draft' || (item.inventory_id && item.quantity_used))
|
2026-01-21 17:19:36 +08:00
|
|
|
|
.map(item => ({
|
2026-01-22 15:39:35 +08:00
|
|
|
|
inventory_id: item.inventory_id ? parseInt(item.inventory_id) : null,
|
|
|
|
|
|
quantity_used: item.quantity_used ? parseFloat(item.quantity_used) : 0,
|
2026-01-21 17:19:36 +08:00
|
|
|
|
unit_id: item.unit_id ? parseInt(item.unit_id) : null,
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
// 使用 router.post 提交完整資料
|
|
|
|
|
|
router.post(route('production-orders.store'), {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
items: formattedItems,
|
2026-01-22 15:39:35 +08:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-01-21 17:19:36 +08:00
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
submit('completed');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-21 17:19:36 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
|
|
|
|
|
|
<Head title="建立生產單" />
|
2026-01-22 15:39:35 +08:00
|
|
|
|
<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>
|
2026-01-21 17:19:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
2026-01-21 17:19:36 +08:00
|
|
|
|
{/* 成品資訊 */}
|
|
|
|
|
|
<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>
|
2026-01-22 15:39:35 +08:00
|
|
|
|
<Input
|
|
|
|
|
|
value={data.output_batch_number}
|
|
|
|
|
|
onChange={(e) => setData('output_batch_number', e.target.value)}
|
|
|
|
|
|
placeholder="選擇商品後自動產生"
|
|
|
|
|
|
className="h-9 font-mono"
|
|
|
|
|
|
/>
|
2026-01-21 17:19:36 +08:00
|
|
|
|
{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>
|
|
|
|
|
|
|
2026-01-22 15:39:35 +08:00
|
|
|
|
{bomItems.length === 0 && (
|
2026-01-21 17:19:36 +08:00
|
|
|
|
<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 && (
|
2026-01-22 15:39:35 +08:00
|
|
|
|
<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) => {
|
|
|
|
|
|
// 取得此列已載入的 Inventory Options
|
|
|
|
|
|
const currentOptions = inventoryMap[item.ui_warehouse_id] || [];
|
|
|
|
|
|
|
|
|
|
|
|
// 過濾商品
|
|
|
|
|
|
const uniqueProductOptions = Array.from(new Map(
|
|
|
|
|
|
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }])
|
|
|
|
|
|
).values());
|
|
|
|
|
|
|
|
|
|
|
|
// 過濾批號
|
|
|
|
|
|
const batchOptions = currentOptions
|
|
|
|
|
|
.filter(inv => String(inv.product_id) === item.ui_product_id)
|
|
|
|
|
|
.map(inv => ({
|
|
|
|
|
|
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
|
|
|
|
|
|
value: String(inv.id)
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TableRow key={index}>
|
|
|
|
|
|
{/* 0. 選擇來源倉庫 */}
|
|
|
|
|
|
<TableCell className="align-top">
|
|
|
|
|
|
<SearchableSelect
|
|
|
|
|
|
value={item.ui_warehouse_id}
|
|
|
|
|
|
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
|
|
|
|
|
|
options={warehouses.map(w => ({ label: w.name, value: String(w.id) }))}
|
|
|
|
|
|
placeholder="選擇倉庫"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 1. 選擇商品 */}
|
|
|
|
|
|
<TableCell className="align-top">
|
|
|
|
|
|
<SearchableSelect
|
|
|
|
|
|
value={item.ui_product_id}
|
|
|
|
|
|
onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)}
|
|
|
|
|
|
options={uniqueProductOptions}
|
|
|
|
|
|
placeholder="選擇商品"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
disabled={!item.ui_warehouse_id}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 2. 選擇批號 */}
|
|
|
|
|
|
<TableCell className="align-top">
|
|
|
|
|
|
<SearchableSelect
|
|
|
|
|
|
value={item.inventory_id}
|
|
|
|
|
|
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
|
|
|
|
|
|
options={batchOptions}
|
|
|
|
|
|
placeholder={item.ui_product_id ? "選擇批號" : "請先選商品"}
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
disabled={!item.ui_product_id}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{item.inventory_id && (() => {
|
|
|
|
|
|
const selectedInv = currentOptions.find(i => 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="1"
|
|
|
|
|
|
value={item.ui_input_quantity}
|
|
|
|
|
|
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
|
|
|
|
|
|
placeholder="0"
|
|
|
|
|
|
className="h-9"
|
|
|
|
|
|
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>
|
2026-01-21 17:19:36 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</AuthenticatedLayout>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|