fix(WEB): 자재투입 모달 UX 개선 - 선택 유지/중복투입 차단/버튼 UI

- 자재 투입 후 전체 새로고침 제거, 로컬 오버라이드로 현재 수주 선택 유지
- 자동선택 useEffect에 현재 선택 유효 가드 추가
- API remainingRequiredQty 활용하여 이미 충족된 품목 추가 선택 차단
- 기투입 수량 표시 및 '투입 완료' 뱃지 표시
- 체크박스 → 버튼 형태(선택/선택됨)로 변경
- 수량 소수점 불필요 자릿수 제거 (parseFloat 래핑)
This commit is contained in:
2026-02-12 20:07:31 +09:00
parent cbb38d48b9
commit 14af77ca65
7 changed files with 770 additions and 181 deletions

View File

@@ -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;
}[];
}
// 작업자 현황

View File

@@ -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 유형별 처리

View File

@@ -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>
);
}
}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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';

View File

@@ -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}