- 新增 database/migrations/tenant 實際產量與耗損原因 - ProductionOrder API 狀態推進與實際產量計算 - 完工入庫新增實際產出數量原生數字輸入框 (step=1) - Create.tsx 補上前端資料驗證與狀態保護 - 建立並更新 UI 數字輸入框設計規範
239 lines
10 KiB
TypeScript
239 lines
10 KiB
TypeScript
/**
|
||
* 生產工單完工入庫 - 選擇倉庫彈窗
|
||
* 含產出確認與耗損記錄功能
|
||
*/
|
||
|
||
import React from 'react';
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/Components/ui/dialog";
|
||
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, AlertTriangle, Tag, CalendarIcon, CheckCircle2, X } from "lucide-react";
|
||
import { formatQuantity } from "@/lib/utils";
|
||
|
||
interface Warehouse {
|
||
id: number;
|
||
name: string;
|
||
}
|
||
|
||
interface WarehouseSelectionModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onConfirm: (data: {
|
||
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({
|
||
isOpen,
|
||
onClose,
|
||
onConfirm,
|
||
warehouses,
|
||
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(() => {
|
||
if (isOpen && productCode && productId) {
|
||
const today = new Date().toISOString().split('T')[0].replace(/-/g, '');
|
||
const originCountry = 'TW';
|
||
|
||
// 先放一個預設值,實際序號由後端在儲存時再次確認或提供 API
|
||
fetch(`/api/warehouses/${selectedId || warehouses[0]?.id || 1}/inventory/batches/${productId}?originCountry=${originCountry}&arrivalDate=${new Date().toISOString().split('T')[0]}`)
|
||
.then(res => res.json())
|
||
.then(result => {
|
||
const seq = result.nextSequence || '01';
|
||
setBatchNumber(`${productCode}-${originCountry}-${today}-${seq}`);
|
||
})
|
||
.catch(() => {
|
||
setBatchNumber(`${productCode}-${originCountry}-${today}-01`);
|
||
});
|
||
}
|
||
}, [isOpen, productCode, productId]);
|
||
|
||
// 計算耗損數量
|
||
const actualQty = parseFloat(actualOutputQuantity) || 0;
|
||
const lossQuantity = outputQuantity - actualQty;
|
||
const hasLoss = lossQuantity > 0;
|
||
|
||
const handleConfirm = () => {
|
||
if (selectedId && batchNumber && actualQty > 0) {
|
||
onConfirm({
|
||
warehouseId: selectedId,
|
||
batchNumber,
|
||
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-[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" />
|
||
完工入庫倉庫 *
|
||
</Label>
|
||
<SearchableSelect
|
||
options={warehouses.map(w => ({ value: w.id.toString(), label: w.name }))}
|
||
value={selectedId?.toString() || ""}
|
||
onValueChange={(val) => setSelectedId(parseInt(val))}
|
||
placeholder="請選擇倉庫..."
|
||
className="w-full"
|
||
/>
|
||
</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" />
|
||
成品批號 *
|
||
</Label>
|
||
<Input
|
||
value={batchNumber}
|
||
onChange={(e) => setBatchNumber(e.target.value)}
|
||
placeholder="輸入成品批號"
|
||
className="h-9 font-mono"
|
||
/>
|
||
</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" />
|
||
成品效期 (選填)
|
||
</Label>
|
||
<Input
|
||
type="date"
|
||
value={expiryDate}
|
||
onChange={(e) => setExpiryDate(e.target.value)}
|
||
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
|
||
variant="outline"
|
||
onClick={onClose}
|
||
disabled={processing}
|
||
className="gap-2 button-outlined-error"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={handleConfirm}
|
||
disabled={!selectedId || !batchNumber || !isActualQtyValid || processing}
|
||
className="gap-2 button-filled-primary"
|
||
>
|
||
<CheckCircle2 className="h-4 w-4" />
|
||
{processing ? "處理中..." : "確認完工入庫"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|