Files
star-erp/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx
sky121113 6ca0bafd60 [FEAT] 新增生產工單實際產量欄位與 UI 規範
- 新增 database/migrations/tenant 實際產量與耗損原因
- ProductionOrder API 狀態推進與實際產量計算
- 完工入庫新增實際產出數量原生數字輸入框 (step=1)
- Create.tsx 補上前端資料驗證與狀態保護
- 建立並更新 UI 數字輸入框設計規範
2026-03-10 15:32:52 +08:00

239 lines
10 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 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>
);
}