- getLotKey에 groupKey 포함하여 그룹별 LOT 선택/배정 독립 처리 - physicalUsed 맵으로 물리LOT 교차그룹 가용량 추적 - handleAutoFill FIFO 자동입력 (교차그룹 가용량 고려) - handleSubmit 그룹별 개별 엔트리 전송 (bom_group_key 포함, replace 모드) - 기투입 LOT 자동 선택 및 배지 표시, 수량 수동 편집 input - allGroupsFulfilled 조건으로 투입 버튼 활성화 제어 - actions.ts: lotInputtedQty 필드 + bom_group_key/replace 파라미터 추가
739 lines
30 KiB
TypeScript
739 lines
30 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 자재투입 모달 (로트 선택 기반)
|
|
*
|
|
* 로트를 체크박스로 선택하면 필요수량만큼 FIFO 순서로 자동 배분합니다.
|
|
* 같은 품목의 여러 로트를 조합하여 필요수량을 충족시킬 수 있습니다.
|
|
*
|
|
* 기능:
|
|
* - 기투입 LOT 표시 및 수정 (replace 모드)
|
|
* - 선택완료 배지
|
|
* - 필요수량 배정 완료 시에만 투입 가능
|
|
* - FIFO 자동입력 버튼
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import { Loader2, Check, Zap } 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, type MaterialForInput, type MaterialForItemInput } 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 materialsLoadedRef = useRef(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>();
|
|
const physicalUsed = new Map<number, number>(); // stockLotId → 그룹 간 누적 사용량
|
|
|
|
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 itemInput = lot as unknown as MaterialForItemInput;
|
|
const maxAvail = lot.lotAvailableQty + (itemInput.lotInputtedQty ?? 0);
|
|
const used = physicalUsed.get(lot.stockLotId) || 0;
|
|
const effectiveAvail = Math.max(0, maxAvail - 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 교차그룹 가용량 추적)
|
|
const handleAutoFill = useCallback(() => {
|
|
const newSelected = new Set<string>();
|
|
const newAllocations = new Map<string, number>();
|
|
const physicalUsed = new Map<number, number>(); // stockLotId → 그룹 간 누적 사용량
|
|
|
|
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 itemInput = lot as unknown as MaterialForItemInput;
|
|
const maxAvail = lot.lotAvailableQty + (itemInput.lotInputtedQty ?? 0);
|
|
const used = physicalUsed.get(lot.stockLotId) || 0;
|
|
const effectiveAvail = Math.max(0, maxAvail - 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]);
|
|
|
|
// 모달이 열릴 때 데이터 로드 + 선택 초기화
|
|
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">
|
|
<p className="text-sm text-gray-500">
|
|
로트를 선택하면 필요수량만큼 자동 배분됩니다.
|
|
</p>
|
|
{!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 py-12 text-center text-sm text-gray-500">
|
|
이 공정에 배정된 자재가 없습니다.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4 flex-1 overflow-y-auto min-h-0">
|
|
{materialGroups.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>
|
|
)}
|
|
</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>
|
|
</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>
|
|
);
|
|
} |