diff --git a/.gitignore b/.gitignore index 4605a219..2644c641 100644 --- a/.gitignore +++ b/.gitignore @@ -193,7 +193,9 @@ scripts/browser-test-*.js # 개인 작업 문서 popdocs/ +kshdocs/ .cursor/rules/popdocs-safety.mdc +.cursor/rules/overtime-registration.mdc # 멀티 에이전트 MCP 태스크 큐 mcp-task-queue/ diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index d575b07a..4c28dfed 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -3,6 +3,87 @@ 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; +} + +/** + * 체크리스트 복사 공통 함수 + * 분할 행/재작업 카드 생성 시 마스터의 체크리스트를 새 행에 복사한다. + * + * 전략: routingDetailId가 있으면 원본 템플릿에서, 없으면 마스터의 기존 결과에서 복사 + */ +async function copyChecklistToSplit( + client: { query: (text: string, values?: any[]) => Promise }, + masterProcessId: string, + newProcessId: string, + routingDetailId: string | null, + companyCode: string, + userId: string +): Promise { + // A. routing_detail_id가 있으면 원본 템플릿(process_work_item + detail)에서 복사 + if (routingDetailId) { + const result = await client.query( + `INSERT INTO process_work_result ( + company_code, work_order_process_id, + source_work_item_id, source_detail_id, + work_phase, item_title, item_sort_order, + detail_content, detail_type, detail_sort_order, is_required, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + input_type, lookup_target, display_fields, duration_minutes, + status, writer + ) + SELECT + pwi.company_code, $1, + pwi.id, pwd.id, + pwi.work_phase, pwi.title, pwi.sort_order::text, + pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required, + pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit, + pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text, + 'pending', $2 + FROM process_work_item pwi + JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id + AND pwd.company_code = pwi.company_code + WHERE pwi.routing_detail_id = $3 + AND pwi.company_code = $4 + ORDER BY pwi.sort_order, pwd.sort_order`, + [newProcessId, userId, routingDetailId, companyCode] + ); + return result.rowCount ?? 0; + } + + // B. routing_detail_id가 없으면 마스터 행의 process_work_result에서 구조만 복사 (타이머/결과값 초기화) + const result = await client.query( + `INSERT INTO process_work_result ( + company_code, work_order_process_id, + source_work_item_id, source_detail_id, + work_phase, item_title, item_sort_order, + detail_content, detail_type, detail_sort_order, is_required, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + input_type, lookup_target, display_fields, duration_minutes, + status, writer + ) + SELECT + company_code, $1, + source_work_item_id, source_detail_id, + work_phase, item_title, item_sort_order, + detail_content, detail_type, detail_sort_order, is_required, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + input_type, lookup_target, display_fields, duration_minutes, + 'pending', $2 + FROM process_work_result + WHERE work_order_process_id = $3 + AND company_code = $4 + ORDER BY item_sort_order, detail_sort_order`, + [newProcessId, userId, masterProcessId, companyCode] + ); + return result.rowCount ?? 0; +} + /** * D-BE1: 작업지시 공정 일괄 생성 * PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성. @@ -102,43 +183,17 @@ 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, ] ); const wopId = wopResult.rows[0].id; - // 3. process_work_result INSERT (스냅샷 복사) - // process_work_item + process_work_item_detail에서 해당 routing_detail의 항목 조회 후 복사 - const snapshotResult = await client.query( - `INSERT INTO process_work_result ( - company_code, work_order_process_id, - source_work_item_id, source_detail_id, - work_phase, item_title, item_sort_order, - detail_content, detail_type, detail_sort_order, is_required, - inspection_code, inspection_method, unit, lower_limit, upper_limit, - input_type, lookup_target, display_fields, duration_minutes, - status, writer - ) - SELECT - pwi.company_code, $1, - pwi.id, pwd.id, - pwi.work_phase, pwi.title, pwi.sort_order::text, - pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required, - pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit, - pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text, - 'pending', $2 - FROM process_work_item pwi - JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id - AND pwd.company_code = pwi.company_code - WHERE pwi.routing_detail_id = $3 - AND pwi.company_code = $4 - ORDER BY pwi.sort_order, pwd.sort_order`, - [wopId, userId, rd.id, companyCode] + // 3. process_work_result INSERT (공통 함수로 체크리스트 복사) + const checklistCount = await copyChecklistToSplit( + client, wopId, wopId, rd.id, companyCode, userId ); - - const checklistCount = snapshotResult.rowCount ?? 0; totalChecklists += checklistCount; processes.push({ @@ -206,10 +261,11 @@ export const controlTimer = async ( }); } - if (!["start", "pause", "resume"].includes(action)) { + if (!["start", "pause", "resume", "complete"].includes(action)) { return res.status(400).json({ success: false, - message: "action은 start, pause, resume 중 하나여야 합니다.", + message: + "action은 start, pause, resume, complete 중 하나여야 합니다.", }); } @@ -262,6 +318,47 @@ export const controlTimer = async ( [work_order_process_id, companyCode] ); break; + + case "complete": { + const { good_qty, defect_qty } = req.body; + + const groupSumResult = await pool.query( + `SELECT COALESCE(SUM( + CASE WHEN group_started_at IS NOT NULL AND group_completed_at IS NOT NULL THEN + EXTRACT(EPOCH FROM group_completed_at::timestamp - group_started_at::timestamp)::int + - COALESCE(group_total_paused_time::int, 0) + ELSE 0 END + ), 0)::text AS total_work_seconds + FROM process_work_result + WHERE work_order_process_id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + const calculatedWorkTime = groupSumResult.rows[0]?.total_work_seconds || "0"; + + result = await pool.query( + `UPDATE work_order_process + SET status = 'completed', + completed_at = NOW()::text, + completed_by = $3, + actual_work_time = $4, + good_qty = COALESCE($5, good_qty), + defect_qty = COALESCE($6, defect_qty), + paused_at = NULL, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + AND status != 'completed' + RETURNING id, status, completed_at, completed_by, actual_work_time, good_qty, defect_qty`, + [ + work_order_process_id, + companyCode, + userId, + calculatedWorkTime, + good_qty || null, + defect_qty || null, + ] + ); + break; + } } if (!result || result.rowCount === 0) { @@ -289,3 +386,1224 @@ export const controlTimer = async ( }); } }; + +/** + * 그룹(작업항목)별 타이머 제어 + * 좌측 사이드바의 각 작업 그룹마다 독립적인 시작/정지/재개/완료 타이머 + */ +export const controlGroupTimer = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const { work_order_process_id, source_work_item_id, action } = req.body; + + if (!work_order_process_id || !source_work_item_id || !action) { + return res.status(400).json({ + success: false, + message: + "work_order_process_id, source_work_item_id, action은 필수입니다.", + }); + } + + if (!["start", "pause", "resume", "complete"].includes(action)) { + return res.status(400).json({ + success: false, + message: + "action은 start, pause, resume, complete 중 하나여야 합니다.", + }); + } + + logger.info("[pop/production] group-timer 요청", { + companyCode, + work_order_process_id, + source_work_item_id, + action, + }); + + const whereClause = `work_order_process_id = $1 AND source_work_item_id = $2 AND company_code = $3`; + const baseParams = [work_order_process_id, source_work_item_id, companyCode]; + + let result; + + switch (action) { + case "start": + result = await pool.query( + `UPDATE process_work_result + SET group_started_at = CASE WHEN group_started_at IS NULL THEN NOW()::text ELSE group_started_at END, + updated_date = NOW() + WHERE ${whereClause} + RETURNING id, group_started_at`, + baseParams + ); + await pool.query( + `UPDATE work_order_process + SET started_at = NOW()::text, updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND started_at IS NULL`, + [work_order_process_id, companyCode] + ); + break; + + case "pause": + result = await pool.query( + `UPDATE process_work_result + SET group_paused_at = NOW()::text, + updated_date = NOW() + WHERE ${whereClause} AND group_paused_at IS NULL + RETURNING id, group_paused_at`, + baseParams + ); + break; + + case "resume": + result = await pool.query( + `UPDATE process_work_result + SET group_total_paused_time = ( + COALESCE(group_total_paused_time::int, 0) + + EXTRACT(EPOCH FROM NOW() - group_paused_at::timestamp)::int + )::text, + group_paused_at = NULL, + updated_date = NOW() + WHERE ${whereClause} AND group_paused_at IS NOT NULL + RETURNING id, group_total_paused_time`, + baseParams + ); + break; + + case "complete": { + result = await pool.query( + `UPDATE process_work_result + SET group_completed_at = NOW()::text, + group_total_paused_time = CASE + WHEN group_paused_at IS NOT NULL THEN ( + COALESCE(group_total_paused_time::int, 0) + + EXTRACT(EPOCH FROM NOW() - group_paused_at::timestamp)::int + )::text + ELSE group_total_paused_time + END, + group_paused_at = NULL, + updated_date = NOW() + WHERE ${whereClause} + RETURNING id, group_started_at, group_completed_at, group_total_paused_time`, + baseParams + ); + break; + } + } + + if (!result || result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "대상 그룹을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", + }); + } + + logger.info("[pop/production] group-timer 완료", { + action, + source_work_item_id, + affectedRows: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows[0], + affectedRows: result.rowCount, + }); + } catch (error: any) { + logger.error("[pop/production] group-timer 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "그룹 타이머 처리 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 불량 유형 목록 조회 (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 wop.status, wop.result_status, wop.total_production_qty, wop.good_qty, + wop.defect_qty, wop.concession_qty, wop.defect_detail, + wop.input_qty, wop.parent_process_id, wop.wo_id, wop.seq_no + FROM work_order_process wop + WHERE wop.id = $1 AND wop.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.parent_process_id) { + const splitCheck = await pool.query( + `SELECT COUNT(*) as cnt FROM work_order_process + WHERE parent_process_id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + if (parseInt(splitCheck.rows[0].cnt, 10) > 0) { + return res.status(400).json({ + success: false, + message: "원본 공정에는 직접 실적을 등록할 수 없습니다. 분할된 접수 카드에서 등록해주세요.", + }); + } + } + + 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) { + logger.warn("[pop/production] 초과 생산 감지", { + work_order_process_id, + prevTotal, requestedQty, acceptedQty, + overAmount: (prevTotal + requestedQty) - acceptedQty, + }); + } + + // 서버 측 양품/불량/특채 계산 (클라이언트 good_qty는 참고만) + const addProduction = parseInt(production_qty, 10) || 0; + let addDefect = 0; + let addConcession = 0; + + 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); + + for (const item of validated) { + const itemQty = parseInt(item.qty, 10) || 0; + addDefect += itemQty; + if (item.disposition === "accept") { + addConcession += itemQty; + } + } + } else { + addDefect = parseInt(defect_qty, 10) || 0; + } + const addGood = addProduction - addDefect; + + 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; + const newConcession = (parseInt(prev.concession_qty, 10) || 0) + addConcession; + + // 기존 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, + concession_qty = $9, + 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, concession_qty, defect_detail, result_note, result_status, status`, + [ + work_order_process_id, + companyCode, + String(newTotal), + String(newGood), + String(newDefect), + mergedDefectDetail, + result_note || null, + userId, + String(newConcession), + ] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없거나 권한이 없습니다.", + }); + } + + // 현재 분할 행의 공정 정보 조회 + const currentSeq = await pool.query( + `SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty, + wop.parent_process_id, wop.process_code, wop.process_name, + wop.is_required, wop.is_fixed_order, wop.standard_time, + wop.equipment_code, wop.routing_detail_id, + 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] + ); + + // 재작업 카드 자동 생성 (disposition = 'rework' 항목이 있을 때) + if (currentSeq.rowCount > 0 && defect_detail && Array.isArray(defect_detail)) { + let totalReworkQty = 0; + for (const item of defect_detail) { + if (item.disposition === "rework") { + totalReworkQty += parseInt(item.qty, 10) || 0; + } + } + if (totalReworkQty > 0) { + const proc = currentSeq.rows[0]; + const masterId = proc.parent_process_id || work_order_process_id; + const reworkInsert = await pool.query( + `INSERT INTO work_order_process ( + wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, + standard_time, equipment_code, routing_detail_id, + status, input_qty, good_qty, defect_qty, concession_qty, total_production_qty, + result_status, is_rework, rework_source_id, + parent_process_id, company_code, writer + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, + 'acceptable', $10, '0', '0', '0', '0', + 'draft', 'Y', $11, + $12, $13, $14 + ) RETURNING id`, + [ + proc.wo_id, proc.seq_no, proc.process_code, proc.process_name, + proc.is_required, proc.is_fixed_order, proc.standard_time, + proc.equipment_code, proc.routing_detail_id, + String(totalReworkQty), work_order_process_id, + masterId, companyCode, userId, + ] + ); + // 재작업 카드에 체크리스트 복사 + const reworkId = reworkInsert.rows[0]?.id; + if (reworkId) { + const reworkChecklistCount = await copyChecklistToSplit( + pool, masterId, reworkId, proc.routing_detail_id, companyCode, userId + ); + logger.info("[pop/production] 재작업 카드 자동 생성", { + reworkId, + sourceId: work_order_process_id, + reworkQty: totalReworkQty, + checklistCount: reworkChecklistCount, + }); + } + } + } + + // 다음 공정 활성화: 양품 발생 시 다음 seq_no의 원본(마스터) 행을 acceptable로 + // waiting -> acceptable (최초 활성화) + // in_progress -> acceptable (앞공정 추가양품으로 접수가능량 복원) + 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 IN ('waiting', 'in_progress') THEN 'acceptable' ELSE status END, + updated_date = NOW() + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NULL + AND status != 'completed' + RETURNING id, process_name, status`, + [wo_id, nextSeq, companyCode] + ); + if (nextUpdate.rowCount > 0) { + logger.info("[pop/production] 다음 공정 상태 전환", { + nextProcess: nextUpdate.rows[0], + }); + } + } + + // 개별 분할 행 자동완료: 이 분할 행의 접수분 전량 생산 시 completed + if (currentSeq.rowCount > 0) { + const { seq_no, wo_id, current_input_qty, instruction_qty } = currentSeq.rows[0]; + const myInputQty = parseInt(current_input_qty, 10) || 0; + + if (newTotal >= myInputQty && myInputQty > 0) { + 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, newTotal, myInputQty, + }); + + // 같은 공정의 모든 분할 행이 completed인지 체크 -> 원본도 completed로 + const seqNum = parseInt(seq_no, 10); + 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(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + 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 = parseInt(prevProcess.rows[0].total_good, 10) || 0; + } + } + + // 같은 seq_no의 모든 분할 행 접수량 합산 + 미완료 행 카운트 + const siblingCheck = await pool.query( + `SELECT + COALESCE(SUM(input_qty::int), 0) as total_input, + COUNT(*) FILTER (WHERE status != 'completed') as incomplete_count + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, + [wo_id, seq_no, companyCode] + ); + + const totalInput = parseInt(siblingCheck.rows[0].total_input, 10) || 0; + const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10) || 0; + const remainingAcceptable = prevGoodQty - totalInput; + + // 모든 분할 행 완료 + 잔여 접수가능 0 -> 원본(마스터)도 completed + if (incompleteCount === 0 && remainingAcceptable <= 0) { + const masterId = currentSeq.rows[0].parent_process_id; + if (masterId) { + 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'`, + [masterId, companyCode, userId] + ); + logger.info("[pop/production] 원본(마스터) 공정 자동 완료", { + masterId, totalInput, prevGoodQty, + }); + } + } + } + + // 작업지시 전체 완료 판정 + const { wo_id: woIdForWi } = currentSeq.rows[0]; + await checkAndCompleteWorkInstruction(pool, woIdForWi, companyCode, userId); + } + + 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, concession_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 || "실적 저장 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 작업지시(work_instruction) 전체 완료 판정 + * 마지막 공정의 모든 행이 completed이면 작업지시도 완료 처리 + */ +const checkAndCompleteWorkInstruction = async ( + pool: any, + woId: string, + companyCode: string, + userId: string +) => { + const maxSeqResult = await pool.query( + `SELECT MAX(seq_no::int) as max_seq + FROM work_order_process + WHERE wo_id = $1 AND company_code = $2`, + [woId, companyCode] + ); + + if (maxSeqResult.rowCount === 0 || !maxSeqResult.rows[0].max_seq) return; + + const maxSeq = String(maxSeqResult.rows[0].max_seq); + + const incompleteCheck = await pool.query( + `SELECT COUNT(*) as cnt + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND status != 'completed'`, + [woId, maxSeq, companyCode] + ); + + if (parseInt(incompleteCheck.rows[0].cnt, 10) > 0) return; + + const totalGoodResult = await pool.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + [woId, maxSeq, companyCode] + ); + + const completedQty = totalGoodResult.rows[0].total_good; + + await pool.query( + `UPDATE work_instruction + SET status = 'completed', + progress_status = 'completed', + completed_qty = $3, + writer = $4, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + AND status != 'completed'`, + [woId, companyCode, String(completedQty), userId] + ); + + logger.info("[pop/production] 작업지시 전체 완료", { + woId, completedQty, companyCode, + }); +}; + +/** + * 실적 확정은 더 이상 단일 확정이 아님. + * 마지막 등록 확인 용도로 유지. 실적은 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 result = await pool.query( + `UPDATE work_order_process + SET result_status = 'confirmed', + status = 'completed', + completed_at = NOW()::text, + completed_by = $3, + 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] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없습니다.", + }); + } + + // 공정 정보 조회 (다음 공정 활성화 + 마스터 캐스케이드용) + const seqCheck = await pool.query( + `SELECT wop.seq_no, wop.wo_id, wop.parent_process_id, + 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 (seqCheck.rowCount > 0) { + const { seq_no, wo_id, parent_process_id, instruction_qty } = seqCheck.rows[0]; + const seqNum = parseInt(seq_no, 10); + const instrQty = parseInt(instruction_qty, 10) || 0; + + // 다음 공정 활성화 (양품이 있으면) + const goodQty = parseInt(result.rows[0].good_qty, 10) || 0; + if (goodQty > 0) { + const nextSeq = String(seqNum + 1); + await pool.query( + `UPDATE work_order_process + SET status = CASE WHEN status IN ('waiting', 'in_progress') THEN 'acceptable' ELSE status END, + updated_date = NOW() + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NULL + AND status != 'completed'`, + [wo_id, nextSeq, companyCode] + ); + } + + // 마스터 자동완료 캐스케이드 (분할 행인 경우) + if (parent_process_id) { + let prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + 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 = parseInt(prevProcess.rows[0].total_good, 10) || 0; + } + } + + const siblingCheck = await pool.query( + `SELECT + COALESCE(SUM(input_qty::int), 0) as total_input, + COUNT(*) FILTER (WHERE status != 'completed') as incomplete_count + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, + [wo_id, seq_no, companyCode] + ); + + const totalInput = parseInt(siblingCheck.rows[0].total_input, 10) || 0; + const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10) || 0; + const remainingAcceptable = prevGoodQty - totalInput; + + if (incompleteCount === 0 && remainingAcceptable <= 0) { + 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'`, + [parent_process_id, companyCode, userId] + ); + logger.info("[pop/production] confirmResult: 마스터 자동 완료", { + masterId: parent_process_id, totalInput, prevGoodQty, + }); + } + } + + // 작업지시 전체 완료 판정 + await checkAndCompleteWorkInstruction(pool, wo_id, companyCode, userId); + } + + logger.info("[pop/production] confirm-result 완료", { + companyCode, + work_order_process_id, + userId, + 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 rawWopId = req.query.work_order_process_id; + const work_order_process_id = Array.isArray(rawWopId) ? rawWopId[0] : rawWopId; + + 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 rawWopId = req.query.work_order_process_id; + const work_order_process_id = Array.isArray(rawWopId) ? rawWopId[0] : rawWopId; + + 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.parent_process_id, + 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, instruction_qty } = current.rows[0]; + const instrQty = parseInt(instruction_qty, 10) || 0; + const seqNum = parseInt(seq_no, 10); + + // 같은 공정(wo_id + seq_no)의 모든 분할 행 접수량 합산 + const totalAccepted = await pool.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total_input + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, + [wo_id, seq_no, companyCode] + ); + const myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0; + + // 앞공정 양품+특채 합산 + let prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + 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 = parseInt(prevProcess.rows[0].total_good, 10) || 0; + } + } + + 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 이상이어야 합니다." }); + } + + // 원본(마스터) 행 조회 - parent_process_id가 NULL인 행 또는 직접 지정된 행 + const current = await pool.query( + `SELECT wop.id, wop.seq_no, wop.wo_id, wop.status, wop.parent_process_id, + wop.process_code, wop.process_name, wop.is_required, wop.is_fixed_order, + wop.standard_time, wop.equipment_code, wop.routing_detail_id, + 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 row = current.rows[0]; + // 접수 대상은 원본(마스터) 행이어야 함 + const masterId = row.parent_process_id || row.id; + + if (row.status === "completed") { + return res.status(400).json({ success: false, message: "이미 완료된 공정입니다." }); + } + if (row.status !== "acceptable") { + return res.status(400).json({ success: false, message: `원본 공정 상태(${row.status})에서는 접수할 수 없습니다. 접수가능 상태의 카드에서 접수해주세요.` }); + } + + const instrQty = parseInt(row.instruction_qty, 10) || 0; + const seqNum = parseInt(row.seq_no, 10); + + // 같은 공정(wo_id + seq_no)의 모든 분할 행 접수량 합산 + const totalAccepted = await pool.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total_input + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, + [row.wo_id, row.seq_no, companyCode] + ); + const currentTotalInput = totalAccepted.rows[0].total_input; + + // 앞공정 양품+특채 합산 + let prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + [row.wo_id, prevSeq, companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; + } + } + + const availableQty = prevGoodQty - currentTotalInput; + if (qty > availableQty) { + return res.status(400).json({ + success: false, + message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수합계: ${currentTotalInput})`, + }); + } + + // 분할 행 INSERT (원본 행에서 공정 정보 복사) + const result = await pool.query( + `INSERT INTO work_order_process ( + wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, + standard_time, equipment_code, routing_detail_id, + status, input_qty, good_qty, defect_qty, total_production_qty, + result_status, accepted_by, accepted_at, started_at, + parent_process_id, company_code, writer + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, + 'in_progress', $10, '0', '0', '0', + 'draft', $11, NOW()::text, NOW()::text, + $12, $13, $11 + ) RETURNING id, input_qty, status, process_name, result_status, accepted_by`, + [ + row.wo_id, row.seq_no, row.process_code, row.process_name, + row.is_required, row.is_fixed_order, row.standard_time, + row.equipment_code, row.routing_detail_id, + String(qty), userId, masterId, companyCode, + ] + ); + + // 분할 행에 체크리스트 복사 (마스터의 routing_detail_id 또는 마스터의 기존 체크리스트에서) + const splitId = result.rows[0].id; + const checklistCount = await copyChecklistToSplit( + pool, masterId, splitId, row.routing_detail_id, companyCode, userId + ); + + // 마스터는 항상 acceptable 유지 (completed 전환은 saveResult에서 처리) + const newTotalInput = currentTotalInput + qty; + + logger.info("[pop/production] accept-process 분할 접수 완료", { + companyCode, userId, masterId, + splitId, + acceptedQty: qty, + totalAccepted: newTotalInput, + prevGoodQty, + checklistCount, + }); + + return res.json({ + success: true, + data: result.rows[0], + message: `${qty}개 접수 완료 (총 접수합계: ${newTotalInput})`, + }); + } 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, + parent_process_id, wo_id, seq_no, process_name + 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.parent_process_id) { + return res.status(400).json({ + success: false, + message: "원본 공정은 접수 취소할 수 없습니다. 분할된 접수 카드에서 취소해주세요.", + }); + } + + 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; + const unproducedQty = currentInputQty - totalProduced; + + if (unproducedQty <= 0) { + return res.status(400).json({ + success: false, + message: "취소할 미소진 접수분이 없습니다. 모든 접수량에 대해 실적이 등록되었습니다.", + }); + } + + let cancelledQty = unproducedQty; + + if (totalProduced === 0) { + // 실적이 없으면 분할 행 완전 삭제 + await pool.query( + `DELETE FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + } else { + // 실적이 있으면 input_qty를 실적 수량으로 축소 + 접수분 전량 생산이므로 completed + await pool.query( + `UPDATE work_order_process + SET input_qty = $3, status = 'completed', result_status = 'confirmed', + completed_at = NOW()::text, completed_by = $4, + updated_date = NOW(), writer = $4 + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode, String(totalProduced), userId] + ); + } + + // 원본(마스터) 행을 다시 acceptable로 복원 (잔여 접수 가능하도록) + await pool.query( + `UPDATE work_order_process + SET status = 'acceptable', updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND parent_process_id IS NULL`, + [proc.parent_process_id, companyCode] + ); + + logger.info("[pop/production] cancel-accept 완료 (분할 행)", { + companyCode, userId, work_order_process_id, + masterId: proc.parent_process_id, + previousInputQty: currentInputQty, + totalProduced, + cancelledQty, + action: totalProduced === 0 ? "DELETE" : "SHRINK", + }); + + return res.json({ + success: true, + data: { id: work_order_process_id, process_name: proc.process_name }, + message: `미소진 ${cancelledQty}개 접수가 취소되었습니다.`, + }); + } 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 f20d470d..57417797 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -3,6 +3,14 @@ import { authenticateToken } from "../middleware/authMiddleware"; import { createWorkProcesses, controlTimer, + controlGroupTimer, + getDefectTypes, + saveResult, + confirmResult, + getResultHistory, + getAvailableQty, + acceptProcess, + cancelAccept, } from "../controllers/popProductionController"; const router = Router(); @@ -11,5 +19,13 @@ 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/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index c7933033..96413443 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -3,11 +3,10 @@ import React, { useEffect, useState } from "react"; import { useParams, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react"; +import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { useAuth } from "@/hooks/useAuth"; @@ -21,8 +20,6 @@ import { GridMode, isPopLayout, createEmptyLayout, - GAP_PRESETS, - GRID_BREAKPOINTS, BLOCK_GAP, BLOCK_PADDING, detectGridMode, @@ -64,7 +61,8 @@ function PopScreenViewPage() { const params = useParams(); const searchParams = useSearchParams(); const router = useRouter(); - const screenId = parseInt(params.screenId as string); + const screenId = parseInt(params.screenId as string, 10); + const isValidScreenId = !isNaN(screenId) && screenId > 0; const isPreviewMode = searchParams.get("preview") === "true"; @@ -86,26 +84,32 @@ function PopScreenViewPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px) - const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로 - - // 모드 결정: - // - 프리뷰 모드: 수동 선택한 device/orientation 사용 - // - 일반 모드: 화면 너비 기준으로 자동 결정 (GRID_BREAKPOINTS와 일치) - const currentModeKey = isPreviewMode - ? getModeKey(deviceType, isLandscape) - : detectGridMode(viewportWidth); + // 실제 브라우저 너비 (모드 감지용) + const [rawWidth, setRawWidth] = useState(1024); useEffect(() => { - const updateViewportWidth = () => { - setViewportWidth(Math.min(window.innerWidth, 1366)); - }; - - updateViewportWidth(); - window.addEventListener("resize", updateViewportWidth); - return () => window.removeEventListener("resize", updateViewportWidth); + const updateWidth = () => setRawWidth(window.innerWidth); + updateWidth(); + window.addEventListener("resize", updateWidth); + return () => window.removeEventListener("resize", updateWidth); }, []); + // 모드 결정 + const currentModeKey = isPreviewMode + ? getModeKey(deviceType, isLandscape) + : detectGridMode(rawWidth); + + // 디자이너와 동일한 기준 너비 사용 (모드별 고정 너비) + const MODE_REFERENCE_WIDTH: Record = { + mobile_portrait: 375, + mobile_landscape: 600, + tablet_portrait: 820, + tablet_landscape: 1024, + }; + const viewportWidth = isPreviewMode + ? DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"].width + : MODE_REFERENCE_WIDTH[currentModeKey]; + // 화면 및 POP 레이아웃 로드 useEffect(() => { const loadScreen = async () => { @@ -122,22 +126,15 @@ function PopScreenViewPage() { if (popLayout && isPopLayout(popLayout)) { const v6Layout = loadLegacyLayout(popLayout); setLayout(v6Layout); - const componentCount = Object.keys(popLayout.components).length; - console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`); } else if (popLayout) { - // 다른 버전 레이아웃은 빈 v5로 처리 - console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version); setLayout(createEmptyLayout()); } else { - console.log("[POP] 레이아웃 없음"); setLayout(createEmptyLayout()); } - } catch (layoutError) { - console.warn("[POP] 레이아웃 로드 실패:", layoutError); + } catch { setLayout(createEmptyLayout()); } } catch (error) { - console.error("[POP] 화면 로드 실패:", error); setError("화면을 불러오는데 실패했습니다."); showErrorToast("POP 화면을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." }); } finally { @@ -145,10 +142,13 @@ function PopScreenViewPage() { } }; - if (screenId) { + if (isValidScreenId) { loadScreen(); + } else if (params.screenId) { + setError("유효하지 않은 화면 ID입니다."); + setLoading(false); } - }, [screenId]); + }, [screenId, isValidScreenId]); // 뷰어 모드에서도 컴포넌트 크기 변경 지원 (더보기 등) const handleRequestResize = React.useCallback((componentId: string, newRowSpan: number, newColSpan?: number) => { @@ -288,26 +288,13 @@ function PopScreenViewPage() { )} - {/* 일반 모드 네비게이션 바 */} - {!isPreviewMode && ( -
- - {screen.screenName} - -
- )} + {/* 일반 모드 네비게이션 바 제거 (프로필 컴포넌트에서 PC 모드 전환 가능) */} {/* POP 화면 컨텐츠 */} -
+
{(() => { const adjustedGap = BLOCK_GAP; diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index d79883ad..96901be1 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -70,8 +70,8 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-text": "텍스트", "pop-icon": "아이콘", "pop-dashboard": "대시보드", - "pop-card-list": "카드 목록", - "pop-card-list-v2": "카드 목록 V2", + "pop-card-list": "장바구니 목록", + "pop-card-list-v2": "MES 공정흐름", "pop-field": "필드", "pop-button": "버튼", "pop-string-list": "리스트 목록", diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index ddedc7d0..f4de9053 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -41,13 +41,13 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, { type: "pop-card-list", - label: "카드 목록", + label: "장바구니 목록", icon: LayoutGrid, description: "테이블 데이터를 카드 형태로 표시", }, { type: "pop-card-list-v2", - label: "카드 목록 V2", + label: "MES 공정흐름", icon: LayoutGrid, description: "슬롯 기반 카드 (CSS Grid + 셀 타입별 렌더링)", }, diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx index 0a64e82a..c6ebde8e 100644 --- a/frontend/components/pop/designer/panels/ConnectionEditor.tsx +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -4,7 +4,6 @@ import React from "react"; import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, @@ -172,7 +171,7 @@ function SendSection({ {conn.filterConfig.isSubTable && ( - 하위 테이블 + 자동 판단 )}
@@ -229,9 +228,6 @@ function SimpleConnectionForm({ const [selectedTargetId, setSelectedTargetId] = React.useState( initial?.targetComponent || "" ); - const [isSubTable, setIsSubTable] = React.useState( - initial?.filterConfig?.isSubTable || false - ); const [targetColumn, setTargetColumn] = React.useState( initial?.filterConfig?.targetColumn || "" ); @@ -255,23 +251,34 @@ function SimpleConnectionForm({ && targetReg?.connectionMeta?.receivable?.some((r) => r.type === "filter_value"); const subTableName = targetComp ? extractSubTableName(targetComp) : null; + const mainTableName = (() => { + const cfg = targetComp?.config as Record | undefined; + const ds = cfg?.dataSource as { tableName?: string } | undefined; + return ds?.tableName || null; + })(); React.useEffect(() => { - if (!isSubTable || !subTableName) { + if (!isFilterConnection || !selectedTargetId) { setSubColumns([]); return; } + const tables = [mainTableName, subTableName].filter(Boolean) as string[]; + if (tables.length === 0) { setSubColumns([]); return; } setLoadingColumns(true); - getTableColumns(subTableName) - .then((res) => { - const cols = res.success && res.data?.columns; - if (Array.isArray(cols)) { - setSubColumns(cols.map((c) => c.columnName || "").filter(Boolean)); + Promise.all(tables.map((t) => getTableColumns(t))) + .then((results) => { + const allCols = new Set(); + for (const res of results) { + const cols = res.success && res.data?.columns; + if (Array.isArray(cols)) { + cols.forEach((c) => { if (c.columnName) allCols.add(c.columnName); }); + } } + setSubColumns([...allCols].sort()); }) .catch(() => setSubColumns([])) .finally(() => setLoadingColumns(false)); - }, [isSubTable, subTableName]); + }, [isFilterConnection, selectedTargetId, mainTableName, subTableName]); const handleSubmit = () => { if (!selectedTargetId) return; @@ -290,11 +297,10 @@ function SimpleConnectionForm({ label: `${srcLabel} → ${tgtLabel}`, }; - if (isFilterConnection && isSubTable && targetColumn) { + if (isFilterConnection && targetColumn) { conn.filterConfig = { targetColumn, filterMode: filterMode as "equals" | "contains" | "starts_with" | "range", - isSubTable: true, }; } @@ -302,7 +308,6 @@ function SimpleConnectionForm({ if (!initial) { setSelectedTargetId(""); - setIsSubTable(false); setTargetColumn(""); setFilterMode("equals"); } @@ -328,7 +333,6 @@ function SimpleConnectionForm({ value={selectedTargetId} onValueChange={(v) => { setSelectedTargetId(v); - setIsSubTable(false); setTargetColumn(""); }} > @@ -345,62 +349,47 @@ function SimpleConnectionForm({
- {isFilterConnection && selectedTargetId && subTableName && ( + {isFilterConnection && selectedTargetId && (
-
- { - setIsSubTable(v === true); - if (!v) setTargetColumn(""); - }} - /> - +
+ 대상 컬럼 + {loadingColumns ? ( +
+ + 컬럼 로딩 중... +
+ ) : ( + + )}
- {isSubTable && ( -
-
- 대상 컬럼 - {loadingColumns ? ( -
- - 컬럼 로딩 중... -
- ) : ( - - )} -
- -
- 비교 방식 - -
-
- )} +
+ 비교 방식 + +
+

+ 메인/하위 테이블 구분은 자동으로 판단됩니다 +

)} diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 3af031b4..f802dfc8 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -75,8 +75,8 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-text": "텍스트", "pop-icon": "아이콘", "pop-dashboard": "대시보드", - "pop-card-list": "카드 목록", - "pop-card-list-v2": "카드 목록 V2", + "pop-card-list": "장바구니 목록", + "pop-card-list-v2": "MES 공정흐름", "pop-button": "버튼", "pop-string-list": "리스트 목록", "pop-search": "검색", @@ -145,13 +145,9 @@ export default function PopRenderer({ return Math.max(10, maxRowEnd + 3); }, [components, overrides, mode, hiddenIds]); - // V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE - const rowTemplate = isDesignMode - ? `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)` - : `repeat(${dynamicRowCount}, minmax(${BLOCK_SIZE}px, auto))`; - const autoRowHeight = isDesignMode - ? `${BLOCK_SIZE}px` - : `minmax(${BLOCK_SIZE}px, auto)`; + // V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE (디자이너/뷰어 동일 = WYSIWYG) + const rowTemplate = `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)`; + const autoRowHeight = `${BLOCK_SIZE}px`; const gridStyle = useMemo((): React.CSSProperties => ({ display: "grid", @@ -161,7 +157,7 @@ export default function PopRenderer({ gap: `${finalGap}px`, padding: `${finalPadding}px`, minHeight: "100%", - backgroundColor: "#ffffff", + backgroundColor: "hsl(var(--background))", position: "relative", }), [columns, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]); @@ -296,11 +292,20 @@ export default function PopRenderer({ ); } - // 뷰어 모드: 드래그 없는 일반 렌더링 (overflow visible로 컨텐츠 확장 허용) + // 콘텐츠 영역 컴포넌트는 라운드 테두리 표시 + const contentTypes = new Set([ + "pop-dashboard", "pop-card-list", "pop-card-list-v2", + "pop-string-list", "pop-work-detail", "pop-sample", + ]); + const needsBorder = contentTypes.has(comp.type); + return (
t.type === "cart-save"); + const isCartMode = config?.preset === "cart" || hasCartSaveTask; const isInboundConfirmMode = config?.preset === "inbound-confirm"; const [cartCount, setCartCount] = useState(0); const [cartIsDirty, setCartIsDirty] = useState(false); @@ -746,8 +750,10 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp }, [isCartMode, componentId, subscribe]); // 저장 완료 수신 (카드 목록에서 saveToDb 완료 후 전달) - const cartScreenIdRef = React.useRef(config?.cart?.cartScreenId); - cartScreenIdRef.current = config?.cart?.cartScreenId; + const resolvedCartScreenId = config?.cart?.cartScreenId + || v2Tasks?.find((t) => t.type === "cart-save")?.cartScreenId; + const cartScreenIdRef = React.useRef(resolvedCartScreenId); + cartScreenIdRef.current = resolvedCartScreenId; useEffect(() => { if (!isCartMode || !componentId) return; @@ -990,7 +996,28 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp return; } - // v2 경로: tasks 배열이 있으면 새 실행 엔진 사용 + // 장바구니 모드 (v1 preset: "cart" 또는 v2 tasks에 cart-save 포함) + if (isCartMode) { + if (cartCount === 0 && !cartIsDirty) { + toast.info("장바구니가 비어 있습니다."); + return; + } + + if (cartIsDirty) { + setShowCartConfirm(true); + } else { + const targetScreenId = resolvedCartScreenId; + if (targetScreenId) { + const cleanId = String(targetScreenId).replace(/^.*\/(\d+)$/, "$1").trim(); + window.location.href = `/pop/screens/${cleanId}`; + } else { + toast.info("장바구니 화면이 설정되지 않았습니다."); + } + } + return; + } + + // v2 경로: tasks 배열이 있으면 새 실행 엔진 사용 (cart-save 제외) if (v2Config) { if (v2Config.confirm?.enabled) { setShowInboundConfirm(true); @@ -1012,27 +1039,6 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp return; } - // 장바구니 모드: isDirty 여부에 따라 분기 - if (isCartMode) { - if (cartCount === 0 && !cartIsDirty) { - toast.info("장바구니가 비어 있습니다."); - return; - } - - if (cartIsDirty) { - setShowCartConfirm(true); - } else { - const targetScreenId = config?.cart?.cartScreenId; - if (targetScreenId) { - const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim(); - window.location.href = `/pop/screens/${cleanId}`; - } else { - toast.info("장바구니 화면이 설정되지 않았습니다."); - } - } - return; - } - const action = config?.action; if (!action) return; @@ -1072,10 +1078,10 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp const cartButtonClass = useMemo(() => { if (!isCartMode) return ""; if (cartCount > 0 && !cartIsDirty) { - return "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600"; + return "bg-primary hover:bg-primary/90 text-primary-foreground border-primary"; } if (cartIsDirty) { - return "bg-amber-500 hover:bg-orange-600 text-white border-orange-500 animate-pulse"; + return "bg-warning hover:bg-warning/90 text-warning-foreground border-warning animate-pulse"; } return ""; }, [isCartMode, cartCount, cartIsDirty]); @@ -1089,19 +1095,19 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp // 데이터 작업 버튼 2상태 색상: 미선택(기본) / 선택됨(초록) const inboundButtonClass = useMemo(() => { if (isCartMode) return ""; - return inboundSelectedCount > 0 ? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600" : ""; + return inboundSelectedCount > 0 ? "bg-primary hover:bg-primary/90 text-primary-foreground border-primary" : ""; }, [isCartMode, inboundSelectedCount]); return ( <> -
-
+
+
+ ); + })}
)} @@ -1078,26 +1248,21 @@ export function PopCardListV2Component({ const locked = !!ownerSortColumn && !!String(row[ownerSortColumn] ?? "") && String(row[ownerSortColumn] ?? "") !== (currentUserId ?? ""); + const cardKey = row.__isAcceptClone + ? `card-clone-${row.__cloneSourceId}-${index}` + : row.__splitProcessId + ? `card-split-${row.__splitProcessId}` + : `card-${row.id ?? index}`; return ( { - const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; - if (!cartId) return; - setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; }); - }} - onDeleteItem={handleDeleteItem} - onUpdateQuantity={handleUpdateQuantity} onRefresh={fetchData} selectMode={selectMode} isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))} @@ -1164,6 +1329,27 @@ export function PopCardListV2Component({ )} + {/* 내장 작업 상세 모달 (풀스크린) */} + { + setWorkDetailOpen(open); + if (!open) setWorkDetailRow(null); + }}> + + + 작업 상세 + +
+ {workDetailRow && ( + + )} +
+
+
+ {/* POP 화면 모달 (풀스크린) */} { setPopModalOpen(open); @@ -1174,7 +1360,7 @@ export function PopCardListV2Component({ }}> - {effectiveConfig?.cardClickModalConfig?.modalTitle || "상세 작업"} + {config?.cardClickModalConfig?.modalTitle || "상세 작업"}
{popModalLayout && ( @@ -1201,14 +1387,8 @@ interface CardV2Props { spec: CardPresetSpec; config?: PopCardListV2Config; onSelect?: (row: RowData) => void; - cart: ReturnType; publish: (eventName: string, payload?: unknown) => void; parentComponentId?: string; - isCartListMode?: boolean; - isSelected?: boolean; - onToggleSelect?: () => void; - onDeleteItem?: (cartId: string) => void; - onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void; onRefresh?: () => void; selectMode?: boolean; isSelectModeSelected?: boolean; @@ -1221,16 +1401,13 @@ interface CardV2Props { } function CardV2({ - row, cardGrid, spec, config, onSelect, cart, publish, - parentComponentId, isCartListMode, isSelected, onToggleSelect, - onDeleteItem, onUpdateQuantity, onRefresh, + row, cardGrid, spec, config, onSelect, publish, + parentComponentId, onRefresh, selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode, onOpenPopModal, currentUserId, isLockedByOther, }: CardV2Props) { const inputField = config?.inputField; - const cartAction = config?.cartAction; const packageConfig = config?.packageConfig; - const keyColumnName = cartAction?.keyColumn || "id"; const [inputValue, setInputValue] = useState(0); const [packageUnit, setPackageUnit] = useState(undefined); @@ -1242,17 +1419,61 @@ function CardV2({ row: RowData; processId?: string | number; action: ActionButtonClickAction; + dynamicMax?: number; } | null>(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; @@ -1301,28 +1522,8 @@ function CardV2({ toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); } } - }, [qtyModalState, onRefresh]); - - const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : ""; - const isCarted = cart.isItemInCart(rowKey); - const existingCartItem = cart.getCartItem(rowKey); - - // DB 장바구니 복원 - useEffect(() => { - if (isCartListMode) return; - if (existingCartItem && existingCartItem._origin === "db") { - setInputValue(existingCartItem.quantity); - setPackageUnit(existingCartItem.packageUnit); - setPackageEntries(existingCartItem.packageEntries || []); - } - }, [isCartListMode, existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]); - - // 장바구니 목록 모드 초기값 - useEffect(() => { - if (!isCartListMode) return; - setInputValue(Number(row.__cart_quantity) || 0); - setPackageUnit(row.__cart_package_unit ? String(row.__cart_package_unit) : undefined); - }, [isCartListMode, row.__cart_quantity, row.__cart_package_unit]); + closeQtyModal(); + }, [qtyModalState, onRefresh, closeQtyModal]); // 제한 컬럼 자동 초기화 const limitCol = inputField?.limitColumn || inputField?.maxColumn; @@ -1332,41 +1533,16 @@ function CardV2({ }, [limitCol, row]); useEffect(() => { - if (isCartListMode) return; if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) { setInputValue(effectiveMax); } - }, [effectiveMax, inputField?.enabled, limitCol, isCartListMode]); + }, [effectiveMax, inputField?.enabled, limitCol]); const handleInputClick = (e: React.MouseEvent) => { e.stopPropagation(); setIsModalOpen(true); }; const handleInputConfirm = (value: number, unit?: string, entries?: PackageEntry[]) => { setInputValue(value); setPackageUnit(unit); setPackageEntries(entries || []); - if (isCartListMode) onUpdateQuantity?.(String(row.__cart_id), value, unit, entries); - }; - - const handleCartAdd = () => { - if (!rowKey) return; - cart.addItem({ row, quantity: inputValue, packageUnit, packageEntries: packageEntries.length > 0 ? packageEntries : undefined }, rowKey); - if (parentComponentId) publish(`__comp_output__${parentComponentId}__cart_updated`, { count: cart.cartCount + 1, isDirty: true }); - }; - - const handleCartCancel = () => { - if (!rowKey) return; - cart.removeItem(rowKey); - if (parentComponentId) publish(`__comp_output__${parentComponentId}__cart_updated`, { count: Math.max(0, cart.cartCount - 1), isDirty: true }); - }; - - const handleCartDelete = async (e: React.MouseEvent) => { - e.stopPropagation(); - const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; - if (!cartId) return; - if (!window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?")) return; - try { - await dataApi.updateRecord("cart_items", cartId, { status: "cancelled" }); - onDeleteItem?.(cartId); - } catch { toast.error("삭제에 실패했습니다."); } }; const borderClass = selectMode @@ -1375,9 +1551,10 @@ function CardV2({ : isSelectable ? "hover:border-2 hover:border-primary/50" : "opacity-40 pointer-events-none" - : isCartListMode - ? 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"; + : "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 ( @@ -1409,15 +1586,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); }} @@ -1426,6 +1606,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); } @@ -1448,28 +1629,12 @@ function CardV2({
)} - {/* 장바구니 목록 모드: 체크박스 + 삭제 */} - {!selectMode && isCartListMode && ( -
- { e.stopPropagation(); onToggleSelect?.(); }} - onClick={(e) => e.stopPropagation()} - className="h-4 w-4 rounded border-input" - /> - -
- )} - {/* CSS Grid 기반 셀 렌더링 */} -
+
{cardGrid.cells.map((cell) => (
{ const cfg = buttonConfig as Record | undefined; - const allActions = (cfg?.__allActions as ActionButtonClickAction[] | undefined) || []; const processId = cfg?.__processId as string | number | undefined; + // 수동 완료 처리 + if (taskPreset === "__manualComplete" && processId) { + if (!window.confirm("이 공정을 수동으로 완료 처리하시겠습니까? 현재 생산량으로 확정됩니다.")) return; + try { + const result = await apiClient.post("/pop/production/confirm-result", { + work_order_process_id: processId, + }); + if (result.data?.success) { + toast.success("공정이 완료 처리되었습니다."); + onRefresh?.(); + } else { + toast.error(result.data?.message || "완료 처리에 실패했습니다."); + } + } catch (err: unknown) { + const errMsg = (err as any)?.response?.data?.message; + toast.error(errMsg || "완료 처리 중 오류가 발생했습니다."); + } + return; + } + + // 접수 취소 처리 (__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 @@ -1511,7 +1714,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) { @@ -1595,9 +1819,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/PopCardListV2Config.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx index 9fc1339a..f7e4c72d 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx @@ -48,7 +48,6 @@ import type { CardSortConfig, V2OverflowConfig, V2CardClickAction, - V2CardClickModalConfig, ActionButtonUpdate, TimelineDataSource, StatusValueMapping, @@ -117,37 +116,35 @@ const V2_DEFAULT_CONFIG: PopCardListV2Config = { cardGap: 8, scrollDirection: "vertical", overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 }, - cardClickAction: "none", + cardClickAction: "modal-open", }; // ===== 탭 정의 ===== -type V2ConfigTab = "data" | "design" | "actions"; +type V2ConfigTab = "info" | "actions"; const TAB_LABELS: { id: V2ConfigTab; label: string }[] = [ - { id: "data", label: "데이터" }, - { id: "design", label: "카드 디자인" }, + { id: "info", label: "정보" }, { id: "actions", label: "동작" }, ]; // ===== 셀 타입 라벨 ===== -const V2_CELL_TYPE_LABELS: Record = { +const V2_CELL_TYPE_LABELS: Record = { text: { label: "텍스트", group: "기본" }, field: { label: "필드 (라벨+값)", group: "기본" }, image: { label: "이미지", group: "기본" }, badge: { label: "배지", group: "기본" }, - button: { label: "버튼", group: "동작" }, "number-input": { label: "숫자 입력", group: "입력" }, - "cart-button": { label: "담기 버튼", group: "입력" }, - "package-summary": { label: "포장 요약", group: "요약" }, "status-badge": { label: "상태 배지", group: "표시" }, timeline: { label: "타임라인", group: "표시" }, "footer-status": { label: "하단 상태", group: "표시" }, "action-buttons": { label: "액션 버튼", group: "동작" }, + "process-qty-summary": { label: "공정 수량 요약", group: "표시" }, + "mes-process-card": { label: "MES 공정 카드", group: "표시" }, }; -const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작", "요약"] as const; +const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작"] as const; // ===== 그리드 유틸 ===== @@ -197,10 +194,8 @@ const shortType = (t: string): string => { // ===== 메인 컴포넌트 ===== export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) { - const [tab, setTab] = useState("data"); - const [tables, setTables] = useState([]); + const [tab, setTab] = useState("info"); const [columns, setColumns] = useState([]); - const [selectedColumns, setSelectedColumns] = useState([]); const cfg: PopCardListV2Config = { ...V2_DEFAULT_CONFIG, @@ -215,28 +210,12 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) }; useEffect(() => { - fetchTableList() - .then(setTables) - .catch(() => setTables([])); - }, []); - - useEffect(() => { - if (!cfg.dataSource.tableName) { - setColumns([]); - return; - } + if (!cfg.dataSource.tableName) { setColumns([]); return; } fetchTableColumns(cfg.dataSource.tableName) .then(setColumns) .catch(() => setColumns([])); }, [cfg.dataSource.tableName]); - useEffect(() => { - if (cfg.selectedColumns && cfg.selectedColumns.length > 0) { - setSelectedColumns(cfg.selectedColumns); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cfg.dataSource.tableName]); - return (
{/* 탭 바 */} @@ -257,56 +236,142 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) ))}
- {/* 탭 컨텐츠 */} - {tab === "data" && ( - { - setSelectedColumns([]); - update({ - dataSource: { ...cfg.dataSource, tableName }, - selectedColumns: [], - cardGrid: { ...cfg.cardGrid, cells: [] }, - }); - }} - onColumnsChange={(cols) => { - setSelectedColumns(cols); - update({ selectedColumns: cols }); - }} - onDataSourceChange={(dataSource) => update({ dataSource })} - onSortChange={(sort) => - update({ dataSource: { ...cfg.dataSource, sort } }) - } - /> - )} - - {tab === "design" && ( - update({ cardGrid })} - onGridColumnsChange={(gridColumns) => update({ gridColumns })} - onCardGapChange={(cardGap) => update({ cardGap })} - /> - )} + {tab === "info" && } {tab === "actions" && ( - + )}
); } -// ===== 탭 1: 데이터 ===== +// ===== 탭 1: 정보 (연결 흐름 요약) ===== + +function TabInfo({ + cfg, + onUpdate, +}: { + cfg: PopCardListV2Config; + onUpdate: (partial: Partial) => void; +}) { + const ds = cfg.dataSource; + const joins = ds.joins || []; + const clickAction = cfg.cardClickAction || "none"; + const cellTypes = cfg.cardGrid.cells.map((c) => c.type); + const hasTimeline = cellTypes.includes("timeline"); + const hasActionButtons = cellTypes.includes("action-buttons"); + const currentCols = cfg.gridColumns || 3; + + return ( +
+ {/* 카드 열 수 (편집 가능) */} +
+ +
+ {[1, 2, 3, 4].map((n) => ( + + ))} +
+
+ + {/* 데이터 소스 */} +
+ +
+ {ds.tableName ? ( + <> +
{ds.tableName}
+ {joins.map((j, i) => ( +
+ + + {j.targetTable} + ({j.joinType}) +
+ ))} + {ds.sort?.[0] && ( +
+ 정렬: {ds.sort[0].column} ({ds.sort[0].direction === "asc" ? "오름차순" : "내림차순"}) +
+ )} + + ) : ( + 테이블 미설정 + )} +
+
+ + {/* 카드 구성 */} +
+ +
+
{cfg.cardGrid.rows}행 x {cfg.cardGrid.cols}열 그리드, 셀 {cfg.cardGrid.cells.length}개
+
+ {hasTimeline && ( + 타임라인 + )} + {hasActionButtons && ( + 액션 버튼 + )} + {cellTypes.includes("status-badge") && ( + 상태 배지 + )} + {cellTypes.includes("number-input") && ( + 수량 입력 + )} + {cellTypes.filter((t) => t === "field" || t === "text").length > 0 && ( + + 텍스트/필드 {cellTypes.filter((t) => t === "field" || t === "text").length}개 + + )} +
+
+
+ + {/* 동작 흐름 */} +
+ +
+ {clickAction === "none" && ( + 동작 없음 + )} + {clickAction === "modal-open" && ( +
+
모달 열기
+ {cfg.cardClickModalConfig?.screenId ? ( +
+ 대상: {cfg.cardClickModalConfig.screenId} + {cfg.cardClickModalConfig.modalTitle && ` (${cfg.cardClickModalConfig.modalTitle})`} +
+ ) : ( +
모달 미설정 - 동작 탭에서 설정하세요
+ )} +
+ )} + {clickAction === "built-in-work-detail" && ( +
+
작업 상세 (내장)
+
진행중(in_progress) 카드만 열림
+
+ )} +
+
+
+ ); +} + +// ===== (레거시) 탭: 데이터 ===== function TabData({ cfg, @@ -1414,7 +1479,7 @@ function CellDetailEditor({ {CELL_TYPE_GROUPS.map((group) => { - const types = (Object.entries(V2_CELL_TYPE_LABELS) as [CardCellType, { label: string; group: string }][]).filter(([, v]) => v.group === group); + const types = Object.entries(V2_CELL_TYPE_LABELS).filter(([, v]) => v.group === group); if (types.length === 0) return null; return ( @@ -1491,15 +1556,6 @@ function CellDetailEditor({
)} - {cell.type === "cart-button" && ( -
- 담기 버튼 설정 -
- onUpdate({ cartLabel: e.target.value })} placeholder="담기" className="h-7 flex-1 text-[10px]" /> - onUpdate({ cartCancelLabel: e.target.value })} placeholder="취소" className="h-7 flex-1 text-[10px]" /> -
-
- )}
); } @@ -2942,9 +2998,9 @@ function TabActions({ columns: ColumnInfo[]; }) { const designerCtx = usePopDesignerContext(); - const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }; const clickAction = cfg.cardClickAction || "none"; const modalConfig = cfg.cardClickModalConfig || { screenId: "" }; + const [advancedOpen, setAdvancedOpen] = useState(false); const [processColumns, setProcessColumns] = useState([]); const timelineCell = cfg.cardGrid?.cells?.find((c) => c.type === "timeline" && c.timelineSource?.processTable); @@ -2971,31 +3027,11 @@ function TabActions({ return (
- {/* 소유자 우선 정렬 */} -
- -
- -
-

- 선택한 컬럼 값이 현재 로그인 사용자와 일치하는 카드가 맨 위에 표시됩니다 -

-
- - {/* 카드 선택 시 */} + {/* 카드 선택 시 동작 */}
- {(["none", "publish", "navigate", "modal-open"] as V2CardClickAction[]).map((action) => ( + {(["none", "modal-open", "built-in-work-detail"] as V2CardClickAction[]).map((action) => ( ))}
+ + {/* 모달 열기 설정 */} {clickAction === "modal-open" && (
- {/* 모달 캔버스 (디자이너 모드) */} {designerCtx && (
{modalConfig.screenId?.startsWith("modal-") ? ( @@ -3049,7 +3085,6 @@ function TabActions({ )}
)} - {/* 뷰어 모드 또는 직접 입력 폴백 */} {!designerCtx && (
모달 ID @@ -3122,118 +3157,137 @@ function TabActions({ )}
)} + + {/* 작업 상세 내장 모드 안내 */} + {clickAction === "built-in-work-detail" && ( +

+ 카드 클릭 시 작업 상세 모달이 자동으로 열립니다. + 진행중(in_progress) 상태 카드만 열 수 있습니다. + 작업 상세 설정은 작업 상세 컴포넌트에서 직접 설정하세요. +

+ )}
- {/* 필터 전 비표시 */} -
- - onUpdate({ hideUntilFiltered: checked })} - /> -
- {cfg.hideUntilFiltered && ( -

- 연결된 컴포넌트에서 필터 값이 전달되기 전까지 데이터를 표시하지 않습니다. + {/* 내 작업 표시 모드 */} +

+ +
+ {([ + { value: "off", label: "전체 보기" }, + { value: "priority", label: "우선 표시" }, + { value: "only", label: "내 작업만" }, + ] as const).map((opt) => { + const current = !cfg.ownerSortColumn + ? "off" + : cfg.ownerFilterMode === "only" + ? "only" + : "priority"; + return ( + + ); + })} +
+

+ {!cfg.ownerSortColumn + ? "모든 작업자의 카드가 동일하게 표시됩니다" + : cfg.ownerFilterMode === "only" + ? "내가 담당인 작업만 표시되고, 다른 작업은 숨겨집니다" + : "내가 담당인 작업이 상단에 표시되고, 다른 작업은 비활성화로 표시됩니다"}

- )} - - {/* 스크롤 방향 */} -
- -
- {(["vertical", "horizontal"] as const).map((dir) => ( - - ))} -
- {/* 오버플로우 */} + {/* 고급 설정 (접이식) */}
- -
- {(["loadMore", "pagination"] as const).map((mode) => ( - - ))} -
-
-
- - onUpdate({ overflow: { ...overflow, visibleCount: Number(e.target.value) || 6 } })} - className="mt-0.5 h-7 text-[10px]" - /> -
- {overflow.mode === "loadMore" && ( + + {advancedOpen && ( +
+ {/* 내장 상태 탭 */} +
+ + onUpdate({ showStatusTabs: checked })} + /> +
+ {cfg.showStatusTabs && ( +

+ 카드 상단에 MES 상태 탭(전체/대기/접수가능/진행/완료)이 표시됩니다. + 별도 상태 바 컴포넌트가 필요 없습니다. +

+ )} + + {/* 필터 전 비표시 */} +
+ + onUpdate({ hideUntilFiltered: checked })} + /> +
+ {cfg.hideUntilFiltered && ( +
+

+ 연결된 컴포넌트에서 필터 값이 전달되기 전까지 데이터를 표시하지 않습니다. +

+
+ + onUpdate({ hideUntilFilteredMessage: e.target.value })} + placeholder="필터를 먼저 선택해주세요." + className="h-7 text-[10px]" + /> +
+
+ )} + + {/* 기본 표시 수 */}
- + onUpdate({ overflow: { ...overflow, loadMoreCount: Number(e.target.value) || 6 } })} + value={(cfg.overflow || { visibleCount: 6 }).visibleCount} + onChange={(e) => onUpdate({ + overflow: { + ...(cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }), + visibleCount: Number(e.target.value) || 6, + }, + })} className="mt-0.5 h-7 text-[10px]" /> +

+ 처음에 표시되는 카드 수 (기본: 6개) +

- )} - {overflow.mode === "pagination" && ( -
- - onUpdate({ overflow: { ...overflow, pageSize: Number(e.target.value) || 6 } })} - className="mt-0.5 h-7 text-[10px]" - /> -
- )} -
-
- - {/* 장바구니 */} -
- - { - if (checked) { - onUpdate({ cartAction: { saveMode: "cart", label: "담기", cancelLabel: "취소" } }); - } else { - onUpdate({ cartAction: undefined }); - } - }} - /> +
+ )}
); diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Preview.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Preview.tsx index 8ebaf913..2f692dd7 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Preview.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Preview.tsx @@ -29,7 +29,7 @@ export function PopCardListV2PreviewComponent({ config }: PopCardListV2PreviewPr
- 카드 목록 V2 + MES 공정흐름
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..deb573fc 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 @@ -9,8 +9,8 @@ import React, { useMemo, useState } from "react"; import { - ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star, - Loader2, CheckCircle2, CircleDot, Clock, + X, Package, Truck, Box, Archive, Heart, Star, + Loader2, CheckCircle2, CircleDot, Clock, Check, ChevronRight, type LucideIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -27,16 +27,9 @@ type RowData = Record; // ===== 공통 유틸 ===== const LUCIDE_ICON_MAP: Record = { - ShoppingCart, Package, Truck, Box, Archive, Heart, Star, + Package, Truck, Box, Archive, Heart, Star, }; -function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) { - if (!name) return ; - const IconComp = LUCIDE_ICON_MAP[name]; - if (!IconComp) return ; - return ; -} - function formatValue(value: unknown): string { if (value === null || value === undefined) return "-"; if (typeof value === "number") return value.toLocaleString(); @@ -60,11 +53,8 @@ export interface CellRendererProps { cell: CardCellDefinitionV2; row: RowData; inputValue?: number; - isCarted?: boolean; isButtonLoading?: boolean; onInputClick?: (e: React.MouseEvent) => void; - onCartAdd?: () => void; - onCartCancel?: () => void; onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void; onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record) => void; onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; @@ -89,8 +79,6 @@ export function renderCellV2(props: CellRendererProps): React.ReactNode { return ; case "number-input": return ; - case "cart-button": - return ; case "package-summary": return ; case "status-badge": @@ -101,6 +89,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 알 수 없는 셀 타입; } @@ -258,43 +250,7 @@ function NumberInputCell({ cell, row, inputValue, onInputClick }: CellRendererPr ); } -// ===== 7. cart-button ===== - -function CartButtonCell({ cell, row, isCarted, onCartAdd, onCartCancel }: CellRendererProps) { - const iconSize = 18; - const label = cell.cartLabel || "담기"; - const cancelLabel = cell.cartCancelLabel || "취소"; - - if (isCarted) { - return ( - - ); - } - - return ( - - ); -} - -// ===== 8. package-summary ===== +// ===== 7. package-summary ===== function PackageSummaryCell({ cell, packageEntries, inputUnit }: CellRendererProps) { if (!packageEntries || packageEntries.length === 0) return null; @@ -349,17 +305,21 @@ function StatusBadgeCell({ cell, row }: CellRendererProps) { ); } - const defaultColors = STATUS_COLORS[strValue]; + // in_progress + 접수분 전부 생산 완료 → "접수분완료" 표시 + const displayValue = strValue; + + const defaultColors = STATUS_COLORS[displayValue]; if (defaultColors) { const labelMap: Record = { - waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료", + waiting: "대기", accepted: "접수", in_progress: "진행중", + completed: "완료", }; return ( - {labelMap[strValue] || strValue} + {labelMap[displayValue] || displayValue} ); } @@ -514,6 +474,8 @@ function TimelineCell({ cell, row }: CellRendererProps) { })}
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}> @@ -587,6 +549,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
+
); } @@ -601,7 +564,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) { @@ -618,13 +585,25 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, currentUserId }: CellRendererProps) { const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; const currentProcess = processFlow?.find((s) => s.isCurrent); - const currentProcessId = currentProcess?.processId; + const currentProcessId = (row.__splitProcessId ?? row.__process_id ?? currentProcess?.processId) as string | number | undefined; 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 +612,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.__process_input_qty ?? row.input_qty ?? "0"), 10) || 0; + const totalProduced = parseInt(String(row.__process_total_production_qty ?? row.total_production_qty ?? "0"), 10) || 0; + const hasUnproduced = rowInputQty > totalProduced; + const showCancelBtn = effectiveStatus === "in_progress" && hasUnproduced && currentProcessId; + return (
+ {showCancelBtn && ( + + )}
); } @@ -703,7 +707,199 @@ 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 ?? row.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(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 && ( + + )} + {/* 프로그레스 바 */} +
+
+
+
+
+ {/* 수량 상세 */} +
+ + + + 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 +931,514 @@ 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" }, + 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 concessionQty = parseInt(String(row.__process_concession_qty ?? row.concession_qty ?? "0"), 10) || 0; + const isRework = String(row.__process_is_rework ?? row.is_rework ?? "N") === "Y"; + 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 displayStatus = rawStatus; + 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 ?? ""); + const itemName = String(row.item_name ?? ""); + + // 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; + let showManualComplete = false; + const isFullyProduced = inputQty > 0 && totalProd >= inputQty; + if (isClone) { + activeBtn = acceptBtn; + } else if (rawStatus === "acceptable") { + activeBtn = acceptBtn; + } else if (rawStatus === "in_progress") { + if (isFullyProduced) { + if (availableQty > 0) activeBtn = acceptBtn; + } else if (totalProd > 0) { + showManualComplete = true; + } else { + activeBtn = cancelBtn; + } + } + return ( + <> +
+ {/* ── 헤더 ── */} +
+
+
+ {woNo} + {processName && ( + + {processName} + {processFlow && processFlow.length > 1 && ` (${currentIdx + 1}/${processFlow.length})`} + + )} +
+
+ {itemName || itemId || "-"} +
+
+
+ {isRework && ( + + 재작업 + + )} + + {st.label} + +
+
+ + {/* ── 수량 메트릭 (상태별) ── */} +
+ {(isClone || rawStatus === "acceptable" || rawStatus === "waiting") && ( + + )} + {rawStatus === "in_progress" && ( + + )} + {rawStatus === "completed" && ( + + )} +
+ + {/* ── 공정 흐름 스트립 (클릭 시 모달) ── */} + {processFlow && processFlow.length > 0 && ( +
{ e.stopPropagation(); setFlowModalOpen(true); }} + title="클릭하여 공정 상세 보기" + > + +
+ )} + + {/* ── 부가정보 ── */} + {(row.end_date || row.equipment_id || row.work_team) && ( +
+
+ {row.end_date && 납기 {formatValue(row.end_date)}} + {row.equipment_id && {String(row.equipment_id)}} + {row.work_team && {String(row.work_team)}} +
+
+ )} + + {/* ── 액션 버튼 ── */} + {(activeBtn || showManualComplete) && ( +
+
+ {activeBtn && ( + + )} + {showManualComplete && ( + + )} +
+
+ )} +
+ + {/* ── 공정 상세 모달 ── */} + + + + {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 safeIdx = currentIdx >= 0 && currentIdx < steps.length ? currentIdx : -1; + const prevStep = safeIdx > 0 ? steps[safeIdx - 1] : null; + const currStep = safeIdx >= 0 ? steps[safeIdx] : null; + const nextStep = safeIdx >= 0 && safeIdx < steps.length - 1 ? steps[safeIdx + 1] : null; + + const hiddenBefore = safeIdx > 1 ? safeIdx - 1 : 0; + const hiddenAfter = safeIdx >= 0 && safeIdx < steps.length - 2 ? steps.length - safeIdx - 2 : 0; + + const allBeforeDone = hiddenBefore > 0 && safeIdx > 1 && steps.slice(0, safeIdx - 1).every(s => { + const sem = s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status] || "pending"; + return sem === "done"; + }); + + const renderNode = (step: TimelineProcessStep, isCurrent: boolean) => { + const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending"; + return ( +
+
+ {sem === "done" && !isCurrent ? : step.seqNo} +
+ + {step.processName} + +
+ ); + }; + + const connDone = "mt-[18px] h-[3px] w-5 shrink-0 bg-emerald-400"; + const connPending = "mt-[18px] h-[3px] w-5 shrink-0 bg-border"; + + return ( +
+ {hiddenBefore > 0 && ( + <> +
+
+ +{hiddenBefore} +
+
+
+ + )} + + {prevStep && ( + <> + {renderNode(prevStep, false)} +
+ + )} + + {currStep && renderNode(currStep, true)} + + {nextStep && ( + <> +
+ {renderNode(nextStep, false)} + + )} + + {hiddenAfter > 0 && ( + <> +
+
+
+ +{hiddenAfter} +
+
+ + )} +
+ ); +} + +// ── 접수가능 메트릭 ── +function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, isFirstProcess, isClone, isRework }: { + instrQty: number; prevGoodQty: number; availableQty: number; inputQty: number; isFirstProcess: boolean; isClone: boolean; isRework?: boolean; +}) { + if (isRework) { + return ( +
+
+ 불량 재작업 대상 +
+
+ 재작업 수량 + {inputQty.toLocaleString()} + EA +
+
+ ); + } + const displayAvail = isClone ? availableQty : (availableQty || prevGoodQty); + return ( +
+
+ 지시 {instrQty.toLocaleString()} + {!isFirstProcess && ( + 전공정양품 {prevGoodQty.toLocaleString()} + )} + {inputQty > 0 && ( + 기접수 {inputQty.toLocaleString()} + )} +
+
+ 접수가능 + {displayAvail.toLocaleString()} + EA +
+
+ ); +} + +// ── 진행중 메트릭 ── +function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, concessionQty, remainingQty, progressPct, availableQty, isBatchDone, statusColor }: { + inputQty: number; totalProd: number; goodQty: number; defectQty: number; concessionQty: number; remainingQty: number; progressPct: number; availableQty: number; isBatchDone: boolean; statusColor: string; +}) { + return ( +
+
+ 접수 {inputQty.toLocaleString()} + {availableQty > 0 && ( + 추가접수가능 {availableQty.toLocaleString()} + )} +
+
+ 생산 + + {totalProd.toLocaleString()} + + / {inputQty.toLocaleString()} + EA +
+
+ + 양품 {goodQty.toLocaleString()} + + {defectQty > 0 && ( + + 불량 {defectQty.toLocaleString()} + + )} + {concessionQty > 0 && ( + + 특채 {concessionQty.toLocaleString()} + + )} + 0 ? "bg-amber-50 text-amber-600" : "bg-emerald-50 text-emerald-600", + )}> + 잔여 {remainingQty.toLocaleString()} + +
+
+ ); +} + +// ── 완료 메트릭 ── +function MesCompletedMetrics({ instrQty, goodQty, defectQty, concessionQty, yieldRate }: { + instrQty: number; goodQty: number; defectQty: number; concessionQty: number; yieldRate: number; +}) { + return ( +
+
+ 지시 {instrQty.toLocaleString()} + = 95 ? "#f0fdf4" : yieldRate >= 80 ? "#fffbeb" : "#fef2f2", + color: yieldRate >= 95 ? "#059669" : yieldRate >= 80 ? "#d97706" : "#ef4444", + }} + > + 수율 {yieldRate}% + +
+
+ 최종양품 + {goodQty.toLocaleString()} + EA +
+ {(defectQty > 0 || concessionQty > 0) && ( +
+ {defectQty > 0 && ( + + 불량 {defectQty.toLocaleString()} + + )} + {concessionQty > 0 && ( + + 특채 {concessionQty.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-v2/index.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx index 138ab941..8bfb91e0 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/index.tsx @@ -32,8 +32,8 @@ const defaultConfig: PopCardListV2Config = { PopComponentRegistry.registerComponent({ id: "pop-card-list-v2", - name: "카드 목록 V2", - description: "슬롯 기반 카드 레이아웃 (CSS Grid + 셀 타입별 렌더링)", + name: "MES 공정흐름", + description: "MES 생산실적 카드 레이아웃 (공정 흐름 + 상태 관리)", category: "display", icon: "LayoutGrid", component: PopCardListV2Component, @@ -44,15 +44,10 @@ PopComponentRegistry.registerComponent({ sendable: [ { key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" }, { key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" }, - { key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" }, - { key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, - { key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, - { key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" }, + { key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (항목 + 매핑)" }, ], receivable: [ { key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, - { key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" }, - { key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" }, { key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" }, ], }, diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/migrate.ts b/frontend/lib/registry/pop-components/pop-card-list-v2/migrate.ts index e4bfed8f..267d1501 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/migrate.ts +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/migrate.ts @@ -66,7 +66,7 @@ export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Conf // 3. 본문 필드들 (이미지 오른쪽) const fieldStartCol = old.cardTemplate?.image?.enabled ? 2 : 1; const fieldColSpan = old.cardTemplate?.image?.enabled ? 2 : 3; - const hasRightActions = !!(old.inputField?.enabled || old.cartAction); + const hasRightActions = !!old.inputField?.enabled; (old.cardTemplate?.body?.fields || []).forEach((field, i) => { cells.push({ @@ -102,20 +102,7 @@ export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Conf limitColumn: old.inputField.limitColumn || old.inputField.maxColumn, }); } - if (old.cartAction) { - cells.push({ - id: "cart", - row: nextRow + Math.ceil(bodyRowSpan / 2), - col: rightCol, - rowSpan: Math.floor(bodyRowSpan / 2) || 1, - colSpan: 1, - type: "cart-button", - cartLabel: old.cartAction.label, - cartCancelLabel: old.cartAction.cancelLabel, - cartIconType: old.cartAction.iconType, - cartIconValue: old.cartAction.iconValue, - }); - } + // 5. 포장 요약 (마지막 행, full-width) if (old.packageConfig?.enabled) { @@ -156,8 +143,6 @@ export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Conf responsiveDisplay: old.responsiveDisplay, inputField: old.inputField, packageConfig: old.packageConfig, - cartAction: old.cartAction, - cartListMode: old.cartListMode, saveMapping: old.saveMapping, }; } 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-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index 60260693..aa94e851 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -14,7 +14,7 @@ import { useRouter } from "next/navigation"; import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star, - Trash2, + Trash2, Search, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -770,6 +770,13 @@ export function PopCardListComponent({ 데이터 소스를 설정해주세요.

+ ) : !isCartListMode && config?.requireFilter && externalFilters.size === 0 ? ( +
+ +

+ {config.requireFilterMessage || "필터를 먼저 선택해주세요."} +

+
) : loading ? (
diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx index f5d06036..793f6069 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -9,8 +9,11 @@ */ import React, { useState, useEffect, useMemo } from "react"; -import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react"; +import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check, ChevronsUpDown } from "lucide-react"; import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections"; +import { useAuth } from "@/hooks/useAuth"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import type { GridMode } from "@/components/pop/designer/types/pop-layout"; import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout"; import { Button } from "@/components/ui/button"; @@ -431,6 +434,32 @@ function BasicSettingsTab({ )} + {/* 필터 필수 설정 (장바구니 모드 아닐 때만) */} + {!isCartListMode && dataSource.tableName && ( + +
+
+ + onUpdate({ requireFilter: checked })} + /> +
+ {config.requireFilter && ( +
+ + onUpdate({ requireFilterMessage: e.target.value })} + placeholder="필터를 먼저 선택해주세요." + className="h-7 text-[10px]" + /> +
+ )} +
+
+ )} + {/* 저장 매핑 (장바구니 모드일 때만) */} {isCartListMode && ( void; }) { const mode: CartListModeConfig = cartListMode || { enabled: false }; - const [screens, setScreens] = useState<{ id: number; name: string }[]>([]); + const [screens, setScreens] = useState<{ id: number; name: string; code: string }[]>([]); const [sourceCardLists, setSourceCardLists] = useState([]); const [loadingComponents, setLoadingComponents] = useState(false); + const [screenOpen, setScreenOpen] = useState(false); + const { companyCode } = useAuth(); - // 화면 목록 로드 useEffect(() => { screenApi - .getScreens({ size: 500 }) + .getScreens({ size: 500, companyCode: companyCode || undefined }) .then((res) => { if (res?.data) { setScreens( res.data.map((s) => ({ id: s.screenId, name: s.screenName || `화면 ${s.screenId}`, + code: s.screenCode || "", })) ); } }) .catch(() => {}); - }, []); + }, [companyCode]); - // 원본 화면 선택 시 -> 해당 화면의 pop-card-list 컴포넌트 목록 로드 useEffect(() => { if (!mode.sourceScreenId) { setSourceCardLists([]); @@ -889,22 +919,7 @@ function CartListModeSection({ .finally(() => setLoadingComponents(false)); }, [mode.sourceScreenId]); - const handleScreenChange = (val: string) => { - const screenId = val === "__none__" ? undefined : Number(val); - onUpdate({ ...mode, sourceScreenId: screenId }); - }; - - const handleComponentSelect = (val: string) => { - if (val === "__none__") { - onUpdate({ ...mode, sourceComponentId: undefined }); - return; - } - const compId = val.startsWith("__comp_") ? val.replace("__comp_", "") : val; - const found = sourceCardLists.find((c) => c.componentId === compId); - if (found) { - onUpdate({ ...mode, sourceComponentId: found.componentId }); - } - }; + const selectedScreen = screens.find((s) => s.id === mode.sourceScreenId); return (
@@ -923,28 +938,69 @@ function CartListModeSection({ {mode.enabled && ( <> - {/* 원본 화면 선택 */} + {/* 원본 화면 선택 (검색 가능 Combobox) */}
- + + + + + + + + + + 검색 결과가 없습니다. + + + {screens.map((s) => ( + { + onUpdate({ + ...mode, + sourceScreenId: mode.sourceScreenId === s.id ? undefined : s.id, + sourceComponentId: mode.sourceScreenId === s.id ? undefined : mode.sourceComponentId, + }); + setScreenOpen(false); + }} + className="text-xs" + > + +
+ {s.name} + ID: {s.id} +
+
+ ))} +
+
+
+
+
- {/* 원본 컴포넌트 선택 (원본 화면에서 자동 로드) */} + {/* 원본 컴포넌트 선택 */} {mode.sourceScreenId && (
@@ -959,7 +1015,14 @@ function CartListModeSection({ ) : ( onValueChange(e.target.value)} - onFocus={() => onOpen?.()} - className="h-7 w-24 text-xs" - /> - ); - } + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && inputValue.trim()) { + e.preventDefault(); + onValueChange(inputValue.trim()); + setInputValue(""); + setOpen(false); + } + }; return ( @@ -2117,33 +2127,51 @@ function JsonKeySelect({ - - + 0}> + 0 ? "키 검색..." : "키 직접 입력..."} + className="text-xs" + value={inputValue} + onValueChange={setInputValue} + onKeyDown={handleInputKeyDown} + /> - - {keys.length === 0 ? "데이터를 불러오는 중..." : "일치하는 키가 없습니다."} - - - {keys.map((k) => ( - { - onValueChange(v === value ? "" : v); - setOpen(false); - }} - className="text-xs" - > - - {k} - - ))} - + {keys.length === 0 ? ( +
+ {inputValue.trim() + ? "Enter로 입력 확정" + : "테이블에 데이터가 없습니다. 키를 직접 입력하세요."} +
+ ) : ( + <> + + {inputValue.trim() + ? "Enter로 직접 입력 확정" + : "일치하는 키가 없습니다."} + + + {keys.map((k) => ( + { + onValueChange(v === value ? "" : v); + setOpen(false); + }} + className="text-xs" + > + + {k} + + ))} + + + )}
diff --git a/frontend/lib/registry/pop-components/pop-icon.tsx b/frontend/lib/registry/pop-components/pop-icon.tsx index ff04ab7b..93cbe9ae 100644 --- a/frontend/lib/registry/pop-components/pop-icon.tsx +++ b/frontend/lib/registry/pop-components/pop-icon.tsx @@ -103,6 +103,7 @@ export interface PopIconConfig { labelColor?: string; labelFontSize?: number; backgroundColor?: string; + iconColor?: string; gradient?: GradientConfig; borderRadiusPercent?: number; sizeMode: IconSizeMode; @@ -337,12 +338,14 @@ export function PopIconComponent({ setPendingNavigate(null); }; - // 배경 스타일 (이미지 타입일 때는 배경 없음) + // 배경 스타일: transparent 설정이 최우선 const backgroundStyle: React.CSSProperties = iconType === "image" ? { backgroundColor: "transparent" } - : config?.gradient - ? buildGradientStyle(config.gradient) - : { backgroundColor: config?.backgroundColor || "#e0e0e0" }; + : config?.backgroundColor === "transparent" + ? { backgroundColor: "transparent" } + : config?.gradient + ? buildGradientStyle(config.gradient) + : { backgroundColor: config?.backgroundColor || "hsl(var(--muted))" }; // 테두리 반경 (0% = 사각형, 100% = 원형) const radiusPercent = config?.borderRadiusPercent ?? 20; @@ -352,6 +355,8 @@ export function PopIconComponent({ const isLabelRight = config?.labelPosition === "right"; const showLabel = config?.labelPosition !== "none" && (config?.label || label); + const effectiveIconColor = config?.iconColor || "#ffffff"; + // 아이콘 렌더링 const renderIcon = () => { // 빠른 선택 @@ -361,7 +366,7 @@ export function PopIconComponent({ ); } else if (config?.quickSelectType === "emoji" && config?.quickSelectValue) { @@ -398,36 +403,40 @@ export function PopIconComponent({ return 📦; }; + const hasLabel = showLabel && (config?.label || label); + const labelFontSize = config?.labelFontSize || 12; + return (
- {/* 아이콘 컨테이너 */} + {/* 아이콘 컨테이너: 라벨이 있으면 라벨 공간만큼 축소 */}
{renderIcon()}
{/* 라벨 */} - {showLabel && ( + {hasLabel && ( {config?.label || label} @@ -453,8 +462,6 @@ export function PopIconComponent({ 확인 후 이동 @@ -853,23 +860,69 @@ function LabelSettings({ config, onUpdate }: PopIconConfigPanelProps) { // 스타일 설정 function StyleSettings({ config, onUpdate }: PopIconConfigPanelProps) { + const bgColor = config?.backgroundColor || ""; + const iconColor = config?.iconColor || "#ffffff"; + const isTransparent = bgColor === "transparent"; + return ( -
- - onUpdate({ - ...config, - borderRadiusPercent: Number(e.target.value) - })} - className="w-full" - /> +
+ {/* 배경색 */} +
+ +
+ + {!isTransparent && ( + onUpdate({ ...config, backgroundColor: e.target.value })} + className="h-8 w-12 cursor-pointer p-0.5" + /> + )} +
+
+ + {/* 아이콘 색상 */} +
+ + onUpdate({ ...config, iconColor: e.target.value })} + className="h-8 w-12 cursor-pointer p-0.5" + /> +
+ + {/* 모서리 */} +
+ + onUpdate({ + ...config, + borderRadiusPercent: Number(e.target.value) + })} + className="w-full" + /> +
); } diff --git a/frontend/lib/registry/pop-components/pop-profile.tsx b/frontend/lib/registry/pop-components/pop-profile.tsx index 49aaa10c..325b3ea3 100644 --- a/frontend/lib/registry/pop-components/pop-profile.tsx +++ b/frontend/lib/registry/pop-components/pop-profile.tsx @@ -111,7 +111,7 @@ function PopProfileComponent({ config: rawConfig }: PopProfileComponentProps) { sizeInfo.container, sizeInfo.text, )} - style={{ minWidth: sizeInfo.px, minHeight: sizeInfo.px }} + style={{ width: sizeInfo.px, height: sizeInfo.px, maxWidth: "100%", maxHeight: "100%" }} > {user?.photo && user.photo.trim() !== "" && user.photo !== "null" ? ( (config.defaultValue ?? ""); const [modalDisplayText, setModalDisplayText] = useState(""); const [simpleModalOpen, setSimpleModalOpen] = useState(false); + const initialValueAppliedRef = useRef(false); const normalizedType = normalizeInputType(config.inputType as string); const isModalType = normalizedType === "modal"; @@ -107,6 +110,21 @@ export function PopSearchComponent({ [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns] ); + // 초기값 고정 세팅: 사용자 프로필에서 자동으로 값 설정 + useEffect(() => { + if (initialValueAppliedRef.current) return; + if (!config.initialValueSource || config.initialValueSource.type !== "user_profile") return; + if (!user) return; + + const col = config.initialValueSource.column; + const profileValue = (user as Record)[col]; + if (profileValue != null && profileValue !== "") { + initialValueAppliedRef.current = true; + const timer = setTimeout(() => emitFilterChanged(profileValue), 100); + return () => clearTimeout(timer); + } + }, [user, config.initialValueSource, emitFilterChanged]); + useEffect(() => { if (!componentId) return; const unsub = subscribe( @@ -238,12 +256,6 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa return ; case "modal": return ; - case "status-chip": - return ( -
- pop-status-bar 컴포넌트를 사용하세요 -
- ); default: return ; } @@ -1014,8 +1026,11 @@ function IconView({ return (
onSelect(row)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect(row); }} >
{firstChar} diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index b0752146..e8fb977a 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -209,6 +209,39 @@ function StepBasicSettings({ cfg, update }: StepProps) {
)} + {/* 초기값 고정 세팅 */} +
+ + + {cfg.initialValueSource && ( +

+ 화면 진입 시 로그인 사용자의 {cfg.initialValueSource.column} 값으로 자동 필터링됩니다 +

+ )} +
+
); } @@ -231,15 +264,6 @@ function StepDetailSettings({ cfg, update, allComponents, connections, component return ; case "modal": return ; - case "status-chip": - return ( -
-

- 상태 칩은 pop-status-bar 컴포넌트로 분리되었습니다. - 새로운 "상태 바" 컴포넌트를 사용해주세요. -

-
- ); case "toggle": return (
diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index 5d455121..669cda03 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -1,25 +1,20 @@ // ===== pop-search 전용 타입 ===== // 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나. -/** 검색 필드 입력 타입 (10종) */ +/** 검색 필드 입력 타입 */ export type SearchInputType = | "text" | "number" | "date" | "date-preset" | "select" - | "multi-select" - | "combo" | "modal" - | "toggle" - | "status-chip"; + | "toggle"; -/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */ -export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid"; - -/** 레거시 타입 -> modal로 정규화 */ +/** 레거시 입력 타입 정규화 (DB 호환) */ export function normalizeInputType(t: string): SearchInputType { if (t === "modal-table" || t === "modal-card" || t === "modal-icon-grid") return "modal"; + if (t === "status-chip" || t === "multi-select" || t === "combo") return "text"; return t as SearchInputType; } @@ -38,15 +33,6 @@ export interface SelectOption { label: string; } -/** 셀렉트 옵션 데이터 소스 (DB에서 동적 로딩) */ -export interface SelectDataSource { - tableName: string; - valueColumn: string; - labelColumn: string; - sortColumn?: string; - sortDirection?: "asc" | "desc"; -} - /** 모달 보여주기 방식: 테이블 or 아이콘 */ export type ModalDisplayStyle = "table" | "icon"; @@ -79,22 +65,9 @@ export interface ModalSelectConfig { distinct?: boolean; } -/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */ -export type StatusChipStyle = "tab" | "pill"; - -/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */ -export interface StatusChipConfig { - showCount?: boolean; - countColumn?: string; - allowAll?: boolean; - allLabel?: string; - chipStyle?: StatusChipStyle; - useSubCount?: boolean; -} - /** pop-search 전체 설정 */ export interface PopSearchConfig { - inputType: SearchInputType | LegacySearchInputType; + inputType: SearchInputType | string; fieldName: string; placeholder?: string; defaultValue?: unknown; @@ -103,9 +76,8 @@ export interface PopSearchConfig { debounceMs?: number; triggerOnEnter?: boolean; - // select/multi-select 전용 + // select 전용 options?: SelectOption[]; - optionsDataSource?: SelectDataSource; // date 전용 dateSelectionMode?: DateSelectionMode; @@ -117,9 +89,6 @@ export interface PopSearchConfig { // modal 전용 modalConfig?: ModalSelectConfig; - // status-chip 전용 - statusChipConfig?: StatusChipConfig; - // 라벨 labelText?: string; labelVisible?: boolean; @@ -129,6 +98,12 @@ export interface PopSearchConfig { // 필터 대상 컬럼 복수 선택 (fieldName은 대표 컬럼, filterColumns는 전체 대상) filterColumns?: string[]; + + // 초기값 고정 세팅 (사용자 프로필에서 자동으로 값 설정) + initialValueSource?: { + type: "user_profile"; + column: string; + }; } /** 기본 설정값 (레지스트리 + 컴포넌트 공유) */ @@ -157,17 +132,8 @@ export const SEARCH_INPUT_TYPE_LABELS: Record = { date: "날짜", "date-preset": "날짜 프리셋", select: "단일 선택", - "multi-select": "다중 선택", - combo: "자동완성", modal: "모달", toggle: "토글", - "status-chip": "상태 칩 (대시보드)", -}; - -/** 상태 칩 스타일 라벨 (설정 패널용) */ -export const STATUS_CHIP_STYLE_LABELS: Record = { - tab: "탭 (큰 숫자)", - pill: "알약 (작은 뱃지)", }; /** 모달 보여주기 방식 라벨 */ 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-text.tsx b/frontend/lib/registry/pop-components/pop-text.tsx index bfbe5e06..d3bb7ab9 100644 --- a/frontend/lib/registry/pop-components/pop-text.tsx +++ b/frontend/lib/registry/pop-components/pop-text.tsx @@ -17,6 +17,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { icons as lucideIcons } from "lucide-react"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; import { FontSize, @@ -70,6 +71,9 @@ export interface PopTextConfig { fontWeight?: FontWeight; textAlign?: TextAlign; verticalAlign?: VerticalAlign; // 상하 정렬 + marquee?: boolean; // 마키(흐르는 텍스트) 활성화 + marqueeSpeed?: number; // 마키 속도 (초, 기본 15) + marqueeIcon?: string; // 마키 앞 아이콘 (lucide 이름) } const TEXT_TYPE_LABELS: Record = { @@ -223,6 +227,16 @@ function DesignModePreview({ ); default: // 일반 텍스트 미리보기 + if (config?.marquee) { + return ( +
+ [마키] + + {config?.content || label || "텍스트"} + +
+ ); + } return (
; + } - // 정렬 래퍼 클래스 const alignWrapperClass = cn( "flex w-full h-full", VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"], @@ -380,12 +398,56 @@ function TextDisplay({ return (
- {config?.content || label || "텍스트"} + {text}
); } +function MarqueeDisplay({ + config, + text, + sizeClass, +}: { + config?: PopTextConfig; + text: string; + sizeClass: string; +}) { + const speed = config?.marqueeSpeed || 15; + const iconName = config?.marqueeIcon; + const weightClass = FONT_WEIGHT_CLASSES[config?.fontWeight || "normal"]; + const uniqueId = React.useId().replace(/:/g, ""); + + return ( +
+ {iconName && (() => { + const pascalName = iconName.replace(/(^|-)(\w)/g, (_: string, __: string, c: string) => c.toUpperCase()); + const LucideIcon = (lucideIcons as Record>)[pascalName]; + return LucideIcon ? ( +
+ +
+ ) : null; + })()} +
+
+ {text} + {text} +
+ +
+
+ ); +} + // ======================================== // 설정 패널 // ======================================== @@ -450,6 +512,44 @@ export function PopTextConfigPanel({ className="text-xs resize-none" />
+ + {/* 마키(흐르는 텍스트) 설정 */} + +
+
+ + onUpdate({ ...config, marquee: v })} + /> +
+ {config?.marquee && ( + <> +
+ + onUpdate({ ...config, marqueeSpeed: Number(e.target.value) })} + className="w-full" + /> +
+
+ + onUpdate({ ...config, marqueeIcon: e.target.value })} + placeholder="예: flag, megaphone, info" + className="h-8 text-xs" + /> +
+ + )} +
+ 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 963d2148..954aab61 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx @@ -1,20 +1,23 @@ "use client"; -import React, { useEffect, useState, useMemo, useCallback } from "react"; +import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package, + ChevronLeft, ChevronRight, Check, X, CircleDot, ClipboardList, + Plus, Trash2, Save, FileCheck, Construction, } 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, ResultSectionType } from "../types"; import type { TimelineProcessStep } from "../types"; // ======================================== @@ -32,7 +35,8 @@ interface WorkResultRow { item_title: string; item_sort_order: string; detail_type: string; - detail_label: string; + detail_label: string | null; + detail_content: string; detail_sort_order: string; spec_value: string | null; lower_limit: string | null; @@ -41,8 +45,23 @@ interface WorkResultRow { result_value: string | null; status: string; is_passed: string | null; + is_required: string | null; recorded_by: string | null; recorded_at: string | null; + started_at: string | null; + group_started_at: string | null; + group_paused_at: string | null; + group_total_paused_time: string | null; + group_completed_at: string | null; + inspection_method: string | null; + unit: string | null; +} + +interface GroupTimerState { + startedAt: string | null; + pausedAt: string | null; + totalPausedTime: number; + completedAt: string | null; } interface WorkGroup { @@ -52,6 +71,8 @@ interface WorkGroup { sortOrder: number; total: number; completed: number; + stepStatus: "pending" | "active" | "completed"; + timer: GroupTimerState; } type WorkPhase = "PRE" | "IN" | "POST"; @@ -61,11 +82,57 @@ interface ProcessTimerData { started_at: string | null; paused_at: string | null; total_paused_time: string | null; + completed_at: string | null; + completed_by: string | null; + actual_work_time: string | null; 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 = [ + { label: "작업지시", column: "wo_no" }, + { label: "품목", column: "item_name" }, + { label: "공정", column: "__process_name" }, + { label: "지시수량", column: "qty" }, +]; + +const DEFAULT_CFG: PopWorkDetailConfig = { + showTimer: true, + showQuantityInput: false, + displayMode: "list", + phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, + infoBar: { enabled: true, fields: [] }, + stepControl: { requireStartBeforeInput: false, autoAdvance: true }, + navigation: { showPrevNext: true, showCompleteButton: true }, + resultSections: [ + { id: "total-qty", type: "total-qty", enabled: true, showCondition: { type: "always" } }, + { id: "good-defect", type: "good-defect", enabled: true, showCondition: { type: "always" } }, + { id: "defect-types", type: "defect-types", enabled: true, showCondition: { type: "always" } }, + { id: "note", type: "note", enabled: true, showCondition: { type: "always" } }, + ], +}; + // ======================================== // Props // ======================================== @@ -76,6 +143,7 @@ interface PopWorkDetailComponentProps { componentId?: string; currentRowSpan?: number; currentColSpan?: number; + parentRow?: RowData; } // ======================================== @@ -85,24 +153,31 @@ interface PopWorkDetailComponentProps { export function PopWorkDetailComponent({ config, screenId, - componentId, + parentRow: parentRowProp, }: PopWorkDetailComponentProps) { - const { getSharedData } = usePopEvent(screenId || "default"); + const { getSharedData, publish } = usePopEvent(screenId || "default"); const { user } = useAuth(); const cfg: PopWorkDetailConfig = { - showTimer: config?.showTimer ?? true, - showQuantityInput: config?.showQuantityInput ?? true, - phaseLabels: config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, + ...DEFAULT_CFG, + ...config, + displayMode: config?.displayMode ?? DEFAULT_CFG.displayMode, + infoBar: { ...DEFAULT_CFG.infoBar, ...config?.infoBar }, + stepControl: { ...DEFAULT_CFG.stepControl, ...config?.stepControl }, + navigation: { ...DEFAULT_CFG.navigation, ...config?.navigation }, + phaseLabels: { ...DEFAULT_CFG.phaseLabels, ...config?.phaseLabels }, }; - // parentRow에서 현재 공정 정보 추출 - const parentRow = getSharedData("parentRow"); + const parentRow = parentRowProp ?? getSharedData("parentRow"); const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined; const currentProcess = processFlow?.find((p) => p.isCurrent); - const workOrderProcessId = currentProcess?.processId - ? String(currentProcess.processId) - : undefined; + const workOrderProcessId = parentRow?.__splitProcessId + ? String(parentRow.__splitProcessId) + : parentRow?.__process_id + ? String(parentRow.__process_id) + : currentProcess?.processId + ? String(currentProcess.processId) + : undefined; const processName = currentProcess?.processName ?? "공정 상세"; // ======================================== @@ -115,13 +190,24 @@ export function PopWorkDetailComponent({ const [selectedGroupId, setSelectedGroupId] = useState(null); const [tick, setTick] = useState(Date.now()); const [savingIds, setSavingIds] = useState>(new Set()); + const [activeStepIds, setActiveStepIds] = useState>(new Set()); - // 수량 입력 로컬 상태 const [goodQty, setGoodQty] = useState(""); const [defectQty, setDefectQty] = useState(""); + 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); // ======================================== - // D-FE1: 데이터 로드 + // 데이터 로드 // ======================================== const fetchData = useCallback(async () => { @@ -129,10 +215,8 @@ export function PopWorkDetailComponent({ setLoading(false); return; } - try { setLoading(true); - const [resultRes, processRes] = await Promise.all([ dataApi.getTableData("process_work_result", { size: 500, @@ -143,9 +227,7 @@ export function PopWorkDetailComponent({ filters: { id: workOrderProcessId }, }), ]); - setAllResults((resultRes.data ?? []) as unknown as WorkResultRow[]); - const proc = (processRes.data?.[0] ?? null) as ProcessTimerData | null; setProcessData(proc); if (proc) { @@ -163,8 +245,20 @@ 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(); + }, []); + // ======================================== - // D-FE2: 좌측 사이드바 - 작업항목 그룹핑 + // 좌측 사이드바 - 작업항목 그룹핑 // ======================================== const groups = useMemo(() => { @@ -179,20 +273,34 @@ export function PopWorkDetailComponent({ sortOrder: parseInt(row.item_sort_order || "0", 10), total: 0, completed: 0, + stepStatus: "pending", + timer: { + startedAt: row.group_started_at ?? null, + pausedAt: row.group_paused_at ?? null, + totalPausedTime: parseInt(row.group_total_paused_time || "0", 10), + completedAt: row.group_completed_at ?? null, + }, }); } const g = map.get(key)!; g.total++; if (row.status === "completed") g.completed++; } - return Array.from(map.values()).sort( + const arr = Array.from(map.values()).sort( (a, b) => (PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) || a.sortOrder - b.sortOrder ); - }, [allResults]); + for (const g of arr) { + if (g.completed >= g.total && g.total > 0) { + g.stepStatus = "completed"; + } else if (activeStepIds.has(g.itemId) || g.timer.startedAt) { + g.stepStatus = "active"; + } + } + return arr; + }, [allResults, activeStepIds]); - // phase별로 그룹핑 const groupsByPhase = useMemo(() => { const result: Record = {}; for (const g of groups) { @@ -202,25 +310,95 @@ export function PopWorkDetailComponent({ return result; }, [groups]); - // 첫 그룹 자동 선택 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( + () => groups.findIndex((g) => g.itemId === selectedGroupId), + [groups, selectedGroupId] + ); // ======================================== - // D-FE3: 우측 체크리스트 + // 네비게이션 + // ======================================== + + const navigateStep = useCallback( + (delta: number) => { + const nextIdx = selectedIndex + delta; + if (nextIdx >= 0 && nextIdx < groups.length) { + setSelectedGroupId(groups[nextIdx].itemId); + contentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + } + }, + [selectedIndex, groups] + ); + + // ======================================== + // 우측 체크리스트 // ======================================== const currentItems = useMemo( () => allResults .filter((r) => r.source_work_item_id === selectedGroupId) - .sort((a, b) => parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10)), + .sort( + (a, b) => + parseInt(a.detail_sort_order || "0", 10) - + parseInt(b.detail_sort_order || "0", 10) + ), [allResults, selectedGroupId] ); + // 스텝 모드: 전체 항목을 flat 리스트로 정렬 + const flatItems = useMemo(() => { + const sorted = [...allResults].sort( + (a, b) => + (PHASE_ORDER[a.work_phase] ?? 9) - (PHASE_ORDER[b.work_phase] ?? 9) || + parseInt(a.item_sort_order || "0", 10) - parseInt(b.item_sort_order || "0", 10) || + parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10) + ); + return sorted; + }, [allResults]); + + // 스텝 모드: 모든 체크리스트 항목 완료 여부 + const allItemsCompleted = useMemo( + () => flatItems.length > 0 && flatItems.every((r) => r.status === "completed"), + [flatItems] + ); + + // 그룹 선택 변경 시 스텝 인덱스 리셋 + useEffect(() => { + setCurrentItemIdx(0); + }, [selectedGroupId]); + + // 스텝 모드 자동 다음 이동 + const stepAutoAdvance = useCallback(() => { + if (cfg.displayMode !== "step") return; + const nextPendingIdx = currentItems.findIndex( + (r, i) => i > currentItemIdx && r.status !== "completed" + ); + if (nextPendingIdx >= 0) { + setCurrentItemIdx(nextPendingIdx); + } else if (currentItems.every((r) => r.status === "completed")) { + // 현재 그룹 완료 → 다음 그룹 + const nextGroupIdx = groups.findIndex( + (g, i) => i > selectedIndex && g.stepStatus !== "completed" + ); + if (nextGroupIdx >= 0) { + setSelectedGroupId(groups[nextGroupIdx].itemId); + toast.success(`${groups[selectedIndex]?.title} 완료`); + } else { + setShowQuantityPanel(true); + } + } + }, [cfg.displayMode, currentItems, currentItemIdx, groups, selectedIndex, selectedGroupId]); + const saveResultValue = useCallback( async ( rowId: string, @@ -230,16 +408,33 @@ export function PopWorkDetailComponent({ ) => { setSavingIds((prev) => new Set(prev).add(rowId)); try { + const existingRow = allResults.find((r) => r.id === rowId); + const isFirstTouch = existingRow && !existingRow.started_at; + const now = new Date().toISOString(); + + const mkTask = (col: string, val: string) => ({ + type: "data-update" as const, + targetTable: "process_work_result", + targetColumn: col, + operationType: "assign" as const, + valueSource: "fixed" as const, + fixedValue: val, + lookupMode: "manual" as const, + manualItemField: "id", + manualPkColumn: "id", + }); + + const tasks = [ + mkTask("result_value", resultValue), + mkTask("status", newStatus), + ...(isPassed !== null ? [mkTask("is_passed", isPassed)] : []), + mkTask("recorded_by", user?.userId ?? ""), + mkTask("recorded_at", now), + ...(isFirstTouch ? [mkTask("started_at", now)] : []), + ]; + await apiClient.post("/pop/execute-action", { - tasks: [ - { type: "data-update", targetTable: "process_work_result", targetColumn: "result_value", value: resultValue, items: [{ id: rowId }] }, - { type: "data-update", targetTable: "process_work_result", targetColumn: "status", value: newStatus, items: [{ id: rowId }] }, - ...(isPassed !== null - ? [{ type: "data-update", targetTable: "process_work_result", targetColumn: "is_passed", value: isPassed, items: [{ id: rowId }] }] - : []), - { type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_by", value: user?.userId ?? "", items: [{ id: rowId }] }, - { type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_at", value: new Date().toISOString(), items: [{ id: rowId }] }, - ], + tasks, data: { items: [{ id: rowId }], fieldValues: {} }, }); @@ -252,11 +447,22 @@ export function PopWorkDetailComponent({ status: newStatus, is_passed: isPassed, recorded_by: user?.userId ?? null, - recorded_at: new Date().toISOString(), + recorded_at: now, + started_at: r.started_at ?? now, } : r ) ); + + if (cfg.stepControl.autoAdvance && newStatus === "completed") { + setTimeout(() => { + if (cfg.displayMode === "step") { + stepAutoAdvance(); + } else { + checkAutoAdvance(); + } + }, 300); + } } catch { toast.error("저장에 실패했습니다."); } finally { @@ -267,74 +473,168 @@ export function PopWorkDetailComponent({ }); } }, - [user?.userId] + // eslint-disable-next-line react-hooks/exhaustive-deps + [user?.userId, cfg.stepControl.autoAdvance, allResults] ); + const checkAutoAdvance = useCallback(() => { + if (!selectedGroupId) return; + const groupItems = allResults.filter( + (r) => r.source_work_item_id === selectedGroupId + ); + const requiredItems = groupItems.filter((r) => r.is_required === "Y"); + const allRequiredDone = requiredItems.length > 0 && requiredItems.every((r) => r.status === "completed"); + const allDone = groupItems.every((r) => r.status === "completed"); + + if (allRequiredDone || allDone) { + const idx = groups.findIndex((g) => g.itemId === selectedGroupId); + if (idx >= 0 && idx < groups.length - 1) { + const nextGroup = groups[idx + 1]; + setSelectedGroupId(nextGroup.itemId); + contentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + toast.success(`${groups[idx].title} 완료 → ${nextGroup.title}`); + } + } + }, [selectedGroupId, allResults, groups]); + // ======================================== - // D-FE4: 타이머 + // 단계 시작/활성화 + // ======================================== + + const isStepLocked = useMemo(() => { + if (!cfg.stepControl.requireStartBeforeInput) return false; + if (!selectedGroupId) return true; + return !activeStepIds.has(selectedGroupId); + }, [cfg.stepControl.requireStartBeforeInput, selectedGroupId, activeStepIds]); + + // ======================================== + // 프로세스 타이머 (전체 공정용) // ======================================== useEffect(() => { - if (!cfg.showTimer || !processData?.started_at) return; + if (!cfg.showTimer) return; + const hasActiveGroupTimer = groups.some( + (g) => g.timer.startedAt && !g.timer.completedAt + ); + if (!hasActiveGroupTimer && !processData?.started_at) return; const id = setInterval(() => setTick(Date.now()), 1000); return () => clearInterval(id); - }, [cfg.showTimer, processData?.started_at]); + }, [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() + // 프로세스 레벨 타이머는 그룹별 타이머로 대체됨 + + // ======================================== + // 그룹별 타이머 + // ======================================== + + const selectedGroupTimer = useMemo(() => { + const g = groups.find((g) => g.itemId === selectedGroupId); + return g?.timer ?? { startedAt: null, pausedAt: null, totalPausedTime: 0, completedAt: null }; + }, [groups, selectedGroupId]); + + // 그룹 타이머: 순수 작업시간 (일시정지 제외) + const groupWorkMs = useMemo(() => { + if (!selectedGroupTimer.startedAt) return 0; + const end = selectedGroupTimer.completedAt + ? new Date(selectedGroupTimer.completedAt).getTime() + : tick; + const start = new Date(selectedGroupTimer.startedAt).getTime(); + const totalMs = end - start; + const pausedMs = selectedGroupTimer.totalPausedTime * 1000; + const currentPauseMs = selectedGroupTimer.pausedAt + ? (selectedGroupTimer.completedAt ? 0 : tick - new Date(selectedGroupTimer.pausedAt).getTime()) : 0; - return Math.max(0, totalMs - pausedSec * 1000 - currentPauseMs); - }, [processData?.started_at, processData?.paused_at, processData?.total_paused_time, tick]); + return Math.max(0, totalMs - pausedMs - currentPauseMs); + }, [selectedGroupTimer, tick]); - const formattedTime = useMemo(() => { - const totalSec = Math.floor(elapsedMs / 1000); - const h = String(Math.floor(totalSec / 3600)).padStart(2, "0"); - const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, "0"); - const s = String(totalSec % 60).padStart(2, "0"); - return `${h}:${m}:${s}`; - }, [elapsedMs]); + // 그룹 타이머: 경과 시간 (일시정지 무시, 시작~끝) + const groupElapsedMs = useMemo(() => { + if (!selectedGroupTimer.startedAt) return 0; + const end = selectedGroupTimer.completedAt + ? new Date(selectedGroupTimer.completedAt).getTime() + : tick; + const start = new Date(selectedGroupTimer.startedAt).getTime(); + return Math.max(0, end - start); + }, [selectedGroupTimer, tick]); - const isPaused = !!processData?.paused_at; - const isStarted = !!processData?.started_at; + const groupTimerFormatted = useMemo(() => formatMsToTime(groupWorkMs), [groupWorkMs]); + const groupElapsedFormatted = useMemo(() => formatMsToTime(groupElapsedMs), [groupElapsedMs]); - const handleTimerAction = useCallback( - async (action: "start" | "pause" | "resume") => { - if (!workOrderProcessId) return; + const isGroupStarted = !!selectedGroupTimer.startedAt; + const isGroupPaused = !!selectedGroupTimer.pausedAt; + const isGroupCompleted = !!selectedGroupTimer.completedAt; + + const handleGroupTimerAction = useCallback( + async (action: "start" | "pause" | "resume" | "complete") => { + if (!workOrderProcessId || !selectedGroupId) return; try { - await apiClient.post("/api/pop/production/timer", { - workOrderProcessId, + await apiClient.post("/pop/production/group-timer", { + work_order_process_id: workOrderProcessId, + source_work_item_id: selectedGroupId, action, }); - // 타이머 상태 새로고침 + await fetchData(); + } catch { + toast.error("그룹 타이머 제어에 실패했습니다."); + } + }, + [workOrderProcessId, selectedGroupId, fetchData] + ); + + const handleTimerAction = useCallback( + async (action: "start" | "pause" | "resume" | "complete") => { + if (!workOrderProcessId) return; + try { + const body: Record = { + work_order_process_id: workOrderProcessId, + action, + }; + if (action === "complete") { + body.good_qty = goodQty || "0"; + body.defect_qty = defectQty || "0"; + } + await apiClient.post("/pop/production/timer", body); const res = await dataApi.getTableData("work_order_process", { size: 1, filters: { id: workOrderProcessId }, }); const proc = (res.data?.[0] ?? null) as ProcessTimerData | null; - if (proc) setProcessData(proc); + if (proc) { + setProcessData(proc); + if (action === "complete") { + toast.success("공정이 완료되었습니다."); + publish("process_completed", { workOrderProcessId, goodQty, defectQty }); + } + } } catch { toast.error("타이머 제어에 실패했습니다."); } }, - [workOrderProcessId] + [workOrderProcessId, goodQty, defectQty, publish] ); // ======================================== - // D-FE5: 수량 등록 + 완료 + // 수량 등록 + 완료 // ======================================== const handleQuantityRegister = useCallback(async () => { if (!workOrderProcessId) return; try { + const mkWopTask = (col: string, val: string) => ({ + type: "data-update" as const, + targetTable: "work_order_process", + targetColumn: col, + operationType: "assign" as const, + valueSource: "fixed" as const, + fixedValue: val, + lookupMode: "manual" as const, + manualItemField: "id", + manualPkColumn: "id", + }); await apiClient.post("/pop/execute-action", { tasks: [ - { type: "data-update", targetTable: "work_order_process", targetColumn: "good_qty", value: goodQty || "0", items: [{ id: workOrderProcessId }] }, - { type: "data-update", targetTable: "work_order_process", targetColumn: "defect_qty", value: defectQty || "0", items: [{ id: workOrderProcessId }] }, + mkWopTask("good_qty", goodQty || "0"), + mkWopTask("defect_qty", defectQty || "0"), ], data: { items: [{ id: workOrderProcessId }], fieldValues: {} }, }); @@ -345,23 +645,8 @@ export function PopWorkDetailComponent({ }, [workOrderProcessId, goodQty, defectQty]); const handleProcessComplete = useCallback(async () => { - if (!workOrderProcessId) return; - try { - await apiClient.post("/pop/execute-action", { - tasks: [ - { type: "data-update", targetTable: "work_order_process", targetColumn: "status", value: "completed", items: [{ id: workOrderProcessId }] }, - { type: "data-update", targetTable: "work_order_process", targetColumn: "completed_at", value: new Date().toISOString(), items: [{ id: workOrderProcessId }] }, - ], - data: { items: [{ id: workOrderProcessId }], fieldValues: {} }, - }); - toast.success("공정이 완료되었습니다."); - setProcessData((prev) => - prev ? { ...prev, status: "completed" } : prev - ); - } catch { - toast.error("공정 완료 처리에 실패했습니다."); - } - }, [workOrderProcessId]); + await handleTimerAction("complete"); + }, [handleTimerAction]); // ======================================== // 안전 장치 @@ -393,7 +678,9 @@ export function PopWorkDetailComponent({ ); } - if (allResults.length === 0) { + const isProcessCompleted = processData?.status === "completed"; + + if (allResults.length === 0 && !hasResultSections) { return (
@@ -401,8 +688,7 @@ export function PopWorkDetailComponent({
); } - - const isProcessCompleted = processData?.status === "completed"; + const selectedGroup = groups.find((g) => g.itemId === selectedGroupId); // ======================================== // 렌더링 @@ -410,144 +696,1002 @@ export function PopWorkDetailComponent({ return (
- {/* 헤더 */} -
-

{processName}

- {cfg.showTimer && ( -
- - - {formattedTime} - - {!isProcessCompleted && ( - <> - {!isStarted && ( - - )} - {isStarted && !isPaused && ( - - )} - {isStarted && isPaused && ( - - )} - - )} -
- )} -
+ {/* 작업지시 정보 바 */} + {cfg.infoBar.enabled && ( + 0 ? cfg.infoBar.fields : DEFAULT_INFO_FIELDS} + parentRow={parentRow} + processName={processName} + /> + )} - {/* 본문: 좌측 사이드바 + 우측 체크리스트 */} + {/* 전체 공정 진행 요약은 제거 - 타이머는 그룹 헤더로 이동 */} + + {/* 본문: 좌측 사이드바 + 우측 콘텐츠 */}
{/* 좌측 사이드바 */} -
+
{(["PRE", "IN", "POST"] as WorkPhase[]).map((phase) => { const phaseGroups = groupsByPhase[phase]; if (!phaseGroups || phaseGroups.length === 0) return null; return (
-
+
{cfg.phaseLabels[phase] ?? phase}
{phaseGroups.map((g) => ( ))}
); })} + + {/* 실적 입력 탭 (resultSections가 설정된 경우만) */} + {cfg.resultSections && cfg.resultSections.some((s) => s.enabled) && ( + <> +
+ + + )}
- {/* 우측 체크리스트 */} -
- {selectedGroupId && ( -
- {currentItems.map((item) => ( - + {/* 실적 입력 패널 (hidden으로 상태 유지, unmount 방지) */} + {hasResultSections && ( +
+ { + setProcessData((prev) => prev ? { ...prev, ...updated } : prev); + publish("process_completed", { workOrderProcessId, status: updated?.status }); + }} + /> +
+ )} + + {/* 체크리스트 영역 */} +
+ {cfg.displayMode === "step" ? ( + /* ======== 스텝 모드 ======== */ + <> + {showQuantityPanel || allItemsCompleted ? ( + /* 수량 등록 + 공정 완료 화면 */ +
+ +

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

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

실적 수량 등록

+
+
+ 양품 + setGoodQty(e.target.value)} placeholder="0" /> +
+
+ 불량 + setDefectQty(e.target.value)} placeholder="0" /> +
+ {(parseInt(goodQty, 10) || 0) + (parseInt(defectQty, 10) || 0) > 0 && ( +

+ 합계: {(parseInt(goodQty, 10) || 0) + (parseInt(defectQty, 10) || 0)} +

+ )} +
+
+ )} + + {!isProcessCompleted && cfg.navigation.showCompleteButton && ( + + )} + + {isProcessCompleted && ( + + 공정이 완료되었습니다 + + )} +
+ ) : ( + /* 단계별 항목 표시 */ + <> + {/* 그룹 헤더 + 타이머 + 진행률 */} + {selectedGroup && ( + <> + +
+
+ {currentItemIdx + 1} / {currentItems.length} +
+
+
0 && selectedGroup.total > 0 ? ((selectedGroup.completed / selectedGroup.total) * 100) : 0}%`, + }} + /> +
+
+ + )} + + {/* 현재 항목 1개 표시 */} +
+ {currentItems[currentItemIdx] && ( +
+ {currentItems[currentItemIdx].started_at && ( +
+ + {currentItems[currentItemIdx].recorded_at + ? formatDuration(currentItems[currentItemIdx].started_at!, currentItems[currentItemIdx].recorded_at!) + : "진행 중..."} +
+ )} + +
+ )} +
+ + {/* 스텝 네비게이션 */} +
+ + + + {selectedGroup?.title} ({currentItemIdx + 1}/{currentItems.length}) + + + +
+ + )} + + ) : ( + /* ======== 리스트 모드 (기존) ======== */ + <> + {/* 그룹 헤더 + 타이머 */} + {selectedGroup && ( + + )} + + {/* 체크리스트 콘텐츠 */} +
+ {selectedGroupId && ( +
+ {currentItems.map((item) => ( + + ))} +
+ )} +
+ + {/* 하단 네비게이션 + 수량/완료 */} +
+ {cfg.showQuantityInput && !hasResultSections && ( +
+ +
+ 양품 + setGoodQty(e.target.value)} disabled={isProcessCompleted} /> +
+
+ 불량 + setDefectQty(e.target.value)} disabled={isProcessCompleted} /> +
+ +
+ )} + + {cfg.navigation.showPrevNext && ( +
+ + + + {selectedIndex + 1} / {groups.length} + + + {selectedIndex < groups.length - 1 ? ( + + ) : cfg.navigation.showCompleteButton && !isProcessCompleted ? ( + + ) : ( +
+ )} +
+ )} +
+ + )} +
+
+
+
+ ); +} + +// ======================================== +// 실적 입력 패널 (분할 실적 누적 방식) +// ======================================== + +const DISPOSITION_OPTIONS = [ + { value: "scrap", label: "폐기" }, + { value: "rework", 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; +} + +const IMPLEMENTED_SECTIONS = new Set(["total-qty", "good-defect", "defect-types", "note"]); + +const SECTION_LABELS: Record = { + "total-qty": "생산수량", + "good-defect": "양품/불량", + "defect-types": "불량 유형 상세", + "note": "비고", + "box-packing": "박스 포장", + "label-print": "라벨 출력", + "photo": "사진", + "document": "문서", + "material-input": "자재 투입", + "barcode-scan": "바코드 스캔", + "plc-data": "PLC 데이터", +}; + +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") && ( +
+ +