From 20fbe85c7440973c7332dcca81e7c36952cdb148 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 17 Mar 2026 21:36:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20BLOCK=20MES-REWORK=20-=20mes-process-ca?= =?UTF-8?q?rd=20=EC=A0=84=EC=9A=A9=20=EC=B9=B4=EB=93=9C=20+=20batch=5Fdone?= =?UTF-8?q?=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20+=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=81=20=EA=B4=80=EB=A6=AC=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?MES=20=EC=B9=B4=EB=93=9C=EB=A5=BC=20CSS=20Grid=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EC=85=80=20=EB=B0=A9=EC=8B=9D=EC=97=90=EC=84=9C=20?= =?UTF-8?q?Flexbox=20=EA=B8=B0=EB=B0=98=20=EB=8B=A8=EC=9D=BC=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EC=B9=B4=EB=93=9C(mes-process-card)=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=ED=95=98=EA=B3=A0,=20batch=5Fdone=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A5=BC=20=EB=8F=84=EC=9E=85=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EB=B6=80=EB=B6=84=20=ED=99=95=EC=A0=95=20=ED=9B=84?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=EC=A0=91=EC=88=98=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=EB=A5=BC=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.=20[mes-process-card=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C]=20-=20CardCellType=20"mes-process-card"=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C:=20=EC=83=81=ED=83=9C=EB=B3=84=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=EB=B3=B4=EB=8D=94+=EB=B0=B0=EA=B2=BD,=20=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=20=ED=9D=90=EB=A6=84=20=EC=8A=A4=ED=8A=B8=EB=A6=BD,?= =?UTF-8?q?=20=ED=81=B4=EB=A6=AD=20=EB=AA=A8=EB=8B=AC=20-=20MesAcceptableM?= =?UTF-8?q?etrics=20/=20MesInProgressMetrics=20/=20MesCompletedMetrics=20?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20-?= =?UTF-8?q?=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20activeBtn=20=EA=B2=B0=EC=A0=95=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20(=EC=83=81=ED=83=9C+=EC=9E=94=EC=97=AC=EB=9F=89=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=EC=9C=BC=EB=A1=9C=20=EB=B2=84=ED=8A=BC=201=EA=B0=9C?= =?UTF-8?q?=EB=A7=8C=20=ED=91=9C=EC=8B=9C)=20-=20=ED=95=98=EB=93=9C?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EC=B7=A8=EC=86=8C=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20DB=20=EC=84=A4=EC=A0=95=20"=EC=A0=91?= =?UTF-8?q?=EC=88=98=EC=B7=A8=EC=86=8C"=20=EB=9D=BC=EB=B2=A8=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=8A=B8=EB=A1=9C=20=ED=86=B5=ED=95=A9=20[ba?= =?UTF-8?q?tch=5Fdone=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0]=20-?= =?UTF-8?q?=20confirmResult=20API:=20=EB=B6=80=EB=B6=84=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20status=20=3D=20'batch=5Fdone'=20(?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=ED=83=AD=20=EC=88=A8=EA=B9=80=20+=20?= =?UTF-8?q?=EC=A0=91=EC=88=98=EA=B0=80=EB=8A=A5=20=ED=83=AD=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80)=20-=20acceptProcess=20API:=20batch=5Fdone=20->=20in?= =?UTF-8?q?=5Fprogress=20=EB=B3=B5=EA=B7=80=20(=EC=B6=94=EA=B0=80=EC=A0=91?= =?UTF-8?q?=EC=88=98)=20-=20=EC=B9=B4=EB=93=9C=20=EB=B3=B5=EC=A0=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=97=90=20batch=5Fdone=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=20(=EC=9E=94=EC=97=AC=EB=9F=89=20=EC=9E=88=EC=9C=BC=EB=A9=B4?= =?UTF-8?q?=20=EC=A0=91=EC=88=98=EA=B0=80=EB=8A=A5=20=ED=83=AD=EC=97=90=20?= =?UTF-8?q?=ED=81=B4=EB=A1=A0=20=EC=B9=B4=EB=93=9C)=20-=20status=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=EC=97=90=20batch=5Fdone=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(semantic:=20active)=20[=EC=8B=A4=EC=A0=81=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=95=ED=99=94]=20-=20PopWorkDetailComponent:?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=81=20=EC=9E=85=EB=A0=A5=20UI=20=EC=A0=84?= =?UTF-8?q?=EB=A9=B4=20=EA=B5=AC=ED=98=84=20(=EC=B0=A8=EC=88=98=EB=B3=84?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D,=20=EB=88=84=EC=A0=81=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=81,=20=EC=9D=B4=EB=A0=A5=20=ED=91=9C=EC=8B=9C)=20-=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EC=8B=A4=EC=A0=81=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=8B=9C=20process=5Fcompleted=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=B0=9C=ED=96=89=20(=EC=B9=B4=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A6=89=EC=8B=9C=20=EA=B0=B1=EC=8B=A0)?= =?UTF-8?q?=20-=20=EC=A0=84=EB=9F=89=EC=A0=91=EC=88=98+=EC=A0=84=EB=9F=89?= =?UTF-8?q?=EC=83=9D=EC=82=B0=20=EC=8B=9C=20=EC=9E=90=EB=8F=99=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20(status=3Dcompleted,=20result=5Fstatus=3Dconfirmed)?= =?UTF-8?q?=20[=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95]=20-=20=EC=84=9C?= =?UTF-8?q?=EB=B8=8C=20=ED=95=84=ED=84=B0=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C?= =?UTF-8?q?=20=5F=5Fprocess=5F*=20=ED=95=84=EB=93=9C=20=EB=AF=B8=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20->=20processFields=20=EC=9E=AC=EC=A3=BC=EC=9E=85=20?= =?UTF-8?q?-=20cancelAccept=20SQL=20inconsistent=20types=20->=20boolean=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EB=B6=84=EB=A6=AC=20-?= =?UTF-8?q?=20=EC=A0=91=EC=88=98=EC=B7=A8=EC=86=8C=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EB=88=84=EB=9D=BD=20->=20taskPreset=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/popProductionController.ts | 830 +++++++++++++++++- .../src/routes/popProductionRoutes.ts | 14 + .../PopCardListV2Component.tsx | 354 +++++--- .../pop-card-list-v2/cell-renderers.tsx | 671 +++++++++++++- .../pop-card-list/NumberInputModal.tsx | 2 + .../pop-status-bar/PopStatusBarComponent.tsx | 6 +- .../PopWorkDetailComponent.tsx | 612 ++++++++++++- frontend/lib/registry/pop-components/types.ts | 31 +- 8 files changed, 2382 insertions(+), 138 deletions(-) diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 59cef801..f2cb9b63 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -3,6 +3,14 @@ import { getPool } from "../database/db"; import logger from "../utils/logger"; import { AuthenticatedRequest } from "../middleware/authMiddleware"; +// 불량 상세 항목 타입 +interface DefectDetailItem { + defect_code: string; + defect_name: string; + qty: string; + disposition: string; +} + /** * D-BE1: 작업지시 공정 일괄 생성 * PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성. @@ -102,7 +110,7 @@ export const createWorkProcesses = async ( rd.is_fixed_order, rd.standard_time, plan_qty || null, - "waiting", + parseInt(rd.seq_no, 10) === 1 ? "acceptable" : "waiting", rd.id, userId, ] @@ -465,3 +473,823 @@ export const controlGroupTimer = async ( }); } }; + +/** + * 불량 유형 목록 조회 (defect_standard_mng) + */ +export const getDefectTypes = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + + let query: string; + let params: unknown[]; + + if (companyCode === "*") { + query = ` + SELECT id, defect_code, defect_name, defect_type, severity, company_code + FROM defect_standard_mng + WHERE is_active = 'Y' + ORDER BY defect_code`; + params = []; + } else { + query = ` + SELECT id, defect_code, defect_name, defect_type, severity, company_code + FROM defect_standard_mng + WHERE is_active = 'Y' AND company_code = $1 + ORDER BY defect_code`; + params = [companyCode]; + } + + const result = await pool.query(query, params); + + logger.info("[pop/production] defect-types 조회", { + companyCode, + count: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("[pop/production] defect-types 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "불량 유형 조회 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 실적 저장 (누적 방식) + * 이번 차수 생산수량을 기존 누적치에 더한다. + * result_status는 'draft' 유지 (확정 전까지 계속 추가 등록 가능) + */ +export const saveResult = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const { + work_order_process_id, + production_qty, + good_qty, + defect_qty, + defect_detail, + result_note, + } = req.body; + + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } + + if (!production_qty || parseInt(production_qty, 10) <= 0) { + return res.status(400).json({ + success: false, + message: "생산수량을 입력해주세요.", + }); + } + + const statusCheck = await pool.query( + `SELECT status, result_status, total_production_qty, good_qty, defect_qty, input_qty + FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + + if (statusCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없습니다.", + }); + } + + const prev = statusCheck.rows[0]; + + if (prev.result_status === "confirmed") { + return res.status(403).json({ + success: false, + message: "이미 확정된 실적입니다. 추가 등록이 불가능합니다.", + }); + } + + // 실적 누적이 접수량을 초과하지 않도록 검증 + const prevTotal = parseInt(prev.total_production_qty, 10) || 0; + const acceptedQty = parseInt(prev.input_qty, 10) || 0; + const requestedQty = parseInt(production_qty, 10) || 0; + if (acceptedQty > 0 && (prevTotal + requestedQty) > acceptedQty) { + return res.status(400).json({ + success: false, + message: `실적 누적(${prevTotal + requestedQty})이 접수량(${acceptedQty})을 초과합니다. 추가 접수 후 등록해주세요.`, + }); + } + + let defectDetailStr: string | null = null; + if (defect_detail && Array.isArray(defect_detail)) { + const validated = defect_detail.map((item: DefectDetailItem) => ({ + defect_code: item.defect_code || "", + defect_name: item.defect_name || "", + qty: item.qty || "0", + disposition: item.disposition || "scrap", + })); + defectDetailStr = JSON.stringify(validated); + } + + const addProduction = parseInt(production_qty, 10) || 0; + const addGood = parseInt(good_qty, 10) || 0; + const addDefect = parseInt(defect_qty, 10) || 0; + + const newTotal = (parseInt(prev.total_production_qty, 10) || 0) + addProduction; + const newGood = (parseInt(prev.good_qty, 10) || 0) + addGood; + const newDefect = (parseInt(prev.defect_qty, 10) || 0) + addDefect; + + // 기존 defect_detail에 이번 차수 상세를 병합 + let mergedDefectDetail: string | null = null; + if (defectDetailStr) { + let existingEntries: DefectDetailItem[] = []; + try { + existingEntries = prev.defect_detail ? JSON.parse(prev.defect_detail) : []; + } catch { /* 파싱 실패 시 빈 배열 */ } + const newEntries: DefectDetailItem[] = JSON.parse(defectDetailStr); + // 같은 불량코드+처리방법 조합은 수량 합산 + const merged = [...existingEntries]; + for (const ne of newEntries) { + const existing = merged.find( + (e) => e.defect_code === ne.defect_code && e.disposition === ne.disposition + ); + if (existing) { + existing.qty = String( + (parseInt(existing.qty, 10) || 0) + (parseInt(ne.qty, 10) || 0) + ); + } else { + merged.push(ne); + } + } + mergedDefectDetail = JSON.stringify(merged); + } + + const result = await pool.query( + `UPDATE work_order_process + SET total_production_qty = $3, + good_qty = $4, + defect_qty = $5, + defect_detail = COALESCE($6, defect_detail), + result_note = COALESCE($7, result_note), + result_status = 'draft', + status = CASE WHEN status IN ('acceptable', 'waiting') THEN 'in_progress' ELSE status END, + writer = $8, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + RETURNING id, total_production_qty, good_qty, defect_qty, defect_detail, result_note, result_status, status`, + [ + work_order_process_id, + companyCode, + String(newTotal), + String(newGood), + String(newDefect), + mergedDefectDetail, + result_note || null, + userId, + ] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없거나 권한이 없습니다.", + }); + } + + // 다음 공정 상태를 acceptable로 전환 (input_qty는 접수 버튼에서만 변경) + const currentSeq = await pool.query( + `SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty, + wi.qty as instruction_qty + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + WHERE wop.id = $1 AND wop.company_code = $2`, + [work_order_process_id, companyCode] + ); + + if (addGood > 0 && currentSeq.rowCount > 0) { + const { seq_no, wo_id } = currentSeq.rows[0]; + const nextSeq = String(parseInt(seq_no, 10) + 1); + const nextUpdate = await pool.query( + `UPDATE work_order_process + SET status = CASE WHEN status = 'waiting' THEN 'acceptable' ELSE status END, + updated_date = NOW() + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + RETURNING id, process_name, status`, + [wo_id, nextSeq, companyCode] + ); + if (nextUpdate.rowCount > 0) { + logger.info("[pop/production] 다음 공정 상태 전환", { + nextProcess: nextUpdate.rows[0], + }); + } + } + + // 자동 완료 체크: 접수가능 잔여 0 + 접수한 수량 전부 완료 시 자동 completed + if (currentSeq.rowCount > 0) { + const { seq_no, wo_id, current_input_qty, instruction_qty } = currentSeq.rows[0]; + const seqNum = parseInt(seq_no, 10); + const myInputQty = parseInt(current_input_qty, 10) || 0; + const instrQty = parseInt(instruction_qty, 10) || 0; + + // 앞공정 완료량 계산 + let prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(good_qty::int, 0) as good_qty + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + [wo_id, prevSeq, companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = prevProcess.rows[0].good_qty; + } + } + + const remainingAcceptable = prevGoodQty - myInputQty; + const allProduced = newTotal >= myInputQty && myInputQty > 0; + + if (remainingAcceptable <= 0 && allProduced) { + await pool.query( + `UPDATE work_order_process + SET status = 'completed', + result_status = 'confirmed', + completed_at = NOW()::text, + completed_by = $3, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + AND status != 'completed'`, + [work_order_process_id, companyCode, userId] + ); + logger.info("[pop/production] 자동 완료 처리", { + work_order_process_id, + remainingAcceptable, + newTotal, + myInputQty, + }); + } + } + + logger.info("[pop/production] save-result 완료 (누적)", { + companyCode, + work_order_process_id, + added: { production_qty: addProduction, good_qty: addGood, defect_qty: addDefect }, + accumulated: { total: newTotal, good: newGood, defect: newDefect }, + }); + + // 자동 완료 후 최신 데이터 반환 (status가 변경되었을 수 있음) + const latestData = await pool.query( + `SELECT id, total_production_qty, good_qty, defect_qty, defect_detail, result_note, result_status, status, input_qty + FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + + return res.json({ + success: true, + data: latestData.rows[0] || result.rows[0], + }); + } catch (error: any) { + logger.error("[pop/production] save-result 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "실적 저장 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 실적 확정은 더 이상 단일 확정이 아님. + * 마지막 등록 확인 용도로 유지. 실적은 save-result에서 차수별로 쌓임. + */ +export const confirmResult = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const { work_order_process_id } = req.body; + + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } + + const statusCheck = await pool.query( + `SELECT status, result_status, total_production_qty FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + + if (statusCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없습니다.", + }); + } + + const currentProcess = statusCheck.rows[0]; + + if (!currentProcess.total_production_qty || + parseInt(currentProcess.total_production_qty, 10) <= 0) { + return res.status(400).json({ + success: false, + message: "등록된 실적이 없습니다. 실적을 먼저 등록해주세요.", + }); + } + + // 잔여 접수가능량 계산하여 completed 여부 결정 + const seqCheck = await pool.query( + `SELECT wop.seq_no, wop.wo_id, wop.input_qty, + wi.qty as instruction_qty + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + WHERE wop.id = $1 AND wop.company_code = $2`, + [work_order_process_id, companyCode] + ); + + let shouldComplete = false; + if (seqCheck.rowCount > 0) { + const { seq_no, wo_id, input_qty: currentInputQty, instruction_qty } = seqCheck.rows[0]; + const seqNum = parseInt(seq_no, 10); + const myInputQty = parseInt(currentInputQty, 10) || 0; + const instrQty = parseInt(instruction_qty, 10) || 0; + + let prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(good_qty::int, 0) as good_qty + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + [wo_id, prevSeq, companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = prevProcess.rows[0].good_qty; + } + } + + const remainingAcceptable = prevGoodQty - myInputQty; + const totalProduced = parseInt(currentProcess.total_production_qty, 10) || 0; + shouldComplete = remainingAcceptable <= 0 && totalProduced >= myInputQty && myInputQty > 0; + } + + // shouldComplete = true: 전량 완료 -> completed + // shouldComplete = false: 부분 확정 -> batch_done (진행 탭에서 숨김) + const newStatus = shouldComplete ? "completed" : "batch_done"; + + const result = await pool.query( + `UPDATE work_order_process + SET result_status = 'confirmed', + status = $4, + completed_at = CASE WHEN $4 = 'completed' THEN NOW()::text ELSE completed_at END, + completed_by = CASE WHEN $4 = 'completed' THEN $3 ELSE completed_by END, + writer = $3, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty`, + [work_order_process_id, companyCode, userId, newStatus] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없습니다.", + }); + } + + // completed로 전환된 경우에만 다음 공정 활성화 + if (shouldComplete && seqCheck.rowCount > 0) { + const { seq_no, wo_id } = seqCheck.rows[0]; + const nextSeq = String(parseInt(seq_no, 10) + 1); + await pool.query( + `UPDATE work_order_process + SET status = CASE WHEN status = 'waiting' THEN 'acceptable' ELSE status END, + updated_date = NOW() + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + [wo_id, nextSeq, companyCode] + ); + } + + logger.info("[pop/production] confirm-result 완료", { + companyCode, + work_order_process_id, + userId, + shouldComplete, + newStatus, + finalStatus: result.rows[0].status, + }); + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("[pop/production] confirm-result 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "실적 확정 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 실적 이력 조회 (work_order_process_log에서 차수별 추출) + * total_production_qty 변경 이력 = 각 차수의 등록 기록 + */ +export const getResultHistory = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const { work_order_process_id } = req.query; + + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } + + // 소유권 확인 + const ownerCheck = await pool.query( + `SELECT id FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + if (ownerCheck.rowCount === 0) { + return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); + } + + // 같은 changed_at 기준으로 그룹핑하여 차수별 이력 추출 + // total_production_qty가 증가한(new_value > old_value) 로그만 = 실적 등록 시점 + const historyResult = await pool.query( + `WITH grouped AS ( + SELECT + changed_at, + MAX(changed_by) as changed_by, + MAX(CASE WHEN changed_column = 'total_production_qty' THEN old_value END) as total_old, + MAX(CASE WHEN changed_column = 'total_production_qty' THEN new_value END) as total_new, + MAX(CASE WHEN changed_column = 'good_qty' THEN old_value END) as good_old, + MAX(CASE WHEN changed_column = 'good_qty' THEN new_value END) as good_new, + MAX(CASE WHEN changed_column = 'defect_qty' THEN old_value END) as defect_old, + MAX(CASE WHEN changed_column = 'defect_qty' THEN new_value END) as defect_new + FROM work_order_process_log + WHERE original_id = $1 + AND changed_column IN ('total_production_qty', 'good_qty', 'defect_qty') + AND new_value IS NOT NULL + GROUP BY changed_at + ) + SELECT * FROM grouped + WHERE total_new IS NOT NULL + AND (COALESCE(total_new::int, 0) - COALESCE(total_old::int, 0)) > 0 + ORDER BY changed_at ASC`, + [work_order_process_id] + ); + + const batches = historyResult.rows.map((row: any, idx: number) => { + const batchQty = (parseInt(row.total_new, 10) || 0) - (parseInt(row.total_old, 10) || 0); + const batchGood = (parseInt(row.good_new, 10) || 0) - (parseInt(row.good_old, 10) || 0); + const batchDefect = (parseInt(row.defect_new, 10) || 0) - (parseInt(row.defect_old, 10) || 0); + + return { + seq: idx + 1, + batch_qty: batchQty, + batch_good: batchGood, + batch_defect: batchDefect, + accumulated_total: parseInt(row.total_new, 10) || 0, + changed_at: row.changed_at, + changed_by: row.changed_by, + }; + }); + + logger.info("[pop/production] result-history 조회", { + work_order_process_id, + batchCount: batches.length, + }); + + return res.json({ + success: true, + data: batches, + }); + } catch (error: any) { + logger.error("[pop/production] result-history 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "이력 조회 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 앞공정 완료량 + 접수가능량 조회 + * GET /api/pop/production/available-qty?work_order_process_id=xxx + * 반환: { prevGoodQty, myInputQty, availableQty, instructionQty } + */ +export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { work_order_process_id } = req.query; + + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id가 필요합니다.", + }); + } + + const current = await pool.query( + `SELECT wop.seq_no, wop.wo_id, wop.input_qty, + wi.qty as instruction_qty + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + WHERE wop.id = $1 AND wop.company_code = $2`, + [work_order_process_id, companyCode] + ); + + if (current.rowCount === 0) { + return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); + } + + const { seq_no, wo_id, input_qty, instruction_qty } = current.rows[0]; + const myInputQty = parseInt(input_qty, 10) || 0; + const instrQty = parseInt(instruction_qty, 10) || 0; + const seqNum = parseInt(seq_no, 10); + + let prevGoodQty = instrQty; // 첫 공정이면 지시수량이 상한 + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(good_qty::int, 0) as good_qty + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + [wo_id, prevSeq, companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = prevProcess.rows[0].good_qty; + } + } + + const availableQty = Math.max(0, prevGoodQty - myInputQty); + + logger.info("[pop/production] available-qty 조회", { + work_order_process_id, + prevGoodQty, + myInputQty, + availableQty, + instructionQty: instrQty, + }); + + return res.json({ + success: true, + data: { + prevGoodQty, + myInputQty, + availableQty, + instructionQty: instrQty, + }, + }); + } catch (error: any) { + logger.error("[pop/production] available-qty 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "접수가능량 조회 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 공정 접수 (수량 지정) + * POST /api/pop/production/accept-process + * body: { work_order_process_id, accept_qty } + * - 접수 상한 = 앞공정.good_qty - 내.input_qty (첫 공정은 지시수량 - input_qty) + * - 추가 접수 가능 (in_progress 상태에서도) + * - status: acceptable/waiting -> in_progress (또는 이미 in_progress면 유지) + */ +export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id, accept_qty } = req.body; + + if (!work_order_process_id || !accept_qty) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 accept_qty가 필요합니다.", + }); + } + + const qty = parseInt(accept_qty, 10); + if (qty <= 0) { + return res.status(400).json({ success: false, message: "접수 수량은 1 이상이어야 합니다." }); + } + + const current = await pool.query( + `SELECT wop.seq_no, wop.wo_id, wop.input_qty, wop.status, wop.accepted_by, + wi.qty as instruction_qty + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + WHERE wop.id = $1 AND wop.company_code = $2`, + [work_order_process_id, companyCode] + ); + + if (current.rowCount === 0) { + return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); + } + + const { seq_no, wo_id, input_qty, status, instruction_qty } = current.rows[0]; + + if (status === "completed") { + return res.status(400).json({ success: false, message: "이미 완료된 공정입니다." }); + } + if (status !== "acceptable" && status !== "in_progress") { + return res.status(400).json({ success: false, message: `현재 상태(${status})에서는 접수할 수 없습니다.` }); + } + + const myInputQty = parseInt(input_qty, 10) || 0; + const instrQty = parseInt(instruction_qty, 10) || 0; + const seqNum = parseInt(seq_no, 10); + + // 앞공정 완료량 계산 + let prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(good_qty::int, 0) as good_qty + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + [wo_id, prevSeq, companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = prevProcess.rows[0].good_qty; + } + } + + const availableQty = prevGoodQty - myInputQty; + if (qty > availableQty) { + return res.status(400).json({ + success: false, + message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수: ${myInputQty})`, + }); + } + + const newInputQty = myInputQty + qty; + const result = await pool.query( + `UPDATE work_order_process + SET input_qty = $3, + status = CASE WHEN status IN ('acceptable', 'waiting', 'batch_done') THEN 'in_progress' ELSE status END, + result_status = CASE WHEN result_status = 'confirmed' THEN 'draft' ELSE result_status END, + accepted_by = $4, + started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END, + updated_date = NOW(), + writer = $4 + WHERE id = $1 AND company_code = $2 + RETURNING id, input_qty, status, process_name, result_status`, + [work_order_process_id, companyCode, String(newInputQty), userId] + ); + + logger.info("[pop/production] accept-process 완료", { + companyCode, + userId, + work_order_process_id, + addedQty: qty, + newInputQty, + prevGoodQty, + }); + + return res.json({ + success: true, + data: result.rows[0], + message: `${qty}개 접수 완료 (총 접수: ${newInputQty})`, + }); + } catch (error: any) { + logger.error("[pop/production] accept-process 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "접수 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 접수 취소: input_qty를 0으로 리셋하고 status를 acceptable로 되돌림 + * 조건: 아직 실적(total_production_qty)이 없어야 함 + */ +export const cancelAccept = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id } = req.body; + + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } + + const current = await pool.query( + `SELECT id, status, input_qty, total_production_qty, result_status + FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + + if (current.rowCount === 0) { + return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); + } + + const proc = current.rows[0]; + + if (proc.status !== "in_progress") { + return res.status(400).json({ + success: false, + message: `현재 상태(${proc.status})에서는 접수 취소할 수 없습니다. 진행중 상태만 가능합니다.`, + }); + } + + const totalProduced = parseInt(proc.total_production_qty ?? "0", 10) || 0; + const currentInputQty = parseInt(proc.input_qty ?? "0", 10) || 0; + + // 미소진 접수분 = input_qty - total_production_qty + const unproducedQty = currentInputQty - totalProduced; + + if (unproducedQty <= 0) { + return res.status(400).json({ + success: false, + message: "취소할 미소진 접수분이 없습니다. 모든 접수량에 대해 실적이 등록되었습니다.", + }); + } + + // input_qty를 total_production_qty로 되돌림 (실적 있는 분량만 유지) + // 실적이 0이면 완전 초기화, 실적이 있으면 부분 취소 + const newInputQty = totalProduced; + const newStatus = totalProduced > 0 ? "in_progress" : "acceptable"; + + const isFullCancel = newInputQty === 0; + const result = await pool.query( + `UPDATE work_order_process + SET input_qty = $3, + status = $4, + accepted_by = CASE WHEN $6 THEN NULL ELSE accepted_by END, + started_at = CASE WHEN $6 THEN NULL ELSE started_at END, + updated_date = NOW(), + writer = $5 + WHERE id = $1 AND company_code = $2 + RETURNING id, input_qty, status, process_name`, + [work_order_process_id, companyCode, String(newInputQty), newStatus, userId, isFullCancel] + ); + + logger.info("[pop/production] cancel-accept 완료", { + companyCode, + userId, + work_order_process_id, + previousInputQty: currentInputQty, + newInputQty, + cancelledQty: unproducedQty, + }); + + return res.json({ + success: true, + data: result.rows[0], + message: `미소진 ${unproducedQty}개 접수가 취소되었습니다. (잔여 접수량: ${newInputQty})`, + }); + } catch (error: any) { + logger.error("[pop/production] cancel-accept 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "접수 취소 중 오류가 발생했습니다.", + }); + } +}; diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index c50f061a..57417797 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -4,6 +4,13 @@ import { createWorkProcesses, controlTimer, controlGroupTimer, + getDefectTypes, + saveResult, + confirmResult, + getResultHistory, + getAvailableQty, + acceptProcess, + cancelAccept, } from "../controllers/popProductionController"; const router = Router(); @@ -13,5 +20,12 @@ router.use(authenticateToken); router.post("/create-work-processes", createWorkProcesses); router.post("/timer", controlTimer); router.post("/group-timer", controlGroupTimer); +router.get("/defect-types", getDefectTypes); +router.post("/save-result", saveResult); +router.post("/confirm-result", confirmResult); +router.get("/result-history", getResultHistory); +router.get("/available-qty", getAvailableQty); +router.post("/accept-process", acceptProcess); +router.post("/cancel-accept", cancelAccept); export default router; diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index bc930e95..2a3121f0 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -116,6 +116,7 @@ function resolveStatusMappings(src: TimelineDataSource): StatusValueMapping[] { { dbValue: sv?.waiting || "waiting", label: "대기", semantic: "pending" as const }, { dbValue: sv?.accepted || "accepted", label: "접수", semantic: "active" as const }, { dbValue: sv?.inProgress || "in_progress", label: "진행중", semantic: "active" as const }, + { dbValue: "batch_done", label: "접수분완료", semantic: "active" as const }, { dbValue: sv?.completed || "completed", label: "완료", semantic: "done" as const }, ]; } @@ -278,6 +279,8 @@ export function PopCardListV2Component({ }, [publish, setSharedData]); const handleCardSelect = useCallback((row: RowData) => { + // 복제 카드(접수가능 가상)는 클릭 시 모달을 열지 않음 - 접수 버튼으로만 동작 + if (row.__isAcceptClone) return; if (effectiveConfig?.cardClickAction === "modal-open" && effectiveConfig?.cardClickModalConfig?.screenId) { const mc = effectiveConfig.cardClickModalConfig; @@ -285,7 +288,13 @@ export function PopCardListV2Component({ const processFlow = row.__processFlow__ as { isCurrent: boolean; status?: string }[] | undefined; const currentProcess = processFlow?.find((s) => s.isCurrent); if (mc.condition.type === "timeline-status") { - if (currentProcess?.status !== mc.condition.value) return; + const condVal = mc.condition.value; + const curStatus = currentProcess?.status; + if (Array.isArray(condVal)) { + if (!curStatus || !condVal.includes(curStatus)) return; + } else { + if (curStatus !== condVal) return; + } } else if (mc.condition.type === "column-value") { if (String(row[mc.condition.column || ""] ?? "") !== mc.condition.value) return; } @@ -373,25 +382,71 @@ export function PopCardListV2Component({ const timelineSource = useMemo(() => { const cells = cardGrid?.cells || []; for (const c of cells) { - if ((c.type === "timeline" || c.type === "status-badge" || c.type === "action-buttons") && c.timelineSource?.processTable) { + if ((c.type === "timeline" || c.type === "status-badge" || c.type === "action-buttons" || c.type === "mes-process-card") && c.timelineSource?.processTable) { return c.timelineSource; } } return undefined; }, [cardGrid?.cells]); - // 외부 필터 (메인 테이블 + 하위 테이블 분기) - const filteredRows = useMemo(() => { - if (externalFilters.size === 0) return rows; + // in_progress + 잔여 접수가능량 > 0인 카드를 복제하여 "접수가능" 탭에도 노출 + const duplicateAcceptableCards = useCallback((sourceRows: RowData[]): RowData[] => { + const result: RowData[] = []; + for (const row of sourceRows) { + result.push(row); + // 이미 복제된 카드는 다시 복제하지 않음 + if (row.__isAcceptClone) continue; - const allFilters = [...externalFilters.values()]; - const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable); - const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable); + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + if (!processFlow || processFlow.length === 0) continue; - // 1단계: 하위 테이블 필터 → __subStatus__ 주입 + const currentStep = processFlow.find((s) => s.isCurrent); + if (!currentStep) continue; + // in_progress 또는 batch_done 공정이면서 잔여 접수가능량 > 0인 경우만 복제 + if (currentStep.status !== "in_progress" && currentStep.status !== "batch_done") continue; + + const availableQty = Number(currentStep.rawData?.__availableQty ?? 0); + if (availableQty <= 0) continue; + + // 복제 카드: isCurrent를 해당 공정의 acceptable 가상 상태로 변경 + const clonedFlow = processFlow.map((s) => ({ + ...s, + isCurrent: s.seqNo === currentStep.seqNo, + status: s.seqNo === currentStep.seqNo ? "acceptable" : s.status, + semantic: s.seqNo === currentStep.seqNo ? ("active" as const) : s.semantic, + })); + + const clonedProcessFields: Record = {}; + if (currentStep.rawData) { + for (const [key, val] of Object.entries(currentStep.rawData)) { + clonedProcessFields[`__process_${key}`] = val; + } + } + + result.push({ + ...row, + __processFlow__: clonedFlow, + __isAcceptClone: true, + __cloneSourceId: String(row.id ?? ""), + [VIRTUAL_SUB_STATUS]: "acceptable", + [VIRTUAL_SUB_SEMANTIC]: "active", + [VIRTUAL_SUB_PROCESS]: currentStep.processName, + [VIRTUAL_SUB_SEQ]: currentStep.seqNo, + ...clonedProcessFields, + }); + } + return result; + }, []); + + // 하위 필터 + 카드 복제 적용 (공통 함수) + const applySubFilterAndDuplicate = useCallback((sourceRows: RowData[], subFilters: Array<{ + fieldName: string; + value: unknown; + filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean }; + }>) => { const afterSubFilter = subFilters.length === 0 - ? rows - : rows + ? sourceRows + : sourceRows .map((row) => { const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; if (!processFlow || processFlow.length === 0) return null; @@ -416,11 +471,19 @@ export function PopCardListV2Component({ if (matchingSteps.length === 0) return null; const matched = matchingSteps[0]; - // 매칭된 공정을 타임라인에서 강조 const updatedFlow = processFlow.map((s) => ({ ...s, isCurrent: s.seqNo === matched.seqNo, })); + // 서브 필터로 공정이 바뀌면 __process_* 필드도 재주입 + const processFields: Record = {}; + if (matched.rawData) { + for (const [key, val] of Object.entries(matched.rawData)) { + processFields[`__process_${key}`] = val; + } + processFields.__availableQty = matched.rawData.__availableQty ?? 0; + processFields.__prevGoodQty = matched.rawData.__prevGoodQty ?? 0; + } return { ...row, __processFlow__: updatedFlow, @@ -428,14 +491,27 @@ export function PopCardListV2Component({ [VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending", [VIRTUAL_SUB_PROCESS]: matched.processName, [VIRTUAL_SUB_SEQ]: matched.seqNo, - }; + ...processFields, + } as RowData; }) .filter((row): row is RowData => row !== null); - // 2단계: 메인 테이블 필터 (__subStatus__ 주입된 데이터 기반) - if (mainFilters.length === 0) return afterSubFilter; + // 카드 복제: in_progress + 잔여 접수가능량 > 0 → 접수가능 탭에도 노출 + return duplicateAcceptableCards(afterSubFilter); + }, [duplicateAcceptableCards]); - return afterSubFilter.filter((row) => + // 메인 필터 적용 (공통 함수) + const applyMainFilters = useCallback(( + sourceRows: RowData[], + mainFilters: Array<{ fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean } }>, + hasSubFilters: boolean, + ) => { + if (mainFilters.length === 0) return sourceRows; + + const subCol = hasSubFilters ? VIRTUAL_SUB_STATUS : null; + const statusCol = timelineSource?.statusColumn || "status"; + + return sourceRows.filter((row) => mainFilters.every((filter) => { const searchValue = String(filter.value).toLowerCase(); if (!searchValue) return true; @@ -447,9 +523,6 @@ export function PopCardListV2Component({ if (columns.length === 0) return true; const mode = fc?.filterMode || "contains"; - // 하위 필터 활성 시: 상태 컬럼(status 등)을 __subStatus__로 대체 - const subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null; - const statusCol = timelineSource?.statusColumn || "status"; const effectiveColumns = subCol ? columns.map((col) => col === statusCol || col === "status" ? subCol : col) : columns; @@ -464,7 +537,19 @@ export function PopCardListV2Component({ }); }), ); - }, [rows, externalFilters, timelineSource]); + }, [timelineSource]); + + // 외부 필터 (메인 테이블 + 하위 테이블 분기) + const filteredRows = useMemo(() => { + if (externalFilters.size === 0) return duplicateAcceptableCards(rows); + + const allFilters = [...externalFilters.values()]; + const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable); + const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable); + + const afterDuplicate = applySubFilterAndDuplicate(rows, subFilters); + return applyMainFilters(afterDuplicate, mainFilters, subFilters.length > 0); + }, [rows, externalFilters, duplicateAcceptableCards, applySubFilterAndDuplicate, applyMainFilters]); // 하위 필터 활성 여부 const hasActiveSubFilter = useMemo(() => { @@ -550,96 +635,32 @@ export function PopCardListV2Component({ }, [selectedRowIds, filteredRows, exitSelectMode]); // status-bar 필터를 제외한 rows (카운트 집계용) - // status-bar에서 "접수가능" 등 선택해도 전체 카운트가 유지되어야 함 const rowsForStatusCount = useMemo(() => { const hasStatusBarFilter = [...externalFilters.values()].some((f) => f._source === "status-bar"); if (!hasStatusBarFilter) return filteredRows; - // status-bar 필터를 제외한 필터만 적용 const nonStatusFilters = new Map( [...externalFilters.entries()].filter(([, f]) => f._source !== "status-bar") ); - if (nonStatusFilters.size === 0) return rows; + if (nonStatusFilters.size === 0) return duplicateAcceptableCards(rows); const allFilters = [...nonStatusFilters.values()]; const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable); const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable); - const afterSubFilter = subFilters.length === 0 - ? rows - : rows - .map((row) => { - const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; - if (!processFlow || processFlow.length === 0) return null; - const matchingSteps = processFlow.filter((step) => - subFilters.every((filter) => { - const searchValue = String(filter.value).toLowerCase(); - if (!searchValue) return true; - const fc = filter.filterConfig; - const col = fc?.targetColumn || filter.fieldName || ""; - if (!col) return true; - const cellValue = String(step.rawData?.[col] ?? "").toLowerCase(); - const mode = fc?.filterMode || "contains"; - switch (mode) { - case "equals": return cellValue === searchValue; - case "starts_with": return cellValue.startsWith(searchValue); - default: return cellValue.includes(searchValue); - } - }), - ); - if (matchingSteps.length === 0) return null; - const matched = matchingSteps[0]; - const updatedFlow = processFlow.map((s) => ({ - ...s, - isCurrent: s.seqNo === matched.seqNo, - })); - return { - ...row, - __processFlow__: updatedFlow, - [VIRTUAL_SUB_STATUS]: matched.status, - [VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending", - [VIRTUAL_SUB_PROCESS]: matched.processName, - [VIRTUAL_SUB_SEQ]: matched.seqNo, - }; - }) - .filter((row): row is RowData => row !== null); - - if (mainFilters.length === 0) return afterSubFilter; - - return afterSubFilter.filter((row) => - mainFilters.every((filter) => { - const searchValue = String(filter.value).toLowerCase(); - if (!searchValue) return true; - const fc = filter.filterConfig; - const columns: string[] = - fc?.targetColumns?.length ? fc.targetColumns - : fc?.targetColumn ? [fc.targetColumn] - : filter.fieldName ? [filter.fieldName] : []; - if (columns.length === 0) return true; - const mode = fc?.filterMode || "contains"; - const subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null; - const statusCol = timelineSource?.statusColumn || "status"; - const effectiveColumns = subCol - ? columns.map((col) => col === statusCol || col === "status" ? subCol : col) - : columns; - return effectiveColumns.some((col) => { - const cellValue = String(row[col] ?? "").toLowerCase(); - switch (mode) { - case "equals": return cellValue === searchValue; - case "starts_with": return cellValue.startsWith(searchValue); - default: return cellValue.includes(searchValue); - } - }); - }), - ); - }, [rows, filteredRows, externalFilters, timelineSource]); + const afterDuplicate = applySubFilterAndDuplicate(rows, subFilters); + return applyMainFilters(afterDuplicate, mainFilters, subFilters.length > 0); + }, [rows, filteredRows, externalFilters, duplicateAcceptableCards, applySubFilterAndDuplicate, applyMainFilters]); // 카운트 집계용 rows 발행 (status-bar 필터 제외) + // originalCount: 복제 카드를 제외한 원본 카드 수 useEffect(() => { if (!componentId || loading) return; + const originalCount = rowsForStatusCount.filter((r) => !r.__isAcceptClone).length; publish(`__comp_output__${componentId}__all_rows`, { rows: rowsForStatusCount, subStatusColumn: hasActiveSubFilter ? VIRTUAL_SUB_STATUS : null, + originalCount, }); }, [componentId, rowsForStatusCount, loading, publish, hasActiveSubFilter]); @@ -809,6 +830,35 @@ export function PopCardListV2Component({ if (firstPending) { firstPending.isCurrent = true; } } + // 각 공정에 접수가능 잔여량(__availableQty) 주입 + for (const [rowId, steps] of processMap) { + steps.sort((a, b) => a.seqNo - b.seqNo); + const parentRow = fetchedRows.find((r) => String(r.id) === rowId); + const instrQty = parseInt(String(parentRow?.qty ?? "0"), 10) || 0; + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const prevStep = i > 0 ? steps[i - 1] : null; + const prevGoodQty = prevStep + ? parseInt(String(prevStep.rawData?.good_qty ?? "0"), 10) || 0 + : instrQty; + const myInputQty = parseInt(String(step.rawData?.input_qty ?? "0"), 10) || 0; + const availableQty = Math.max(0, prevGoodQty - myInputQty); + if (step.rawData) { + step.rawData.__availableQty = availableQty; + step.rawData.__prevGoodQty = prevGoodQty; + } + // TimelineProcessStep에 수량 필드 직접 주입 (process-qty-summary 셀용) + step.inputQty = myInputQty; + step.totalProductionQty = parseInt(String(step.rawData?.total_production_qty ?? "0"), 10) || 0; + step.goodQty = parseInt(String(step.rawData?.good_qty ?? "0"), 10) || 0; + step.defectQty = parseInt(String(step.rawData?.defect_qty ?? "0"), 10) || 0; + step.yieldRate = step.totalProductionQty > 0 + ? Math.round((step.goodQty / step.totalProductionQty) * 100) + : 0; + } + } + return fetchedRows.map((row) => { const steps = processMap.get(String(row.id)) || []; const current = steps.find((s) => s.isCurrent); @@ -818,6 +868,11 @@ export function PopCardListV2Component({ processFields[`__process_${key}`] = val; } } + // row 레벨에 현재 공정의 접수가능/전공정양품 주입 (process-qty-summary 셀용) + if (current?.rawData) { + processFields.__availableQty = current.rawData.__availableQty ?? 0; + processFields.__prevGoodQty = current.rawData.__prevGoodQty ?? 0; + } return { ...row, __processFlow__: steps, ...processFields }; }); }, []); @@ -1086,9 +1141,12 @@ export function PopCardListV2Component({ const locked = !!ownerSortColumn && !!String(row[ownerSortColumn] ?? "") && String(row[ownerSortColumn] ?? "") !== (currentUserId ?? ""); + const cardKey = row.__isAcceptClone + ? `card-clone-${row.__cloneSourceId}-${index}` + : `card-${row.id ?? index}`; return ( (null); + // 수량 모달이 열려 있을 때 카드 클릭 차단 (모달 닫힘 직후 이벤트 전파 방지) + const qtyModalClosedAtRef = useRef(0); + const closeQtyModal = useCallback(() => { + qtyModalClosedAtRef.current = Date.now(); + setQtyModalState(null); + }, []); + const handleQtyConfirm = useCallback(async (value: number) => { if (!qtyModalState) return; const { row: actionRow, processId: qtyProcessId, action } = qtyModalState; - setQtyModalState(null); - if (!action.targetTable || !action.updates) return; + if (!action.targetTable || !action.updates) { closeQtyModal(); return; } const rowId = qtyProcessId ?? actionRow.id ?? actionRow.pk; if (!rowId) { toast.error("대상 레코드 ID를 찾을 수 없습니다."); return; } + // MES 전용: work_order_process 접수는 accept-process API 사용 + const isAcceptAction = action.targetTable === "work_order_process" + && action.updates.some((u) => u.column === "input_qty"); + + if (isAcceptAction) { + let wopId = qtyProcessId; + if (!wopId) { + const pf = actionRow.__processFlow__ as Array<{ isCurrent: boolean; processId?: string | number }> | undefined; + const cur = pf?.find((p) => p.isCurrent); + wopId = cur?.processId; + } + if (!wopId) { + toast.error("공정 ID를 찾을 수 없습니다."); + closeQtyModal(); + return; + } + try { + const result = await apiClient.post("/pop/production/accept-process", { + work_order_process_id: wopId, + accept_qty: value, + }); + if (result.data?.success) { + toast.success(result.data.message || "접수 완료"); + onRefresh?.(); + } else { + toast.error(result.data?.message || "접수 실패"); + } + } catch (err: unknown) { + const errMsg = (err as any)?.response?.data?.message; + toast.error(errMsg || "접수 중 오류 발생"); + onRefresh?.(); + } + closeQtyModal(); + return; + } + + // 일반 quantity-input (기존 로직) const lookupValue = action.joinConfig ? String(actionRow[action.joinConfig.sourceColumn] ?? rowId) : rowId; @@ -1309,7 +1411,8 @@ function CardV2({ toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); } } - }, [qtyModalState, onRefresh]); + closeQtyModal(); + }, [qtyModalState, onRefresh, closeQtyModal]); const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : ""; const isCarted = cart.isItemInCart(rowKey); @@ -1387,6 +1490,9 @@ function CardV2({ ? isSelected ? "border-primary border-2 hover:border-primary/80" : "hover:border-2 hover:border-blue-500" : isCarted ? "border-emerald-500 border-2 hover:border-emerald-600" : "hover:border-2 hover:border-blue-500"; + // mes-process-card 전용 카드일 때 래퍼 스타일 변경 + const isMesCard = cardGrid?.cells.some((c) => c.type === "mes-process-card"); + if (!cardGrid || cardGrid.cells.length === 0) { return (
@@ -1417,15 +1523,18 @@ function CardV2({ return (
{ if (isLockedByOther) return; + if (qtyModalState?.open) return; + if (Date.now() - qtyModalClosedAtRef.current < 500) return; if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } if (!selectMode) onSelect?.(row); }} @@ -1434,6 +1543,7 @@ function CardV2({ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { if (isLockedByOther) return; + if (qtyModalState?.open) return; if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } if (!selectMode) onSelect?.(row); } @@ -1499,9 +1609,30 @@ function CardV2({ onEnterSelectMode, onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => { const cfg = buttonConfig as Record | undefined; - const allActions = (cfg?.__allActions as ActionButtonClickAction[] | undefined) || []; const processId = cfg?.__processId as string | number | undefined; + // 접수 취소 처리 (__cancelAccept 또는 "접수취소" 라벨 버튼) + if ((taskPreset === "__cancelAccept" || taskPreset === "접수취소") && processId) { + if (!window.confirm("접수를 취소하시겠습니까? 실적이 없는 경우에만 가능합니다.")) return; + try { + const result = await apiClient.post("/pop/production/cancel-accept", { + work_order_process_id: processId, + }); + if (result.data?.success) { + toast.success(result.data.message || "접수가 취소되었습니다."); + onRefresh?.(); + } else { + toast.error(result.data?.message || "접수 취소에 실패했습니다."); + } + } catch (err: unknown) { + const errMsg = (err as any)?.response?.data?.message; + toast.error(errMsg || "접수 취소 중 오류가 발생했습니다."); + } + return; + } + + const allActions = (cfg?.__allActions as ActionButtonClickAction[] | undefined) || []; + // 단일 액션 폴백 (기존 구조 호환) const actionsToRun = allActions.length > 0 ? allActions @@ -1519,7 +1650,28 @@ function CardV2({ for (const action of actionsToRun) { if (action.type === "quantity-input" && action.targetTable && action.updates) { if (action.confirmMessage && !window.confirm(action.confirmMessage)) return; - setQtyModalState({ open: true, row: actionRow, processId, action }); + + // MES 전용: accept-process API 기반 접수 상한 조회 + const isAcceptAction = action.targetTable === "work_order_process" + && action.updates.some((u) => u.column === "input_qty"); + let dynamicMax: number | undefined; + let resolvedProcessId = processId; + if (!resolvedProcessId) { + const pf = actionRow.__processFlow__ as Array<{ isCurrent: boolean; processId?: string | number }> | undefined; + resolvedProcessId = pf?.find((p) => p.isCurrent)?.processId; + } + if (isAcceptAction && resolvedProcessId) { + try { + const aqRes = await apiClient.get("/pop/production/available-qty", { + params: { work_order_process_id: resolvedProcessId }, + }); + if (aqRes.data?.success) { + dynamicMax = aqRes.data.data.availableQty; + } + } catch { /* fallback to static */ } + } + + setQtyModalState({ open: true, row: actionRow, processId: resolvedProcessId ?? processId, action, dynamicMax }); return; } else if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) { if (action.confirmMessage) { @@ -1603,9 +1755,9 @@ function CardV2({ {qtyModalState?.open && ( { if (!open) setQtyModalState(null); }} + onOpenChange={(open) => { if (!open) closeQtyModal(); }} unit={qtyModalState.action.quantityInput?.unit || "EA"} - maxValue={calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)} + maxValue={qtyModalState.dynamicMax ?? calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)} showPackageUnit={qtyModalState.action.quantityInput?.enablePackage ?? false} onConfirm={(value) => handleQtyConfirm(value)} /> diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx index 180dc219..e3d450bc 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx @@ -101,6 +101,10 @@ export function renderCellV2(props: CellRendererProps): React.ReactNode { return ; case "footer-status": return ; + case "process-qty-summary": + return ; + case "mes-process-card": + return ; default: return 알 수 없는 셀 타입; } @@ -327,6 +331,7 @@ const STATUS_COLORS: Record = { waiting: { bg: "#94a3b820", text: "#64748b" }, accepted: { bg: "#3b82f620", text: "#2563eb" }, in_progress: { bg: "#f59e0b20", text: "#d97706" }, + batch_done: { bg: "#8b5cf620", text: "#7c3aed" }, completed: { bg: "#10b98120", text: "#059669" }, }; @@ -349,17 +354,28 @@ function StatusBadgeCell({ cell, row }: CellRendererProps) { ); } - const defaultColors = STATUS_COLORS[strValue]; + // in_progress + 접수분 전부 생산 완료 → "접수분완료" 표시 + let displayValue = strValue; + if (strValue === "in_progress") { + const inputQty = parseInt(String(row.input_qty ?? "0"), 10) || 0; + const totalProd = parseInt(String(row.total_production_qty ?? "0"), 10) || 0; + if (inputQty > 0 && totalProd >= inputQty) { + displayValue = "batch_done"; + } + } + + const defaultColors = STATUS_COLORS[displayValue]; if (defaultColors) { const labelMap: Record = { - waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료", + waiting: "대기", accepted: "접수", in_progress: "진행중", + batch_done: "접수분완료", completed: "완료", }; return ( - {labelMap[strValue] || strValue} + {labelMap[displayValue] || displayValue} ); } @@ -601,7 +617,11 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId if (cond.type === "timeline-status") { const subStatus = row[VIRTUAL_SUB_STATUS]; - matched = subStatus !== undefined && String(subStatus) === cond.value; + if (Array.isArray(cond.value)) { + matched = subStatus !== undefined && cond.value.includes(String(subStatus)); + } else { + matched = subStatus !== undefined && String(subStatus) === cond.value; + } } else if (cond.type === "column-value" && cond.column) { matched = String(row[cond.column] ?? "") === (cond.value ?? ""); } else if (cond.type === "owner-match" && cond.column) { @@ -621,10 +641,22 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, const currentProcessId = currentProcess?.processId; if (cell.actionButtons && cell.actionButtons.length > 0) { - const evaluated = cell.actionButtons.map((btn) => ({ - btn, - state: evaluateShowCondition(btn, row, currentUserId), - })); + const evaluated = cell.actionButtons.map((btn) => { + let state = evaluateShowCondition(btn, row, currentUserId); + // 접수가능 조건 버튼이 원본 카드의 in_progress에서 보이지 않도록 차단 + // (접수는 접수가능 탭의 클론 카드에서만 가능) + if (state === "visible" && !row.__isAcceptClone) { + const cond = btn.showCondition; + if (cond?.type === "timeline-status") { + const condValues = Array.isArray(cond.value) ? cond.value : [cond.value]; + const currentSubStatus = String(row[VIRTUAL_SUB_STATUS] ?? ""); + if (condValues.includes("acceptable") && currentSubStatus === "in_progress") { + state = "hidden"; + } + } + } + return { btn, state }; + }); const activeBtn = evaluated.find((e) => e.state === "visible"); const disabledBtn = activeBtn ? null : evaluated.find((e) => e.state === "disabled"); @@ -633,6 +665,14 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, const { btn, state } = pick; + // in_progress 상태 + 미소진 접수분 존재 시 접수취소 버튼 추가 + const subStatus = row[VIRTUAL_SUB_STATUS]; + const effectiveStatus = subStatus !== undefined ? String(subStatus) : ""; + const rowInputQty = parseInt(String(row.input_qty ?? "0"), 10) || 0; + const totalProduced = parseInt(String(row.total_production_qty ?? "0"), 10) || 0; + const hasUnproduced = rowInputQty > totalProduced; + const showCancelBtn = effectiveStatus === "in_progress" && hasUnproduced && currentProcessId; + return (
+ {showCancelBtn && ( + + )}
); } @@ -703,7 +759,205 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, ); } -// ===== 12. footer-status ===== +// ===== 12. process-qty-summary ===== + +function ProcessQtySummaryCell({ cell, row }: CellRendererProps) { + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + const status = String(row[VIRTUAL_SUB_STATUS] ?? row.status ?? ""); + const isClone = !!row.__isAcceptClone; + + const instructionQty = parseInt(String(row.instruction_qty ?? "0"), 10) || 0; + const inputQty = parseInt(String(row.input_qty ?? "0"), 10) || 0; + const totalProd = parseInt(String(row.total_production_qty ?? "0"), 10) || 0; + const goodQty = parseInt(String(row.good_qty ?? "0"), 10) || 0; + const defectQty = parseInt(String(row.defect_qty ?? "0"), 10) || 0; + const availableQty = parseInt(String(row.__availableQty ?? "0"), 10) || 0; + const prevGoodQty = parseInt(String(row.__prevGoodQty ?? String(instructionQty)), 10) || 0; + + const currentStep = processFlow?.find((s) => s.isCurrent); + const currentIdx = processFlow?.findIndex((s) => s.isCurrent) ?? -1; + const isFirstProcess = currentIdx === 0; + const totalSteps = processFlow?.length ?? 0; + + const remainingQty = Math.max(0, inputQty - totalProd); + const progressPct = inputQty > 0 ? Math.min(100, Math.round((totalProd / inputQty) * 100)) : 0; + const yieldRate = totalProd > 0 ? Math.round((goodQty / totalProd) * 100) : 0; + + // 접수가능 탭 (클론 카드) - 접수 가능 수량 중심 + if (isClone || status === "acceptable" || status === "waiting") { + const showQty = isClone ? availableQty : (status === "acceptable" ? availableQty || prevGoodQty : 0); + return ( +
+ {/* 미니 공정 흐름 바 */} + {processFlow && processFlow.length > 1 && ( + + )} + {/* 핵심 수량 */} +
+
+ 지시 + {instructionQty.toLocaleString()} +
+ {!isFirstProcess && ( +
+ 전공정양품 + {prevGoodQty.toLocaleString()} +
+ )} +
+ 접수가능 + {(showQty || prevGoodQty).toLocaleString()} +
+
+
+ ); + } + + // 진행중 / 접수분완료 - 작업 현황 중심 + if (status === "in_progress") { + const isBatchDone = inputQty > 0 && totalProd >= inputQty; + return ( +
+ {/* 미니 공정 흐름 바 */} + {processFlow && processFlow.length > 1 && ( + + )} + {/* 프로그레스 바 */} +
+
+
+
+ + {progressPct}% + +
+ {/* 수량 상세 */} +
+ + + + 0 ? "#f59e0b" : "#10b981"} /> +
+ {/* 추가접수가능 수량 (있을 때만) */} + {availableQty > 0 && ( +
+ 추가접수가능 + {availableQty.toLocaleString()} +
+ )} +
+ ); + } + + // 완료 상태 - 최종 결과 요약 + if (status === "completed") { + return ( +
+ {/* 미니 공정 흐름 바 */} + {processFlow && processFlow.length > 1 && ( + + )} + {/* 완료 프로그레스 */} +
+
+
+
+ 완료 +
+ {/* 최종 수량 */} +
+ + + + {totalProd > 0 && ( +
+ 수율 + {yieldRate}% +
+ )} +
+
+ ); + } + + // fallback + return ( +
+ 지시수량 + {instructionQty.toLocaleString()} +
+ ); +} + +// --- 미니 공정 흐름 바 --- +function MiniProcessBar({ steps, currentIdx }: { steps: TimelineProcessStep[]; currentIdx: number }) { + return ( +
+ {steps.map((step, idx) => { + const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending"; + const isCurrent = idx === currentIdx; + let bg = "#e2e8f0"; // pending + if (sem === "done") bg = "#10b981"; + else if (sem === "active") bg = "#3b82f6"; + + const pct = step.totalProductionQty && step.inputQty && step.inputQty > 0 + ? Math.round((step.totalProductionQty / step.inputQty) * 100) + : undefined; + + return ( +
+
+
+ ); + })} +
+ ); +} + +// --- 수량 칩 --- +function QtyChip({ + label, value, color, showZero = true, +}: { + label: string; value: number; color: string; showZero?: boolean; +}) { + if (!showZero && value === 0) return null; + return ( +
+ {label} + + {value.toLocaleString()} + +
+ ); +} + +// ===== 13. footer-status ===== function FooterStatusCell({ cell, row }: CellRendererProps) { const value = cell.footerStatusColumn ? row[cell.footerStatusColumn] : ""; @@ -735,3 +989,402 @@ function FooterStatusCell({ cell, row }: CellRendererProps) {
); } + +// ===== 14. mes-process-card (MES 공정 전용 카드) ===== + +const MES_STATUS: Record = { + waiting: { label: "대기", color: "#94a3b8", bg: "#f8fafc" }, + acceptable: { label: "접수가능", color: "#2563eb", bg: "#eff6ff" }, + in_progress: { label: "진행중", color: "#d97706", bg: "#fffbeb" }, + batch_done: { label: "접수분완료", color: "#7c3aed", bg: "#f5f3ff" }, + completed: { label: "완료", color: "#059669", bg: "#ecfdf5" }, +}; + +function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: CellRendererProps) { + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + const rawStatus = String(row[VIRTUAL_SUB_STATUS] ?? row.status ?? ""); + const isClone = !!row.__isAcceptClone; + const [flowModalOpen, setFlowModalOpen] = useState(false); + + const instrQty = parseInt(String(row.qty ?? row.instruction_qty ?? "0"), 10) || 0; + const inputQty = parseInt(String(row.__process_input_qty ?? row.input_qty ?? "0"), 10) || 0; + const totalProd = parseInt(String(row.__process_total_production_qty ?? row.total_production_qty ?? "0"), 10) || 0; + const goodQty = parseInt(String(row.__process_good_qty ?? row.good_qty ?? "0"), 10) || 0; + const defectQty = parseInt(String(row.__process_defect_qty ?? row.defect_qty ?? "0"), 10) || 0; + const availableQty = parseInt(String(row.__availableQty ?? "0"), 10) || 0; + const prevGoodQty = parseInt(String(row.__prevGoodQty ?? String(instrQty)), 10) || 0; + const resultStatus = String(row.__process_result_status ?? ""); + + const currentStep = processFlow?.find((s) => s.isCurrent); + const currentIdx = processFlow?.findIndex((s) => s.isCurrent) ?? -1; + const isFirstProcess = currentIdx === 0; + const processId = currentStep?.processId; + + const remainingQty = Math.max(0, inputQty - totalProd); + const progressPct = inputQty > 0 ? Math.min(100, Math.round((totalProd / inputQty) * 100)) : 0; + const yieldRate = totalProd > 0 ? Math.round((goodQty / totalProd) * 100) : 0; + + const isBatchDone = rawStatus === "in_progress" && inputQty > 0 && totalProd >= inputQty; + let displayStatus = rawStatus; + if (isBatchDone) displayStatus = "batch_done"; + const st = MES_STATUS[displayStatus] || MES_STATUS.waiting; + + const processName = currentStep?.processName || String(row.__process_process_name ?? ""); + const woNo = String(row.work_instruction_no ?? ""); + const itemId = String(row.item_id ?? ""); + + // MES 워크플로우 상태 기반 버튼 결정 + const acceptBtn = (cell.actionButtons || []).find((b) => b.showCondition?.type === "timeline-status"); + const cancelBtn = (cell.actionButtons || []).find((b) => b.showCondition?.type === "owner-match"); + + let activeBtn: ActionButtonDef | undefined; + if (isClone) { + activeBtn = acceptBtn; + } else if (rawStatus === "acceptable") { + activeBtn = acceptBtn; + } else if (rawStatus === "batch_done") { + if (availableQty > 0) activeBtn = acceptBtn; + } else if (rawStatus === "in_progress") { + if (isBatchDone || resultStatus === "confirmed") { + if (availableQty > 0) activeBtn = acceptBtn; + } else { + activeBtn = cancelBtn; + } + } + return ( + <> +
+ {/* ── 헤더 ── */} +
+
+
+ {woNo} + {itemId && itemId !== "-" && ( + {itemId} + )} +
+ {processName && ( +
+ {processName} + {processFlow && processFlow.length > 1 && ( + + ({currentIdx + 1}/{processFlow.length}공정) + + )} +
+ )} +
+ + {st.label} + +
+ + {/* ── 수량 메트릭 (상태별) ── */} +
+ {(isClone || rawStatus === "acceptable" || rawStatus === "waiting") && ( + + )} + {(rawStatus === "in_progress" || rawStatus === "batch_done") && ( + + )} + {rawStatus === "completed" && ( + + )} +
+ + {/* ── 공정 흐름 스트립 (클릭 시 모달) ── */} + {processFlow && processFlow.length > 0 && ( +
{ e.stopPropagation(); setFlowModalOpen(true); }} + title="클릭하여 공정 상세 보기" + > + +
+ )} + + {/* ── 하단: 부가정보 + 액션 ── */} +
+
+ {row.end_date && 납기 {formatValue(row.end_date)}} + {row.equipment_id && {String(row.equipment_id)}} + {row.work_team && {String(row.work_team)}} +
+
+ {activeBtn && ( + + )} +
+
+
+ + {/* ── 공정 상세 모달 ── */} + + + + {woNo} 공정 현황 + + {processFlow?.length ?? 0}개 공정 중 {processFlow?.filter(s => (s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status]) === "done").length ?? 0}개 완료 + + +
+ {processFlow?.map((step, idx) => { + const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending"; + const styles = getTimelineStyle(step); + const sInstr = instrQty; + const sInput = step.inputQty || 0; + const sProd = step.totalProductionQty || 0; + const sGood = step.goodQty || 0; + const sDefect = step.defectQty || 0; + const sYield = step.yieldRate || 0; + const sPct = sInput > 0 ? Math.round((sProd / sInput) * 100) : (sem === "done" ? 100 : 0); + const statusLabel = sem === "done" ? "완료" : sem === "active" ? "진행" : "대기"; + + return ( +
+
+ {idx > 0 &&
} +
+ {step.seqNo} +
+ {idx < (processFlow?.length ?? 0) - 1 &&
} +
+
+
+
+ + {step.processName} + + + {statusLabel} + +
+ {(sInput > 0 || sem === "done") && ( +
+ 양품 {sGood.toLocaleString()} + {sDefect > 0 && 불량 {sDefect.toLocaleString()}} + 수율 = 95 ? "#059669" : sYield >= 80 ? "#d97706" : "#ef4444" }}>{sYield}% +
+ )} +
+
+ {sProd}/{sInput || sInstr} +
+
+
+
+
+
+ ); + })} +
+ +
+ + ); +} + +// ── 공정 흐름 스트립 (카드 내 표시) ── +function ProcessFlowStrip({ steps, currentIdx, instrQty }: { + steps: TimelineProcessStep[]; currentIdx: number; instrQty: number; +}) { + const maxShow = 5; + const showAll = steps.length <= maxShow; + const visible = showAll ? steps : steps.slice(0, maxShow); + const hiddenCount = steps.length - visible.length; + + return ( +
+ {visible.map((step, idx) => { + const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending"; + const isCurr = idx === currentIdx; + const sInput = step.inputQty || 0; + const sProd = step.totalProductionQty || 0; + const sGood = step.goodQty || 0; + const pct = sInput > 0 ? Math.round((sProd / sInput) * 100) : (sem === "done" ? 100 : 0); + + let barColor = "#e2e8f0"; + if (sem === "done") barColor = "#10b981"; + else if (sem === "active") barColor = "#3b82f6"; + + return ( + +
+ {isCurr && ( +
+ )} + + {step.processName} + +
+
+
+ + {sem === "pending" && sInput === 0 ? "-" : `${sGood}/${sInput || instrQty}`} + +
+ {idx < visible.length - 1 && ( +
+ )} + + ); + })} + {hiddenCount > 0 && ( +
+ +{hiddenCount} +
+ )} +
+ ); +} + +// ── 접수가능 메트릭 ── +function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, isFirstProcess, isClone }: { + instrQty: number; prevGoodQty: number; availableQty: number; inputQty: number; isFirstProcess: boolean; isClone: boolean; +}) { + const displayAvail = isClone ? availableQty : (availableQty || prevGoodQty); + return ( +
+
+ 지시 {instrQty.toLocaleString()} + {!isFirstProcess && ( + 전공정양품 {prevGoodQty.toLocaleString()} + )} + {inputQty > 0 && ( + 기접수 {inputQty.toLocaleString()} + )} +
+
+ 접수가능  + {displayAvail.toLocaleString()} +
+
+ ); +} + +// ── 진행중 메트릭 ── +function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, remainingQty, progressPct, availableQty, isBatchDone, statusColor }: { + inputQty: number; totalProd: number; goodQty: number; defectQty: number; remainingQty: number; progressPct: number; availableQty: number; isBatchDone: boolean; statusColor: string; +}) { + return ( +
+ {/* 메인 프로그레스 */} +
+ 접수 {inputQty.toLocaleString()} +
+
+
+ {progressPct}% +
+ {/* 수량 4칸 */} +
+ + + + 0 ? "#f59e0b" : "#10b981"} /> +
+ {availableQty > 0 && ( +
+ 추가접수가능 {availableQty.toLocaleString()} +
+ )} +
+ ); +} + +// ── 완료 메트릭 ── +function MesCompletedMetrics({ instrQty, goodQty, defectQty, yieldRate }: { + instrQty: number; goodQty: number; defectQty: number; yieldRate: number; +}) { + return ( +
+
+ 지시 {instrQty.toLocaleString()} + 최종양품 {goodQty.toLocaleString()} + 수율 = 95 ? "#059669" : yieldRate >= 80 ? "#d97706" : "#ef4444" }}>{yieldRate}% +
+ {defectQty > 0 && ( +
불량 {defectQty.toLocaleString()}
+ )} +
+ ); +} + +// ── 메트릭 박스 ── +function MesMetricBox({ label, value, color, dimZero = false }: { + label: string; value: number; color: string; dimZero?: boolean; +}) { + const isDim = dimZero && value === 0; + return ( +
+ {label} + {value.toLocaleString()} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx index 4136d144..a3015be3 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx @@ -229,6 +229,8 @@ export function NumberInputModal({ e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} > 수량 입력 {/* 헤더 */} diff --git a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx index 805fadcd..8cdead89 100644 --- a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx @@ -25,6 +25,7 @@ export function PopStatusBarComponent({ const [selectedValue, setSelectedValue] = useState(""); const [allRows, setAllRows] = useState[]>([]); const [autoSubStatusColumn, setAutoSubStatusColumn] = useState(null); + const [originalCount, setOriginalCount] = useState(null); // all_rows 이벤트 구독 useEffect(() => { @@ -47,13 +48,16 @@ export function PopStatusBarComponent({ const envelope = inner as { rows?: unknown; subStatusColumn?: string | null; + originalCount?: number; }; if (Array.isArray(envelope.rows)) setAllRows(envelope.rows as Record[]); setAutoSubStatusColumn(envelope.subStatusColumn ?? null); + setOriginalCount(envelope.originalCount ?? null); } else if (Array.isArray(inner)) { setAllRows(inner as Record[]); setAutoSubStatusColumn(null); + setOriginalCount(null); } } ); @@ -130,7 +134,7 @@ export function PopStatusBarComponent({ return map; }, [allRows, effectiveCountColumn, showCount]); - const totalCount = allRows.length; + const totalCount = originalCount ?? allRows.length; const chipItems = useMemo(() => { const items: { value: string; label: string; count: number }[] = []; diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx index 5aa894cf..da9dcf17 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx @@ -3,19 +3,21 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package, - ChevronLeft, ChevronRight, Check, X, CircleDot, + ChevronLeft, ChevronRight, Check, X, CircleDot, ClipboardList, + Plus, Trash2, Save, FileCheck, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { dataApi } from "@/lib/api/data"; import { apiClient } from "@/lib/api/client"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useAuth } from "@/hooks/useAuth"; -import type { PopWorkDetailConfig } from "../types"; +import type { PopWorkDetailConfig, ResultSectionConfig } from "../types"; import type { TimelineProcessStep } from "../types"; // ======================================== @@ -86,6 +88,26 @@ interface ProcessTimerData { status: string; good_qty: string | null; defect_qty: string | null; + total_production_qty: string | null; + defect_detail: string | null; + result_note: string | null; + result_status: string | null; + input_qty: string | null; +} + +interface DefectDetailEntry { + defect_code: string; + defect_name: string; + qty: string; + disposition: string; +} + +interface DefectTypeOption { + id: string; + defect_code: string; + defect_name: string; + defect_type: string; + severity: string; } const DEFAULT_INFO_FIELDS = [ @@ -163,6 +185,13 @@ export function PopWorkDetailComponent({ const [currentItemIdx, setCurrentItemIdx] = useState(0); const [showQuantityPanel, setShowQuantityPanel] = useState(false); + // 실적 입력 탭 상태 + const [resultTabActive, setResultTabActive] = useState(false); + const hasResultSections = !!(cfg.resultSections && cfg.resultSections.some((s) => s.enabled)); + + // 불량 유형 목록 (부모에서 1회 로드, ResultPanel에 전달) + const [cachedDefectTypes, setCachedDefectTypes] = useState([]); + const contentRef = useRef(null); // ======================================== @@ -204,6 +233,18 @@ export function PopWorkDetailComponent({ fetchData(); }, [fetchData]); + useEffect(() => { + const loadDefectTypes = async () => { + try { + const res = await apiClient.get("/pop/production/defect-types"); + if (res.data?.success) { + setCachedDefectTypes(res.data.data || []); + } + } catch { /* 실패 시 빈 배열 유지 */ } + }; + loadDefectTypes(); + }, []); + // ======================================== // 좌측 사이드바 - 작업항목 그룹핑 // ======================================== @@ -260,8 +301,10 @@ export function PopWorkDetailComponent({ useEffect(() => { if (groups.length > 0 && !selectedGroupId) { setSelectedGroupId(groups[0].itemId); + } else if (groups.length === 0 && hasResultSections && !resultTabActive) { + setResultTabActive(true); } - }, [groups, selectedGroupId]); + }, [groups, selectedGroupId, hasResultSections, resultTabActive]); // 현재 선택 인덱스 const selectedIndex = useMemo( @@ -446,10 +489,6 @@ export function PopWorkDetailComponent({ // 단계 시작/활성화 // ======================================== - const handleStepStart = useCallback((itemId: string) => { - setActiveStepIds((prev) => new Set(prev).add(itemId)); - }, []); - const isStepLocked = useMemo(() => { if (!cfg.stepControl.requireStartBeforeInput) return false; if (!selectedGroupId) return true; @@ -470,19 +509,7 @@ export function PopWorkDetailComponent({ return () => clearInterval(id); }, [cfg.showTimer, processData?.started_at, groups]); - const elapsedMs = useMemo(() => { - if (!processData?.started_at) return 0; - const now = tick; - const totalMs = now - new Date(processData.started_at).getTime(); - const pausedSec = parseInt(processData.total_paused_time || "0", 10); - const currentPauseMs = processData.paused_at - ? now - new Date(processData.paused_at).getTime() - : 0; - return Math.max(0, totalMs - pausedSec * 1000 - currentPauseMs); - }, [processData?.started_at, processData?.paused_at, processData?.total_paused_time, tick]); - - // formattedTime은 제거 - 그룹별 타이머로 대체됨 - // isPaused, isStarted는 프로세스 레벨 (사용하지 않으나 processData 참조용으로 유지) + // 프로세스 레벨 타이머는 그룹별 타이머로 대체됨 // ======================================== // 그룹별 타이머 @@ -639,7 +666,9 @@ export function PopWorkDetailComponent({ ); } - if (allResults.length === 0) { + const isProcessCompleted = processData?.status === "completed"; + + if (allResults.length === 0 && !hasResultSections) { return (
@@ -647,8 +676,6 @@ export function PopWorkDetailComponent({
); } - - const isProcessCompleted = processData?.status === "completed"; const selectedGroup = groups.find((g) => g.itemId === selectedGroupId); // ======================================== @@ -691,6 +718,7 @@ export function PopWorkDetailComponent({ )} onClick={() => { setSelectedGroupId(g.itemId); + setResultTabActive(false); contentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }} > @@ -714,10 +742,59 @@ export function PopWorkDetailComponent({
); })} + + {/* 실적 입력 탭 (resultSections가 설정된 경우만) */} + {cfg.resultSections && cfg.resultSections.some((s) => s.enabled) && ( + <> +
+ + + )}
{/* 우측 콘텐츠 */}
+ {/* 실적 입력 패널 (hidden으로 상태 유지, unmount 방지) */} + {hasResultSections && ( +
+ { + setProcessData((prev) => prev ? { ...prev, ...updated } : prev); + publish("process_completed", { workOrderProcessId, status: updated?.status }); + }} + /> +
+ )} + + {/* 체크리스트 영역 */} +
{cfg.displayMode === "step" ? ( /* ======== 스텝 모드 ======== */ <> @@ -727,7 +804,7 @@ export function PopWorkDetailComponent({

모든 작업 항목이 완료되었습니다

- {cfg.showQuantityInput && !isProcessCompleted && ( + {cfg.showQuantityInput && !isProcessCompleted && !hasResultSections && (

실적 수량 등록

@@ -906,7 +983,7 @@ export function PopWorkDetailComponent({ {/* 하단 네비게이션 + 수량/완료 */}
- {cfg.showQuantityInput && ( + {cfg.showQuantityInput && !hasResultSections && (
@@ -965,12 +1042,497 @@ export function PopWorkDetailComponent({
)} +
); } +// ======================================== +// 실적 입력 패널 (분할 실적 누적 방식) +// ======================================== + +const DISPOSITION_OPTIONS = [ + { value: "scrap", label: "폐기" }, + { value: "rework", label: "재작업" }, + { value: "downgrade", label: "등급하향" }, + { value: "return", label: "반품" }, + { value: "accept", label: "특채" }, +]; + +interface BatchHistoryItem { + seq: number; + batch_qty: number; + batch_good: number; + batch_defect: number; + accumulated_total: number; + changed_at: string; + changed_by: string | null; +} + +interface ResultPanelProps { + workOrderProcessId: string; + processData: ProcessTimerData | null; + sections: ResultSectionConfig[]; + isProcessCompleted: boolean; + defectTypes: DefectTypeOption[]; + onSaved: (updated: Partial) => void; +} + +function ResultPanel({ + workOrderProcessId, + processData, + sections, + isProcessCompleted, + defectTypes, + onSaved, +}: ResultPanelProps) { + // 이번 차수 입력값 (누적치가 아닌 이번에 생산한 수량) + const [batchQty, setBatchQty] = useState(""); + const [batchDefect, setBatchDefect] = useState(""); + const [resultNote, setResultNote] = useState(""); + const [defectEntries, setDefectEntries] = useState([]); + const [saving, setSaving] = useState(false); + const [confirming, setConfirming] = useState(false); + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [availableInfo, setAvailableInfo] = useState<{ + prevGoodQty: number; + availableQty: number; + instructionQty: number; + } | null>(null); + + const isConfirmed = processData?.result_status === "confirmed"; + + const inputQty = parseInt(processData?.input_qty ?? "0", 10) || 0; + const accumulatedTotal = parseInt(processData?.total_production_qty ?? "0", 10) || 0; + const accumulatedGood = parseInt(processData?.good_qty ?? "0", 10) || 0; + const accumulatedDefect = parseInt(processData?.defect_qty ?? "0", 10) || 0; + const remainingQty = Math.max(0, inputQty - accumulatedTotal); + + const batchGood = useMemo(() => { + const production = parseInt(batchQty, 10) || 0; + const defect = parseInt(batchDefect, 10) || 0; + return Math.max(0, production - defect); + }, [batchQty, batchDefect]); + + const totalDefectFromEntries = useMemo( + () => defectEntries.reduce((sum, e) => sum + (parseInt(e.qty, 10) || 0), 0), + [defectEntries] + ); + + useEffect(() => { + if (totalDefectFromEntries > 0) { + setBatchDefect(String(totalDefectFromEntries)); + } + }, [totalDefectFromEntries]); + + const enabledSections = sections.filter((s) => s.enabled); + + // 접수가능량 로드 + const loadAvailableQty = useCallback(async () => { + try { + const res = await apiClient.get("/pop/production/available-qty", { + params: { work_order_process_id: workOrderProcessId }, + }); + if (res.data?.success) { + setAvailableInfo(res.data.data); + } + } catch { /* ignore */ } + }, [workOrderProcessId]); + + useEffect(() => { + loadAvailableQty(); + }, [loadAvailableQty]); + + // 이력 로드 + const loadHistory = useCallback(async () => { + setHistoryLoading(true); + try { + const res = await apiClient.get("/pop/production/result-history", { + params: { work_order_process_id: workOrderProcessId }, + }); + if (res.data?.success) { + setHistory(res.data.data || []); + } + } catch { /* 실패 시 빈 배열 유지 */ } + finally { setHistoryLoading(false); } + }, [workOrderProcessId]); + + useEffect(() => { + loadHistory(); + }, [loadHistory]); + + const addDefectEntry = () => { + setDefectEntries((prev) => [ + ...prev, + { defect_code: "", defect_name: "", qty: "", disposition: "scrap" }, + ]); + }; + + const removeDefectEntry = (idx: number) => { + setDefectEntries((prev) => prev.filter((_, i) => i !== idx)); + }; + + const updateDefectEntry = (idx: number, field: keyof DefectDetailEntry, value: string) => { + setDefectEntries((prev) => + prev.map((e, i) => { + if (i !== idx) return e; + const updated = { ...e, [field]: value }; + if (field === "defect_code") { + const found = defectTypes.find((dt) => dt.defect_code === value); + if (found) updated.defect_name = found.defect_name; + } + return updated; + }) + ); + }; + + const resetForm = () => { + setBatchQty(""); + setBatchDefect(""); + setResultNote(""); + setDefectEntries([]); + }; + + const handleSubmitBatch = async () => { + if (!batchQty || parseInt(batchQty, 10) <= 0) { + toast.error("생산수량을 입력해주세요."); + return; + } + const batchNum = parseInt(batchQty, 10); + if (inputQty > 0 && (accumulatedTotal + batchNum) > inputQty) { + toast.error(`생산수량(${batchNum})이 잔여량(${remainingQty})을 초과합니다.`); + return; + } + setSaving(true); + try { + const res = await apiClient.post("/pop/production/save-result", { + work_order_process_id: workOrderProcessId, + production_qty: batchQty, + good_qty: String(batchGood), + defect_qty: batchDefect || "0", + defect_detail: defectEntries.length > 0 ? defectEntries : null, + result_note: resultNote || null, + }); + if (res.data?.success) { + const savedData = res.data.data; + if (savedData?.status === "completed") { + toast.success("모든 수량이 완료되어 자동 확정되었습니다."); + } else { + toast.success(`${batchQty}개 실적이 등록되었습니다.`); + } + onSaved(savedData); + resetForm(); + loadHistory(); + loadAvailableQty(); + } else { + toast.error(res.data?.message || "실적 등록에 실패했습니다."); + } + } catch { + toast.error("실적 등록 중 오류가 발생했습니다."); + } finally { + setSaving(false); + } + }; + + const handleConfirm = async () => { + if (accumulatedTotal <= 0) { + toast.error("등록된 실적이 없습니다."); + return; + } + setConfirming(true); + try { + const res = await apiClient.post("/pop/production/confirm-result", { + work_order_process_id: workOrderProcessId, + }); + if (res.data?.success) { + toast.success("실적이 확정되었습니다."); + onSaved({ ...res.data.data, result_status: "confirmed" }); + } else { + toast.error(res.data?.message || "실적 확정에 실패했습니다."); + } + } catch { + toast.error("실적 확정 중 오류가 발생했습니다."); + } finally { + setConfirming(false); + } + }; + + return ( +
+
+ {/* 확정 상태 배너 */} + {isConfirmed && ( +
+ + 실적이 확정되었습니다 +
+ )} + + {/* 공정 현황: 접수량 / 작업완료 / 잔여 + 앞공정 완료량 */} +
+
공정 현황
+
+
+
{inputQty}
+
접수량
+
+
+
{accumulatedTotal}
+
작업완료
+
+
+
0 ? "text-amber-600" : "text-green-600"}`}> + {remainingQty} +
+
잔여
+
+ {availableInfo && availableInfo.availableQty > 0 && ( +
+
{availableInfo.availableQty}
+
추가접수가능
+
+ )} +
+ {inputQty > 0 && ( +
+
+
+ )} + {availableInfo && ( +
+ 앞공정 완료: {availableInfo.prevGoodQty} + 지시수량: {availableInfo.instructionQty} +
+ )} +
+ + {/* 누적 실적 현황 */} +
+
누적 실적
+
+
+
{accumulatedTotal}
+
총생산
+
+
+
{accumulatedGood}
+
양품
+
+
+
{accumulatedDefect}
+
불량
+
+
+
{history.length}
+
차수
+
+
+ {accumulatedTotal > 0 && ( +
+
0 ? (accumulatedGood / accumulatedTotal) * 100 : 0}%` }} + /> +
+ )} +
+ + {/* 이번 차수 실적 입력 */} + {!isConfirmed && ( +
+
이번 차수 실적
+ + {/* 생산수량 */} + {enabledSections.some((s) => s.type === "total-qty") && ( +
+ +
+ setBatchQty(e.target.value)} + placeholder="0" + /> + EA +
+
+ )} + + {/* 양품/불량 */} + {enabledSections.some((s) => s.type === "good-defect") && ( +
+ +
+
+ 양품 + 0 ? String(batchGood) : ""} + readOnly + placeholder="자동" + /> +
+
+ 불량 + setBatchDefect(e.target.value)} + placeholder="0" + /> +
+
+ {(parseInt(batchQty, 10) || 0) > 0 && ( +

+ 양품 {batchGood} = 생산 {batchQty} - 불량 {batchDefect || 0} +

+ )} +
+ )} + + {/* 불량 유형 상세 */} + {enabledSections.some((s) => s.type === "defect-types") && ( +
+
+ + +
+ {defectEntries.length === 0 ? ( +

등록된 불량 유형이 없습니다.

+ ) : ( +
+ {defectEntries.map((entry, idx) => ( +
+ + updateDefectEntry(idx, "qty", e.target.value)} + placeholder="수량" + /> + + +
+ ))} +
+ )} +
+ )} + + {/* 비고 */} + {enabledSections.some((s) => s.type === "note") && ( +
+ +