- max() → SUM: 같은 LOT의 기투입(lotInputtedQty)을 모든 그룹에서 합산 - replace 모드에서 각 그룹의 기투입이 복원되므로 합산이 정확 - 예: 가용4 + 상부덮개기투입3 + 마구리기투입1 = 총8 (max는 7로 부정확)
1056 lines
49 KiB
TypeScript
1056 lines
49 KiB
TypeScript
'use client';
|
||
|
||
/**
|
||
* 자재투입 모달 (로트 선택 기반)
|
||
*
|
||
* 로트를 체크박스로 선택하면 필요수량만큼 FIFO 순서로 자동 배분합니다.
|
||
* 같은 품목의 여러 로트를 조합하여 필요수량을 충족시킬 수 있습니다.
|
||
*
|
||
* 기능:
|
||
* - 기투입 LOT 표시 및 수정 (replace 모드)
|
||
* - 선택완료 배지
|
||
* - 필요수량 배정 완료 시에만 투입 가능
|
||
* - FIFO 자동입력 버튼
|
||
*/
|
||
|
||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||
import { Loader2, Check, Zap, Info, Search, X } from 'lucide-react';
|
||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog';
|
||
import { Button } from '@/components/ui/button';
|
||
import { cn } from '@/lib/utils';
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table';
|
||
import { toast } from 'sonner';
|
||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||
import { getMaterialsForWorkOrder, registerMaterialInput, getMaterialsForItem, registerMaterialInputForItem, searchStockByCode, forceCreateReceiving, searchItems, type MaterialForInput, type MaterialForItemInput, type StockSearchResult, type ItemSearchResult } from './actions';
|
||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||
import type { MaterialInput } from './types';
|
||
import { formatNumber } from '@/lib/utils/amount';
|
||
|
||
interface MaterialInputModalProps {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
order: WorkOrder | null;
|
||
workOrderItemId?: number; // 개소(작업지시품목) ID (첫 번째 item, 호환용)
|
||
workOrderItemIds?: number[]; // 개소 내 모든 작업지시품목 IDs (절곡 등 복수 item)
|
||
workOrderItemName?: string; // 개소명 (모달 헤더 표시용)
|
||
onComplete?: () => void;
|
||
isCompletionFlow?: boolean;
|
||
onSaveMaterials?: (orderId: string, materials: MaterialInput[]) => void;
|
||
savedMaterials?: MaterialInput[];
|
||
}
|
||
|
||
interface MaterialGroup {
|
||
itemId: number;
|
||
groupKey: string; // 그룹 식별 키 (itemId 또는 itemId_woItemId)
|
||
materialName: string;
|
||
materialCode: string;
|
||
requiredQty: number;
|
||
effectiveRequiredQty: number; // 남은 필요수량 (이미 투입분 차감)
|
||
alreadyInputted: number; // 이미 투입된 수량
|
||
unit: string;
|
||
lots: MaterialForInput[];
|
||
// dynamic_bom 추가 정보
|
||
workOrderItemId?: number;
|
||
lotPrefix?: string;
|
||
partType?: string;
|
||
category?: string;
|
||
}
|
||
|
||
const fmtQty = (v: number) => formatNumber(parseFloat(String(v)));
|
||
|
||
export function MaterialInputModal({
|
||
open,
|
||
onOpenChange,
|
||
order,
|
||
workOrderItemId,
|
||
workOrderItemIds,
|
||
workOrderItemName,
|
||
onComplete,
|
||
isCompletionFlow = false,
|
||
onSaveMaterials,
|
||
}: MaterialInputModalProps) {
|
||
const [materials, setMaterials] = useState<MaterialForInput[]>([]);
|
||
const [selectedLotKeys, setSelectedLotKeys] = useState<Set<string>>(new Set());
|
||
const [manualAllocations, setManualAllocations] = useState<Map<string, number>>(new Map());
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [showUnfulfilledOnly, setShowUnfulfilledOnly] = useState(false);
|
||
const materialsLoadedRef = useRef(false);
|
||
|
||
// 재공품 여부 판별 — 재공품이면 검색 시 원자재(RM)만 조회
|
||
const isWipOrder = useMemo(() => {
|
||
if (!order) return false;
|
||
return order.projectName === '재고생산' || order.salesOrderNo?.startsWith('STK');
|
||
}, [order]);
|
||
|
||
// 재고 검색 상태
|
||
const [searchOpenGroup, setSearchOpenGroup] = useState<string | null>(null);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [searchResults, setSearchResults] = useState<StockSearchResult[]>([]);
|
||
const [isSearching, setIsSearching] = useState(false);
|
||
|
||
// 품목 검색 결과 (재고 없는 품목 포함)
|
||
const [itemSearchResults, setItemSearchResults] = useState<ItemSearchResult[]>([]);
|
||
|
||
const handleStockSearch = useCallback(async (query: string | undefined) => {
|
||
if (!query?.trim()) { setSearchResults([]); setItemSearchResults([]); return; }
|
||
setIsSearching(true);
|
||
// 재공품: 원자재(RM)만 검색, 일반: 전체 검색
|
||
const typeFilter = isWipOrder ? 'RM' : undefined;
|
||
const [stockResult, itemResult] = await Promise.all([
|
||
searchStockByCode(query.trim(), typeFilter),
|
||
searchItems(query.trim(), typeFilter),
|
||
]);
|
||
if (stockResult.success) setSearchResults(stockResult.data);
|
||
if (itemResult.success) {
|
||
// 재고 검색 결과에 이미 있는 품목은 제외
|
||
const stockItemIds = new Set(stockResult.data?.map(s => s.itemId) || []);
|
||
setItemSearchResults(itemResult.data.filter(i => !stockItemIds.has(i.itemId)));
|
||
}
|
||
setIsSearching(false);
|
||
}, [isWipOrder]);
|
||
|
||
const [isForceCreating, setIsForceCreating] = useState(false);
|
||
|
||
// 목업 자재 데이터 (개발용)
|
||
const MOCK_MATERIALS: MaterialForInput[] = Array.from({ length: 5 }, (_, i) => ({
|
||
stockLotId: 100 + i,
|
||
itemId: 200 + i,
|
||
lotNo: `LOT-2026-${String(i + 1).padStart(3, '0')}`,
|
||
materialCode: `MAT-${String(i + 1).padStart(3, '0')}`,
|
||
materialName: `자재 ${i + 1}`,
|
||
specification: '',
|
||
unit: 'EA',
|
||
requiredQty: 100,
|
||
lotAvailableQty: 500 - i * 50,
|
||
fifoRank: i + 1,
|
||
}));
|
||
|
||
// 로트 키 생성 (그룹별 독립 — 같은 LOT가 여러 그룹에 있어도 구분)
|
||
const getLotKey = useCallback((material: MaterialForInput, groupKey: string) =>
|
||
`${String(material.stockLotId ?? `item-${material.itemId}`)}__${groupKey}`, []);
|
||
|
||
// 기투입 LOT 존재 여부
|
||
const hasPreInputted = useMemo(() => {
|
||
return materials.some(m => {
|
||
const itemInput = m as unknown as MaterialForItemInput;
|
||
return (itemInput.lotInputtedQty ?? 0) > 0;
|
||
});
|
||
}, [materials]);
|
||
|
||
// 품목별 그룹핑 (BOM 엔트리별 고유키 사용 — 같은 item_id라도 category+partType 다르면 별도 그룹)
|
||
const materialGroups: MaterialGroup[] = useMemo(() => {
|
||
const groups = new Map<string, MaterialForInput[]>();
|
||
for (const m of materials) {
|
||
const itemInput = m as unknown as MaterialForItemInput;
|
||
const groupKey = itemInput.bomGroupKey
|
||
?? (m.workOrderItemId ? `${m.itemId}_${m.workOrderItemId}` : String(m.itemId));
|
||
const existing = groups.get(groupKey) || [];
|
||
existing.push(m);
|
||
groups.set(groupKey, existing);
|
||
}
|
||
// 작업일지와 동일한 카테고리 순서
|
||
const categoryOrder: Record<string, number> = {
|
||
guideRail: 0, bottomBar: 1, shutterBox: 2, smokeBarrier: 3,
|
||
};
|
||
|
||
return Array.from(groups.entries()).map(([groupKey, lots]) => {
|
||
const first = lots[0];
|
||
const itemInput = first as unknown as MaterialForItemInput;
|
||
const alreadyInputted = itemInput.alreadyInputted ?? 0;
|
||
const effectiveRequiredQty = Math.max(0, itemInput.remainingRequiredQty ?? first.requiredQty);
|
||
return {
|
||
itemId: first.itemId,
|
||
groupKey,
|
||
materialName: first.materialName,
|
||
materialCode: first.materialCode,
|
||
requiredQty: first.requiredQty,
|
||
effectiveRequiredQty,
|
||
alreadyInputted,
|
||
unit: first.unit,
|
||
lots: lots.sort((a, b) => a.fifoRank - b.fifoRank),
|
||
workOrderItemId: first.workOrderItemId,
|
||
lotPrefix: first.lotPrefix,
|
||
partType: first.partType,
|
||
category: first.category,
|
||
};
|
||
}).sort((a, b) => {
|
||
const catA = categoryOrder[a.category ?? ''] ?? 99;
|
||
const catB = categoryOrder[b.category ?? ''] ?? 99;
|
||
return catA - catB;
|
||
});
|
||
}, [materials]);
|
||
|
||
// 그룹별 목표 수량 (기투입 있으면 전체 필요수량, 없으면 남은 필요수량)
|
||
const getGroupTargetQty = useCallback((group: MaterialGroup) => {
|
||
return group.alreadyInputted > 0 ? group.requiredQty : group.effectiveRequiredQty;
|
||
}, []);
|
||
|
||
// 배정 수량 계산 (manual 우선 → 나머지 FIFO 자동배분, 물리LOT 교차 추적)
|
||
const allocations = useMemo(() => {
|
||
const result = new Map<string, number>();
|
||
|
||
// 물리 LOT별 실제 가용량: lotAvailableQty + SUM(모든 그룹의 lotInputtedQty)
|
||
const physicalAvail = new Map<number, number>();
|
||
const lotBaseAvail = new Map<number, number>();
|
||
for (const group of materialGroups) {
|
||
for (const lot of group.lots) {
|
||
if (!lot.stockLotId) continue;
|
||
const itemInput = lot as unknown as MaterialForItemInput;
|
||
const inputted = itemInput.lotInputtedQty ?? 0;
|
||
if (!lotBaseAvail.has(lot.stockLotId)) {
|
||
lotBaseAvail.set(lot.stockLotId, lot.lotAvailableQty);
|
||
physicalAvail.set(lot.stockLotId, lot.lotAvailableQty + inputted);
|
||
} else {
|
||
physicalAvail.set(lot.stockLotId, (physicalAvail.get(lot.stockLotId) ?? 0) + inputted);
|
||
}
|
||
}
|
||
}
|
||
const physicalUsed = new Map<number, number>();
|
||
|
||
for (const group of materialGroups) {
|
||
const targetQty = getGroupTargetQty(group);
|
||
let remaining = targetQty;
|
||
|
||
// 1차: manual allocations 적용
|
||
for (const lot of group.lots) {
|
||
const lotKey = getLotKey(lot, group.groupKey);
|
||
if (selectedLotKeys.has(lotKey) && lot.stockLotId && manualAllocations.has(lotKey)) {
|
||
const val = manualAllocations.get(lotKey)!;
|
||
result.set(lotKey, val);
|
||
remaining -= val;
|
||
physicalUsed.set(lot.stockLotId, (physicalUsed.get(lot.stockLotId) || 0) + val);
|
||
}
|
||
}
|
||
|
||
// 2차: non-manual 선택 로트 FIFO 자동배분 (물리LOT 교차 추적)
|
||
for (const lot of group.lots) {
|
||
const lotKey = getLotKey(lot, group.groupKey);
|
||
if (selectedLotKeys.has(lotKey) && lot.stockLotId && !manualAllocations.has(lotKey)) {
|
||
const totalAvail = physicalAvail.get(lot.stockLotId) ?? lot.lotAvailableQty;
|
||
const used = physicalUsed.get(lot.stockLotId) || 0;
|
||
const effectiveAvail = Math.max(0, totalAvail - used);
|
||
const alloc = remaining > 0 ? Math.min(effectiveAvail, remaining) : 0;
|
||
result.set(lotKey, alloc);
|
||
remaining -= alloc;
|
||
if (alloc > 0) {
|
||
physicalUsed.set(lot.stockLotId, used + alloc);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return result;
|
||
}, [materialGroups, selectedLotKeys, manualAllocations, getLotKey, getGroupTargetQty]);
|
||
|
||
// 전체 배정 완료 여부
|
||
const allGroupsFulfilled = useMemo(() => {
|
||
if (materialGroups.length === 0) return false;
|
||
return materialGroups.every((group) => {
|
||
const targetQty = getGroupTargetQty(group);
|
||
if (targetQty <= 0) return true;
|
||
const allocated = group.lots.reduce(
|
||
(sum, lot) => sum + (allocations.get(getLotKey(lot, group.groupKey)) || 0),
|
||
0
|
||
);
|
||
return allocated >= targetQty;
|
||
});
|
||
}, [materialGroups, allocations, getLotKey, getGroupTargetQty]);
|
||
|
||
// 배정된 항목 존재 여부
|
||
const hasAnyAllocation = useMemo(() => {
|
||
return Array.from(allocations.values()).some((v) => v > 0);
|
||
}, [allocations]);
|
||
|
||
// 로트 선택/해제
|
||
const toggleLot = useCallback((lotKey: string) => {
|
||
setSelectedLotKeys((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(lotKey)) {
|
||
next.delete(lotKey);
|
||
setManualAllocations(prev => {
|
||
const n = new Map(prev);
|
||
n.delete(lotKey);
|
||
return n;
|
||
});
|
||
} else {
|
||
next.add(lotKey);
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
// 수량 수동 변경
|
||
const handleAllocationChange = useCallback((lotKey: string, value: number, maxAvailable: number) => {
|
||
const clamped = Math.max(0, Math.min(value, maxAvailable));
|
||
setManualAllocations(prev => {
|
||
const next = new Map(prev);
|
||
next.set(lotKey, clamped);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
// FIFO 자동입력 (물리LOT 가용량 교차 추적 — 동일 stockLotId의 실제 잔량 공유)
|
||
const handleAutoFill = useCallback(() => {
|
||
const newSelected = new Set<string>();
|
||
const newAllocations = new Map<string, number>();
|
||
|
||
// 물리 LOT별 실제 가용량 계산: lotAvailableQty + SUM(모든 그룹의 lotInputtedQty)
|
||
// replace 모드에서 기투입분이 복원되므로 모든 그룹의 기투입을 합산
|
||
const physicalAvail = new Map<number, number>();
|
||
const lotBaseAvail = new Map<number, number>();
|
||
|
||
for (const group of materialGroups) {
|
||
for (const lot of group.lots) {
|
||
if (!lot.stockLotId) continue;
|
||
const itemInput = lot as unknown as MaterialForItemInput;
|
||
const inputted = itemInput.lotInputtedQty ?? 0;
|
||
if (!lotBaseAvail.has(lot.stockLotId)) {
|
||
lotBaseAvail.set(lot.stockLotId, lot.lotAvailableQty);
|
||
physicalAvail.set(lot.stockLotId, lot.lotAvailableQty + inputted);
|
||
} else {
|
||
physicalAvail.set(lot.stockLotId, (physicalAvail.get(lot.stockLotId) ?? 0) + inputted);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 물리 LOT 사용량 추적
|
||
const physicalUsed = new Map<number, number>();
|
||
|
||
// 2차: 그룹별 FIFO 배정
|
||
for (const group of materialGroups) {
|
||
const targetQty = getGroupTargetQty(group);
|
||
if (targetQty <= 0) continue;
|
||
|
||
let remaining = targetQty;
|
||
for (const lot of group.lots) {
|
||
if (!lot.stockLotId || remaining <= 0) continue;
|
||
const lotKey = getLotKey(lot, group.groupKey);
|
||
const totalAvail = physicalAvail.get(lot.stockLotId) ?? lot.lotAvailableQty;
|
||
const used = physicalUsed.get(lot.stockLotId) || 0;
|
||
const effectiveAvail = Math.max(0, totalAvail - used);
|
||
const alloc = Math.min(effectiveAvail, remaining);
|
||
if (alloc > 0) {
|
||
newSelected.add(lotKey);
|
||
newAllocations.set(lotKey, alloc);
|
||
remaining -= alloc;
|
||
physicalUsed.set(lot.stockLotId, used + alloc);
|
||
}
|
||
}
|
||
}
|
||
|
||
setSelectedLotKeys(newSelected);
|
||
setManualAllocations(newAllocations);
|
||
}, [materialGroups, getLotKey, getGroupTargetQty]);
|
||
|
||
// API로 자재 목록 로드
|
||
const loadMaterials = useCallback(async () => {
|
||
if (!order) return;
|
||
|
||
setIsLoading(true);
|
||
materialsLoadedRef.current = false;
|
||
try {
|
||
// 목업 아이템인 경우 목업 자재 데이터 사용
|
||
if (order.id.startsWith('mock-')) {
|
||
setMaterials(MOCK_MATERIALS);
|
||
setIsLoading(false);
|
||
materialsLoadedRef.current = true;
|
||
return;
|
||
}
|
||
|
||
// 개소 대표 아이템 1개만 조회 (dynamic_bom은 개소 내 모든 아이템에 동일하게 저장됨)
|
||
const itemId = workOrderItemId
|
||
?? (workOrderItemIds && workOrderItemIds.length > 0 ? workOrderItemIds[0] : null);
|
||
|
||
if (itemId) {
|
||
const result = await getMaterialsForItem(order.id, itemId);
|
||
if (result.success) {
|
||
const tagged = result.data.map((m) => ({
|
||
...m,
|
||
workOrderItemId: m.workOrderItemId || itemId,
|
||
}));
|
||
setMaterials(tagged);
|
||
materialsLoadedRef.current = true;
|
||
} else {
|
||
toast.error(result.error || '자재 목록 조회에 실패했습니다.');
|
||
}
|
||
} else {
|
||
// 전체 작업지시 기준 조회
|
||
const result = await getMaterialsForWorkOrder(order.id);
|
||
if (result.success) {
|
||
setMaterials(result.data);
|
||
materialsLoadedRef.current = true;
|
||
} else {
|
||
toast.error(result.error || '자재 목록 조회에 실패했습니다.');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if (isNextRedirectError(error)) throw error;
|
||
console.error('[MaterialInputModal] loadMaterials error:', error);
|
||
toast.error('자재 목록 로드 중 오류가 발생했습니다.');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [order, workOrderItemId, workOrderItemIds]);
|
||
|
||
// [개발전용] 입고 강제생성 핸들러
|
||
const handleForceReceiving = useCallback(async (itemId: number, itemCode: string) => {
|
||
setIsForceCreating(true);
|
||
const result = await forceCreateReceiving(itemId, 100);
|
||
if (result.success && result.data) {
|
||
const d = result.data as Record<string, unknown>;
|
||
const rm = d.rm_item_code || d.rm_item_name;
|
||
const pt = d.pt_item_code || d.item_code;
|
||
const label = rm ? `원자재 ${rm} → 재공품 ${pt}` : String(pt);
|
||
toast.success(`${label} 입고 완료 (LOT: ${d.lot_no}, ${d.qty}EA)`);
|
||
handleStockSearch(itemCode);
|
||
loadMaterials();
|
||
} else {
|
||
toast.error(result.error || '입고 생성 실패');
|
||
}
|
||
setIsForceCreating(false);
|
||
}, [handleStockSearch, loadMaterials]);
|
||
|
||
// 모달이 열릴 때 데이터 로드 + 선택 초기화
|
||
useEffect(() => {
|
||
if (open && order) {
|
||
loadMaterials();
|
||
setSelectedLotKeys(new Set());
|
||
setManualAllocations(new Map());
|
||
}
|
||
}, [open, order, loadMaterials]);
|
||
|
||
// 자재 로드 후 기투입 LOT 자동 선택 (그룹별 독립 처리)
|
||
useEffect(() => {
|
||
if (!materialsLoadedRef.current || materials.length === 0 || materialGroups.length === 0) return;
|
||
|
||
const preSelected = new Set<string>();
|
||
const preAllocations = new Map<string, number>();
|
||
|
||
for (const group of materialGroups) {
|
||
for (const m of group.lots) {
|
||
const itemInput = m as unknown as MaterialForItemInput;
|
||
const lotInputted = itemInput.lotInputtedQty ?? 0;
|
||
if (lotInputted > 0 && m.stockLotId) {
|
||
const lotKey = getLotKey(m, group.groupKey);
|
||
preSelected.add(lotKey);
|
||
preAllocations.set(lotKey, lotInputted);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (preSelected.size > 0) {
|
||
setSelectedLotKeys(prev => new Set([...prev, ...preSelected]));
|
||
setManualAllocations(prev => new Map([...prev, ...preAllocations]));
|
||
}
|
||
// 한 번만 실행하도록 ref 초기화
|
||
materialsLoadedRef.current = false;
|
||
}, [materials, materialGroups, getLotKey]);
|
||
|
||
// 투입 등록
|
||
const handleSubmit = async () => {
|
||
if (!order) return;
|
||
|
||
// 배분된 로트를 그룹별 개별 엔트리로 추출 (bom_group_key 포함)
|
||
const inputs: { stock_lot_id: number; qty: number; bom_group_key: string }[] = [];
|
||
for (const group of materialGroups) {
|
||
for (const lot of group.lots) {
|
||
const lotKey = getLotKey(lot, group.groupKey);
|
||
const allocQty = allocations.get(lotKey) || 0;
|
||
if (allocQty > 0 && lot.stockLotId) {
|
||
inputs.push({
|
||
stock_lot_id: lot.stockLotId,
|
||
qty: allocQty,
|
||
bom_group_key: group.groupKey,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
if (inputs.length === 0) {
|
||
toast.error('투입할 로트를 선택해주세요.');
|
||
return;
|
||
}
|
||
|
||
setIsSubmitting(true);
|
||
try {
|
||
// 대표 아이템 기준 자재 투입 등록
|
||
let result: { success: boolean; error?: string };
|
||
const targetItemId = workOrderItemId
|
||
?? (workOrderItemIds && workOrderItemIds.length > 0 ? workOrderItemIds[0] : null);
|
||
|
||
if (targetItemId) {
|
||
// 기투입 LOT 있으면 replace 모드 (기존 투입 삭제 후 재등록)
|
||
result = await registerMaterialInputForItem(order.id, targetItemId, inputs, hasPreInputted);
|
||
} else {
|
||
result = await registerMaterialInput(order.id, inputs);
|
||
}
|
||
|
||
if (result.success) {
|
||
toast.success('자재 투입이 등록되었습니다.');
|
||
|
||
if (onSaveMaterials) {
|
||
// 표시용: 같은 LOT는 합산 (자재투입목록 UI)
|
||
const lotTotals = new Map<number, number>();
|
||
for (const inp of inputs) {
|
||
lotTotals.set(inp.stock_lot_id, (lotTotals.get(inp.stock_lot_id) || 0) + inp.qty);
|
||
}
|
||
const savedList: MaterialInput[] = [];
|
||
const processedLotIds = new Set<number>();
|
||
for (const group of materialGroups) {
|
||
for (const lot of group.lots) {
|
||
if (!lot.stockLotId || processedLotIds.has(lot.stockLotId)) continue;
|
||
const totalQty = lotTotals.get(lot.stockLotId) || 0;
|
||
if (totalQty > 0) {
|
||
processedLotIds.add(lot.stockLotId);
|
||
savedList.push({
|
||
id: String(lot.stockLotId),
|
||
lotNo: lot.lotNo || '',
|
||
materialName: lot.materialName,
|
||
quantity: lot.lotAvailableQty,
|
||
unit: lot.unit,
|
||
inputQuantity: totalQty,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
onSaveMaterials(order.id, savedList);
|
||
}
|
||
|
||
resetAndClose();
|
||
|
||
if (isCompletionFlow && onComplete) {
|
||
onComplete();
|
||
}
|
||
} else {
|
||
toast.error(result.error || '자재 투입 등록에 실패했습니다.');
|
||
}
|
||
} catch (error) {
|
||
if (isNextRedirectError(error)) throw error;
|
||
console.error('[MaterialInputModal] handleSubmit error:', error);
|
||
toast.error('자재 투입 등록 중 오류가 발생했습니다.');
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const resetAndClose = () => {
|
||
setSelectedLotKeys(new Set());
|
||
setManualAllocations(new Map());
|
||
onOpenChange(false);
|
||
};
|
||
|
||
if (!order) return null;
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="!max-w-3xl p-0 gap-0 max-h-[85vh] flex flex-col">
|
||
{/* 헤더 */}
|
||
<DialogHeader className="p-6 pb-4 shrink-0">
|
||
<DialogTitle className="text-xl font-semibold">
|
||
자재 투입{workOrderItemName ? ` - ${workOrderItemName}` : ''}
|
||
</DialogTitle>
|
||
<div className="flex items-center justify-between mt-1">
|
||
<div className="flex items-center gap-2">
|
||
<p className="text-sm text-gray-500">
|
||
로트를 선택하면 필요수량만큼 자동 배분됩니다.
|
||
</p>
|
||
{!isLoading && materialGroups.length > 0 && (() => {
|
||
const fulfilled = materialGroups.filter((g) => {
|
||
const target = getGroupTargetQty(g);
|
||
if (target <= 0 && g.alreadyInputted <= 0) return true;
|
||
const allocated = g.lots.reduce((sum, lot) => sum + (allocations.get(getLotKey(lot, g.groupKey)) || 0), 0);
|
||
return allocated >= target;
|
||
}).length;
|
||
const total = materialGroups.length;
|
||
return (
|
||
<button
|
||
onClick={() => setShowUnfulfilledOnly(prev => !prev)}
|
||
className={cn(
|
||
"text-xs font-semibold px-2.5 py-1 rounded-full cursor-pointer transition-colors",
|
||
showUnfulfilledOnly
|
||
? "bg-amber-200 text-amber-800 ring-2 ring-amber-400"
|
||
: fulfilled === total ? "bg-emerald-100 text-emerald-700 hover:bg-emerald-200" : "bg-amber-100 text-amber-700 hover:bg-amber-200"
|
||
)}
|
||
>
|
||
{fulfilled === total ? <Check className="h-3 w-3 inline mr-0.5" /> : null}
|
||
{showUnfulfilledOnly ? `미배정 ${total - fulfilled}건` : `${fulfilled} / ${total} 배정완료`}
|
||
</button>
|
||
);
|
||
})()}
|
||
</div>
|
||
{!isLoading && materials.length > 0 && (
|
||
<button
|
||
onClick={handleAutoFill}
|
||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-semibold bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors shrink-0"
|
||
>
|
||
<Zap className="h-3 w-3" />
|
||
자동입력
|
||
</button>
|
||
)}
|
||
</div>
|
||
</DialogHeader>
|
||
|
||
<div className="px-6 pb-6 space-y-4 flex-1 min-h-0 flex flex-col">
|
||
{/* 자재 목록 */}
|
||
{isLoading ? (
|
||
<ContentSkeleton type="table" rows={4} />
|
||
) : materials.length === 0 ? (
|
||
<div className="border rounded-lg overflow-hidden">
|
||
<div className="py-8 text-center text-sm text-gray-500">
|
||
이 공정에 배정된 자재가 없습니다.
|
||
</div>
|
||
{/* 자재 없을 때도 재고 검색 + 강제입고 가능 */}
|
||
<div className="px-4 py-3 bg-blue-50 border-t space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Search className="h-3.5 w-3.5 text-blue-500 shrink-0" />
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleStockSearch(searchQuery)}
|
||
placeholder={isWipOrder ? "원자재 코드 또는 자재명으로 검색" : "품목코드 또는 자재명으로 재고 검색"}
|
||
className="flex-1 text-xs px-2 py-1.5 border rounded bg-white"
|
||
/>
|
||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => handleStockSearch(searchQuery)}>
|
||
{isSearching ? <Loader2 className="h-3 w-3 animate-spin" /> : '검색'}
|
||
</Button>
|
||
</div>
|
||
{searchResults.length > 0 ? (
|
||
<div className="space-y-1">
|
||
<p className="text-[11px] text-blue-600 font-medium">{searchResults.length}건 검색됨:</p>
|
||
{searchResults.map((s, si) => (
|
||
<div key={`${s.itemId}-${si}`} className={cn(
|
||
"text-[11px] px-2 py-1.5 rounded border",
|
||
s.availableQty > 0 ? "bg-white border-emerald-200" : "bg-gray-50 border-gray-200"
|
||
)}>
|
||
<div className="flex justify-between items-center">
|
||
<span className="font-medium">{s.itemCode}</span>
|
||
<span className={s.availableQty > 0 ? "text-emerald-600 font-semibold" : "text-gray-400"}>
|
||
가용 {formatNumber(s.availableQty)} EA
|
||
</span>
|
||
</div>
|
||
<div className="text-gray-500">{s.itemName}</div>
|
||
{s.lots.length > 0 && s.lots.slice(0, 3).map((l, li) => (
|
||
<div key={li} className="text-[10px] text-gray-400 ml-2">
|
||
LOT {l.lotNo} | {formatNumber(l.availableQty)} EA | FIFO #{l.fifoOrder}
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : isSearching ? (
|
||
<p className="text-[11px] text-gray-400 flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" />검색 중...</p>
|
||
) : searchQuery && searchResults.length === 0 && itemSearchResults.length === 0 ? (
|
||
<p className="text-[11px] text-red-500">검색 결과 없음</p>
|
||
) : null}
|
||
{/* 재고 없는 품목 목록 (items 테이블에만 존재) */}
|
||
{itemSearchResults.length > 0 && (
|
||
<div className="space-y-1">
|
||
<p className="text-[11px] text-orange-600 font-medium">재고 없는 품목 {itemSearchResults.length}건:</p>
|
||
{itemSearchResults.map((item, idx) => (
|
||
<div key={`item-${item.itemId}-${idx}`} className="text-[11px] px-2 py-1.5 rounded border bg-orange-50 border-orange-200 flex items-center justify-between">
|
||
<div>
|
||
<span className="font-medium">{item.itemCode}</span>
|
||
<span className="text-gray-500 ml-1">{item.itemName}</span>
|
||
<span className="text-[10px] text-gray-400 ml-1">({item.itemType})</span>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-6 text-[10px] px-2 border-orange-300 text-orange-600 hover:bg-orange-100"
|
||
disabled={isForceCreating}
|
||
onClick={() => handleForceReceiving(item.itemId, item.itemCode)}
|
||
>
|
||
{isForceCreating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3 mr-0.5" />}
|
||
강제입고
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4 flex-1 overflow-y-auto min-h-0">
|
||
{materialGroups.filter((group) => {
|
||
if (!showUnfulfilledOnly) return true;
|
||
const target = getGroupTargetQty(group);
|
||
if (target <= 0 && group.alreadyInputted <= 0) return false;
|
||
const allocated = group.lots.reduce((sum, lot) => sum + (allocations.get(getLotKey(lot, group.groupKey)) || 0), 0);
|
||
return allocated < target;
|
||
}).map((group, groupIdx) => {
|
||
// 같은 카테고리 내 순번 계산 (①②③...)
|
||
const categoryIndex = group.category
|
||
? materialGroups.slice(0, groupIdx).filter(g => g.category === group.category).length
|
||
: -1;
|
||
const circledNumbers = ['①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩'];
|
||
const circledNum = categoryIndex >= 0 && categoryIndex < circledNumbers.length
|
||
? circledNumbers[categoryIndex] : '';
|
||
|
||
const targetQty = getGroupTargetQty(group);
|
||
const groupAllocated = group.lots.reduce(
|
||
(sum, lot) => sum + (allocations.get(getLotKey(lot, group.groupKey)) || 0),
|
||
0
|
||
);
|
||
const isGroupComplete = targetQty <= 0 && group.alreadyInputted <= 0;
|
||
const isFulfilled = isGroupComplete || groupAllocated >= targetQty;
|
||
|
||
return (
|
||
<div key={group.groupKey} className="border rounded-lg overflow-hidden">
|
||
{/* 품목 그룹 헤더 */}
|
||
<div className="flex flex-wrap items-center justify-between gap-1.5 px-4 py-2.5 bg-gray-50 border-b">
|
||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||
{group.category && (
|
||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||
group.category === 'guideRail' ? 'bg-blue-100 text-blue-700' :
|
||
group.category === 'bottomBar' ? 'bg-green-100 text-green-700' :
|
||
group.category === 'shutterBox' ? 'bg-orange-100 text-orange-700' :
|
||
group.category === 'smokeBarrier' ? 'bg-red-100 text-red-700' :
|
||
'bg-gray-100 text-gray-600'
|
||
}`}>
|
||
{group.category === 'guideRail' ? '가이드레일' :
|
||
group.category === 'bottomBar' ? '하단마감재' :
|
||
group.category === 'shutterBox' ? '셔터박스' :
|
||
group.category === 'smokeBarrier' ? '연기차단재' :
|
||
group.category}
|
||
</span>
|
||
)}
|
||
{group.partType && (
|
||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-600 font-medium">
|
||
{circledNum}{group.partType}
|
||
</span>
|
||
)}
|
||
<span className="text-sm font-semibold text-gray-900">
|
||
{group.materialName}
|
||
</span>
|
||
{group.materialCode && (
|
||
<span className="text-xs text-gray-400">
|
||
{group.materialCode}
|
||
</span>
|
||
)}
|
||
{/* 매칭 정보 말풍선 */}
|
||
<span className="relative group/info inline-flex">
|
||
<Info className={cn(
|
||
"h-3.5 w-3.5 cursor-help",
|
||
group.lots.some(l => l.stockLotId !== null) ? "text-blue-400" : "text-red-400"
|
||
)} />
|
||
<span className="absolute left-0 top-5 z-50 hidden group-hover/info:block w-64 p-2.5 rounded-lg shadow-lg border bg-white text-xs text-gray-700 leading-relaxed">
|
||
<span className="font-semibold text-gray-900 block mb-1">자재 매칭 정보</span>
|
||
<span className="block text-[11px] text-gray-500 mb-1.5">
|
||
품목코드: {group.materialCode || '-'}<br/>
|
||
규격: {group.lots[0]?.specification || '-'}<br/>
|
||
매칭기준: 품목코드 + 규격 일치하는 입고 LOT 검색 (FIFO)
|
||
</span>
|
||
{(() => {
|
||
const availableLots = group.lots.filter(l => l.stockLotId !== null);
|
||
const noStockLots = group.lots.filter(l => l.stockLotId === null);
|
||
if (availableLots.length > 0) {
|
||
return (
|
||
<span className="block">
|
||
<span className="text-emerald-600 font-medium">
|
||
✓ {availableLots.length}건 매칭 완료
|
||
</span>
|
||
{availableLots.map((l, i) => (
|
||
<span key={i} className="block text-[11px] text-gray-500 ml-2">
|
||
LOT {l.lotNo} | 가용 {formatNumber(l.lotAvailableQty)}{group.unit} | FIFO #{l.fifoRank}
|
||
</span>
|
||
))}
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="block text-red-600 font-medium">
|
||
✗ 매칭 가능한 입고 LOT 없음
|
||
<span className="block text-[11px] text-red-500 font-normal mt-0.5">
|
||
→ [{group.materialCode}] {group.lots[0]?.specification || ''} 자재를 입고해주세요
|
||
</span>
|
||
</span>
|
||
);
|
||
})()}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs text-gray-500">
|
||
{group.alreadyInputted > 0 ? (
|
||
<>
|
||
필요:{' '}
|
||
<span className="font-semibold text-gray-900">
|
||
{fmtQty(group.requiredQty)}
|
||
</span>{' '}
|
||
{group.unit}
|
||
<span className="ml-1 text-blue-500">
|
||
(기투입: {fmtQty(group.alreadyInputted)})
|
||
</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
필요:{' '}
|
||
<span className="font-semibold text-gray-900">
|
||
{fmtQty(group.requiredQty)}
|
||
</span>{' '}
|
||
{group.unit}
|
||
</>
|
||
)}
|
||
</span>
|
||
<span
|
||
className={`text-xs font-semibold px-2 py-0.5 rounded-full flex items-center gap-1 ${
|
||
isFulfilled
|
||
? 'bg-emerald-100 text-emerald-700'
|
||
: groupAllocated > 0
|
||
? 'bg-amber-100 text-amber-700'
|
||
: 'bg-gray-100 text-gray-500'
|
||
}`}
|
||
>
|
||
{isFulfilled ? (
|
||
<>
|
||
<Check className="h-3 w-3" />
|
||
배정 완료
|
||
</>
|
||
) : (
|
||
`${fmtQty(groupAllocated)} / ${fmtQty(targetQty)}`
|
||
)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 로트 테이블 */}
|
||
<div className="overflow-x-auto">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="text-center w-24">선택</TableHead>
|
||
<TableHead className="text-center">로트번호</TableHead>
|
||
<TableHead className="text-center">가용수량</TableHead>
|
||
<TableHead className="text-center">단위</TableHead>
|
||
<TableHead className="text-center">배정수량</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{group.lots.map((lot, idx) => {
|
||
const lotKey = getLotKey(lot, group.groupKey);
|
||
const hasStock = lot.stockLotId !== null;
|
||
const isSelected = selectedLotKeys.has(lotKey);
|
||
const allocated = allocations.get(lotKey) || 0;
|
||
const itemInput = lot as unknown as MaterialForItemInput;
|
||
const lotInputted = itemInput.lotInputtedQty ?? 0;
|
||
const isPreInputted = lotInputted > 0;
|
||
// 가용수량 = 현재 가용 + 기투입분 (replace 시 복원되므로)
|
||
const effectiveAvailable = lot.lotAvailableQty + lotInputted;
|
||
const canSelect = hasStock && (!isFulfilled || isSelected);
|
||
|
||
return (
|
||
<TableRow
|
||
key={`${lotKey}-${idx}`}
|
||
className={cn(
|
||
isSelected && allocated > 0 ? 'bg-blue-50/50' : '',
|
||
isPreInputted && isSelected ? 'bg-blue-50/70' : ''
|
||
)}
|
||
>
|
||
<TableCell className="text-center">
|
||
{hasStock ? (
|
||
<button
|
||
onClick={() => toggleLot(lotKey)}
|
||
disabled={!canSelect}
|
||
className={cn(
|
||
'min-w-[64px] px-3 py-1.5 rounded-lg text-xs font-semibold transition-all',
|
||
isSelected
|
||
? 'bg-blue-600 text-white shadow-sm'
|
||
: canSelect
|
||
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||
)}
|
||
>
|
||
{isSelected ? '선택완료' : '선택'}
|
||
</button>
|
||
) : null}
|
||
</TableCell>
|
||
<TableCell className="text-center text-sm">
|
||
<div className="flex items-center justify-center gap-1">
|
||
{lot.lotNo || (
|
||
<span className="text-gray-400">
|
||
재고 없음
|
||
</span>
|
||
)}
|
||
{isPreInputted && (
|
||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-600 font-medium">
|
||
기투입
|
||
</span>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-center text-sm">
|
||
{hasStock ? (
|
||
isPreInputted ? (
|
||
<span>
|
||
{fmtQty(lot.lotAvailableQty)}
|
||
<span className="text-blue-500 text-xs ml-1">(+{fmtQty(lotInputted)})</span>
|
||
</span>
|
||
) : (
|
||
fmtQty(lot.lotAvailableQty)
|
||
)
|
||
) : (
|
||
<span className="text-red-500">0</span>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-center text-sm">
|
||
{lot.unit}
|
||
</TableCell>
|
||
<TableCell className="text-center text-sm font-medium">
|
||
{isSelected && hasStock ? (
|
||
<input
|
||
type="number"
|
||
value={allocated || ''}
|
||
onChange={(e) => {
|
||
const val = parseFloat(e.target.value) || 0;
|
||
handleAllocationChange(lotKey, val, effectiveAvailable);
|
||
}}
|
||
className="w-20 text-center text-blue-600 font-semibold border border-blue-200 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||
min={0}
|
||
max={effectiveAvailable}
|
||
/>
|
||
) : allocated > 0 ? (
|
||
<span className="text-blue-600">
|
||
{fmtQty(allocated)}
|
||
</span>
|
||
) : (
|
||
<span className="text-gray-300">-</span>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
{/* 재고 검색 패널 */}
|
||
{!group.lots.some(l => l.stockLotId !== null) && searchOpenGroup !== group.groupKey && (
|
||
<button
|
||
onClick={() => {
|
||
setSearchOpenGroup(group.groupKey);
|
||
setSearchQuery(group.materialCode || group.materialName);
|
||
setSearchResults([]);
|
||
handleStockSearch(group.materialCode || group.materialName);
|
||
}}
|
||
className="w-full px-4 py-2 text-xs text-red-600 bg-red-50 border-t flex items-center gap-1.5 hover:bg-red-100 transition-colors"
|
||
>
|
||
<Search className="h-3.5 w-3.5" />
|
||
매칭 가능한 입고 재고 없음 — 클릭하여 재고 검색
|
||
</button>
|
||
)}
|
||
{searchOpenGroup === group.groupKey && (
|
||
<div className="px-4 py-3 bg-blue-50 border-t space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Search className="h-3.5 w-3.5 text-blue-500 shrink-0" />
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleStockSearch(searchQuery)}
|
||
placeholder={isWipOrder ? "원자재 코드 또는 자재명 검색" : "품목코드 또는 자재명 검색"}
|
||
className="flex-1 text-xs px-2 py-1.5 border rounded bg-white"
|
||
/>
|
||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => handleStockSearch(searchQuery)}>
|
||
{isSearching ? <Loader2 className="h-3 w-3 animate-spin" /> : '검색'}
|
||
</Button>
|
||
<button onClick={() => setSearchOpenGroup(null)} className="text-gray-400 hover:text-gray-600">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
{searchResults.length > 0 ? (
|
||
<div className="space-y-1">
|
||
<p className="text-[11px] text-blue-600 font-medium">{searchResults.length}건 검색됨:</p>
|
||
{searchResults.map((s, si) => (
|
||
<div key={`${s.itemId}-${si}`} className={cn(
|
||
"text-[11px] px-2 py-1.5 rounded border",
|
||
s.availableQty > 0 ? "bg-white border-emerald-200" : "bg-gray-50 border-gray-200"
|
||
)}>
|
||
<div className="flex justify-between items-center">
|
||
<span className="font-medium">{s.itemCode}</span>
|
||
<span className={s.availableQty > 0 ? "text-emerald-600 font-semibold" : "text-gray-400"}>
|
||
가용 {formatNumber(s.availableQty)} EA
|
||
</span>
|
||
</div>
|
||
<div className="text-gray-500">{s.itemName}</div>
|
||
{s.lots.length > 0 && (
|
||
<div className="mt-1 space-y-0.5">
|
||
{s.lots.slice(0, 3).map((l, li) => (
|
||
<div key={li} className="text-[10px] text-gray-400 ml-2">
|
||
LOT {l.lotNo} | {formatNumber(l.availableQty)} EA | FIFO #{l.fifoOrder}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : isSearching ? (
|
||
<p className="text-[11px] text-gray-400 flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" />검색 중...</p>
|
||
) : searchQuery && (
|
||
<p className="text-[11px] text-red-500">검색 결과 없음 — 해당 품목의 입고 처리가 필요합니다</p>
|
||
)}
|
||
{/* [개발전용] 입고 강제생성 버튼 */}
|
||
{group.lots[0]?.itemId && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="w-full h-8 text-xs mt-1 border-orange-300 text-orange-600 hover:bg-orange-50"
|
||
disabled={isForceCreating}
|
||
onClick={() => handleForceReceiving(group.lots[0].itemId, group.materialCode || group.materialName)}
|
||
>
|
||
{isForceCreating ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Zap className="h-3 w-3 mr-1" />}
|
||
[DEV] 입고자료 강제생성 ({group.materialCode || '품목'} × 100EA)
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* 버튼 영역 */}
|
||
<div className="flex gap-3 shrink-0">
|
||
<Button
|
||
variant="outline"
|
||
onClick={resetAndClose}
|
||
disabled={isSubmitting}
|
||
className="flex-1 py-6 text-base font-medium"
|
||
>
|
||
취소
|
||
</Button>
|
||
<Button
|
||
onClick={handleSubmit}
|
||
disabled={isSubmitting || !allGroupsFulfilled || !hasAnyAllocation}
|
||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||
>
|
||
{isSubmitting ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
등록 중...
|
||
</>
|
||
) : (
|
||
'투입'
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
} |