[FEAT] 新增生產工單實際產量欄位與 UI 規範

- 新增 database/migrations/tenant 實際產量與耗損原因
- ProductionOrder API 狀態推進與實際產量計算
- 完工入庫新增實際產出數量原生數字輸入框 (step=1)
- Create.tsx 補上前端資料驗證與狀態保護
- 建立並更新 UI 數字輸入框設計規範
This commit is contained in:
2026-03-10 15:32:52 +08:00
parent adf13410ba
commit 6ca0bafd60
8 changed files with 325 additions and 129 deletions

View File

@@ -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" />