Files
sam-react-prod/src/components/production/WorkerScreen/MaterialInputModal.tsx
유병철 f344dc7d00 refactor(WEB): 회계/견적/설정/생산 등 전반적 코드 개선 및 공통화 2차
- 회계 모듈 전면 개선: 청구/입금/출금/매입/매출/세금계산서/일반전표/거래처원장 등
- 견적 모듈 금액 포맷/할인/수식/미리보기 등 코드 정리
- 설정 모듈: 계정관리/직급/직책/권한 상세 간소화
- 생산 모듈: 작업지시서/작업자화면/검수 문서 코드 정리
- UniversalListPage 엑셀 다운로드 및 필터 기능 확장
- 대시보드/게시판/수주 등 날짜 유틸 공통화 적용
- claudedocs 문서 인덱스 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:45:47 +09:00

490 lines
18 KiB
TypeScript

'use client';
/**
* 자재투입 모달 (로트 선택 기반)
*
* 로트를 체크박스로 선택하면 필요수량만큼 FIFO 순서로 자동 배분합니다.
* 같은 품목의 여러 로트를 조합하여 필요수량을 충족시킬 수 있습니다.
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Loader2, Check } 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
workOrderItemName?: string; // 개소명 (모달 헤더 표시용)
onComplete?: () => void;
isCompletionFlow?: boolean;
onSaveMaterials?: (orderId: string, materials: MaterialInput[]) => void;
savedMaterials?: MaterialInput[];
}
interface MaterialGroup {
itemId: number;
materialName: string;
materialCode: string;
requiredQty: number;
effectiveRequiredQty: number; // 남은 필요수량 (이미 투입분 차감)
alreadyInputted: number; // 이미 투입된 수량
unit: string;
lots: MaterialForInput[];
}
const fmtQty = (v: number) => formatNumber(parseFloat(String(v)));
export function MaterialInputModal({
open,
onOpenChange,
order,
workOrderItemId,
workOrderItemName,
onComplete,
isCompletionFlow = false,
onSaveMaterials,
}: MaterialInputModalProps) {
const [materials, setMaterials] = useState<MaterialForInput[]>([]);
const [selectedLotKeys, setSelectedLotKeys] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = 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,
}));
// 로트 키 생성
const getLotKey = (material: MaterialForInput) =>
String(material.stockLotId ?? `item-${material.itemId}`);
// 품목별 그룹핑
const materialGroups: MaterialGroup[] = useMemo(() => {
const groups = new Map<number, MaterialForInput[]>();
for (const m of materials) {
const existing = groups.get(m.itemId) || [];
existing.push(m);
groups.set(m.itemId, existing);
}
return Array.from(groups.entries()).map(([itemId, 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,
materialName: first.materialName,
materialCode: first.materialCode,
requiredQty: first.requiredQty,
effectiveRequiredQty,
alreadyInputted,
unit: first.unit,
lots: lots.sort((a, b) => a.fifoRank - b.fifoRank),
};
});
}, [materials]);
// 선택된 로트에 FIFO 순서로 자동 배분 계산
const allocations = useMemo(() => {
const result = new Map<string, number>();
for (const group of materialGroups) {
let remaining = group.effectiveRequiredQty;
for (const lot of group.lots) {
const lotKey = getLotKey(lot);
if (selectedLotKeys.has(lotKey) && lot.stockLotId && remaining > 0) {
const alloc = Math.min(lot.lotAvailableQty, remaining);
result.set(lotKey, alloc);
remaining -= alloc;
}
}
}
return result;
}, [materialGroups, selectedLotKeys]);
// 전체 배정 완료 여부
const allGroupsFulfilled = useMemo(() => {
if (materialGroups.length === 0) return false;
return materialGroups.every((group) => {
const allocated = group.lots.reduce(
(sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0),
0
);
return group.effectiveRequiredQty <= 0 || allocated >= group.effectiveRequiredQty;
});
}, [materialGroups, allocations]);
// 배정된 항목 존재 여부
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);
} else {
next.add(lotKey);
}
return next;
});
}, []);
// API로 자재 목록 로드
const loadMaterials = useCallback(async () => {
if (!order) return;
setIsLoading(true);
try {
// 목업 아이템인 경우 목업 자재 데이터 사용
if (order.id.startsWith('mock-')) {
setMaterials(MOCK_MATERIALS);
setIsLoading(false);
return;
}
// 개소별 API vs 전체 API 분기
const result = workOrderItemId
? await getMaterialsForItem(order.id, workOrderItemId)
: await getMaterialsForWorkOrder(order.id);
if (result.success) {
setMaterials(result.data);
} 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]);
// 모달이 열릴 때 데이터 로드 + 선택 초기화
useEffect(() => {
if (open && order) {
loadMaterials();
setSelectedLotKeys(new Set());
}
}, [open, order, loadMaterials]);
// 투입 등록
const handleSubmit = async () => {
if (!order) return;
// 배분된 로트만 추출
const inputs: { stock_lot_id: number; qty: number }[] = [];
for (const [lotKey, allocQty] of allocations) {
if (allocQty > 0) {
const material = materials.find((m) => getLotKey(m) === lotKey);
if (material?.stockLotId) {
inputs.push({ stock_lot_id: material.stockLotId, qty: allocQty });
}
}
}
if (inputs.length === 0) {
toast.error('투입할 로트를 선택해주세요.');
return;
}
setIsSubmitting(true);
try {
// 개소별 API vs 전체 API 분기
const result = workOrderItemId
? await registerMaterialInputForItem(order.id, workOrderItemId, inputs)
: await registerMaterialInput(order.id, inputs);
if (result.success) {
toast.success('자재 투입이 등록되었습니다.');
if (onSaveMaterials) {
const savedList: MaterialInput[] = [];
for (const [lotKey, allocQty] of allocations) {
if (allocQty > 0) {
const material = materials.find((m) => getLotKey(m) === lotKey);
if (material) {
savedList.push({
id: String(material.stockLotId),
lotNo: material.lotNo || '',
materialName: material.materialName,
quantity: material.lotAvailableQty,
unit: material.unit,
inputQuantity: allocQty,
});
}
}
}
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());
onOpenChange(false);
};
if (!order) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-3xl p-0 gap-0">
{/* 헤더 */}
<DialogHeader className="p-6 pb-4">
<DialogTitle className="text-xl font-semibold">
{workOrderItemName ? ` - ${workOrderItemName}` : ''}
</DialogTitle>
<p className="text-sm text-gray-500 mt-1">
.
</p>
</DialogHeader>
<div className="px-6 pb-6 space-y-4">
{/* 자재 목록 */}
{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 max-h-[60vh] overflow-y-auto">
{materialGroups.map((group) => {
const groupAllocated = group.lots.reduce(
(sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0),
0
);
const isAlreadyComplete = group.effectiveRequiredQty <= 0;
const isFulfilled = isAlreadyComplete || groupAllocated >= group.effectiveRequiredQty;
return (
<div key={group.itemId} className="border rounded-lg overflow-hidden">
{/* 품목 그룹 헤더 */}
<div className="flex items-center justify-between px-4 py-2.5 bg-gray-50 border-b">
<div className="flex items-center gap-2">
<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-3">
<span className="text-xs text-gray-500">
{group.alreadyInputted > 0 ? (
<>
:{' '}
<span className="font-semibold text-gray-900">
{fmtQty(group.effectiveRequiredQty)}
</span>{' '}
{group.unit}
<span className="ml-1 text-gray-400">
(: {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 ${
isAlreadyComplete
? 'bg-emerald-100 text-emerald-700'
: isFulfilled
? 'bg-emerald-100 text-emerald-700'
: groupAllocated > 0
? 'bg-amber-100 text-amber-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{isAlreadyComplete ? (
<>
<Check className="h-3 w-3" />
</>
) : isFulfilled ? (
<>
<Check className="h-3 w-3" />
</>
) : (
`${fmtQty(groupAllocated)} / ${fmtQty(group.effectiveRequiredQty)}`
)}
</span>
</div>
</div>
{/* 로트 테이블 */}
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-center w-20"></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);
const hasStock = lot.stockLotId !== null;
const isSelected = selectedLotKeys.has(lotKey);
const allocated = allocations.get(lotKey) || 0;
const canSelect = hasStock && !isAlreadyComplete && (!isFulfilled || isSelected);
return (
<TableRow
key={`${lotKey}-${idx}`}
className={
isSelected && allocated > 0
? 'bg-blue-50/50'
: ''
}
>
<TableCell className="text-center">
{hasStock ? (
<button
onClick={() => toggleLot(lotKey)}
disabled={!canSelect}
className={cn(
'min-w-[56px] 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">
{lot.lotNo || (
<span className="text-gray-400">
</span>
)}
</TableCell>
<TableCell className="text-center text-sm">
{hasStock ? (
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">
{allocated > 0 ? (
<span className="text-blue-600">
{fmtQty(allocated)}
</span>
) : (
<span className="text-gray-300">-</span>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
})}
</div>
)}
{/* 버튼 영역 */}
<div className="flex gap-3">
<Button
variant="outline"
onClick={resetAndClose}
disabled={isSubmitting}
className="flex-1 py-6 text-base font-medium"
>
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !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>
);
}