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/.omc/project-memory.json b/.omc/project-memory.json index b780218b..80e41159 100644 --- a/.omc/project-memory.json +++ b/.omc/project-memory.json @@ -354,8 +354,8 @@ "hotPaths": [ { "path": "frontend/app/(main)/sales/order/page.tsx", - "accessCount": 16, - "lastAccessed": 1774313958064, + "accessCount": 19, + "lastAccessed": 1774408850812, "type": "file" }, { @@ -366,14 +366,14 @@ }, { "path": "frontend/components/common/DataGrid.tsx", - "accessCount": 3, - "lastAccessed": 1774313504763, + "accessCount": 4, + "lastAccessed": 1774408732451, "type": "file" }, { "path": "frontend/components/common/DynamicSearchFilter.tsx", - "accessCount": 2, - "lastAccessed": 1774313460662, + "accessCount": 3, + "lastAccessed": 1774408732309, "type": "file" }, { @@ -435,6 +435,12 @@ "accessCount": 1, "lastAccessed": 1774313925751, "type": "file" + }, + { + "path": "frontend/components/common/TableSettingsModal.tsx", + "accessCount": 1, + "lastAccessed": 1774409034693, + "type": "file" } ], "userDirectives": [] diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts index face22f5..4974c80f 100644 --- a/backend-node/src/controllers/packagingController.ts +++ b/backend-node/src/controllers/packagingController.ts @@ -173,7 +173,11 @@ export async function getPkgUnitItems( const pool = getPool(); const result = await pool.query( - `SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`, + `SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit + FROM pkg_unit_item pui + LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code + WHERE pui.pkg_code=$1 AND pui.company_code=$2 + ORDER BY pui.created_date DESC`, [pkgCode, companyCode] ); @@ -410,7 +414,11 @@ export async function getLoadingUnitPkgs( const pool = getPool(); const result = await pool.query( - `SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`, + `SELECT lup.*, pu.pkg_name, pu.pkg_type + FROM loading_unit_pkg lup + LEFT JOIN pkg_unit pu ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code + WHERE lup.loading_code=$1 AND lup.company_code=$2 + ORDER BY lup.created_date DESC`, [loadingCode, companyCode] ); 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/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts index a8d99fb1..3b64928b 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -148,9 +148,9 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo const { processCode } = req.params; const result = await pool.query( - `SELECT pe.*, ei.equipment_name + `SELECT pe.*, em.equipment_name FROM process_equipment pe - LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code + LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code WHERE pe.process_code = $1 AND pe.company_code = $2 ORDER BY pe.equipment_code`, [processCode, companyCode] @@ -214,7 +214,7 @@ export async function getEquipmentList(req: AuthenticatedRequest, res: Response) const params = companyCode === "*" ? [] : [companyCode]; const result = await pool.query( - `SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`, + `SELECT objid AS id, equipment_code, equipment_name FROM equipment_mng ${condition} ORDER BY equipment_code`, params ); 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/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index cbb40203..7f5c5f2e 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2717,6 +2717,43 @@ export class TableManagementService { logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`); } + // entity 컬럼의 display_column 자동 채우기 (예: supplier_code → supplier_name) + try { + const companyCode = data.company_code || "*"; + const entityColsResult = await query( + `SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column + FROM table_type_columns + WHERE table_name = $1 AND input_type = 'entity' + AND reference_table IS NOT NULL AND reference_table != '' + AND display_column IS NOT NULL AND display_column != '' + AND company_code IN ($2, '*') + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + [tableName, companyCode] + ); + + for (const ec of entityColsResult) { + const srcVal = data[ec.column_name]; + const displayCol = ec.display_column; + // display_column이 테이블에 존재하고, 값이 비어있거나 없으면 자동 조회 + if (srcVal && columnTypeMap.has(displayCol) && (!data[displayCol] || data[displayCol] === "")) { + try { + const refResult = await query( + `SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`, + [srcVal, companyCode] + ); + if (refResult.length > 0 && refResult[0][displayCol]) { + data[displayCol] = refResult[0][displayCol]; + logger.info(`Entity auto-fill: ${tableName}.${displayCol} = ${data[displayCol]} (from ${ec.reference_table})`); + } + } catch (refErr: any) { + logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`); + } + } + } + } catch (entityErr: any) { + logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`); + } + // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시) const skippedColumns: string[] = []; const existingColumns = Object.keys(data).filter((col) => { @@ -2868,6 +2905,42 @@ export class TableManagementService { logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`); } + // entity 컬럼의 display_column 자동 채우기 (수정 시) + try { + const companyCode = updatedData.company_code || originalData.company_code || "*"; + const entityColsResult = await query( + `SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column + FROM table_type_columns + WHERE table_name = $1 AND input_type = 'entity' + AND reference_table IS NOT NULL AND reference_table != '' + AND display_column IS NOT NULL AND display_column != '' + AND company_code IN ($2, '*') + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + [tableName, companyCode] + ); + + for (const ec of entityColsResult) { + const srcVal = updatedData[ec.column_name]; + const displayCol = ec.display_column; + if (srcVal && columnTypeMap.has(displayCol) && (!updatedData[displayCol] || updatedData[displayCol] === "")) { + try { + const refResult = await query( + `SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`, + [srcVal, companyCode] + ); + if (refResult.length > 0 && refResult[0][displayCol]) { + updatedData[displayCol] = refResult[0][displayCol]; + logger.info(`Entity auto-fill (edit): ${tableName}.${displayCol} = ${updatedData[displayCol]}`); + } + } catch (refErr: any) { + logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`); + } + } + } + } catch (entityErr: any) { + logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`); + } + // SET 절 생성 (수정할 데이터) - 먼저 생성 // 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외) const setConditions: string[] = []; diff --git a/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl b/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl new file mode 100644 index 00000000..eeffca86 --- /dev/null +++ b/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl @@ -0,0 +1,14 @@ +{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":110167} +{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":196548} +{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":253997} +{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":339528} +{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":380641} +{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":413980} +{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":401646} diff --git a/frontend/.omc/state/idle-notif-cooldown.json b/frontend/.omc/state/idle-notif-cooldown.json index 0a83ceb2..9b6eaa2a 100644 --- a/frontend/.omc/state/idle-notif-cooldown.json +++ b/frontend/.omc/state/idle-notif-cooldown.json @@ -1,3 +1,3 @@ { - "lastSentAt": "2026-03-25T01:37:37.051Z" + "lastSentAt": "2026-03-25T05:06:13.529Z" } \ No newline at end of file diff --git a/frontend/.omc/state/last-tool-error.json b/frontend/.omc/state/last-tool-error.json index 4ee2ec12..cc6d2569 100644 --- a/frontend/.omc/state/last-tool-error.json +++ b/frontend/.omc/state/last-tool-error.json @@ -1,7 +1,7 @@ { - "tool_name": "Read", - "tool_input_preview": "{\"file_path\":\"/Users/kimjuseok/ERP-node/frontend/app/(main)/sales/sales-item/page.tsx\"}", - "error": "File content (13282 tokens) exceeds maximum allowed tokens (10000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.", - "timestamp": "2026-03-25T01:36:58.910Z", + "tool_name": "Bash", + "tool_input_preview": "{\"command\":\"wc -l /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx\",\"description\":\"Get total line count of the file\"}", + "error": "Exit code 1\n(eval):1: no matches found: /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx", + "timestamp": "2026-03-25T05:00:38.410Z", "retry_count": 1 } \ No newline at end of file diff --git a/frontend/.omc/state/mission-state.json b/frontend/.omc/state/mission-state.json index 900ee157..a46a9962 100644 --- a/frontend/.omc/state/mission-state.json +++ b/frontend/.omc/state/mission-state.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-03-25T01:37:19.659Z", + "updatedAt": "2026-03-25T05:06:35.487Z", "missions": [ { "id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none", @@ -104,6 +104,178 @@ "sourceKey": "session-stop:a9a231d40fd5a150b" } ] + }, + { + "id": "session:037169c7-72ba-4843-8e9a-417ca1423715:none", + "source": "session", + "name": "none", + "objective": "Session mission", + "createdAt": "2026-03-25T04:59:24.101Z", + "updatedAt": "2026-03-25T05:06:35.487Z", + "status": "done", + "workerCount": 7, + "taskCounts": { + "total": 7, + "pending": 0, + "blocked": 0, + "inProgress": 0, + "completed": 7, + "failed": 0 + }, + "agents": [ + { + "name": "executor:a32b34c", + "role": "executor", + "ownership": "a32b34c341b854da5", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:06:18.081Z" + }, + { + "name": "executor:ad2c89c", + "role": "executor", + "ownership": "ad2c89cf14936ea42", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:02:45.524Z" + }, + { + "name": "executor:a2c140c", + "role": "executor", + "ownership": "a2c140c5a5adb0719", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:05:13.388Z" + }, + { + "name": "executor:a2e5213", + "role": "executor", + "ownership": "a2e52136ea8f04385", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:03:53.163Z" + }, + { + "name": "executor:a3735bf", + "role": "executor", + "ownership": "a3735bf51a74d6fc8", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:01:33.817Z" + }, + { + "name": "executor:a77742b", + "role": "executor", + "ownership": "a77742ba65fd2451c", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:06:09.324Z" + }, + { + "name": "executor:a4eb932", + "role": "executor", + "ownership": "a4eb932c438b898c0", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:06:35.487Z" + } + ], + "timeline": [ + { + "id": "session-start:a3735bf51a74d6fc8:2026-03-25T04:59:43.650Z", + "at": "2026-03-25T04:59:43.650Z", + "kind": "update", + "agent": "executor:a3735bf", + "detail": "started executor:a3735bf", + "sourceKey": "session-start:a3735bf51a74d6fc8" + }, + { + "id": "session-start:a77742ba65fd2451c:2026-03-25T04:59:48.683Z", + "at": "2026-03-25T04:59:48.683Z", + "kind": "update", + "agent": "executor:a77742b", + "detail": "started executor:a77742b", + "sourceKey": "session-start:a77742ba65fd2451c" + }, + { + "id": "session-start:a4eb932c438b898c0:2026-03-25T04:59:53.841Z", + "at": "2026-03-25T04:59:53.841Z", + "kind": "update", + "agent": "executor:a4eb932", + "detail": "started executor:a4eb932", + "sourceKey": "session-start:a4eb932c438b898c0" + }, + { + "id": "session-stop:a3735bf51a74d6fc8:2026-03-25T05:01:33.817Z", + "at": "2026-03-25T05:01:33.817Z", + "kind": "completion", + "agent": "executor:a3735bf", + "detail": "completed", + "sourceKey": "session-stop:a3735bf51a74d6fc8" + }, + { + "id": "session-stop:ad2c89cf14936ea42:2026-03-25T05:02:45.524Z", + "at": "2026-03-25T05:02:45.524Z", + "kind": "completion", + "agent": "executor:ad2c89c", + "detail": "completed", + "sourceKey": "session-stop:ad2c89cf14936ea42" + }, + { + "id": "session-stop:a2e52136ea8f04385:2026-03-25T05:03:53.163Z", + "at": "2026-03-25T05:03:53.163Z", + "kind": "completion", + "agent": "executor:a2e5213", + "detail": "completed", + "sourceKey": "session-stop:a2e52136ea8f04385" + }, + { + "id": "session-stop:a2c140c5a5adb0719:2026-03-25T05:05:13.388Z", + "at": "2026-03-25T05:05:13.388Z", + "kind": "completion", + "agent": "executor:a2c140c", + "detail": "completed", + "sourceKey": "session-stop:a2c140c5a5adb0719" + }, + { + "id": "session-stop:a77742ba65fd2451c:2026-03-25T05:06:09.324Z", + "at": "2026-03-25T05:06:09.324Z", + "kind": "completion", + "agent": "executor:a77742b", + "detail": "completed", + "sourceKey": "session-stop:a77742ba65fd2451c" + }, + { + "id": "session-stop:a32b34c341b854da5:2026-03-25T05:06:18.081Z", + "at": "2026-03-25T05:06:18.081Z", + "kind": "completion", + "agent": "executor:a32b34c", + "detail": "completed", + "sourceKey": "session-stop:a32b34c341b854da5" + }, + { + "id": "session-stop:a4eb932c438b898c0:2026-03-25T05:06:35.487Z", + "at": "2026-03-25T05:06:35.487Z", + "kind": "completion", + "agent": "executor:a4eb932", + "detail": "completed", + "sourceKey": "session-stop:a4eb932c438b898c0" + } + ] } ] } \ No newline at end of file diff --git a/frontend/.omc/state/subagent-tracking.json b/frontend/.omc/state/subagent-tracking.json index 32d6a63e..355a60d1 100644 --- a/frontend/.omc/state/subagent-tracking.json +++ b/frontend/.omc/state/subagent-tracking.json @@ -44,10 +44,73 @@ "status": "completed", "completed_at": "2026-03-25T01:37:19.659Z", "duration_ms": 139427 + }, + { + "agent_id": "a32b34c341b854da5", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:24.101Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:06:18.081Z", + "duration_ms": 413980 + }, + { + "agent_id": "ad2c89cf14936ea42", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:28.976Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:02:45.524Z", + "duration_ms": 196548 + }, + { + "agent_id": "a2c140c5a5adb0719", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:33.860Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:05:13.388Z", + "duration_ms": 339528 + }, + { + "agent_id": "a2e52136ea8f04385", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:39.166Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:03:53.163Z", + "duration_ms": 253997 + }, + { + "agent_id": "a3735bf51a74d6fc8", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:43.650Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:01:33.817Z", + "duration_ms": 110167 + }, + { + "agent_id": "a77742ba65fd2451c", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:48.683Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:06:09.324Z", + "duration_ms": 380641 + }, + { + "agent_id": "a4eb932c438b898c0", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:53.841Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:06:35.487Z", + "duration_ms": 401646 } ], - "total_spawned": 5, - "total_completed": 5, + "total_spawned": 12, + "total_completed": 12, "total_failed": 0, - "last_updated": "2026-03-25T01:37:19.762Z" + "last_updated": "2026-03-25T05:06:35.589Z" } \ No newline at end of file diff --git a/frontend/app/(main)/equipment/info/page.tsx b/frontend/app/(main)/equipment/info/page.tsx index fb82e1f2..9bb5fede 100644 --- a/frontend/app/(main)/equipment/info/page.tsx +++ b/frontend/app/(main)/equipment/info/page.tsx @@ -20,13 +20,14 @@ import { Checkbox } from "@/components/ui/checkbox"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, - Wrench, ClipboardCheck, Package, Copy, Info, + Wrench, ClipboardCheck, Package, Copy, Info, Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; @@ -78,6 +79,8 @@ export default function EquipmentInfoPage() { const [equipLoading, setEquipLoading] = useState(false); const [equipCount, setEquipCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); const [selectedEquipId, setSelectedEquipId] = useState(null); // 우측 탭 @@ -119,6 +122,15 @@ export default function EquipmentInfoPage() { const [excelChainConfig, setExcelChainConfig] = useState(null); const [excelDetecting, setExcelDetecting] = useState(false); + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + useEffect(() => { + const saved = loadTableSettings("equipment-info"); + if (saved) applyTableSettings(saved); + }, []); + // 카테고리 로드 useEffect(() => { const load = async () => { @@ -395,8 +407,12 @@ export default function EquipmentInfoPage() { return (
+
); diff --git a/frontend/app/(main)/logistics/packaging/page.tsx b/frontend/app/(main)/logistics/packaging/page.tsx index 0b91e1ae..79059277 100644 --- a/frontend/app/(main)/logistics/packaging/page.tsx +++ b/frontend/app/(main)/logistics/packaging/page.tsx @@ -105,6 +105,7 @@ export default function PackagingPage() { const [pkgMatchQty, setPkgMatchQty] = useState(1); const [pkgMatchMethod, setPkgMatchMethod] = useState(""); const [pkgMatchSelected, setPkgMatchSelected] = useState(null); + const [pkgMatchSearchKw, setPkgMatchSearchKw] = useState(""); const [saving, setSaving] = useState(false); @@ -313,7 +314,7 @@ export default function PackagingPage() { // --- 포장단위 추가 모달 (적재함 구성) --- const openPkgMatchModal = () => { - setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod(""); + setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod(""); setPkgMatchSearchKw(""); setPkgMatchModalOpen(true); }; @@ -790,11 +791,19 @@ export default function PackagingPage() { 포장재에 매칭할 품목을 검색하여 추가합니다.
-
- setItemMatchKeyword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && searchItemsForMatch()} className="h-9 text-xs" /> - -
+ { + setItemMatchKeyword(e.target.value); + const kw = e.target.value; + clearTimeout((window as any).__itemMatchTimer); + (window as any).__itemMatchTimer = setTimeout(async () => { + try { + const res = await getGeneralItems(kw || undefined); + if (res.success) setItemMatchResults(res.data); + } catch { /* ignore */ } + }, 300); + }} + className="h-9 text-xs" />
@@ -808,9 +817,9 @@ export default function PackagingPage() { - {itemMatchResults.length === 0 ? ( + {itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).length === 0 ? ( 검색 결과가 없습니다 - ) : itemMatchResults.map((item) => ( + ) : itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).map((item) => ( setItemMatchSelected(item)}> {itemMatchSelected?.id === item.id ? "✓" : ""} @@ -830,8 +839,8 @@ export default function PackagingPage() {
- - setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" /> + + setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
@@ -844,41 +853,59 @@ export default function PackagingPage() { {/* 포장단위 추가 모달 (적재함 구성) */} - + 포장단위 추가 — {selectedLoading?.loading_name} 적재함에 적재할 포장단위를 선택합니다.
-
+ setPkgMatchSearchKw(e.target.value)} + className="h-9 text-xs" + /> +
- 포장코드 + 포장코드 포장명 유형 + 크기(mm) + 최대중량 - {pkgUnits.length === 0 ? ( - 포장단위가 없습니다 - ) : pkgUnits.filter(p => p.status === "ACTIVE").map((p) => ( - setPkgMatchSelected(p)}> - {pkgMatchSelected?.id === p.id ? "✓" : ""} - {p.pkg_code} - {p.pkg_name} - {PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type} - - ))} + {(() => { + const kw = pkgMatchSearchKw.toLowerCase(); + const filtered = pkgUnits.filter(p => + p.status === "ACTIVE" + && !loadingPkgs.some(lp => lp.pkg_code === p.pkg_code) + && (!kw || p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw)) + ); + return filtered.length === 0 ? ( + 추가 가능한 포장단위가 없습니다 + ) : filtered.map((p) => ( + setPkgMatchSelected(p)}> + {pkgMatchSelected?.id === p.id ? "✓" : ""} + {p.pkg_code} + {p.pkg_name} + {PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type} + {fmtSize(p.width_mm, p.length_mm, p.height_mm)} + {Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"} + + )); + })()}
- - setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" /> + + setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
diff --git a/frontend/app/(main)/master-data/department/page.tsx b/frontend/app/(main)/master-data/department/page.tsx index 796f4ef0..4e943810 100644 --- a/frontend/app/(main)/master-data/department/page.tsx +++ b/frontend/app/(main)/master-data/department/page.tsx @@ -21,13 +21,14 @@ import { Label } from "@/components/ui/label"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, - Building2, Users, + Building2, Users, Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; @@ -63,7 +64,9 @@ export default function DepartmentPage() { const [deptLoading, setDeptLoading] = useState(false); const [deptCount, setDeptCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); - const [selectedDeptCode, setSelectedDeptCode] = useState(null); + const [selectedDeptId, setSelectedDeptId] = useState(null); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); // 우측: 사원 const [members, setMembers] = useState([]); @@ -77,12 +80,22 @@ export default function DepartmentPage() { // 사원 모달 const [userModalOpen, setUserModalOpen] = useState(false); + const [userEditMode, setUserEditMode] = useState(false); const [userForm, setUserForm] = useState>({}); const [formErrors, setFormErrors] = useState>({}); // 엑셀 const [excelUploadOpen, setExcelUploadOpen] = useState(false); + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + useEffect(() => { + const saved = loadTableSettings("department"); + if (saved) applyTableSettings(saved); + }, []); + // 부서 조회 const fetchDepts = useCallback(async () => { setDeptLoading(true); @@ -93,7 +106,9 @@ export default function DepartmentPage() { dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - const data = res.data?.data?.data || res.data?.data?.rows || []; + const raw = res.data?.data?.data || res.data?.data?.rows || []; + // dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑 + const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code })); setDepts(data); setDeptCount(res.data?.data?.total || data.length); } catch (err) { @@ -107,25 +122,27 @@ export default function DepartmentPage() { useEffect(() => { fetchDepts(); }, [fetchDepts]); // 선택된 부서 - const selectedDept = depts.find((d) => d.dept_code === selectedDeptCode); + const selectedDept = depts.find((d) => d.id === selectedDeptId); + const selectedDeptCode = selectedDept?.dept_code || null; - // 우측: 사원 조회 - useEffect(() => { - if (!selectedDeptCode) { setMembers([]); return; } - const fetchMembers = async () => { - setMemberLoading(true); - try { - const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, { - page: 1, size: 500, - dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] }, - autoFilter: true, - }); - setMembers(res.data?.data?.data || res.data?.data?.rows || []); - } catch { setMembers([]); } finally { setMemberLoading(false); } - }; - fetchMembers(); + // 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서) + const fetchMembers = useCallback(async () => { + setMemberLoading(true); + try { + const filters = selectedDeptCode + ? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] + : []; + const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, { + page: 1, size: 500, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + setMembers(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setMembers([]); } finally { setMemberLoading(false); } }, [selectedDeptCode]); + useEffect(() => { fetchMembers(); }, [fetchMembers]); + // 부서 등록 const openDeptRegister = () => { setDeptForm({}); @@ -180,14 +197,20 @@ export default function DepartmentPage() { data: [{ dept_code: selectedDeptCode }], }); toast.success("삭제되었습니다."); - setSelectedDeptCode(null); + setSelectedDeptId(null); fetchDepts(); } catch { toast.error("삭제에 실패했습니다."); } }; // 사원 추가 - const openUserModal = () => { - setUserForm({ dept_code: selectedDeptCode || "" }); + const openUserModal = (editData?: any) => { + if (editData) { + setUserEditMode(true); + setUserForm({ ...editData, user_password: "" }); + } else { + setUserEditMode(false); + setUserForm({ dept_code: selectedDeptCode || "", user_password: "" }); + } setFormErrors({}); setUserModalOpen(true); }; @@ -208,14 +231,34 @@ export default function DepartmentPage() { setSaving(true); try { - const { created_date, updated_date, ...fields } = userForm; - await apiClient.post(`/table-management/tables/${USER_TABLE}/add`, fields); - toast.success("사원이 추가되었습니다."); + // 비밀번호 미입력 시 기본값 (신규만) + const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined); + + await apiClient.post("/admin/users/with-dept", { + userInfo: { + user_id: userForm.user_id, + user_name: userForm.user_name, + user_name_eng: userForm.user_name_eng || undefined, + user_password: password || undefined, + email: userForm.email || undefined, + tel: userForm.tel || undefined, + cell_phone: userForm.cell_phone || undefined, + sabun: userForm.sabun || undefined, + position_name: userForm.position_name || undefined, + dept_code: userForm.dept_code || undefined, + dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined, + status: userForm.status || "active", + }, + mainDept: userForm.dept_code ? { + dept_code: userForm.dept_code, + dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name, + position_name: userForm.position_name || undefined, + } : undefined, + isUpdate: userEditMode, + }); + toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다."); setUserModalOpen(false); - // 우측 새로고침 - const code = selectedDeptCode; - setSelectedDeptCode(null); - setTimeout(() => setSelectedDeptCode(code), 50); + fetchMembers(); } catch (err: any) { toast.error(err.response?.data?.message || "저장에 실패했습니다."); } finally { @@ -241,8 +284,12 @@ export default function DepartmentPage() { filterId="department" onFilterChange={setSearchFilters} dataCount={deptCount} + externalFilterConfig={filterConfig} extraActions={
+ @@ -275,10 +322,9 @@ export default function DepartmentPage() { columns={LEFT_COLUMNS} data={depts} loading={deptLoading} - selectedId={selectedDeptCode} + selectedId={selectedDeptId} onSelect={(id) => { - const dept = depts.find((d) => d.dept_code === id || d.id === id); - setSelectedDeptCode(dept?.dept_code || id); + setSelectedDeptId((prev) => (prev === id ? null : id)); }} onRowDoubleClick={() => openDeptEdit()} emptyMessage="등록된 부서가 없습니다" @@ -293,29 +339,25 @@ export default function DepartmentPage() {
- 부서 인원 + + {selectedDept ? "부서 인원" : "전체 사원"} {selectedDept && {selectedDept.dept_name}} {members.length > 0 && {members.length}명}
-
- {!selectedDeptCode ? ( -
- 좌측에서 부서를 선택하세요 -
- ) : ( - - )} + openUserModal(row)} + />
@@ -365,14 +407,14 @@ export default function DepartmentPage() { - 사원 추가 - {selectedDept?.dept_name} 부서에 사원을 추가합니다. + {userEditMode ? "사원 정보 수정" : "사원 추가"} + {userEditMode ? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.` : selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}
setUserForm((p) => ({ ...p, user_id: e.target.value }))} - placeholder="사용자 ID" className="h-9" /> + placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
@@ -387,7 +429,7 @@ export default function DepartmentPage() {
setUserForm((p) => ({ ...p, user_password: e.target.value }))} - placeholder="비밀번호" className="h-9" type="password" /> + placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" />
@@ -443,6 +485,14 @@ export default function DepartmentPage() { /> {ConfirmDialogComponent} + +
); } diff --git a/frontend/app/(main)/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/outsourcing/subcontractor-item/page.tsx index d66e5e46..08c8f7bb 100644 --- a/frontend/app/(main)/outsourcing/subcontractor-item/page.tsx +++ b/frontend/app/(main)/outsourcing/subcontractor-item/page.tsx @@ -20,13 +20,14 @@ import { } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search } from "lucide-react"; +import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, Settings2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; @@ -68,6 +69,8 @@ export default function SubcontractorItemPage() { const [itemLoading, setItemLoading] = useState(false); const [itemCount, setItemCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); const [selectedItemId, setSelectedItemId] = useState(null); // 우측: 외주업체 @@ -92,6 +95,15 @@ export default function SubcontractorItemPage() { // 엑셀 const [excelUploadOpen, setExcelUploadOpen] = useState(false); + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + useEffect(() => { + const saved = loadTableSettings("subcontractor-item"); + if (saved) applyTableSettings(saved); + }, []); + // 카테고리 로드 useEffect(() => { const load = async () => { @@ -296,8 +308,12 @@ export default function SubcontractorItemPage() { filterId="subcontractor-item" onFilterChange={setSearchFilters} dataCount={itemCount} + externalFilterConfig={filterConfig} extraActions={
+ @@ -504,6 +520,14 @@ export default function SubcontractorItemPage() { onSuccess={() => fetchItems()} /> + + {ConfirmDialogComponent}
); diff --git a/frontend/app/(main)/outsourcing/subcontractor/page.tsx b/frontend/app/(main)/outsourcing/subcontractor/page.tsx index f586c838..eadb3a2a 100644 --- a/frontend/app/(main)/outsourcing/subcontractor/page.tsx +++ b/frontend/app/(main)/outsourcing/subcontractor/page.tsx @@ -24,13 +24,14 @@ import { Label } from "@/components/ui/label"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, - Wrench, Package, Search, X, + Wrench, Package, Search, X, Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal"; @@ -79,6 +80,8 @@ export default function SubcontractorManagementPage() { const [subcontractorLoading, setSubcontractorLoading] = useState(false); const [subcontractorCount, setSubcontractorCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); const [selectedSubcontractorId, setSelectedSubcontractorId] = useState(null); // 우측: 품목 단가 @@ -158,6 +161,15 @@ export default function SubcontractorManagementPage() { load(); }, []); + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + useEffect(() => { + const saved = loadTableSettings("subcontractor-mng"); + if (saved) applyTableSettings(saved); + }, []); + // 외주업체 목록 조회 const fetchSubcontractors = useCallback(async () => { setSubcontractorLoading(true); @@ -728,8 +740,12 @@ export default function SubcontractorManagementPage() { filterId="subcontractor-mng" onFilterChange={setSearchFilters} dataCount={subcontractorCount} + externalFilterConfig={filterConfig} extraActions={
+
); diff --git a/frontend/app/(main)/production/plan-management/page.tsx b/frontend/app/(main)/production/plan-management/page.tsx index 0d8ee776..2d5dcd45 100644 --- a/frontend/app/(main)/production/plan-management/page.tsx +++ b/frontend/app/(main)/production/plan-management/page.tsx @@ -53,6 +53,7 @@ import { Maximize2, Minimize2, Merge, + Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -80,6 +81,7 @@ import TimelineScheduler, { type StatusColor, } from "@/components/common/TimelineScheduler"; import { DynamicSearchFilter, type FilterValue } from "@/components/common/DynamicSearchFilter"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; import { exportToExcel } from "@/lib/utils/excelExport"; @@ -134,6 +136,8 @@ export default function ProductionPlanManagementPage() { // 검색 필터 (DynamicSearchFilter에서 사용) const [searchFilters, setSearchFilters] = useState([]); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); const [searchItemCode, setSearchItemCode] = useState(""); const [searchStatus, setSearchStatus] = useState("all"); const [searchStartDate, setSearchStartDate] = useState(""); @@ -277,6 +281,15 @@ export default function ProductionPlanManagementPage() { [] ); + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + useEffect(() => { + const saved = loadTableSettings("production-plan"); + if (saved) applyTableSettings(saved); + }, []); + // ========== 토글/선택 핸들러 ========== const toggleItemExpand = useCallback((itemCode: string) => { @@ -879,6 +892,7 @@ export default function ProductionPlanManagementPage() { filterId="production-plan" onFilterChange={handleSearchFilterChange} dataCount={finishedPlans.length + semiPlans.length} + externalFilterConfig={filterConfig} extraActions={
); } diff --git a/frontend/app/(main)/sales/customer/page.tsx b/frontend/app/(main)/sales/customer/page.tsx index 9f1354ad..461df230 100644 --- a/frontend/app/(main)/sales/customer/page.tsx +++ b/frontend/app/(main)/sales/customer/page.tsx @@ -25,7 +25,7 @@ import { Label } from "@/components/ui/label"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, - Users, Package, MapPin, Search, X, Maximize2, Minimize2, + Users, Package, MapPin, Search, X, Maximize2, Minimize2, Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -40,6 +40,7 @@ import { exportToExcel } from "@/lib/utils/excelExport"; import { validateField, validateForm, formatField } from "@/lib/utils/validation"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; const CUSTOMER_TABLE = "customer_mng"; const MAPPING_TABLE = "customer_item_mapping"; @@ -81,6 +82,8 @@ export default function CustomerManagementPage() { const [customerLoading, setCustomerLoading] = useState(false); const [customerCount, setCustomerCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); const [selectedCustomerId, setSelectedCustomerId] = useState(null); // 우측: 탭 @@ -169,6 +172,15 @@ export default function CustomerManagementPage() { load(); }, []); + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + useEffect(() => { + const saved = loadTableSettings("customer-mng"); + if (saved) applyTableSettings(saved); + }, []); + // 거래처 목록 조회 const fetchCustomers = useCallback(async () => { setCustomerLoading(true); @@ -777,8 +789,12 @@ export default function CustomerManagementPage() { filterId="customer-mng" onFilterChange={setSearchFilters} dataCount={customerCount} + externalFilterConfig={filterConfig} extraActions={
+
); diff --git a/frontend/app/(main)/sales/order/page.tsx b/frontend/app/(main)/sales/order/page.tsx index 203f618a..283f4362 100644 --- a/frontend/app/(main)/sales/order/page.tsx +++ b/frontend/app/(main)/sales/order/page.tsx @@ -13,7 +13,7 @@ import { import { Label } from "@/components/ui/label"; import { Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download, - ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, + ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -27,6 +27,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; const DETAIL_TABLE = "sales_order_detail"; @@ -98,6 +99,39 @@ export default function SalesOrderPage() { // 체크된 행 (다중선택) const [checkedIds, setCheckedIds] = useState([]); + // 테이블 설정 + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [gridColumns, setGridColumns] = useState(GRID_COLUMNS); + const [filterConfig, setFilterConfig] = useState(); + + // 테이블 설정 적용 (컬럼 + 필터) + const applyTableSettings = useCallback((settings: TableSettings) => { + // 컬럼 표시/숨김/순서/너비 + const colMap = new Map(GRID_COLUMNS.map((c) => [c.key, c])); + const applied: DataGridColumn[] = []; + for (const cs of settings.columns) { + if (!cs.visible) continue; + const orig = colMap.get(cs.columnName); + if (orig) { + applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined }); + } + } + const settingKeys = new Set(settings.columns.map((c) => c.columnName)); + for (const col of GRID_COLUMNS) { + if (!settingKeys.has(col.key)) applied.push(col); + } + setGridColumns(applied.length > 0 ? applied : GRID_COLUMNS); + + // 필터 설정 → DynamicSearchFilter에 전달 + setFilterConfig(settings.filters); + }, []); + + // 마운트 시 저장된 설정 복원 + useEffect(() => { + const saved = loadTableSettings("sales-order"); + if (saved) applyTableSettings(saved); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + // 카테고리 로드 useEffect(() => { const loadCategories = async () => { @@ -537,6 +571,7 @@ export default function SalesOrderPage() { filterId="sales-order" onFilterChange={setSearchFilters} dataCount={totalCount} + externalFilterConfig={filterConfig} /> {/* 메인 테이블 */} @@ -568,12 +603,15 @@ export default function SalesOrderPage() { +
fetchOrders()} /> + {/* 테이블 설정 모달 */} + + {/* 공통 확인 다이얼로그 */} {ConfirmDialogComponent}
diff --git a/frontend/app/(main)/sales/sales-item/page.tsx b/frontend/app/(main)/sales/sales-item/page.tsx index 373bdf30..a5097b42 100644 --- a/frontend/app/(main)/sales/sales-item/page.tsx +++ b/frontend/app/(main)/sales/sales-item/page.tsx @@ -20,12 +20,13 @@ import { } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, X } from "lucide-react"; +import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, X, Settings2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; @@ -71,6 +72,10 @@ export default function SalesItemPage() { const [searchFilters, setSearchFilters] = useState([]); const [selectedItemId, setSelectedItemId] = useState(null); + // 테이블 설정 + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); + // 우측: 거래처 const [customerItems, setCustomerItems] = useState([]); const [customerLoading, setCustomerLoading] = useState(false); @@ -106,6 +111,17 @@ export default function SalesItemPage() { const [priceCategoryOptions, setPriceCategoryOptions] = useState>({}); const [editCustData, setEditCustData] = useState(null); + // 테이블 설정 적용 (필터) + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + // 마운트 시 저장된 설정 복원 + useEffect(() => { + const saved = loadTableSettings("sales-item"); + if (saved) applyTableSettings(saved); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + // 카테고리 로드 useEffect(() => { const load = async () => { @@ -522,8 +538,12 @@ export default function SalesItemPage() { filterId="sales-item" onFilterChange={setSearchFilters} dataCount={itemCount} + externalFilterConfig={filterConfig} extraActions={
+ @@ -884,6 +904,14 @@ export default function SalesItemPage() { /> {ConfirmDialogComponent} + +
); } 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/common/DynamicSearchFilter.tsx b/frontend/components/common/DynamicSearchFilter.tsx index ac44160d..e61c7c5e 100644 --- a/frontend/components/common/DynamicSearchFilter.tsx +++ b/frontend/components/common/DynamicSearchFilter.tsx @@ -23,7 +23,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; -import { Settings, ChevronsUpDown, RotateCcw } from "lucide-react"; +import { Settings, ChevronsUpDown, RotateCcw, Search, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; @@ -50,6 +50,14 @@ export interface FilterValue { value: string; } +export interface ExternalFilterConfig { + columnName: string; + displayName: string; + enabled: boolean; + filterType: FilterType; + width: number; +} + export interface DynamicSearchFilterProps { /** 테이블명 (컬럼 목록 + 카테고리 옵션 로드에 사용) */ tableName: string; @@ -61,6 +69,8 @@ export interface DynamicSearchFilterProps { dataCount?: number; /** 추가 액션 버튼 영역 */ extraActions?: React.ReactNode; + /** TableSettingsModal에서 전달된 외부 필터 설정 (제공 시 자체 설정 모달 숨김) */ + externalFilterConfig?: ExternalFilterConfig[]; } const FILTER_TYPE_OPTIONS: { value: FilterType; label: string }[] = [ @@ -86,12 +96,14 @@ export function DynamicSearchFilter({ onFilterChange, dataCount, extraActions, + externalFilterConfig, }: DynamicSearchFilterProps) { const [allColumns, setAllColumns] = useState([]); const [activeFilters, setActiveFilters] = useState([]); const [filterValues, setFilterValues] = useState>({}); const [selectOptions, setSelectOptions] = useState>({}); const [settingsOpen, setSettingsOpen] = useState(false); + const [selectSearchTerms, setSelectSearchTerms] = useState>({}); const [tempColumns, setTempColumns] = useState([]); const STORAGE_KEY_FILTERS = `dynamic_filter_config_${filterId}`; @@ -149,6 +161,22 @@ export function DynamicSearchFilter({ loadColumns(); }, [tableName, STORAGE_KEY_FILTERS, STORAGE_KEY_VALUES]); + // 외부 필터 설정 적용 (TableSettingsModal에서 전달) + useEffect(() => { + if (!externalFilterConfig) return; + const active: FilterColumn[] = externalFilterConfig + .filter((f) => f.enabled) + .map((f) => ({ + columnName: f.columnName, + columnLabel: f.displayName, + originalType: f.filterType, + filterType: f.filterType, + enabled: true, + width: f.width, + })); + setActiveFilters(active); + }, [externalFilterConfig]); + // select 타입 필터의 옵션 로드 useEffect(() => { const loadOptions = async () => { @@ -305,9 +333,22 @@ export function DynamicSearchFilter({ handleValueChange(filter.columnName, next.length > 0 ? next : ""); }; + const searchTerm = (selectSearchTerms[filter.columnName] || "").toLowerCase(); + const filteredOptions = searchTerm + ? options.filter((opt) => opt.label.toLowerCase().includes(searchTerm)) + : options; + return (
- + { + if (!open) { + setSelectSearchTerms((prev) => { + const next = { ...prev }; + delete next[filter.columnName]; + return next; + }); + } + }}> + {options.length > 5 && ( +
+
+ + + setSelectSearchTerms((prev) => ({ ...prev, [filter.columnName]: e.target.value })) + } + placeholder="검색..." + className="h-7 pl-7 pr-7 text-xs" + /> + {selectSearchTerms[filter.columnName] && ( + + )} +
+
+ )}
{options.length === 0 ? (
옵션 없음
- ) : options.map((opt, i) => ( + ) : filteredOptions.length === 0 ? ( +
검색 결과 없음
+ ) : filteredOptions.map((opt, i) => (
toggleOption(opt.value, !selectedValues.includes(opt.value))}> @@ -376,9 +444,11 @@ export function DynamicSearchFilter({
)} {extraActions} - + {!externalFilterConfig && ( + + )}
{/* 필터 설정 모달 */} diff --git a/frontend/components/common/TableSettingsModal.tsx b/frontend/components/common/TableSettingsModal.tsx new file mode 100644 index 00000000..9a007b52 --- /dev/null +++ b/frontend/components/common/TableSettingsModal.tsx @@ -0,0 +1,569 @@ +"use client"; + +/** + * TableSettingsModal -- 하드코딩 페이지용 테이블 설정 모달 (3탭) + * + * 탭 1: 컬럼 설정 -- 컬럼 표시/숨김, 드래그 순서 변경, 너비(px) 설정, 틀고정 + * 탭 2: 필터 설정 -- 필터 활성/비활성, 필터 타입(텍스트/선택/날짜), 너비(%) 설정, 그룹별 합산 + * 탭 3: 그룹 설정 -- 그룹핑 컬럼 선택 + * + * 설정값은 localStorage에 저장되며, onSave 콜백으로 부모 컴포넌트에 전달 + * DynamicSearchFilter, DataGrid와 함께 사용 + */ + +import React, { useState, useEffect } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, +} from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { GripVertical, Settings2, SlidersHorizontal, Layers, RotateCcw, Lock } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { + DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, verticalListSortingStrategy, useSortable, arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +// ===== 타입 ===== + +export interface ColumnSetting { + columnName: string; + displayName: string; + visible: boolean; + width: number; +} + +export interface FilterSetting { + columnName: string; + displayName: string; + enabled: boolean; + filterType: "text" | "select" | "date"; + width: number; +} + +export interface GroupSetting { + columnName: string; + displayName: string; + enabled: boolean; +} + +export interface TableSettings { + columns: ColumnSetting[]; + filters: FilterSetting[]; + groups: GroupSetting[]; + frozenCount: number; + groupSumEnabled: boolean; +} + +export interface TableSettingsModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** 테이블명 (web-types API 호출용) */ + tableName: string; + /** localStorage 키 분리용 고유 ID */ + settingsId: string; + /** 저장 시 콜백 */ + onSave?: (settings: TableSettings) => void; + /** 초기 탭 */ + initialTab?: "columns" | "filters" | "groups"; +} + +// ===== 상수 ===== + +const FILTER_TYPE_OPTIONS = [ + { value: "text", label: "텍스트" }, + { value: "select", label: "선택" }, + { value: "date", label: "날짜" }, +]; + +const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"]; + +// ===== 유틸 ===== + +function getStorageKey(settingsId: string) { + return `table_settings_${settingsId}`; +} + +/** localStorage에서 저장된 설정 로드 (외부에서도 사용 가능) */ +export function loadTableSettings(settingsId: string): TableSettings | null { + try { + const raw = localStorage.getItem(getStorageKey(settingsId)); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +} + +/** 저장된 컬럼 순서/설정을 API 컬럼과 병합 */ +function mergeColumns(fresh: ColumnSetting[], saved: ColumnSetting[]): ColumnSetting[] { + const savedMap = new Map(saved.map((s) => [s.columnName, s])); + const ordered: ColumnSetting[] = []; + // 저장된 순서대로 + for (const s of saved) { + const f = fresh.find((c) => c.columnName === s.columnName); + if (f) ordered.push({ ...f, visible: s.visible, width: s.width }); + } + // 새로 추가된 컬럼은 맨 뒤에 + for (const f of fresh) { + if (!savedMap.has(f.columnName)) ordered.push(f); + } + return ordered; +} + +// ===== Sortable Column Row (탭 1) ===== + +function SortableColumnRow({ + col, + onToggleVisible, + onWidthChange, +}: { + col: ColumnSetting & { _idx: number }; + onToggleVisible: (idx: number) => void; + onWidthChange: (idx: number, width: number) => void; +}) { + const { + attributes, listeners, setNodeRef, transform, transition, isDragging, + } = useSortable({ id: col.columnName }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+ {/* 드래그 핸들 */} + + + {/* 표시 체크박스 */} + onToggleVisible(col._idx)} + /> + + {/* 표시 토글 (Switch) */} + onToggleVisible(col._idx)} + className="shrink-0" + /> + + {/* 컬럼명 + 기술명 */} +
+
{col.displayName}
+
{col.columnName}
+
+ + {/* 너비 입력 */} +
+ 너비: + onWidthChange(col._idx, Number(e.target.value) || 100)} + className="h-8 w-[70px] text-xs text-center" + min={50} + max={500} + /> +
+
+ ); +} + +// ===== TableSettingsModal ===== + +export function TableSettingsModal({ + open, + onOpenChange, + tableName, + settingsId, + onSave, + initialTab = "columns", +}: TableSettingsModalProps) { + const [activeTab, setActiveTab] = useState(initialTab); + const [loading, setLoading] = useState(false); + + // 임시 설정 (모달 내에서만 수정, 저장 시 반영) + const [tempColumns, setTempColumns] = useState([]); + const [tempFilters, setTempFilters] = useState([]); + const [tempGroups, setTempGroups] = useState([]); + const [tempFrozenCount, setTempFrozenCount] = useState(0); + const [tempGroupSum, setTempGroupSum] = useState(false); + + // 원본 컬럼 (초기화용) + const [defaultColumns, setDefaultColumns] = useState([]); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) + ); + + // 모달 열릴 때 데이터 로드 + useEffect(() => { + if (!open) return; + setActiveTab(initialTab); + loadData(); + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + const loadData = async () => { + setLoading(true); + try { + const res = await apiClient.get(`/table-management/tables/${tableName}/web-types`); + const types: any[] = res.data?.data || []; + + // 기본 컬럼 설정 생성 + const freshColumns: ColumnSetting[] = types + .filter((t) => !AUTO_COLS.includes(t.columnName)) + .map((t) => ({ + columnName: t.columnName, + displayName: t.displayName || t.columnLabel || t.columnName, + visible: true, + width: 120, + })); + + // 기본 필터 설정 생성 + const freshFilters: FilterSetting[] = freshColumns.map((c) => { + const wt = types.find((t) => t.columnName === c.columnName); + let filterType: "text" | "select" | "date" = "text"; + if (wt?.inputType === "category" || wt?.inputType === "select") filterType = "select"; + else if (wt?.inputType === "date" || wt?.inputType === "datetime") filterType = "date"; + return { + columnName: c.columnName, + displayName: c.displayName, + enabled: false, + filterType, + width: 25, + }; + }); + + // 기본 그룹 설정 생성 + const freshGroups: GroupSetting[] = freshColumns.map((c) => ({ + columnName: c.columnName, + displayName: c.displayName, + enabled: false, + })); + + setDefaultColumns(freshColumns); + + // localStorage에서 저장된 설정 복원 + const saved = loadTableSettings(settingsId); + if (saved) { + setTempColumns(mergeColumns(freshColumns, saved.columns)); + setTempFilters(freshFilters.map((f) => { + const s = saved.filters?.find((sf) => sf.columnName === f.columnName); + return s ? { ...f, enabled: s.enabled, filterType: s.filterType, width: s.width } : f; + })); + setTempGroups(freshGroups.map((g) => { + const s = saved.groups?.find((sg) => sg.columnName === g.columnName); + return s ? { ...g, enabled: s.enabled } : g; + })); + setTempFrozenCount(saved.frozenCount || 0); + setTempGroupSum(saved.groupSumEnabled || false); + } else { + setTempColumns(freshColumns); + setTempFilters(freshFilters); + setTempGroups(freshGroups); + setTempFrozenCount(0); + setTempGroupSum(false); + } + } catch (err) { + console.error("테이블 설정 로드 실패:", err); + } finally { + setLoading(false); + } + }; + + // 저장 + const handleSave = () => { + const settings: TableSettings = { + columns: tempColumns, + filters: tempFilters, + groups: tempGroups, + frozenCount: tempFrozenCount, + groupSumEnabled: tempGroupSum, + }; + localStorage.setItem(getStorageKey(settingsId), JSON.stringify(settings)); + onSave?.(settings); + onOpenChange(false); + }; + + // 컬럼 설정 초기화 + const handleResetColumns = () => { + setTempColumns(defaultColumns.map((c) => ({ ...c }))); + setTempFrozenCount(0); + }; + + // ===== 컬럼 설정 핸들러 ===== + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + setTempColumns((prev) => { + const oldIdx = prev.findIndex((c) => c.columnName === active.id); + const newIdx = prev.findIndex((c) => c.columnName === over.id); + return arrayMove(prev, oldIdx, newIdx); + }); + }; + + const toggleColumnVisible = (idx: number) => { + setTempColumns((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], visible: !next[idx].visible }; + return next; + }); + }; + + const changeColumnWidth = (idx: number, width: number) => { + setTempColumns((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], width }; + return next; + }); + }; + + // ===== 필터 설정 핸들러 ===== + + const allFiltersEnabled = tempFilters.length > 0 && tempFilters.every((f) => f.enabled); + + const toggleFilterAll = (checked: boolean) => { + setTempFilters((prev) => prev.map((f) => ({ ...f, enabled: checked }))); + }; + + const toggleFilter = (idx: number) => { + setTempFilters((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], enabled: !next[idx].enabled }; + return next; + }); + }; + + const changeFilterType = (idx: number, filterType: "text" | "select" | "date") => { + setTempFilters((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], filterType }; + return next; + }); + }; + + const changeFilterWidth = (idx: number, width: number) => { + setTempFilters((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], width }; + return next; + }); + }; + + // ===== 그룹 설정 핸들러 ===== + + const toggleGroup = (idx: number) => { + setTempGroups((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], enabled: !next[idx].enabled }; + return next; + }); + }; + + const visibleCount = tempColumns.filter((c) => c.visible).length; + + return ( + + + + 테이블 설정 + 테이블의 컬럼, 필터, 그룹화를 설정합니다 + + + {loading ? ( +
+ 로딩 중... +
+ ) : ( + setActiveTab(v as typeof activeTab)} className="flex-1 flex flex-col min-h-0"> + + + 컬럼 설정 + + + 필터 설정 + + + 그룹 설정 + + + + {/* ===== 탭 1: 컬럼 설정 ===== */} + + {/* 헤더: 표시 수 / 틀고정 / 초기화 */} +
+
+ + {visibleCount}/{tempColumns.length}개 컬럼 표시 중 + +
+ + 틀고정: + + setTempFrozenCount( + Math.min(Math.max(0, Number(e.target.value) || 0), tempColumns.length) + ) + } + className="h-7 w-[50px] text-xs text-center" + min={0} + max={tempColumns.length} + /> + 개 컬럼 +
+
+ +
+ + {/* 컬럼 목록 (드래그 순서 변경 가능) */} + + c.columnName)} + strategy={verticalListSortingStrategy} + > +
+ {tempColumns.map((col, idx) => ( + + ))} +
+
+
+
+ + {/* ===== 탭 2: 필터 설정 ===== */} + + {/* 전체 선택 */} +
toggleFilterAll(!allFiltersEnabled)} + > + + 전체 선택 +
+ + {/* 필터 목록 */} +
+ {tempFilters.map((filter, idx) => ( +
+ toggleFilter(idx)} + /> +
{filter.displayName}
+ +
+ changeFilterWidth(idx, Number(e.target.value) || 25)} + className="h-8 w-[55px] text-xs text-center" + min={10} + max={100} + /> + % +
+
+ ))} +
+ + {/* 그룹별 합산 토글 */} +
+
+
그룹별 합산
+
같은 값끼리 그룹핑하여 합산
+
+ +
+
+ + {/* ===== 탭 3: 그룹 설정 ===== */} + +
+ 사용 가능한 컬럼 +
+ +
+ {tempGroups.map((group, idx) => ( +
toggleGroup(idx)} + > + +
+
{group.displayName}
+
{group.columnName}
+
+
+ ))} +
+
+
+ )} + + + + + +
+
+ ); +} 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 (
void; +}> = ({ value, onChange }) => { + const [open, setOpen] = useState(false); + const [screens, setScreens] = useState>([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const loadScreens = async () => { + setLoading(true); + try { + const { screenApi } = await import("@/lib/api/screen"); + const response = await screenApi.getScreens({ page: 1, size: 1000 }); + setScreens( + response.data.map((s: any) => ({ screenId: s.screenId, screenName: s.screenName, screenCode: s.screenCode })), + ); + } catch (error) { + console.error("화면 목록 로드 실패:", error); + } finally { + setLoading(false); + } + }; + loadScreens(); + }, []); + + const selectedScreen = screens.find((s) => s.screenId === value); + + return ( + + + + + + + + + 화면을 찾을 수 없습니다. + + {screens.map((screen) => ( + { + onChange(screen.screenId === value ? undefined : screen.screenId); + setOpen(false); + }} + className="text-xs" + > + +
+ {screen.screenName} + {screen.screenCode} +
+
+ ))} +
+
+
+
+
+ ); +}; + // ─── 수평 Switch Row (토스 패턴) ─── function SwitchRow({ label, @@ -2002,6 +2076,23 @@ export const V2SplitPanelLayoutConfigPanel: React.FC updateTab(tabIndex, { showAdd: checked })} /> + {tab.showAdd && ( +
+ 추가 시 열릴 화면 + { + updateTab(tabIndex, { + addButton: { + enabled: true, + mode: screenId ? "modal" : "auto", + modalScreenId: screenId, + }, + }); + }} + /> +
+ )} (panel: "left" | "right") => { console.log("🆕 [추가모달] handleAddClick 호출:", { panel, activeTabIndex }); - // screenId 기반 모달 확인 + // 추가 탭의 addButton.modalScreenId 확인 + if (panel === "right" && activeTabIndex > 0) { + const tabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; + if (tabConfig?.addButton?.mode === "modal" && tabConfig.addButton.modalScreenId) { + if (!selectedLeftItem) { + toast({ + title: "항목을 선택해주세요", + description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.", + variant: "destructive", + }); + return; + } + + const tableName = tabConfig.tableName || ""; + const urlParams: Record = { mode: "add", tableName }; + const parentData: Record = {}; + + if (selectedLeftItem) { + const relation = tabConfig.relation; + if (relation?.keys && Array.isArray(relation.keys)) { + for (const key of relation.keys) { + if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) { + parentData[key.rightColumn] = selectedLeftItem[key.leftColumn]; + } + } + } + } + + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: tabConfig.addButton.modalScreenId, + urlParams, + splitPanelParentData: parentData, + }, + }), + ); + return; + } + } + + // screenId 기반 모달 확인 (기본 패널) const panelConfig = panel === "left" ? componentConfig.leftPanel : componentConfig.rightPanel; const addModalConfig = panelConfig?.addModal; diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 51441e88..41716d83 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -675,8 +675,50 @@ const AdditionalTabConfigPanel: React.FC = ({ )}
- {/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */} + {/* ===== 7. 추가 버튼 설정 (showAdd일 때) ===== */} {tab.showAdd && ( +
+ +
+
+ + +
+ + {tab.addButton?.mode === "modal" && ( +
+ + { + updateTab({ + addButton: { ...tab.addButton, enabled: true, mode: "modal", modalScreenId: screenId }, + }); + }} + /> +
+ )} +
+
+ )} + + {/* ===== 7-1. 추가 모달 컬럼 설정 (showAdd && mode=auto일 때) ===== */} + {tab.showAdd && (!tab.addButton?.mode || tab.addButton?.mode === "auto") && (
diff --git a/frontend/lib/registry/components/split-panel-layout/types.ts b/frontend/lib/registry/components/split-panel-layout/types.ts index 123dc13a..b8add7dd 100644 --- a/frontend/lib/registry/components/split-panel-layout/types.ts +++ b/frontend/lib/registry/components/split-panel-layout/types.ts @@ -63,6 +63,12 @@ export interface AdditionalTabConfig { }>; }; + addButton?: { + enabled: boolean; + mode: "auto" | "modal"; + modalScreenId?: number; + }; + addConfig?: { targetTable?: string; autoFillColumns?: Record; diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx index 3746272c..a3be3e59 100644 --- a/frontend/lib/registry/pop-components/pop-button.tsx +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -651,8 +651,12 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp const { subscribe, publish } = usePopEvent(screenId || "default"); - // 장바구니 모드 상태 - const isCartMode = config?.preset === "cart"; + // 장바구니 모드 상태 (v1 preset 또는 v2 tasks에 cart-save가 있으면 활성) + const v2Tasks = (config && "tasks" in config && Array.isArray((config as any).tasks)) + ? (config as any).tasks as PopButtonTask[] + : null; + const hasCartSaveTask = !!v2Tasks?.some((t) => 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") && ( +
+ +