fix(WEB): 자재투입 모달 UX 개선 - 선택 유지/중복투입 차단/버튼 UI
- 자재 투입 후 전체 새로고침 제거, 로컬 오버라이드로 현재 수주 선택 유지 - 자동선택 useEffect에 현재 선택 유효 가드 추가 - API remainingRequiredQty 활용하여 이미 충족된 품목 추가 선택 차단 - 기투입 수량 표시 및 '투입 완료' 뱃지 표시 - 체크박스 → 버튼 형태(선택/선택됨)로 변경 - 수량 소수점 불필요 자릿수 제거 (parseFloat 래핑)
This commit is contained in:
@@ -55,6 +55,15 @@ export interface WorkOrderNodeItem {
|
||||
quantity: number;
|
||||
specification?: string | null;
|
||||
options?: Record<string, unknown> | null;
|
||||
materialInputs?: {
|
||||
id: number;
|
||||
stockLotId: number;
|
||||
lotNo: string | null;
|
||||
itemId: number;
|
||||
materialName: string | null;
|
||||
qty: number;
|
||||
unit: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// 작업자 현황
|
||||
|
||||
@@ -374,7 +374,7 @@ export const TemplateInspectionContent = forwardRef<InspectionContentRef, Templa
|
||||
const key = `${rowIdx}-${col.id}`;
|
||||
const cell = cellValues[key];
|
||||
|
||||
const mType = sectionItem.measurement_type || sectionItem.measurementType || '';
|
||||
const mType = sectionItem.measurement_type || '';
|
||||
|
||||
if (col.column_type === 'complex' && col.sub_labels) {
|
||||
// 복합 컬럼: sub_label 유형별 처리
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 자재투입 모달 (로트 기반)
|
||||
* 자재투입 모달 (로트 선택 기반)
|
||||
*
|
||||
* 입고관리에서 생성된 실제 로트번호 기준으로 자재를 표시합니다.
|
||||
* 컬럼: 로트번호 | 품목명 | 가용수량 | 단위 | 투입 수량 (input, 숫자만)
|
||||
* 하단: 취소 / 투입
|
||||
* 로트를 체크박스로 선택하면 필요수량만큼 FIFO 순서로 자동 배분합니다.
|
||||
* 같은 품목의 여러 로트를 조합하여 필요수량을 충족시킬 수 있습니다.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Loader2, Check } from 'lucide-react';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -29,7 +28,7 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { getMaterialsForWorkOrder, registerMaterialInput, type MaterialForInput } from './actions';
|
||||
import { getMaterialsForWorkOrder, registerMaterialInput, getMaterialsForItem, registerMaterialInputForItem, type MaterialForInput, type MaterialForItemInput } from './actions';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import type { MaterialInput } from './types';
|
||||
|
||||
@@ -37,22 +36,39 @@ 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) => parseFloat(String(v)).toLocaleString();
|
||||
|
||||
export function MaterialInputModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
order,
|
||||
workOrderItemId,
|
||||
workOrderItemName,
|
||||
onComplete,
|
||||
isCompletionFlow = false,
|
||||
onSaveMaterials,
|
||||
}: MaterialInputModalProps) {
|
||||
const [materials, setMaterials] = useState<MaterialForInput[]>([]);
|
||||
const [inputQuantities, setInputQuantities] = useState<Record<string, string>>({});
|
||||
const [selectedLotKeys, setSelectedLotKeys] = useState<Set<string>>(new Set());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -70,6 +86,83 @@ export function MaterialInputModal({
|
||||
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;
|
||||
@@ -79,23 +172,17 @@ export function MaterialInputModal({
|
||||
// 목업 아이템인 경우 목업 자재 데이터 사용
|
||||
if (order.id.startsWith('mock-')) {
|
||||
setMaterials(MOCK_MATERIALS);
|
||||
const initialQuantities: Record<string, string> = {};
|
||||
MOCK_MATERIALS.forEach((m) => {
|
||||
initialQuantities[String(m.stockLotId)] = '';
|
||||
});
|
||||
setInputQuantities(initialQuantities);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await getMaterialsForWorkOrder(order.id);
|
||||
// 개소별 API vs 전체 API 분기
|
||||
const result = workOrderItemId
|
||||
? await getMaterialsForItem(order.id, workOrderItemId)
|
||||
: await getMaterialsForWorkOrder(order.id);
|
||||
|
||||
if (result.success) {
|
||||
setMaterials(result.data);
|
||||
const initialQuantities: Record<string, string> = {};
|
||||
result.data.forEach((m) => {
|
||||
initialQuantities[String(m.stockLotId ?? `item-${m.itemId}`)] = '';
|
||||
});
|
||||
setInputQuantities(initialQuantities);
|
||||
} else {
|
||||
toast.error(result.error || '자재 목록 조회에 실패했습니다.');
|
||||
}
|
||||
@@ -106,74 +193,63 @@ export function MaterialInputModal({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [order]);
|
||||
}, [order, workOrderItemId]);
|
||||
|
||||
// 모달이 열릴 때 데이터 로드
|
||||
// 모달이 열릴 때 데이터 로드 + 선택 초기화
|
||||
useEffect(() => {
|
||||
if (open && order) {
|
||||
loadMaterials();
|
||||
setSelectedLotKeys(new Set());
|
||||
}
|
||||
}, [open, order, loadMaterials]);
|
||||
|
||||
// 투입 수량 변경 핸들러 (숫자만 허용)
|
||||
const handleQuantityChange = (key: string, value: string) => {
|
||||
const numericValue = value.replace(/[^0-9]/g, '');
|
||||
setInputQuantities((prev) => ({
|
||||
...prev,
|
||||
[key]: numericValue,
|
||||
}));
|
||||
};
|
||||
|
||||
// 로트 키 생성
|
||||
const getLotKey = (material: MaterialForInput) =>
|
||||
String(material.stockLotId ?? `item-${material.itemId}`);
|
||||
|
||||
// 투입 등록
|
||||
const handleSubmit = async () => {
|
||||
if (!order) return;
|
||||
|
||||
// 투입 수량이 입력된 항목 필터 (재고 있는 로트만)
|
||||
const materialsWithQuantity = materials.filter((m) => {
|
||||
if (!m.stockLotId) return false;
|
||||
const qty = inputQuantities[getLotKey(m)];
|
||||
return qty && parseInt(qty) > 0;
|
||||
});
|
||||
|
||||
if (materialsWithQuantity.length === 0) {
|
||||
toast.error('투입 수량을 입력해주세요.');
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 가용수량 초과 검증
|
||||
for (const m of materialsWithQuantity) {
|
||||
const inputQty = parseInt(inputQuantities[getLotKey(m)] || '0');
|
||||
if (inputQty > m.lotAvailableQty) {
|
||||
toast.error(`${m.lotNo}: 가용수량(${m.lotAvailableQty})을 초과할 수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
if (inputs.length === 0) {
|
||||
toast.error('투입할 로트를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const inputs = materialsWithQuantity.map((m) => ({
|
||||
stock_lot_id: m.stockLotId!,
|
||||
qty: parseInt(inputQuantities[getLotKey(m)] || '0'),
|
||||
}));
|
||||
|
||||
const result = await registerMaterialInput(order.id, inputs);
|
||||
// 개소별 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[] = materialsWithQuantity.map((m) => ({
|
||||
id: String(m.stockLotId),
|
||||
lotNo: m.lotNo || '',
|
||||
materialName: m.materialName,
|
||||
quantity: m.lotAvailableQty,
|
||||
unit: m.unit,
|
||||
inputQuantity: parseInt(inputQuantities[getLotKey(m)] || '0'),
|
||||
}));
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -194,12 +270,8 @@ export function MaterialInputModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
resetAndClose();
|
||||
};
|
||||
|
||||
const resetAndClose = () => {
|
||||
setInputQuantities({});
|
||||
setSelectedLotKeys(new Set());
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -210,94 +282,177 @@ export function MaterialInputModal({
|
||||
<DialogContent className="!max-w-3xl p-0 gap-0">
|
||||
{/* 헤더 */}
|
||||
<DialogHeader className="p-6 pb-4">
|
||||
<DialogTitle className="text-xl font-semibold">자재 투입</DialogTitle>
|
||||
<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-6">
|
||||
{/* 자재 목록 테이블 */}
|
||||
<div className="px-6 pb-6 space-y-4">
|
||||
{/* 자재 목록 */}
|
||||
{isLoading ? (
|
||||
<ContentSkeleton type="table" rows={4} />
|
||||
) : materials.length === 0 ? (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center">로트번호</TableHead>
|
||||
<TableHead className="text-center">품목명</TableHead>
|
||||
<TableHead className="text-center">필요수량</TableHead>
|
||||
<TableHead className="text-center">가용수량</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-center">투입 수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-12 text-gray-500">
|
||||
이 공정에 배정된 자재가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="border rounded-lg py-12 text-center text-sm text-gray-500">
|
||||
이 공정에 배정된 자재가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="text-center font-medium">로트번호</TableHead>
|
||||
<TableHead className="text-center font-medium">품목명</TableHead>
|
||||
<TableHead className="text-center font-medium">필요수량</TableHead>
|
||||
<TableHead className="text-center font-medium">가용수량</TableHead>
|
||||
<TableHead className="text-center font-medium">단위</TableHead>
|
||||
<TableHead className="text-center font-medium">투입 수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{materials.map((material, index) => {
|
||||
const lotKey = getLotKey(material);
|
||||
const hasStock = material.stockLotId !== null;
|
||||
return (
|
||||
<TableRow key={`mat-${lotKey}-${index}`}>
|
||||
<TableCell className="text-center text-sm">
|
||||
{material.lotNo || (
|
||||
<span className="text-gray-400">재고 없음</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{material.materialName}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{material.requiredQty.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{hasStock ? material.lotAvailableQty.toLocaleString() : (
|
||||
<span className="text-red-500">0</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{material.unit}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{hasStock ? (
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="0"
|
||||
value={inputQuantities[lotKey] || ''}
|
||||
onChange={(e) =>
|
||||
handleQuantityChange(lotKey, e.target.value)
|
||||
}
|
||||
className="w-20 mx-auto text-center h-8 text-sm"
|
||||
/>
|
||||
<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="text-gray-400 text-sm">-</span>
|
||||
<>
|
||||
필요:{' '}
|
||||
<span className="font-semibold text-gray-900">
|
||||
{fmtQty(group.requiredQty)}
|
||||
</span>{' '}
|
||||
{group.unit}
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -305,7 +460,7 @@ export function MaterialInputModal({
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
onClick={resetAndClose}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 py-6 text-base font-medium"
|
||||
>
|
||||
@@ -313,7 +468,7 @@ export function MaterialInputModal({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || !hasAnyAllocation}
|
||||
className="flex-1 py-6 text-base font-medium bg-gray-900 hover:bg-gray-800"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
@@ -330,4 +485,4 @@ export function MaterialInputModal({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ export const WorkItemCard = memo(function WorkItemCard({
|
||||
<TableRow key={mat.id}>
|
||||
<TableCell className="text-center text-xs">{mat.lotNo}</TableCell>
|
||||
<TableCell className="text-center text-xs">{mat.itemName}</TableCell>
|
||||
<TableCell className="text-center text-xs">{mat.quantity.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-center text-xs">{parseFloat(String(mat.quantity)).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-center text-xs">{mat.unit}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
|
||||
@@ -11,10 +11,13 @@
|
||||
* - 양식 미매핑 시 processType 폴백
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Loader2, Save } from 'lucide-react';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { getWorkOrderById, getMaterialInputLots } from '../WorkOrders/actions';
|
||||
import { saveWorkLog } from './actions';
|
||||
import type { MaterialInputLot } from '../WorkOrders/actions';
|
||||
import type { WorkOrder, ProcessType } from '../WorkOrders/types';
|
||||
import { WorkLogContent } from './WorkLogContent';
|
||||
@@ -58,6 +61,7 @@ export function WorkLogModal({
|
||||
const [order, setOrder] = useState<WorkOrder | null>(null);
|
||||
const [materialLots, setMaterialLots] = useState<MaterialInputLot[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 목업 WorkOrder 생성
|
||||
@@ -136,6 +140,38 @@ export function WorkLogModal({
|
||||
}
|
||||
}, [open, workOrderId, processType]);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!workOrderId || !order) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// 현재 아이템 데이터를 table_data로 변환
|
||||
const tableData = (order.items || []).map((item) => ({
|
||||
id: item.id,
|
||||
item_name: item.productName,
|
||||
specification: item.specification || item.floorCode,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit || 'EA',
|
||||
}));
|
||||
|
||||
const result = await saveWorkLog(workOrderId, {
|
||||
table_data: tableData,
|
||||
title: workLogTemplateName || '작업일지',
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success('작업일지가 저장되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [workOrderId, order, workLogTemplateName]);
|
||||
|
||||
if (!workOrderId) return null;
|
||||
|
||||
// 로딩/에러 상태는 DocumentViewer 내부에서 처리
|
||||
@@ -183,6 +219,17 @@ export function WorkLogModal({
|
||||
// 양식명으로 문서 제목 결정
|
||||
const documentTitle = workLogTemplateName || '작업일지';
|
||||
|
||||
const toolbarExtra = (
|
||||
<Button onClick={handleSave} disabled={isSaving} size="sm">
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-1.5" />
|
||||
)}
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
title={documentTitle}
|
||||
@@ -190,6 +237,7 @@ export function WorkLogModal({
|
||||
preset="inspection"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
toolbarExtra={toolbarExtra}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64 bg-white">
|
||||
|
||||
@@ -54,6 +54,16 @@ interface WorkOrderApiItem {
|
||||
symbol_code?: string | null;
|
||||
node?: { id: number; name: string; code: string } | null;
|
||||
} | null;
|
||||
material_inputs?: {
|
||||
id: number;
|
||||
stock_lot_id: number;
|
||||
item_id: number;
|
||||
qty: number;
|
||||
input_by: number | null;
|
||||
input_at: string | null;
|
||||
stock_lot?: { id: number; lot_no: string } | null;
|
||||
item?: { id: number; code: string; name: string; unit: string } | null;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -141,6 +151,15 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
|
||||
quantity: Number(it.quantity),
|
||||
specification: it.specification,
|
||||
options: it.options,
|
||||
materialInputs: (it.material_inputs || []).map((mi) => ({
|
||||
id: mi.id,
|
||||
stockLotId: mi.stock_lot_id,
|
||||
lotNo: mi.stock_lot?.lot_no || null,
|
||||
itemId: mi.item_id,
|
||||
materialName: mi.item?.name || null,
|
||||
qty: Number(mi.qty),
|
||||
unit: mi.item?.unit || 'EA',
|
||||
})),
|
||||
})),
|
||||
totalQuantity: (g.items || []).reduce((sum, it) => sum + Number(it.quantity), 0),
|
||||
}));
|
||||
@@ -265,6 +284,136 @@ export async function registerMaterialInput(
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 개소별 자재 목록 조회 =====
|
||||
export interface MaterialForItemInput extends MaterialForInput {
|
||||
alreadyInputted: number; // 이미 투입된 수량
|
||||
remainingRequiredQty: number; // 남은 필요 수량
|
||||
}
|
||||
|
||||
export async function getMaterialsForItem(
|
||||
workOrderId: string,
|
||||
itemId: number
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data: MaterialForItemInput[];
|
||||
error?: string;
|
||||
}> {
|
||||
interface MaterialItemApiItem {
|
||||
stock_lot_id: number | null; item_id: number; lot_no: string | null;
|
||||
material_code: string; material_name: string; specification: string;
|
||||
unit: string; bom_qty: number; required_qty: number;
|
||||
already_inputted: number; remaining_required_qty: number;
|
||||
lot_available_qty: number; fifo_rank: number;
|
||||
lot_qty: number; lot_reserved_qty: number;
|
||||
receipt_date: string | null; supplier: string | null;
|
||||
}
|
||||
const result = await executeServerAction<MaterialItemApiItem[]>({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/materials`,
|
||||
errorMessage: '개소별 자재 목록 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.map((item) => ({
|
||||
stockLotId: item.stock_lot_id, itemId: item.item_id, lotNo: item.lot_no,
|
||||
materialCode: item.material_code, materialName: item.material_name,
|
||||
specification: item.specification ?? '', unit: item.unit,
|
||||
requiredQty: item.required_qty, lotAvailableQty: item.lot_available_qty,
|
||||
fifoRank: item.fifo_rank,
|
||||
alreadyInputted: item.already_inputted,
|
||||
remainingRequiredQty: item.remaining_required_qty,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 개소별 자재 투입 등록 =====
|
||||
export async function registerMaterialInputForItem(
|
||||
workOrderId: string,
|
||||
itemId: number,
|
||||
inputs: { stock_lot_id: number; qty: number }[]
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/material-inputs`,
|
||||
method: 'POST',
|
||||
body: { inputs },
|
||||
errorMessage: '개소별 자재 투입 등록에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 개소별 자재 투입 이력 조회 =====
|
||||
export interface MaterialInputHistoryItem {
|
||||
id: number;
|
||||
stockLotId: number;
|
||||
lotNo: string | null;
|
||||
itemId: number;
|
||||
materialCode: string | null;
|
||||
materialName: string | null;
|
||||
qty: number;
|
||||
unit: string;
|
||||
inputBy: number | null;
|
||||
inputByName: string | null;
|
||||
inputAt: string | null;
|
||||
}
|
||||
|
||||
export async function getMaterialInputsForItem(
|
||||
workOrderId: string,
|
||||
itemId: number
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data: MaterialInputHistoryItem[];
|
||||
error?: string;
|
||||
}> {
|
||||
interface HistoryApiItem {
|
||||
id: number; stock_lot_id: number; lot_no: string | null;
|
||||
item_id: number; material_code: string | null; material_name: string | null;
|
||||
qty: number; unit: string;
|
||||
input_by: number | null; input_by_name: string | null; input_at: string | null;
|
||||
}
|
||||
const result = await executeServerAction<HistoryApiItem[]>({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/material-inputs`,
|
||||
errorMessage: '개소별 투입 이력 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
|
||||
return {
|
||||
success: true,
|
||||
data: result.data.map((item) => ({
|
||||
id: item.id, stockLotId: item.stock_lot_id, lotNo: item.lot_no,
|
||||
itemId: item.item_id, materialCode: item.material_code,
|
||||
materialName: item.material_name, qty: item.qty, unit: item.unit,
|
||||
inputBy: item.input_by, inputByName: item.input_by_name, inputAt: item.input_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 자재 투입 삭제 (재고 복원) =====
|
||||
export async function deleteMaterialInput(
|
||||
workOrderId: string,
|
||||
inputId: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/material-inputs/${inputId}`,
|
||||
method: 'DELETE',
|
||||
errorMessage: '자재 투입 삭제에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 자재 투입 수량 수정 =====
|
||||
export async function updateMaterialInput(
|
||||
workOrderId: string,
|
||||
inputId: number,
|
||||
qty: number
|
||||
): Promise<{ success: boolean; data?: { id: number; qty: number; changed: boolean }; error?: string }> {
|
||||
const result = await executeServerAction<{ id: number; qty: number; changed: boolean }>({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/material-inputs/${inputId}`,
|
||||
method: 'PATCH',
|
||||
body: { qty },
|
||||
errorMessage: '자재 투입 수정에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 이슈 보고 =====
|
||||
export async function reportIssue(
|
||||
workOrderId: string,
|
||||
@@ -548,6 +697,54 @@ export async function getWorkOrderInspectionData(
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 작업일지 저장 =====
|
||||
export async function saveWorkLog(
|
||||
workOrderId: string,
|
||||
data: {
|
||||
basic_data?: Record<string, string>;
|
||||
table_data?: Array<Record<string, unknown>>;
|
||||
remarks?: string;
|
||||
title?: string;
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: { document_id: number; document_no: string; status: string };
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction<{ document_id: number; document_no: string; status: string }>({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/work-log`,
|
||||
method: 'POST',
|
||||
body: data,
|
||||
errorMessage: '작업일지 저장에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 작업일지 조회 =====
|
||||
export async function getWorkLog(
|
||||
workOrderId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
template: Record<string, unknown>;
|
||||
document: Record<string, unknown> | null;
|
||||
auto_values: Record<string, string>;
|
||||
work_stats: Record<string, unknown>;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction<{
|
||||
template: Record<string, unknown>;
|
||||
document: Record<string, unknown> | null;
|
||||
auto_values: Record<string, string>;
|
||||
work_stats: Record<string, unknown>;
|
||||
}>({
|
||||
url: `${API_URL}/api/v1/work-orders/${workOrderId}/work-log`,
|
||||
errorMessage: '작업일지 조회에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data, error: result.error };
|
||||
}
|
||||
|
||||
// ===== 검사 문서 템플릿 타입 (types.ts에서 import) =====
|
||||
import type { InspectionTemplateData } from './types';
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useMenuStore } from '@/stores/menuStore';
|
||||
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle, ChevronDown, ChevronUp, List } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ContentSkeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -33,7 +40,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getMyWorkOrders, completeWorkOrder, saveItemInspection, getWorkOrderInspectionData, saveInspectionDocument, getInspectionTemplate, toggleStepProgress } from './actions';
|
||||
import { getMyWorkOrders, completeWorkOrder, saveItemInspection, getWorkOrderInspectionData, saveInspectionDocument, getInspectionTemplate, toggleStepProgress, deleteMaterialInput, updateMaterialInput } from './actions';
|
||||
import type { InspectionTemplateData } from './types';
|
||||
import { getProcessList } from '@/components/process-management/actions';
|
||||
import type { InspectionSetting, Process } from '@/types/process';
|
||||
@@ -358,6 +365,8 @@ export default function WorkerScreen() {
|
||||
const [selectedOrder, setSelectedOrder] = useState<WorkOrder | null>(null);
|
||||
const [isCompletionDialogOpen, setIsCompletionDialogOpen] = useState(false);
|
||||
const [isMaterialModalOpen, setIsMaterialModalOpen] = useState(false);
|
||||
const [selectedWorkOrderItemId, setSelectedWorkOrderItemId] = useState<number | undefined>();
|
||||
const [selectedWorkOrderItemName, setSelectedWorkOrderItemName] = useState<string | undefined>();
|
||||
const [isWorkLogModalOpen, setIsWorkLogModalOpen] = useState(false);
|
||||
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
|
||||
const [isIssueReportModalOpen, setIsIssueReportModalOpen] = useState(false);
|
||||
@@ -389,6 +398,10 @@ export default function WorkerScreen() {
|
||||
new Map()
|
||||
);
|
||||
|
||||
// 자재 수정 Dialog 상태
|
||||
const [editMaterialTarget, setEditMaterialTarget] = useState<{ itemId: string; material: MaterialListItem } | null>(null);
|
||||
const [editMaterialQty, setEditMaterialQty] = useState('');
|
||||
|
||||
// 완료 토스트 상태
|
||||
const [toastInfo, setToastInfo] = useState<CompletionToastInfo | null>(null);
|
||||
|
||||
@@ -499,6 +512,7 @@ export default function WorkerScreen() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedSidebarOrderId]);
|
||||
|
||||
|
||||
// ===== 탭별 필터링된 작업 =====
|
||||
const filteredWorkOrders = useMemo(() => {
|
||||
const selectedProcess = processListCache.find((p) => p.id === activeTab);
|
||||
@@ -528,6 +542,12 @@ export default function WorkerScreen() {
|
||||
if (isLoading) return;
|
||||
|
||||
const allOrders: SidebarOrder[] = [...apiSidebarOrders, ...MOCK_SIDEBAR_ORDERS[activeProcessTabKey]];
|
||||
|
||||
// 현재 선택이 유효하면 자동 전환하지 않음 (데이터 새로고침 시 선택 유지)
|
||||
if (selectedSidebarOrderId && allOrders.some((o) => o.id === selectedSidebarOrderId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 우선순위 순서: urgent → priority → normal
|
||||
for (const group of PRIORITY_GROUPS) {
|
||||
const first = allOrders.find((o) => o.priority === group.key);
|
||||
@@ -543,7 +563,7 @@ export default function WorkerScreen() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [isLoading, apiSidebarOrders, activeProcessTabKey]);
|
||||
}, [isLoading, apiSidebarOrders, activeProcessTabKey, selectedSidebarOrderId]);
|
||||
|
||||
// ===== 통계 계산 (탭별) =====
|
||||
const stats: WorkerStats = useMemo(() => {
|
||||
@@ -566,11 +586,23 @@ export default function WorkerScreen() {
|
||||
const workItems: WorkItemData[] = useMemo(() => {
|
||||
const selectedOrder = filteredWorkOrders.find((wo) => wo.id === selectedSidebarOrderId);
|
||||
const stepsKey = (activeProcessTabKey === 'bending' && bendingSubMode === 'wip') ? 'bending_wip' : activeProcessTabKey;
|
||||
const stepsTemplate = PROCESS_STEPS[stepsKey];
|
||||
const hardcodedSteps = PROCESS_STEPS[stepsKey];
|
||||
|
||||
// PROCESS_STEPS 폴백 step에 processListCache 설정 매칭하는 헬퍼
|
||||
// 공정관리 API에서 가져온 단계가 있으면 우선 사용, 없으면 하드코딩 폴백
|
||||
const useApiSteps = activeProcessSteps.length > 0;
|
||||
const stepsTemplate: { name: string; isMaterialInput: boolean; isInspection?: boolean }[] = useApiSteps
|
||||
? activeProcessSteps
|
||||
.filter((ps) => ps.isActive)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((ps) => ({
|
||||
name: ps.stepName,
|
||||
isMaterialInput: ps.stepName.includes('자재투입'),
|
||||
isInspection: ps.needsInspection,
|
||||
}))
|
||||
: hardcodedSteps;
|
||||
|
||||
// step에 API 설정 속성을 매칭하는 헬퍼
|
||||
const enrichStep = (st: { name: string; isMaterialInput: boolean; isInspection?: boolean }, stepId: string, stepKey: string) => {
|
||||
// 단계명으로 processListCache의 단계 설정 매칭
|
||||
const matched = activeProcessSteps.find((ps) => ps.stepName === st.name);
|
||||
return {
|
||||
id: stepId,
|
||||
@@ -604,6 +636,32 @@ export default function WorkerScreen() {
|
||||
const firstItem = group.items[0];
|
||||
const opts = (firstItem?.options || {}) as Record<string, unknown>;
|
||||
|
||||
// 개소별 투입 자재 매핑 (로컬 오버라이드 > API 초기 데이터)
|
||||
const itemMapKey = firstItem?.id ? `${selectedOrder.id}-item-${firstItem.id}` : '';
|
||||
const savedMats = inputMaterialsMap.get(itemMapKey) || inputMaterialsMap.get(selectedOrder.id);
|
||||
let materialInputsList: MaterialListItem[];
|
||||
if (savedMats) {
|
||||
materialInputsList = savedMats.map((m) => ({ id: m.id, lotNo: m.lotNo, itemName: m.materialName, quantity: m.inputQuantity, unit: m.unit }));
|
||||
} else {
|
||||
// API 초기 데이터에서 투입 이력 추출
|
||||
const apiMaterialInputs = group.items.flatMap((it) => it.materialInputs || []);
|
||||
materialInputsList = apiMaterialInputs.map((mi) => ({
|
||||
id: String(mi.id),
|
||||
lotNo: mi.lotNo || '',
|
||||
itemName: mi.materialName || '',
|
||||
quantity: mi.qty,
|
||||
unit: mi.unit,
|
||||
}));
|
||||
}
|
||||
|
||||
// API 데이터에서 자재투입 이력이 있으면 자재투입 step 완료 처리
|
||||
if (materialInputsList.length > 0) {
|
||||
const matStep = steps.find((s) => s.isMaterialInput);
|
||||
if (matStep && !matStep.isCompleted) {
|
||||
matStep.isCompleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
const workItem: WorkItemData = {
|
||||
id: `${selectedOrder.id}-node-${nodeKey}`,
|
||||
apiItemId: firstItem?.id as number | undefined,
|
||||
@@ -618,7 +676,7 @@ export default function WorkerScreen() {
|
||||
quantity: group.totalQuantity,
|
||||
processType: activeProcessTabKey,
|
||||
steps,
|
||||
materialInputs: [],
|
||||
materialInputs: materialInputsList,
|
||||
};
|
||||
|
||||
// 공정별 추가 정보 추출
|
||||
@@ -661,6 +719,10 @@ export default function WorkerScreen() {
|
||||
const stepKey = `${selectedOrder.id}-${st.name}`;
|
||||
return enrichStep(st, `${selectedOrder.id}-step-${si}`, stepKey);
|
||||
});
|
||||
const fallbackMats = inputMaterialsMap.get(selectedOrder.id);
|
||||
const fallbackMaterialsList: MaterialListItem[] = fallbackMats
|
||||
? fallbackMats.map((m) => ({ id: m.id, lotNo: m.lotNo, itemName: m.materialName, quantity: m.inputQuantity, unit: m.unit }))
|
||||
: [];
|
||||
apiItems.push({
|
||||
id: selectedOrder.id,
|
||||
workOrderId: selectedOrder.id,
|
||||
@@ -674,7 +736,7 @@ export default function WorkerScreen() {
|
||||
quantity: selectedOrder.quantity || 0,
|
||||
processType: activeProcessTabKey,
|
||||
steps,
|
||||
materialInputs: [],
|
||||
materialInputs: fallbackMaterialsList,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -699,7 +761,7 @@ export default function WorkerScreen() {
|
||||
}));
|
||||
|
||||
return [...apiItems, ...mockItems];
|
||||
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps]);
|
||||
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps, inputMaterialsMap]);
|
||||
|
||||
// ===== 수주 정보 (사이드바 선택 항목 기반) =====
|
||||
const orderInfo = useMemo(() => {
|
||||
@@ -798,10 +860,14 @@ export default function WorkerScreen() {
|
||||
const handleStepClick = useCallback(
|
||||
(itemId: string, step: WorkStepData) => {
|
||||
if (step.isMaterialInput) {
|
||||
// 자재투입 → 자재 투입 모달 열기
|
||||
// 자재투입 → 자재 투입 모달 열기 (개소별)
|
||||
const order = workOrders.find((o) => o.id === itemId || itemId.startsWith(`${o.id}-node-`));
|
||||
const workItem = workItems.find((item) => item.id === itemId);
|
||||
if (order) {
|
||||
setSelectedOrder(order);
|
||||
// 개소별 API 호출을 위한 apiItemId 설정
|
||||
setSelectedWorkOrderItemId(workItem?.apiItemId);
|
||||
setSelectedWorkOrderItemName(workItem ? `${workItem.itemName} (${workItem.code})` : undefined);
|
||||
setIsMaterialModalOpen(true);
|
||||
} else {
|
||||
// 목업 아이템인 경우 합성 WorkOrder 생성
|
||||
@@ -847,37 +913,117 @@ export default function WorkerScreen() {
|
||||
[workOrders, workItems, handleInspectionClick]
|
||||
);
|
||||
|
||||
// 자재 수정 핸들러
|
||||
// 자재 수정 핸들러 - Dialog 열기
|
||||
const handleEditMaterial = useCallback(
|
||||
(itemId: string, material: MaterialListItem) => {
|
||||
// 추후 구현
|
||||
setEditMaterialTarget({ itemId, material });
|
||||
setEditMaterialQty(String(material.quantity));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 자재 수정 확정
|
||||
const handleEditMaterialConfirm = useCallback(async () => {
|
||||
if (!editMaterialTarget) return;
|
||||
const { itemId, material } = editMaterialTarget;
|
||||
const newQty = parseFloat(editMaterialQty);
|
||||
if (isNaN(newQty) || newQty <= 0) {
|
||||
toast.error('올바른 수량을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const workItem = workItems.find((w) => w.id === itemId);
|
||||
const orderId = workItem?.workOrderId;
|
||||
if (!orderId) return;
|
||||
|
||||
const result = await updateMaterialInput(orderId, parseInt(material.id), newQty);
|
||||
if (result.success) {
|
||||
toast.success('투입 수량이 수정되었습니다.');
|
||||
setEditMaterialTarget(null);
|
||||
// 데이터 새로고침
|
||||
try {
|
||||
const refreshResult = await getMyWorkOrders();
|
||||
if (refreshResult.success) setWorkOrders(refreshResult.data);
|
||||
} catch {}
|
||||
} else {
|
||||
toast.error(result.error || '수정에 실패했습니다.');
|
||||
}
|
||||
}, [editMaterialTarget, editMaterialQty, workItems]);
|
||||
|
||||
// 자재 삭제 핸들러
|
||||
const handleDeleteMaterial = useCallback(
|
||||
(itemId: string, materialId: string) => {
|
||||
// 추후 구현
|
||||
async (itemId: string, materialId: string) => {
|
||||
const workItem = workItems.find((w) => w.id === itemId);
|
||||
const orderId = workItem?.workOrderId;
|
||||
if (!orderId) return;
|
||||
|
||||
const result = await deleteMaterialInput(orderId, parseInt(materialId));
|
||||
if (result.success) {
|
||||
toast.success('자재 투입이 삭제되었습니다.');
|
||||
|
||||
// 해당 개소에 더이상 투입 이력이 없으면 자재투입 step 완료 해제
|
||||
const nodeMatch = itemId.match(/-node-(.+)$/);
|
||||
if (nodeMatch) {
|
||||
const stepKey = `${orderId}-${nodeMatch[1]}-자재투입`;
|
||||
// 현재 해당 노드의 materialInputs에서 삭제 대상 제외 후 남은 것 확인
|
||||
const currentInputs = workItem.materialInputs?.filter((m) => m.id !== materialId);
|
||||
if (!currentInputs || currentInputs.length === 0) {
|
||||
setStepCompletionMap((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[stepKey];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 새로고침
|
||||
try {
|
||||
const refreshResult = await getMyWorkOrders();
|
||||
if (refreshResult.success) {
|
||||
setWorkOrders(refreshResult.data);
|
||||
// 로컬 오버라이드 모두 제거 (API 데이터가 최신)
|
||||
setInputMaterialsMap(new Map());
|
||||
}
|
||||
} catch {}
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
},
|
||||
[]
|
||||
[workItems]
|
||||
);
|
||||
|
||||
// 자재 저장 핸들러
|
||||
const handleSaveMaterials = useCallback((orderId: string, materials: MaterialInput[]) => {
|
||||
const handleSaveMaterials = useCallback(async (orderId: string, materials: MaterialInput[]) => {
|
||||
// 개소별 키: workOrderItemId가 있으면 개소별, 없으면 orderId 기준
|
||||
const mapKey = selectedWorkOrderItemId ? `${orderId}-item-${selectedWorkOrderItemId}` : orderId;
|
||||
setInputMaterialsMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(orderId, materials);
|
||||
next.set(mapKey, materials);
|
||||
return next;
|
||||
});
|
||||
|
||||
// 자재투입 step 완료로 마킹
|
||||
const stepKey = `${orderId}-자재투입`;
|
||||
setStepCompletionMap((prev) => ({
|
||||
...prev,
|
||||
[stepKey]: true,
|
||||
}));
|
||||
}, []);
|
||||
// 자재투입 step 완료로 마킹 - workItem의 id 기반으로 stepKey 생성
|
||||
// workItems에서 해당 개소를 찾아 정확한 stepKey 사용
|
||||
const matchedItem = workItems.find((item) =>
|
||||
selectedWorkOrderItemId
|
||||
? item.apiItemId === selectedWorkOrderItemId
|
||||
: item.workOrderId === orderId || item.id === orderId
|
||||
);
|
||||
if (matchedItem) {
|
||||
// workItems의 step 생성 시 stepKey = `${orderId}-${nodeKey}-자재투입` 형식
|
||||
// matchedItem.id에서 nodeKey 추출: `${orderId}-node-${nodeKey}`
|
||||
const nodeMatch = matchedItem.id.match(/-node-(.+)$/);
|
||||
const stepKey = nodeMatch
|
||||
? `${orderId}-${nodeMatch[1]}-자재투입`
|
||||
: `${orderId}-자재투입`;
|
||||
setStepCompletionMap((prev) => ({
|
||||
...prev,
|
||||
[stepKey]: true,
|
||||
}));
|
||||
}
|
||||
|
||||
// 로컬 오버라이드로 즉시 반영 (전체 새로고침 없이 현재 선택 수주 유지)
|
||||
}, [selectedWorkOrderItemId, workItems]);
|
||||
|
||||
// 완료 확인 → MaterialInputModal 열기
|
||||
const handleCompletionConfirm = useCallback(() => {
|
||||
@@ -1381,8 +1527,16 @@ export default function WorkerScreen() {
|
||||
|
||||
<MaterialInputModal
|
||||
open={isMaterialModalOpen}
|
||||
onOpenChange={setIsMaterialModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsMaterialModalOpen(open);
|
||||
if (!open) {
|
||||
setSelectedWorkOrderItemId(undefined);
|
||||
setSelectedWorkOrderItemName(undefined);
|
||||
}
|
||||
}}
|
||||
order={selectedOrder}
|
||||
workOrderItemId={selectedWorkOrderItemId}
|
||||
workOrderItemName={selectedWorkOrderItemName}
|
||||
isCompletionFlow={isCompletionFlow}
|
||||
onComplete={handleWorkCompletion}
|
||||
onSaveMaterials={handleSaveMaterials}
|
||||
@@ -1425,6 +1579,32 @@ export default function WorkerScreen() {
|
||||
onConfirm={handleCompletionResultConfirm}
|
||||
/>
|
||||
|
||||
{/* 자재 투입 수량 수정 Dialog */}
|
||||
<Dialog open={!!editMaterialTarget} onOpenChange={(open) => { if (!open) setEditMaterialTarget(null); }}>
|
||||
<DialogContent className="!max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>투입 수량 수정</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<p className="text-sm text-gray-600">{editMaterialTarget?.material.itemName}</p>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={editMaterialQty}
|
||||
onChange={(e) => setEditMaterialQty(e.target.value)}
|
||||
placeholder="수량 입력"
|
||||
min={1}
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleEditMaterialConfirm(); }}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setEditMaterialTarget(null)}>취소</Button>
|
||||
<Button onClick={handleEditMaterialConfirm}>수정</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<InspectionInputModal
|
||||
open={isInspectionInputModalOpen}
|
||||
onOpenChange={setIsInspectionInputModalOpen}
|
||||
|
||||
Reference in New Issue
Block a user