[FEAT] 新增生產工單實際產量欄位與 UI 規範
- 新增 database/migrations/tenant 實際產量與耗損原因 - ProductionOrder API 狀態推進與實際產量計算 - 完工入庫新增實際產出數量原生數字輸入框 (step=1) - Create.tsx 補上前端資料驗證與狀態保護 - 建立並更新 UI 數字輸入框設計規範
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 生產工單完工入庫 - 選擇倉庫彈窗
|
||||
* 含產出確認與耗損記錄功能
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
@@ -8,7 +9,8 @@ import { Button } from "@/Components/ui/button";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Warehouse as WarehouseIcon, Calendar as CalendarIcon, Tag, X, CheckCircle2 } from "lucide-react";
|
||||
import { Warehouse as WarehouseIcon, AlertTriangle, Tag, CalendarIcon, CheckCircle2, X } from "lucide-react";
|
||||
import { formatQuantity } from "@/lib/utils";
|
||||
|
||||
interface Warehouse {
|
||||
id: number;
|
||||
@@ -22,12 +24,18 @@ interface WarehouseSelectionModalProps {
|
||||
warehouseId: number;
|
||||
batchNumber: string;
|
||||
expiryDate: string;
|
||||
actualOutputQuantity: number;
|
||||
lossReason: string;
|
||||
}) => void;
|
||||
warehouses: Warehouse[];
|
||||
processing?: boolean;
|
||||
// 新增商品資訊以利產生批號
|
||||
// 商品資訊用於產生批號
|
||||
productCode?: string;
|
||||
productId?: number;
|
||||
// 預計產量(用於耗損計算)
|
||||
outputQuantity: number;
|
||||
// 成品單位名稱
|
||||
unitName?: string;
|
||||
}
|
||||
|
||||
export default function WarehouseSelectionModal({
|
||||
@@ -38,10 +46,22 @@ export default function WarehouseSelectionModal({
|
||||
processing = false,
|
||||
productCode,
|
||||
productId,
|
||||
outputQuantity,
|
||||
unitName = '',
|
||||
}: WarehouseSelectionModalProps) {
|
||||
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
||||
const [batchNumber, setBatchNumber] = React.useState<string>("");
|
||||
const [expiryDate, setExpiryDate] = React.useState<string>("");
|
||||
const [actualOutputQuantity, setActualOutputQuantity] = React.useState<string>("");
|
||||
const [lossReason, setLossReason] = React.useState<string>("");
|
||||
|
||||
// 當開啟時,初始化實際產出數量為預計產量
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setActualOutputQuantity(String(outputQuantity));
|
||||
setLossReason("");
|
||||
}
|
||||
}, [isOpen, outputQuantity]);
|
||||
|
||||
// 當開啟時,嘗試產生成品批號 (若有資訊)
|
||||
React.useEffect(() => {
|
||||
@@ -62,26 +82,37 @@ export default function WarehouseSelectionModal({
|
||||
}
|
||||
}, [isOpen, productCode, productId]);
|
||||
|
||||
// 計算耗損數量
|
||||
const actualQty = parseFloat(actualOutputQuantity) || 0;
|
||||
const lossQuantity = outputQuantity - actualQty;
|
||||
const hasLoss = lossQuantity > 0;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedId && batchNumber) {
|
||||
if (selectedId && batchNumber && actualQty > 0) {
|
||||
onConfirm({
|
||||
warehouseId: selectedId,
|
||||
batchNumber,
|
||||
expiryDate
|
||||
expiryDate,
|
||||
actualOutputQuantity: actualQty,
|
||||
lossReason: hasLoss ? lossReason : '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 驗證:實際產出不可大於預計產量,也不可小於等於 0
|
||||
const isActualQtyValid = actualQty > 0 && actualQty <= outputQuantity;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-primary-main">
|
||||
<WarehouseIcon className="h-5 w-5" />
|
||||
選擇完工入庫倉庫
|
||||
完工入庫確認
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-6 space-y-6">
|
||||
{/* 倉庫選擇 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||
<WarehouseIcon className="h-3 w-3" />
|
||||
@@ -96,6 +127,7 @@ export default function WarehouseSelectionModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 成品批號 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
@@ -109,6 +141,7 @@ export default function WarehouseSelectionModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 成品效期 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
@@ -121,6 +154,64 @@ export default function WarehouseSelectionModal({
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分隔線 - 產出確認區 */}
|
||||
<div className="border-t border-grey-4 pt-4">
|
||||
<p className="text-xs font-bold text-grey-2 uppercase tracking-wider mb-4">產出確認</p>
|
||||
|
||||
{/* 預計產量(唯讀) */}
|
||||
<div className="flex items-center justify-between mb-3 px-3 py-2 bg-grey-5 rounded-lg border border-grey-4">
|
||||
<span className="text-sm text-grey-2">預計產量</span>
|
||||
<span className="font-bold text-grey-0">
|
||||
{formatQuantity(outputQuantity)} {unitName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 實際產出數量 */}
|
||||
<div className="space-y-1 mb-3">
|
||||
<Label className="text-xs font-medium text-grey-2">
|
||||
實際產出數量 *
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
max={outputQuantity}
|
||||
value={actualOutputQuantity}
|
||||
onChange={(e) => setActualOutputQuantity(e.target.value)}
|
||||
className={`h-9 font-bold ${!isActualQtyValid && actualOutputQuantity !== '' ? 'border-red-400 focus:ring-red-400' : ''}`}
|
||||
/>
|
||||
{unitName && <span className="text-sm text-grey-2 whitespace-nowrap">{unitName}</span>}
|
||||
</div>
|
||||
{actualQty > outputQuantity && (
|
||||
<p className="text-xs text-red-500 mt-1">實際產出不可超過預計產量</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 耗損顯示 */}
|
||||
{hasLoss && (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 space-y-2 animate-in fade-in duration-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm font-bold text-orange-700">
|
||||
耗損數量:{formatQuantity(lossQuantity)} {unitName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-orange-600">
|
||||
耗損原因 (選填)
|
||||
</Label>
|
||||
<Input
|
||||
value={lossReason}
|
||||
onChange={(e) => setLossReason(e.target.value)}
|
||||
placeholder="例如:製作過程損耗、品質不合格..."
|
||||
className="h-9 border-orange-200 focus:ring-orange-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
@@ -134,7 +225,7 @@ export default function WarehouseSelectionModal({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedId || !batchNumber || processing}
|
||||
disabled={!selectedId || !batchNumber || !isActualQtyValid || processing}
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
|
||||
@@ -38,6 +38,8 @@ interface SearchableSelectProps {
|
||||
showSearch?: boolean;
|
||||
/** 是否可清除選取 */
|
||||
isClearable?: boolean;
|
||||
/** 是否為無效狀態(顯示紅色邊框) */
|
||||
"aria-invalid"?: boolean;
|
||||
}
|
||||
|
||||
export function SearchableSelect({
|
||||
@@ -52,6 +54,7 @@ export function SearchableSelect({
|
||||
searchThreshold = 10,
|
||||
showSearch,
|
||||
isClearable = false,
|
||||
"aria-invalid": ariaInvalid,
|
||||
}: SearchableSelectProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
@@ -79,12 +82,15 @@ export function SearchableSelect({
|
||||
!selectedOption && "text-grey-3",
|
||||
// Focus state - primary border with ring
|
||||
"focus-visible:border-primary-main focus-visible:ring-primary-main/20 focus-visible:ring-[3px]",
|
||||
// Error state
|
||||
ariaInvalid && "border-destructive ring-destructive/20",
|
||||
// Disabled state
|
||||
"disabled:border-grey-4 disabled:bg-background-light-grey disabled:text-grey-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
// Height
|
||||
"h-9",
|
||||
className
|
||||
)}
|
||||
aria-invalid={ariaInvalid}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
|
||||
@@ -96,11 +96,11 @@ export default function Create({ products, warehouses }: Props) {
|
||||
const [recipes, setRecipes] = useState<any[]>([]);
|
||||
const [selectedRecipeId, setSelectedRecipeId] = useState<string>("");
|
||||
|
||||
const { data, setData, processing, errors } = useForm({
|
||||
// 提交表單
|
||||
const { data, setData, processing, errors, setError, clearErrors } = useForm({
|
||||
product_id: "",
|
||||
warehouse_id: "",
|
||||
output_quantity: "",
|
||||
// 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄
|
||||
remark: "",
|
||||
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
||||
});
|
||||
@@ -108,7 +108,6 @@ export default function Create({ products, warehouses }: Props) {
|
||||
// 獲取特定商品在各倉庫的庫存分佈
|
||||
const fetchProductInventories = async (productId: string) => {
|
||||
if (!productId) return;
|
||||
// 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
|
||||
if (loadingProducts[productId]) return;
|
||||
|
||||
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
|
||||
@@ -159,7 +158,6 @@ export default function Create({ products, warehouses }: Props) {
|
||||
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;
|
||||
@@ -180,21 +178,13 @@ export default function Create({ products, warehouses }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -203,45 +193,31 @@ export default function Create({ products, warehouses }: Props) {
|
||||
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_large_unit_id = inv.large_unit_id;
|
||||
item.ui_purchase_unit_id = inv.purchase_unit_id;
|
||||
item.ui_conversion_rate = inv.conversion_rate || 1;
|
||||
item.ui_unit_cost = inv.unit_cost || 0;
|
||||
|
||||
// 預設單位
|
||||
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 || '');
|
||||
}
|
||||
item.quantity_used = item.ui_selected_unit === 'large' ? String(inputQty * rate) : 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),
|
||||
@@ -250,33 +226,22 @@ export default function Create({ products, warehouses }: Props) {
|
||||
})));
|
||||
}, [bomItems]);
|
||||
|
||||
// 應用配方到表單 (獨立函式)
|
||||
const applyRecipe = (recipe: any) => {
|
||||
if (!recipe || !recipe.items) return;
|
||||
|
||||
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
|
||||
// 自動帶入配方標準產量
|
||||
setData('output_quantity', formatQuantity(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));
|
||||
}
|
||||
|
||||
if (item.product_id) fetchProductInventories(String(item.product_id));
|
||||
return {
|
||||
inventory_id: "",
|
||||
quantity_used: String(calculatedQty),
|
||||
quantity_used: String(item.quantity || "0"),
|
||||
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: formatQuantity(calculatedQty),
|
||||
ui_input_quantity: formatQuantity(item.quantity || "0"),
|
||||
ui_selected_unit: 'base',
|
||||
ui_base_unit_name: item.unit_name,
|
||||
ui_base_unit_id: item.unit_id,
|
||||
@@ -284,45 +249,30 @@ export default function Create({ products, warehouses }: Props) {
|
||||
};
|
||||
});
|
||||
setBomItems(newBomItems);
|
||||
|
||||
toast.success(`已自動載入配方: ${recipe.name}`, {
|
||||
description: `標準產量: ${formatQuantity(yieldQty)} 份`
|
||||
});
|
||||
toast.success(`已自動載入配方: ${recipe.name}`);
|
||||
};
|
||||
|
||||
// 當手動切換配方時
|
||||
useEffect(() => {
|
||||
if (!selectedRecipeId) return;
|
||||
const targetRecipe = recipes.find(r => String(r.id) === selectedRecipeId);
|
||||
if (targetRecipe) {
|
||||
applyRecipe(targetRecipe);
|
||||
}
|
||||
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));
|
||||
setSelectedRecipeId(String(recipesData[0].id));
|
||||
} else {
|
||||
// 若無配方
|
||||
setRecipes([]);
|
||||
setSelectedRecipeId("");
|
||||
setBomItems([]); // 清空 BOM
|
||||
setBomItems([]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch recipes", e);
|
||||
setRecipes([]);
|
||||
setBomItems([]);
|
||||
}
|
||||
@@ -330,61 +280,74 @@ export default function Create({ products, warehouses }: Props) {
|
||||
fetchRecipes();
|
||||
}, [data.product_id]);
|
||||
|
||||
// 當生產數量變動時,如果是從配方載入的,則按比例更新用量
|
||||
// 當有驗證錯誤時,自動聚焦到第一個錯誤欄位
|
||||
useEffect(() => {
|
||||
if (bomItems.length > 0 && data.output_quantity) {
|
||||
// 這個部位比較複雜,因為使用者可能已經手動修改過或是選了批號
|
||||
// 目前先保持簡單:如果使用者改了產出量,我們不強行覆蓋已經選好批號的明細,避免困擾
|
||||
// 但如果是剛載入(inventory_id 為空),可以考慮連動?暫時先不處理以維護操作穩定性
|
||||
const errorKeys = Object.keys(errors);
|
||||
if (errorKeys.length > 0) {
|
||||
// 延遲一下確保 DOM 已更新(例如 BOM 明細行渲染)
|
||||
setTimeout(() => {
|
||||
const firstInvalid = document.querySelector('[aria-invalid="true"]');
|
||||
if (firstInvalid instanceof HTMLElement) {
|
||||
firstInvalid.focus();
|
||||
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [data.output_quantity]);
|
||||
}, [errors]);
|
||||
|
||||
// 提交表單
|
||||
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('原物料明細');
|
||||
const submit = (status: 'draft') => {
|
||||
clearErrors();
|
||||
let hasError = false;
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
toast.error("請填寫必要欄位", {
|
||||
description: `缺漏:${missingFields.join('、')}`
|
||||
});
|
||||
return;
|
||||
// 草稿建立時也要求必填生產數量與預計入庫倉庫
|
||||
if (!data.product_id) { setError('product_id', '請選擇成品商品'); hasError = true; }
|
||||
if (!data.output_quantity) { setError('output_quantity', '請輸入生產數量'); hasError = true; }
|
||||
if (!selectedWarehouse) { setError('warehouse_id', '請選擇預計入庫倉庫'); hasError = true; }
|
||||
if (bomItems.length === 0) { toast.error("請至少新增一項原物料明細"); hasError = true; }
|
||||
|
||||
// 驗證 BOM 明細
|
||||
bomItems.forEach((item, index) => {
|
||||
if (!item.ui_product_id) {
|
||||
setError(`items.${index}.ui_product_id` as any, '請選擇商品');
|
||||
hasError = true;
|
||||
} else {
|
||||
if (!item.inventory_id) {
|
||||
setError(`items.${index}.inventory_id` as any, '請選擇批號');
|
||||
hasError = true;
|
||||
}
|
||||
if (!item.quantity_used || parseFloat(item.quantity_used) <= 0) {
|
||||
setError(`items.${index}.quantity_used` as any, '請輸入數量');
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasError) {
|
||||
toast.error("建立失敗,請檢查標單內紅框欄位");
|
||||
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,
|
||||
}));
|
||||
const formattedItems = bomItems.map(item => ({
|
||||
inventory_id: parseInt(item.inventory_id),
|
||||
quantity_used: parseFloat(item.quantity_used),
|
||||
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} 個欄位有誤,請修正後再試`
|
||||
});
|
||||
onError: () => {
|
||||
toast.error("建立失敗,請檢查表單");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
submit('completed');
|
||||
submit('draft');
|
||||
};
|
||||
|
||||
const getBomItemUnitCost = (item: BomItem) => {
|
||||
@@ -423,7 +386,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Factory className="h-6 w-6 text-primary-main" />
|
||||
建立生產工單
|
||||
</h1>
|
||||
@@ -431,14 +394,18 @@ export default function Create({ products, warehouses }: Props) {
|
||||
建立新的生產排程,選擇原物料並記錄產出
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => submit('draft')}
|
||||
disabled={processing}
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
儲存工單 (草稿)
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => submit('draft')}
|
||||
disabled={processing}
|
||||
className="button-filled-primary gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
儲存工單 (草稿)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -458,6 +425,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
}))}
|
||||
placeholder="選擇成品"
|
||||
className="w-full h-9"
|
||||
aria-invalid={!!errors.product_id}
|
||||
/>
|
||||
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
|
||||
|
||||
@@ -493,6 +461,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
onChange={(e) => setData('output_quantity', e.target.value)}
|
||||
placeholder="例如: 50"
|
||||
className="h-9 font-mono"
|
||||
aria-invalid={!!errors.output_quantity}
|
||||
/>
|
||||
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
|
||||
</div>
|
||||
@@ -508,6 +477,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
}))}
|
||||
placeholder="選擇倉庫"
|
||||
className="w-full h-9"
|
||||
aria-invalid={!!errors.warehouse_id}
|
||||
/>
|
||||
{errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
|
||||
</div>
|
||||
@@ -600,6 +570,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
options={productOptions}
|
||||
placeholder="選擇商品"
|
||||
className="w-full"
|
||||
aria-invalid={!!errors[`items.${index}.ui_product_id` as any]}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -628,6 +599,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
|
||||
className="w-full"
|
||||
disabled={!item.ui_warehouse_id}
|
||||
aria-invalid={!!errors[`items.${index}.inventory_id` as any]}
|
||||
/>
|
||||
{item.inventory_id && (() => {
|
||||
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
|
||||
@@ -655,6 +627,7 @@ export default function Create({ products, warehouses }: Props) {
|
||||
placeholder="0"
|
||||
className="h-9 text-right"
|
||||
disabled={!item.inventory_id}
|
||||
aria-invalid={!!errors[`items.${index}.quantity_used` as any]}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ interface ProductionOrder {
|
||||
output_batch_number: string;
|
||||
output_box_count: string | null;
|
||||
output_quantity: number;
|
||||
actual_output_quantity: number | null;
|
||||
loss_reason: string | null;
|
||||
production_date: string;
|
||||
expiry_date: string | null;
|
||||
status: ProductionOrderStatus;
|
||||
@@ -88,12 +90,16 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
||||
warehouseId?: number;
|
||||
batchNumber?: string;
|
||||
expiryDate?: string;
|
||||
actualOutputQuantity?: number;
|
||||
lossReason?: string;
|
||||
}) => {
|
||||
router.patch(route('production-orders.update-status', productionOrder.id), {
|
||||
status: newStatus,
|
||||
warehouse_id: extraData?.warehouseId,
|
||||
output_batch_number: extraData?.batchNumber,
|
||||
expiry_date: extraData?.expiryDate,
|
||||
actual_output_quantity: extraData?.actualOutputQuantity,
|
||||
loss_reason: extraData?.lossReason,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsWarehouseModalOpen(false);
|
||||
@@ -129,6 +135,8 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
||||
processing={processing}
|
||||
productCode={productionOrder.product?.code}
|
||||
productId={productionOrder.product?.id}
|
||||
outputQuantity={Number(productionOrder.output_quantity)}
|
||||
unitName={productionOrder.product?.base_unit?.name}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
|
||||
@@ -276,7 +284,7 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">預計/實際產量</p>
|
||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">預計產量</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<p className="font-bold text-grey-0 text-xl">
|
||||
{formatQuantity(productionOrder.output_quantity)}
|
||||
@@ -289,6 +297,28 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 實際產量與耗損(僅完成狀態顯示) */}
|
||||
{productionOrder.status === PRODUCTION_ORDER_STATUS.COMPLETED && productionOrder.actual_output_quantity != null && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">實際產量</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<p className="font-bold text-grey-0 text-xl">
|
||||
{formatQuantity(productionOrder.actual_output_quantity)}
|
||||
</p>
|
||||
{productionOrder.product?.base_unit?.name && (
|
||||
<span className="text-grey-2 font-medium">{productionOrder.product.base_unit.name}</span>
|
||||
)}
|
||||
{Number(productionOrder.output_quantity) > Number(productionOrder.actual_output_quantity) && (
|
||||
<span className="ml-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-orange-100 text-orange-700 text-xs font-bold border border-orange-200">
|
||||
耗損 {formatQuantity(Number(productionOrder.output_quantity) - Number(productionOrder.actual_output_quantity))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{productionOrder.loss_reason && (
|
||||
<p className="text-xs text-orange-600 mt-1">原因:{productionOrder.loss_reason}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">入庫倉庫</p>
|
||||
<div className="flex items-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4">
|
||||
|
||||
Reference in New Issue
Block a user