From 542fab21400e34bc8a23c5c7fc414cbe7420e1da Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Sat, 7 Mar 2026 03:02:36 +0900 Subject: [PATCH] Enhance approval process by adding after approval flow ID to templates and implementing user selection via Combobox in the Approval Request Modal. --- approval-company7-report.txt | 33 +++ approval-test-report.txt | 29 ++ .../src/controllers/approvalController.ts | 262 ++++++++++++++---- .../approval/ApprovalRequestModal.tsx | 199 ++++++------- frontend/lib/api/approval.ts | 1 + frontend/scripts/po-approval-company7-test.ts | 179 ++++++++++++ .../scripts/purchase-order-approval-test.ts | 174 ++++++++++++ .../scripts/screen-approval-modal-test.ts | 101 +++++++ test-results/.last-run.json | 2 +- 9 files changed, 814 insertions(+), 166 deletions(-) create mode 100644 approval-company7-report.txt create mode 100644 approval-test-report.txt create mode 100644 frontend/scripts/po-approval-company7-test.ts create mode 100644 frontend/scripts/purchase-order-approval-test.ts create mode 100644 frontend/scripts/screen-approval-modal-test.ts diff --git a/approval-company7-report.txt b/approval-company7-report.txt new file mode 100644 index 00000000..57760435 --- /dev/null +++ b/approval-company7-report.txt @@ -0,0 +1,33 @@ + +=== Step 1: 로그인 (topseal_admin) === + 현재 URL: http://localhost:9771/screens/138 + 스크린샷: 01-after-login.png + OK: 로그인 완료 + +=== Step 2: 발주관리 화면 이동 === + 스크린샷: 02-po-screen.png + OK: 발주관리 화면 로드 + +=== Step 3: 그리드 컬럼 및 데이터 확인 === + 컬럼 헤더 (전체): ["결재상태","발주번호","품목코드","품목명","규격","발주수량","출하수량","단위","구분","유형","재질","규격","품명"] + 첫 번째 컬럼: "결재상태" + 결재상태(한글) 표시됨 + 데이터 행 수: 11 + 데이터 있음 + 첫 번째 컬럼 값(샘플): ["","","","",""] + 발주번호 형식 데이터: ["PO-2026-0001","PO-2026-0001","PO-2026-0001","PO-2026-0045","PO-2026-0045"] + 스크린샷: 03-grid-detail.png + OK: 그리드 상세 스크린샷 저장 + +=== Step 4: 결재 요청 버튼 확인 === + OK: '결재 요청' 파란색 버튼 확인됨 + 스크린샷: 04-approval-button.png + +=== Step 5: 행 선택 후 결재 요청 === + OK: 행 선택 완료 + 스크린샷: 05-approval-modal.png + OK: 결재 모달 열림 + 스크린샷: 06-approver-search-results.png + 결재자 검색 결과: 8명 + 결재자 목록: ["상신결재","다단 결재순차적으로 결재","동시 결재모든 결재자 동시 진행","TEST(Kim1542)김동현","김혜인(qwer0578)배달집행부 / 차장","김지수(area09)배달집행부 / 대리","김한길(qoznd123)배달집행부 / 과장","김하세(kaoe123)배달집행부 / 사원"] + 스크린샷: 07-final.png \ No newline at end of file diff --git a/approval-test-report.txt b/approval-test-report.txt new file mode 100644 index 00000000..4a2e6386 --- /dev/null +++ b/approval-test-report.txt @@ -0,0 +1,29 @@ + +=== Step 1: 로그인 === + 스크린샷: 01-login-page.png + 스크린샷: 02-after-login.png + OK: 로그인 완료, 대시보드 로드 + +=== Step 2: 구매관리 → 발주관리 메뉴 이동 === + INFO: 메뉴에서 발주관리 미발견, 직접 URL로 이동 + 메뉴 목록: ["관리자 메뉴로 전환","회사 선택","관리자해외영업부"] + 스크린샷: 04-po-screen-loaded.png + OK: /screen/COMPANY_7_064 직접 이동 완료 + +=== Step 3: 그리드 컬럼 확인 === + 스크린샷: 05-grid-columns.png + 컬럼 목록: ["approval_status","발주번호","품목코드","품목명","규격","발주수량","출하","단위","구분","유형","재질","규격","품명"] + FAIL: '결재상태' 컬럼 없음 + 결재상태 값: 데이터 없음 또는 해당 값 없음 + +=== Step 4: 행 선택 및 결재 요청 버튼 클릭 === + 스크린샷: 06-row-selected.png + OK: 첫 번째 행 선택 + 스크린샷: 07-approval-modal-opened.png + OK: 결재 모달 열림 + +=== Step 5: 결재자 검색 테스트 === + 스크린샷: 08-approver-search-results.png + 검색 결과 수: 12명 + 결재자 목록: ["상신결재","템플릿","다단 결재순차적으로 결재","동시 결재모든 결재자 동시 진행","김동열(drkim)-","김아름(qwe123)생산부 / 차장","TEST(Kim1542)김동현","김혜인(qwer0578)배달집행부 / 차장","김욱동(dnrehd0171)-","김지수(area09)배달집행부 / 대리"] + 스크린샷: 09-final-state.png \ No newline at end of file diff --git a/backend-node/src/controllers/approvalController.ts b/backend-node/src/controllers/approvalController.ts index bd1bcfa4..eabe77ce 100644 --- a/backend-node/src/controllers/approvalController.ts +++ b/backend-node/src/controllers/approvalController.ts @@ -2,6 +2,7 @@ import { Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { query, queryOne, transaction } from "../database/db"; import { PoolClient } from "pg"; +import { NodeFlowExecutionService } from "../services/nodeFlowExecutionService"; // 트랜잭션 내부에서 throw하고 외부에서 instanceof로 구분하기 위한 커스텀 에러 class ValidationError extends Error { @@ -354,7 +355,7 @@ export class ApprovalTemplateController { return res.status(401).json({ success: false, message: "인증 정보가 없습니다." }); } - const { template_name, description, definition_id, is_active = "Y", steps = [] } = req.body; + const { template_name, description, definition_id, after_approval_flow_id, is_active = "Y", steps = [] } = req.body; if (!template_name) { return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." }); @@ -365,9 +366,9 @@ export class ApprovalTemplateController { let result: any; await transaction(async (client) => { const { rows } = await client.query( - `INSERT INTO approval_line_templates (template_name, description, definition_id, is_active, company_code, created_by, updated_by) - VALUES ($1, $2, $3, $4, $5, $6, $6) RETURNING *`, - [template_name, description, definition_id, is_active, companyCode, userId] + `INSERT INTO approval_line_templates (template_name, description, definition_id, after_approval_flow_id, is_active, company_code, created_by, updated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) RETURNING *`, + [template_name, description, definition_id, after_approval_flow_id || null, is_active, companyCode, userId] ); result = rows[0]; @@ -422,7 +423,7 @@ export class ApprovalTemplateController { return res.status(404).json({ success: false, message: "결재선 템플릿을 찾을 수 없습니다." }); } - const { template_name, description, definition_id, is_active, steps } = req.body; + const { template_name, description, definition_id, after_approval_flow_id, is_active, steps } = req.body; const userId = req.user?.userId || "system"; let result: any; @@ -434,6 +435,7 @@ export class ApprovalTemplateController { if (template_name !== undefined) { fields.push(`template_name = $${idx++}`); params.push(template_name); } if (description !== undefined) { fields.push(`description = $${idx++}`); params.push(description); } if (definition_id !== undefined) { fields.push(`definition_id = $${idx++}`); params.push(definition_id); } + if (after_approval_flow_id !== undefined) { fields.push(`after_approval_flow_id = $${idx++}`); params.push(after_approval_flow_id); } if (is_active !== undefined) { fields.push(`is_active = $${idx++}`); params.push(is_active); } fields.push(`updated_by = $${idx++}`, `updated_at = NOW()`); params.push(userId); @@ -519,6 +521,131 @@ export class ApprovalTemplateController { // notification step은 자동 통과 후 재귀적으로 다음 step 진행 // ============================================================ +// 결재 상태 변경 시 원본 테이블(target_table)의 approval_status를 동기화하는 범용 hook +async function syncApprovalStatusToTarget( + client: PoolClient, + requestId: number, + newStatus: string, + companyCode: string, +): Promise { + try { + const { rows: [req] } = await client.query( + `SELECT target_table, target_record_id FROM approval_requests WHERE request_id = $1 AND company_code = $2`, + [requestId, companyCode], + ); + if (!req?.target_table || !req?.target_record_id || req.target_record_id === "0") return; + + const { rows: cols } = await client.query( + `SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'approval_status'`, + [req.target_table], + ); + if (cols.length === 0) return; + + const statusMap: Record = { + in_progress: "결재중", + approved: "결재완료", + rejected: "반려", + cancelled: "작성중", + draft: "작성중", + post_pending: "후결대기", + }; + + const businessStatus = statusMap[newStatus] || newStatus; + const safeTable = req.target_table.replace(/[^a-zA-Z0-9_]/g, ""); + + // super admin(company_code='*')은 다른 회사 레코드도 업데이트 가능 + if (companyCode === "*") { + await client.query( + `UPDATE "${safeTable}" SET approval_status = $1 WHERE id = $2`, + [businessStatus, req.target_record_id], + ); + } else { + await client.query( + `UPDATE "${safeTable}" SET approval_status = $1 WHERE id = $2 AND company_code = $3`, + [businessStatus, req.target_record_id, companyCode], + ); + } + + // 결재 완료(approved) 시 제어관리(노드 플로우) 자동 실행 + if (newStatus === "approved") { + await executeAfterApprovalFlow(client, requestId, companyCode, req); + } + } catch (err) { + console.error("[syncApprovalStatusToTarget] 원본 테이블 상태 동기화 실패:", err); + } +} + +// 결재 완료 후 제어관리(노드 플로우) 실행 hook +// 우선순위: 템플릿(template) > 정의(definition) > 요청(request) 직접 지정 +async function executeAfterApprovalFlow( + client: PoolClient, + requestId: number, + companyCode: string, + approvalReq: { target_table: string; target_record_id: string }, +): Promise { + try { + const { rows: [reqData] } = await client.query( + `SELECT r.after_approval_flow_id, r.definition_id, r.template_id, r.title, r.requester_id + FROM approval_requests r WHERE r.request_id = $1`, + [requestId], + ); + + let flowId: number | null = null; + + // 1순위: 템플릿에 연결된 제어관리 플로우 + if (reqData?.template_id) { + const { rows: [tmpl] } = await client.query( + `SELECT after_approval_flow_id FROM approval_line_templates WHERE template_id = $1`, + [reqData.template_id], + ); + flowId = tmpl?.after_approval_flow_id || null; + } + + // 2순위: 정의(definition)에 연결된 제어관리 플로우 (fallback) + if (!flowId && reqData?.definition_id) { + const { rows: [def] } = await client.query( + `SELECT after_approval_flow_id FROM approval_definitions WHERE definition_id = $1`, + [reqData.definition_id], + ); + flowId = def?.after_approval_flow_id || null; + } + + // 3순위: 요청 자체에 직접 지정된 플로우 + if (!flowId) { + flowId = reqData?.after_approval_flow_id || null; + } + + if (!flowId) return; + + // 3. 원본 레코드 데이터 조회 + const safeTable = approvalReq.target_table.replace(/[^a-zA-Z0-9_]/g, ""); + const { rows: [targetRecord] } = await client.query( + `SELECT * FROM "${safeTable}" WHERE id = $1`, + [approvalReq.target_record_id], + ); + + // 4. 노드 플로우 실행 + console.log(`[제어관리] 결재 완료 후 플로우 #${flowId} 실행 (request_id=${requestId})`); + const result = await NodeFlowExecutionService.executeFlow(flowId, { + formData: targetRecord || {}, + approvalInfo: { + requestId, + title: reqData.title, + requesterId: reqData.requester_id, + targetTable: approvalReq.target_table, + targetRecordId: approvalReq.target_record_id, + }, + companyCode, + selectedRows: targetRecord ? [targetRecord] : [], + }); + + console.log(`[제어관리] 플로우 #${flowId} 실행 결과: ${result.success ? "성공" : "실패"} (${result.executionTime}ms)`); + } catch (err) { + // 제어관리 실패는 결재 승인 자체에 영향 주지 않음 + console.error("[executeAfterApprovalFlow] 제어관리 실행 실패:", err); + } +} + async function activateNextStep( client: PoolClient, requestId: number, @@ -541,6 +668,7 @@ async function activateNextStep( WHERE request_id = $3 AND company_code = $4`, [userId, comment, requestId, companyCode] ); + await syncApprovalStatusToTarget(client, requestId, "approved", companyCode); return; } @@ -561,6 +689,7 @@ async function activateNextStep( WHERE request_id = $3 AND company_code = $4`, [userId, comment, requestId, companyCode] ); + await syncApprovalStatusToTarget(client, requestId, "approved", companyCode); return; } @@ -745,7 +874,7 @@ export class ApprovalRequestController { } const { - title, description, definition_id, target_table, target_record_id, + title, description, definition_id, template_id, target_table, target_record_id, target_record_data, screen_id, button_component_id, approvers, approval_mode, @@ -786,16 +915,16 @@ export class ApprovalRequestController { await transaction(async (client) => { const { rows: reqRows } = await client.query( `INSERT INTO approval_requests ( - title, description, definition_id, target_table, target_record_id, + title, description, definition_id, template_id, target_table, target_record_id, target_record_data, status, current_step, total_steps, approval_type, requester_id, requester_name, requester_dept, screen_id, button_component_id, company_code, final_approver_id, completed_at - ) VALUES ($1, $2, $3, $4, $5, $6, 'approved', 1, 1, 'self', - $7, $8, $9, $10, $11, $12, $7, NOW()) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'approved', 1, 1, 'self', + $8, $9, $10, $11, $12, $13, $8, NOW()) RETURNING *`, [ - title, description, definition_id, target_table, safeTargetRecordId, + title, description, definition_id, template_id || null, target_table, safeTargetRecordId, JSON.stringify(mergedRecordData), userId, userName, deptName, screen_id, button_component_id, companyCode, @@ -811,6 +940,8 @@ export class ApprovalRequestController { ) VALUES ($1, 1, $2, $3, $4, $5, '자기결재', 'approved', 'approval', NOW(), $6)`, [result.request_id, userId, userName, req.user?.positionName || null, deptName, companyCode] ); + + await syncApprovalStatusToTarget(client, result.request_id, "approved", companyCode); }); return res.status(201).json({ success: true, data: result, message: "자기결재(전결) 처리되었습니다." }); @@ -902,14 +1033,14 @@ export class ApprovalRequestController { await transaction(async (client) => { const { rows: reqRows } = await client.query( `INSERT INTO approval_requests ( - title, description, definition_id, target_table, target_record_id, + title, description, definition_id, template_id, target_table, target_record_id, target_record_data, status, current_step, total_steps, approval_type, requester_id, requester_name, requester_dept, screen_id, button_component_id, company_code - ) VALUES ($1, $2, $3, $4, $5, $6, $7, 1, $8, $9, $10, $11, $12, $13, $14, $15) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING *`, [ - title, description, definition_id, target_table, safeTargetRecordId, + title, description, definition_id, template_id || null, target_table, safeTargetRecordId, JSON.stringify(mergedRecordData), initialStatus, totalSteps, storedApprovalType, userId, userName, deptName, screen_id, button_component_id, companyCode, @@ -971,6 +1102,9 @@ export class ApprovalRequestController { [result.request_id, companyCode] ); result.status = "in_progress"; + await syncApprovalStatusToTarget(client, result.request_id, "in_progress", companyCode); + } else { + await syncApprovalStatusToTarget(client, result.request_id, "post_pending", companyCode); } }); @@ -1012,10 +1146,13 @@ export class ApprovalRequestController { return res.status(400).json({ success: false, message: "이미 처리된 결재 요청은 회수할 수 없습니다." }); } - await query( - "UPDATE approval_requests SET status = 'cancelled', updated_at = NOW() WHERE request_id = $1 AND company_code = $2", - [id, companyCode] - ); + await transaction(async (client) => { + await client.query( + "UPDATE approval_requests SET status = 'cancelled', updated_at = NOW() WHERE request_id = $1 AND company_code = $2", + [id, companyCode] + ); + await syncApprovalStatusToTarget(client, Number(id), "cancelled", companyCode); + }); return res.json({ success: true, message: "결재 요청이 회수되었습니다." }); } catch (error) { @@ -1068,13 +1205,16 @@ export class ApprovalRequestController { return res.status(400).json({ success: false, message: "모든 결재자의 승인이 완료되지 않았습니다." }); } - await query( - `UPDATE approval_requests - SET status = 'approved', is_post_approved = true, post_approved_at = NOW(), - final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW() - WHERE request_id = $3 AND company_code = $4`, - [userId, comment || null, id, companyCode] - ); + await transaction(async (client) => { + await client.query( + `UPDATE approval_requests + SET status = 'approved', is_post_approved = true, post_approved_at = NOW(), + final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW() + WHERE request_id = $3 AND company_code = $4`, + [userId, comment || null, id, companyCode] + ); + await syncApprovalStatusToTarget(client, Number(id), "approved", companyCode); + }); return res.json({ success: true, message: "후결 처리가 완료되었습니다." }); } catch (error) { @@ -1110,11 +1250,13 @@ export class ApprovalLineController { } await transaction(async (client) => { - // FOR UPDATE로 결재 라인 잠금 (동시성 방어) - const { rows: [line] } = await client.query( - `SELECT * FROM approval_lines WHERE line_id = $1 AND company_code = $2 FOR UPDATE`, - [lineId, companyCode] - ); + // FOR UPDATE로 결재 라인 잠금 + // super admin(*)은 모든 회사의 라인을 처리할 수 있음 + const lineQuery = companyCode === "*" + ? `SELECT * FROM approval_lines WHERE line_id = $1 FOR UPDATE` + : `SELECT * FROM approval_lines WHERE line_id = $1 AND company_code IN ($2, '*') FOR UPDATE`; + const lineParams = companyCode === "*" ? [lineId] : [lineId, companyCode]; + const { rows: [line] } = await client.query(lineQuery, lineParams); if (!line) { throw new ValidationError(404, "결재 라인을 찾을 수 없습니다."); @@ -1129,51 +1271,60 @@ export class ApprovalLineController { let proxyReasonVal: string | null = null; if (line.approver_id !== userId) { - const { rows: proxyRows } = await client.query( - `SELECT * FROM approval_proxy_settings - WHERE original_user_id = $1 AND proxy_user_id = $2 - AND is_active = 'Y' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE - AND company_code = $3`, - [line.approver_id, userId, companyCode] - ); - if (proxyRows.length === 0) { - throw new ValidationError(403, "본인이 결재자로 지정된 건만 처리할 수 있습니다."); + // super admin(company_code='*')은 모든 결재를 대리 처리 가능 + if (companyCode === "*") { + proxyFor = line.approver_id; + proxyReasonVal = proxy_reason || "최고관리자 대리 처리"; + } else { + const { rows: proxyRows } = await client.query( + `SELECT * FROM approval_proxy_settings + WHERE original_user_id = $1 AND proxy_user_id = $2 + AND is_active = 'Y' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE + AND company_code = $3`, + [line.approver_id, userId, companyCode] + ); + if (proxyRows.length === 0) { + throw new ValidationError(403, "본인이 결재자로 지정된 건만 처리할 수 있습니다."); + } + proxyFor = line.approver_id; + proxyReasonVal = proxy_reason || proxyRows[0].reason || "대결 처리"; } - proxyFor = line.approver_id; - proxyReasonVal = proxy_reason || proxyRows[0].reason || "대결 처리"; } - // 현재 라인 처리 (proxy_for, proxy_reason 포함) + // 현재 라인 처리 (proxy_for, proxy_reason 포함) - 라인의 company_code 기준 await client.query( `UPDATE approval_lines SET status = $1, comment = $2, processed_at = NOW(), proxy_for = $3, proxy_reason = $4 WHERE line_id = $5 AND company_code = $6`, - [action, comment || null, proxyFor, proxyReasonVal, lineId, companyCode] + [action, comment || null, proxyFor, proxyReasonVal, lineId, line.company_code] ); - // 결재 요청 조회 (FOR UPDATE) + // 결재 요청 조회 (FOR UPDATE) - 라인의 company_code 기준 const { rows: [request] } = await client.query( `SELECT * FROM approval_requests WHERE request_id = $1 AND company_code = $2 FOR UPDATE`, - [line.request_id, companyCode] + [line.request_id, line.company_code] ); if (!request) return; + const lineCC = line.company_code; + if (action === "rejected") { // 반려: 전체 요청 반려 처리 await client.query( `UPDATE approval_requests SET status = 'rejected', final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW() WHERE request_id = $3 AND company_code = $4`, - [userId, comment || null, line.request_id, companyCode] + [userId, comment || null, line.request_id, lineCC] ); // 남은 pending/waiting 라인도 skipped 처리 await client.query( `UPDATE approval_lines SET status = 'skipped' WHERE request_id = $1 AND status IN ('pending', 'waiting') AND line_id != $2 AND company_code = $3`, - [line.request_id, lineId, companyCode] + [line.request_id, lineId, lineCC] ); + await syncApprovalStatusToTarget(client, line.request_id, "rejected", lineCC); } else { // 승인 처리: step_type 기반 분기 const currentStepType = line.step_type || "approval"; @@ -1186,9 +1337,8 @@ export class ApprovalLineController { // 레거시 동시결재 (하위호환) const { rows: remainingLines } = await client.query( `SELECT COUNT(*) as cnt FROM approval_lines - WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3 - FOR UPDATE`, - [line.request_id, lineId, companyCode] + WHERE request_id = $1 AND status = 'pending' AND line_id != $2 AND company_code = $3`, + [line.request_id, lineId, lineCC] ); const remaining = parseInt(remainingLines[0]?.cnt || "0"); @@ -1197,8 +1347,9 @@ export class ApprovalLineController { `UPDATE approval_requests SET status = 'approved', final_approver_id = $1, final_comment = $2, completed_at = NOW(), updated_at = NOW() WHERE request_id = $3 AND company_code = $4`, - [userId, comment || null, line.request_id, companyCode] + [userId, comment || null, line.request_id, lineCC] ); + await syncApprovalStatusToTarget(client, line.request_id, "approved", lineCC); } } else if (currentStepType === "consensus") { // 합의결재: 같은 step의 모든 결재자 승인 확인 @@ -1206,23 +1357,22 @@ export class ApprovalLineController { `SELECT COUNT(*) as cnt FROM approval_lines WHERE request_id = $1 AND step_order = $2 AND status NOT IN ('approved', 'skipped') - AND line_id != $3 AND company_code = $4 - FOR UPDATE`, - [line.request_id, line.step_order, lineId, companyCode] + AND line_id != $3 AND company_code = $4`, + [line.request_id, line.step_order, lineId, lineCC] ); if (parseInt(remaining[0].cnt) === 0) { // 합의 완료 → 다음 step 활성화 await activateNextStep( client, line.request_id, line.step_order, request.total_steps, - companyCode, userId, comment || null, + lineCC, userId, comment || null, ); } } else { // approval (기존 sequential 로직): 다음 step 활성화 await activateNextStep( client, line.request_id, line.step_order, request.total_steps, - companyCode, userId, comment || null, + lineCC, userId, comment || null, ); } } @@ -1260,7 +1410,7 @@ export class ApprovalLineController { `SELECT l.*, r.title, r.target_table, r.target_record_id, r.requester_name, r.requester_dept, r.created_at as request_created_at FROM approval_lines l JOIN approval_requests r ON l.request_id = r.request_id AND l.company_code = r.company_code - WHERE l.approver_id = $1 AND l.status = 'pending' AND l.company_code = $2 + WHERE l.approver_id = $1 AND l.status = 'pending' AND l.company_code IN ($2, '*') ORDER BY r.created_at ASC`, [userId, companyCode] ); diff --git a/frontend/components/approval/ApprovalRequestModal.tsx b/frontend/components/approval/ApprovalRequestModal.tsx index 3e7e8904..45c7ef86 100644 --- a/frontend/components/approval/ApprovalRequestModal.tsx +++ b/frontend/components/approval/ApprovalRequestModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect } from "react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -12,7 +12,9 @@ import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Plus, X, Loader2, Search, GripVertical, Users, ArrowDown, Layers, FileText } from "lucide-react"; +import { Plus, X, Loader2, GripVertical, Users, ArrowDown, Layers, FileText, ChevronsUpDown } from "lucide-react"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { toast } from "sonner"; import { createApprovalRequest, @@ -98,13 +100,10 @@ export const ApprovalRequestModal: React.FC = ({ const [showTemplatePopover, setShowTemplatePopover] = useState(false); const [isLoadingTemplates, setIsLoadingTemplates] = useState(false); - // 사용자 검색 상태 - const [searchOpen, setSearchOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const searchInputRef = useRef(null); - const searchTimerRef = useRef(null); + // 결재자 Combobox 상태 + const [comboboxOpen, setComboboxOpen] = useState(false); + const [allUsers, setAllUsers] = useState([]); + const [isLoadingUsers, setIsLoadingUsers] = useState(false); // 모달 닫힐 때 초기화 useEffect(() => { @@ -115,21 +114,43 @@ export const ApprovalRequestModal: React.FC = ({ setApprovalType("escalation"); setApprovers([]); setError(null); - setSearchOpen(false); - setSearchQuery(""); - setSearchResults([]); + setComboboxOpen(false); + setAllUsers([]); setSelectedTemplateId(null); setShowTemplatePopover(false); } }, [open]); - // 모달 열릴 때 템플릿 목록 로드 + // 모달 열릴 때 템플릿 + 사용자 목록 로드 useEffect(() => { if (open) { loadTemplates(); + loadUsers(); } }, [open]); + const loadUsers = async () => { + setIsLoadingUsers(true); + try { + const res = await getUserList({ limit: 100 }); + const data = res?.data || res || []; + const rawUsers: any[] = Array.isArray(data) ? data : []; + const users: UserSearchResult[] = rawUsers.map((u: any) => ({ + userId: u.userId || u.user_id || "", + userName: u.userName || u.user_name || "", + positionName: u.positionName || u.position_name || "", + deptName: u.deptName || u.dept_name || "", + deptCode: u.deptCode || u.dept_code || "", + email: u.email || "", + })); + setAllUsers(users.filter((u) => u.userId)); + } catch { + setAllUsers([]); + } finally { + setIsLoadingUsers(false); + } + }; + const loadTemplates = async () => { setIsLoadingTemplates(true); try { @@ -180,48 +201,10 @@ export const ApprovalRequestModal: React.FC = ({ } }; - // 사용자 검색 (디바운스) - const searchUsers = useCallback(async (query: string) => { - if (!query.trim() || query.trim().length < 1) { - setSearchResults([]); - return; - } - - setIsSearching(true); - try { - const res = await getUserList({ search: query.trim(), limit: 20 }); - const data = res?.data || res || []; - const rawUsers: any[] = Array.isArray(data) ? data : []; - const users: UserSearchResult[] = rawUsers.map((u: any) => ({ - userId: u.userId || u.user_id || "", - userName: u.userName || u.user_name || "", - positionName: u.positionName || u.position_name || "", - deptName: u.deptName || u.dept_name || "", - deptCode: u.deptCode || u.dept_code || "", - email: u.email || "", - })); - const existingIds = new Set(approvers.map((a) => a.user_id)); - setSearchResults(users.filter((u) => u.userId && !existingIds.has(u.userId))); - } catch { - setSearchResults([]); - } finally { - setIsSearching(false); - } - }, [approvers]); - - useEffect(() => { - if (searchTimerRef.current) clearTimeout(searchTimerRef.current); - if (!searchQuery.trim()) { - setSearchResults([]); - return; - } - searchTimerRef.current = setTimeout(() => { - searchUsers(searchQuery); - }, 300); - return () => { - if (searchTimerRef.current) clearTimeout(searchTimerRef.current); - }; - }, [searchQuery, searchUsers]); + // Combobox에서 이미 선택된 사용자 제외한 목록 + const availableUsers = allUsers.filter( + (u) => !approvers.some((a) => a.user_id === u.userId) + ); const addApprover = (user: UserSearchResult) => { setApprovers((prev) => [ @@ -234,9 +217,7 @@ export const ApprovalRequestModal: React.FC = ({ dept_name: user.deptName || "", }, ]); - setSearchQuery(""); - setSearchResults([]); - setSearchOpen(false); + setComboboxOpen(false); }; const removeApprover = (id: string) => { @@ -277,6 +258,7 @@ export const ApprovalRequestModal: React.FC = ({ const res = await createApprovalRequest({ title: title.trim(), description: description.trim() || undefined, + template_id: selectedTemplateId || undefined, target_table: eventDetail.targetTable, target_record_id: eventDetail.targetRecordId || undefined, target_record_data: eventDetail.targetRecordData, @@ -491,47 +473,54 @@ export const ApprovalRequestModal: React.FC = ({ - {/* 검색 입력 */} -
- - { - setSearchQuery(e.target.value); - setSearchOpen(true); - }} - onFocus={() => setSearchOpen(true)} - placeholder="이름 또는 사번으로 검색..." - className="h-8 pl-9 text-xs sm:h-10 sm:text-sm" - /> - - {/* 검색 결과 드롭다운 */} - {searchOpen && searchQuery.trim() && ( -
- {isSearching ? ( -
- - 검색 중... -
- ) : searchResults.length === 0 ? ( -
-

검색 결과가 없습니다.

-
+ {/* 결재자 Combobox (Select + 검색) */} + + + + + + + + + + 검색 결과가 없습니다. + + + {availableUsers.map((user) => ( + addApprover(user)} - className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-accent" + value={`${user.userName} ${user.userId} ${user.deptName || ""} ${user.positionName || ""}`} + onSelect={() => addApprover(user)} + className="flex cursor-pointer items-center gap-3 px-3 py-2 text-xs sm:text-sm" > -
- +
+
-

+

{user.userName} ({user.userId}) @@ -542,26 +531,18 @@ export const ApprovalRequestModal: React.FC = ({

- + ))} -
- )} -
- )} -
- - {/* 클릭 외부 영역 닫기 */} - {searchOpen && ( -
setSearchOpen(false)} - /> - )} + + + + + {/* 선택된 결재자 목록 */} {approvers.length === 0 ? (

- 위 검색창에서 결재자를 검색하여 추가하세요 + 위 선택창에서 결재자를 선택하여 추가하세요

) : (
diff --git a/frontend/lib/api/approval.ts b/frontend/lib/api/approval.ts index af628a00..6fe5c03e 100644 --- a/frontend/lib/api/approval.ts +++ b/frontend/lib/api/approval.ts @@ -134,6 +134,7 @@ export interface CreateApprovalRequestInput { title: string; description?: string; definition_id?: number; + template_id?: number; target_table: string; target_record_id?: string; target_record_data?: Record; diff --git a/frontend/scripts/po-approval-company7-test.ts b/frontend/scripts/po-approval-company7-test.ts new file mode 100644 index 00000000..a1023c89 --- /dev/null +++ b/frontend/scripts/po-approval-company7-test.ts @@ -0,0 +1,179 @@ +/** + * COMPANY_7 사용자(topseal_admin) 발주관리 결재 시스템 테스트 + * 실행: npx tsx frontend/scripts/po-approval-company7-test.ts + */ +import { chromium } from "playwright"; +import { writeFileSync } from "fs"; + +const BASE_URL = "http://localhost:9771"; +const LOGIN_ID = "topseal_admin"; +const LOGIN_PW = "qlalfqjsgh11"; +const SCREEN_URL = `${BASE_URL}/screen/COMPANY_7_064`; + +const results: string[] = []; +const screenshotDir = "/Users/gbpark/ERP-node/approval-company7-screenshots"; + +async function main() { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + const screenshot = async (name: string) => { + const path = `${screenshotDir}/${name}.png`; + await page.screenshot({ path, fullPage: true }); + results.push(` 스크린샷: ${name}.png`); + }; + + try { + // Step 1: 로그인 + results.push("\n=== Step 1: 로그인 (topseal_admin) ==="); + await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(2000); + + const loginPage = page.locator('input[type="text"], input[name="userId"], #userId').first(); + if ((await loginPage.count()) > 0) { + await page.getByPlaceholder("사용자 ID를 입력하세요").or(page.locator('#userId, input[name="userId"]')).first().fill(LOGIN_ID); + await page.getByPlaceholder("비밀번호를 입력하세요").or(page.locator('#password, input[name="password"]')).first().fill(LOGIN_PW); + await page.getByRole("button", { name: "로그인" }).or(page.locator('button[type="submit"]')).first().click(); + await page.waitForTimeout(3000); + try { + await page.waitForURL((url) => !url.toString().includes("/login"), { timeout: 25000 }); + } catch { + results.push(" WARN: 로그인 후 URL 변경 없음 - 로그인 실패 가능"); + } + } + await page.waitForTimeout(3000); + const urlAfterLogin = page.url(); + results.push(` 현재 URL: ${urlAfterLogin}`); + await screenshot("01-after-login"); + if (urlAfterLogin.includes("/login")) { + results.push(" FAIL: 로그인 실패 - 여전히 로그인 페이지에 있음"); + } else { + results.push(" OK: 로그인 완료"); + } + + // Step 2: 구매관리 메뉴 또는 직접 URL + results.push("\n=== Step 2: 발주관리 화면 이동 ==="); + const purchaseMenu = page.locator('text="구매관리"').first(); + const hasPurchaseMenu = (await purchaseMenu.count()) > 0; + if (hasPurchaseMenu) { + await purchaseMenu.click(); + await page.waitForTimeout(800); + const poMenu = page.locator('text="발주관리"').or(page.locator('text="발주 관리"')).first(); + if ((await poMenu.count()) > 0) { + await poMenu.click(); + await page.waitForTimeout(3000); + } else { + await page.goto(SCREEN_URL, { waitUntil: "domcontentloaded", timeout: 20000 }); + await page.waitForTimeout(5000); + } + } else { + results.push(" INFO: 구매관리 메뉴 없음, 직접 URL 이동"); + await page.goto(SCREEN_URL, { waitUntil: "domcontentloaded", timeout: 20000 }); + await page.waitForTimeout(5000); + } + await screenshot("02-po-screen"); + results.push(" OK: 발주관리 화면 로드"); + + // Step 3: 그리드 컬럼 상세 확인 + results.push("\n=== Step 3: 그리드 컬럼 및 데이터 확인 ==="); + await page.waitForTimeout(2000); + + const headers = await page.locator("table th, [role='columnheader']").allTextContents(); + const headerTexts = headers.map((h) => h.trim()).filter((h) => h.length > 0); + results.push(` 컬럼 헤더 (전체): ${JSON.stringify(headerTexts)}`); + + const firstCol = headerTexts[0] || ""; + const isFirstColKorean = firstCol === "결재상태"; + const isFirstColEnglish = firstCol === "approval_status" || firstCol.toLowerCase().includes("approval"); + results.push(` 첫 번째 컬럼: "${firstCol}"`); + results.push(isFirstColKorean ? " 결재상태(한글) 표시됨" : isFirstColEnglish ? " approval_status(영문) 표시됨" : ` 기타: ${firstCol}`); + + const rows = await page.locator("table tbody tr, [role='row']").count(); + const hasEmptyMsg = (await page.locator('text="데이터가 없습니다"').count()) > 0; + results.push(` 데이터 행 수: ${rows}`); + results.push(hasEmptyMsg ? " 빈 그리드: '데이터가 없습니다' 메시지 표시" : " 데이터 있음"); + + if (rows > 0 && !hasEmptyMsg) { + const firstColCells = await page.locator("table tbody tr td:first-child").allTextContents(); + results.push(` 첫 번째 컬럼 값(샘플): ${JSON.stringify(firstColCells.slice(0, 5))}`); + + const poNumbers = await page.locator("table tbody td").filter({ hasText: /PO-|발주/ }).allTextContents(); + results.push(` 발주번호 형식 데이터: ${poNumbers.length > 0 ? JSON.stringify(poNumbers.slice(0, 5)) : "없음"}`); + } + + await screenshot("03-grid-detail"); + results.push(" OK: 그리드 상세 스크린샷 저장"); + + // Step 4: 결재 요청 버튼 확인 + results.push("\n=== Step 4: 결재 요청 버튼 확인 ==="); + const approvalBtn = page.getByRole("button", { name: "결재 요청" }).or(page.locator('button:has-text("결재 요청")')); + const hasApprovalBtn = (await approvalBtn.count()) > 0; + results.push(hasApprovalBtn ? " OK: '결재 요청' 파란색 버튼 확인됨" : " FAIL: '결재 요청' 버튼 없음"); + await screenshot("04-approval-button"); + + // Step 5: 행 선택 후 결재 요청 클릭 + results.push("\n=== Step 5: 행 선택 후 결재 요청 ==="); + const firstRow = page.locator("table tbody tr").first(); + const checkbox = page.locator("table tbody tr input[type='checkbox']").first(); + const hasRows = (await firstRow.count()) > 0; + const hasCheckbox = (await checkbox.count()) > 0; + + if (hasRows) { + if (hasCheckbox) { + await checkbox.click(); + await page.waitForTimeout(300); + } else { + await firstRow.click(); + await page.waitForTimeout(300); + } + results.push(" OK: 행 선택 완료"); + } else { + results.push(" INFO: 데이터 행 없음, 행 선택 없이 진행"); + } + + if (hasApprovalBtn) { + await approvalBtn.first().click({ force: true }); + await page.waitForTimeout(2000); + await screenshot("05-approval-modal"); + + const modal = page.locator('[role="dialog"]'); + const modalOpened = (await modal.count()) > 0; + results.push(modalOpened ? " OK: 결재 모달 열림" : " FAIL: 결재 모달 열리지 않음"); + + if (modalOpened) { + const searchInput = page.getByPlaceholder("이름 또는 사번으로 검색...").or(page.locator('[role="dialog"] input[placeholder*="검색"]')); + if ((await searchInput.count()) > 0) { + await searchInput.first().fill("김"); + await page.waitForTimeout(2000); + await screenshot("06-approver-search-results"); + + const searchResults = page.locator('[role="dialog"] div.max-h-48 button, [role="dialog"] div.overflow-y-auto button'); + const resultCount = await searchResults.count(); + const resultTexts = await searchResults.allTextContents(); + results.push(` 결재자 검색 결과: ${resultCount}명`); + if (resultTexts.length > 0) { + results.push(` 결재자 목록: ${JSON.stringify(resultTexts.slice(0, 10))}`); + } + } + } + } + + await screenshot("07-final"); + } catch (err: any) { + results.push(`\nERROR: ${err.message}`); + await page.screenshot({ path: `${screenshotDir}/error.png`, fullPage: true }).catch(() => {}); + } finally { + await browser.close(); + } + + const output = results.join("\n"); + console.log("\n" + "=".repeat(60)); + console.log("COMPANY_7 (topseal_admin) 발주관리 결재 테스트 결과"); + console.log("=".repeat(60)); + console.log(output); + console.log("=".repeat(60)); + writeFileSync("/Users/gbpark/ERP-node/approval-company7-report.txt", output); +} + +main(); diff --git a/frontend/scripts/purchase-order-approval-test.ts b/frontend/scripts/purchase-order-approval-test.ts new file mode 100644 index 00000000..0ea0cbe1 --- /dev/null +++ b/frontend/scripts/purchase-order-approval-test.ts @@ -0,0 +1,174 @@ +/** + * 발주관리 화면 결재 시스템 E2E 테스트 + * 메뉴: 구매관리 → 발주관리 + * 실행: npx tsx frontend/scripts/purchase-order-approval-test.ts + */ +import { chromium } from "playwright"; +import { writeFileSync } from "fs"; + +const BASE_URL = "http://localhost:9771"; +const LOGIN_ID = "wace"; +const LOGIN_PW = "qlalfqjsgh11"; + +const results: string[] = []; +const screenshotDir = "/Users/gbpark/ERP-node/approval-test-screenshots"; + +async function main() { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + const screenshot = async (name: string) => { + const path = `${screenshotDir}/${name}.png`; + await page.screenshot({ path, fullPage: true }); + results.push(` 스크린샷: ${name}.png`); + }; + + try { + // Step 1: 로그인 + results.push("\n=== Step 1: 로그인 ==="); + await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: 30000 }); + await screenshot("01-login-page"); + + const userIdInput = page.getByPlaceholder("사용자 ID를 입력하세요").or(page.locator('#userId, input[name="userId"]')); + const pwInput = page.getByPlaceholder("비밀번호를 입력하세요").or(page.locator('#password, input[name="password"]')); + const loginBtn = page.getByRole("button", { name: "로그인" }).or(page.locator('button[type="submit"]')); + + await userIdInput.first().fill(LOGIN_ID); + await pwInput.first().fill(LOGIN_PW); + await loginBtn.first().click(); + await page.waitForURL((url) => !url.toString().includes("/login"), { timeout: 30000 }); + await page.waitForLoadState("domcontentloaded"); + await page.waitForTimeout(5000); // 메뉴 로드 대기 + await screenshot("02-after-login"); + results.push(" OK: 로그인 완료, 대시보드 로드"); + + // Step 2: 구매관리 → 발주관리 메뉴 이동 (또는 직접 URL) + results.push("\n=== Step 2: 구매관리 → 발주관리 메뉴 이동 ==="); + const purchaseMenu = page.locator('text="구매관리"').first(); + const hasPurchaseMenu = (await purchaseMenu.count()) > 0; + let poScreenLoaded = false; + + if (hasPurchaseMenu) { + await purchaseMenu.click(); + await page.waitForTimeout(800); + await screenshot("03-purchase-menu-expanded"); + + const poMenu = page.locator('text="발주관리"').or(page.locator('text="발주 관리"')).first(); + const hasPoMenu = (await poMenu.count()) > 0; + if (hasPoMenu) { + await poMenu.click(); + await page.waitForTimeout(3000); + await screenshot("04-po-screen-loaded"); + poScreenLoaded = true; + results.push(" OK: 메뉴로 발주관리 화면 이동 완료"); + } + } + + if (!poScreenLoaded) { + results.push(" INFO: 메뉴에서 발주관리 미발견, 직접 URL로 이동"); + const allMenuTexts = await page.locator("aside a, aside button, aside [role='menuitem']").allTextContents(); + results.push(` 메뉴 목록: ${JSON.stringify(allMenuTexts.slice(0, 30))}`); + await page.goto(`${BASE_URL}/screen/COMPANY_7_064`, { waitUntil: "domcontentloaded", timeout: 20000 }); + await page.waitForTimeout(4000); + await screenshot("04-po-screen-loaded"); + results.push(" OK: /screen/COMPANY_7_064 직접 이동 완료"); + } + + // Step 3: 그리드 컬럼 확인 + results.push("\n=== Step 3: 그리드 컬럼 확인 ==="); + await page.waitForTimeout(2000); + await screenshot("05-grid-columns"); + + const headers = await page.locator("table th, [role='columnheader']").allTextContents(); + const headerTexts = headers.map((h) => h.trim()).filter((h) => h.length > 0); + results.push(` 컬럼 목록: ${JSON.stringify(headerTexts)}`); + + const hasApprovalColumn = headerTexts.some((h) => h.includes("결재상태")); + results.push(hasApprovalColumn ? " OK: '결재상태' 컬럼 확인됨" : " FAIL: '결재상태' 컬럼 없음"); + + // 결재상태 값 확인 (작성중 등) + const statusCellTexts = await page.locator("table tbody td").allTextContents(); + const approvalValues = statusCellTexts.filter((t) => + ["작성중", "결재중", "결재완료", "반려"].some((s) => t.includes(s)) + ); + results.push(` 결재상태 값: ${approvalValues.length > 0 ? approvalValues.join(", ") : "데이터 없음 또는 해당 값 없음"}`); + + // Step 4: 행 선택 후 결재 요청 버튼 클릭 + results.push("\n=== Step 4: 행 선택 및 결재 요청 버튼 클릭 ==="); + const firstRow = page.locator("table tbody tr, [role='row']").first(); + const hasRows = (await firstRow.count()) > 0; + if (hasRows) { + await firstRow.click({ force: true }); + await page.waitForTimeout(500); + await screenshot("06-row-selected"); + results.push(" OK: 첫 번째 행 선택"); + } else { + results.push(" INFO: 데이터 행 없음, 행 선택 없이 진행"); + } + + const approvalBtn = page.getByRole("button", { name: "결재 요청" }).or(page.locator('button:has-text("결재 요청")')); + const hasApprovalBtn = (await approvalBtn.count()) > 0; + if (!hasApprovalBtn) { + results.push(" FAIL: '결재 요청' 버튼 없음"); + } else { + await approvalBtn.first().click({ force: true }); + await page.waitForTimeout(2000); + await screenshot("07-approval-modal-opened"); + + const modal = page.locator('[role="dialog"]'); + const modalOpened = (await modal.count()) > 0; + results.push(modalOpened ? " OK: 결재 모달 열림" : " FAIL: 결재 모달 열리지 않음"); + } + + // Step 5: 결재자 검색 테스트 + results.push("\n=== Step 5: 결재자 검색 테스트 ==="); + const searchInput = page.getByPlaceholder("이름 또는 사번으로 검색...").or( + page.locator('input[placeholder*="검색"]') + ); + const hasSearchInput = (await searchInput.count()) > 0; + if (!hasSearchInput) { + results.push(" FAIL: 결재자 검색 입력 필드 없음"); + } else { + await searchInput.first().fill("김"); + await page.waitForTimeout(2000); + await screenshot("08-approver-search-results"); + + // 검색 결과 확인 (ApprovalRequestModal: div.max-h-48 내부 button) + const searchResults = page.locator( + '[role="dialog"] div.max-h-48 button, [role="dialog"] div.overflow-y-auto button' + ); + const resultCount = await searchResults.count(); + const resultTexts = await searchResults.allTextContents(); + results.push(` 검색 결과 수: ${resultCount}명`); + if (resultTexts.length > 0) { + const names = resultTexts.map((t) => t.trim()).filter((t) => t.length > 0); + results.push(` 결재자 목록: ${JSON.stringify(names.slice(0, 10))}`); + } + + // "검색 결과가 없습니다" 또는 "검색 중" 메시지 확인 + const noResultsMsg = page.locator('text="검색 결과가 없습니다"'); + const searchingMsg = page.locator('text="검색 중"'); + if ((await noResultsMsg.count()) > 0) results.push(" (검색 결과 없음 메시지 표시됨)"); + if ((await searchingMsg.count()) > 0) results.push(" (검색 중 메시지 표시됨 - 대기 부족 가능)"); + } + + // 최종 스크린샷 + await screenshot("09-final-state"); + } catch (err: any) { + results.push(`\nERROR: ${err.message}`); + await page.screenshot({ path: `${screenshotDir}/error.png`, fullPage: true }).catch(() => {}); + } finally { + await browser.close(); + } + + const output = results.join("\n"); + console.log("\n" + "=".repeat(60)); + console.log("발주관리 결재 시스템 테스트 결과"); + console.log("=".repeat(60)); + console.log(output); + console.log("=".repeat(60)); + writeFileSync("/Users/gbpark/ERP-node/approval-test-report.txt", output); +} + +main(); diff --git a/frontend/scripts/screen-approval-modal-test.ts b/frontend/scripts/screen-approval-modal-test.ts new file mode 100644 index 00000000..cc47073c --- /dev/null +++ b/frontend/scripts/screen-approval-modal-test.ts @@ -0,0 +1,101 @@ +/** + * 결재 모달 테스트: 버튼 클릭 vs CustomEvent 직접 발송 + * 실행: npx tsx frontend/scripts/screen-approval-modal-test.ts + */ +import { chromium } from "playwright"; +import { writeFileSync } from "fs"; + +const BASE_URL = "http://localhost:9771"; +const LOGIN_ID = "wace"; +const LOGIN_PW = "qlalfqjsgh11"; +const SCREEN_URL = `${BASE_URL}/screen/COMPANY_7_064`; + +const results: string[] = []; + +async function main() { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + try { + // 1. 로그인 + results.push("=== 1. 로그인 ==="); + await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle", timeout: 15000 }); + await page.getByPlaceholder("사용자 ID를 입력하세요").or(page.locator('#userId, input[name="userId"]')).first().fill(LOGIN_ID); + await page.getByPlaceholder("비밀번호를 입력하세요").or(page.locator('#password, input[name="password"]')).first().fill(LOGIN_PW); + await page.getByRole("button", { name: "로그인" }).or(page.locator('button[type="submit"]')).first().click(); + await page.waitForURL((url) => !url.toString().includes("/login"), { timeout: 30000 }); + await page.waitForLoadState("networkidle"); + results.push("OK: 로그인 성공"); + + // 2. 화면 이동 및 대기 + results.push("\n=== 2. 화면 COMPANY_7_064 이동 ==="); + await page.goto(SCREEN_URL, { waitUntil: "networkidle", timeout: 20000 }); + await page.waitForTimeout(3000); + results.push("OK: 페이지 로드 완료"); + + // 3. 전체 페이지 스크린샷 + results.push("\n=== 3. 전체 페이지 스크린샷 ==="); + await page.screenshot({ path: "/Users/gbpark/ERP-node/approval-test-1-full-page.png", fullPage: true }); + results.push("OK: approval-test-1-full-page.png 저장"); + + // 4. "결재 요청" 버튼 클릭 + results.push("\n=== 4. 결재 요청 버튼 클릭 ==="); + const approvalBtn = page.getByRole("button", { name: "결재 요청" }).or(page.locator('button:has-text("결재 요청")')); + await approvalBtn.first().click({ force: true }); + await page.waitForTimeout(2000); + + // 5. 클릭 후 스크린샷 + results.push("\n=== 5. 클릭 후 스크린샷 ==="); + await page.screenshot({ path: "/Users/gbpark/ERP-node/approval-test-2-after-button-click.png", fullPage: true }); + results.push("OK: approval-test-2-after-button-click.png 저장"); + + // 6. 모달 등장 여부 확인 + results.push("\n=== 6. 버튼 클릭 후 모달 확인 ==="); + const modalAfterClick = page.locator('[role="dialog"]'); + const modalVisibleAfterClick = (await modalAfterClick.count()) > 0; + results.push(modalVisibleAfterClick ? "OK: 버튼 클릭으로 모달 열림" : "FAIL: 버튼 클릭 후 모달 없음"); + + // 7. CustomEvent 직접 발송 (모달이 없었을 때) + results.push("\n=== 7. CustomEvent 직접 발송 ==="); + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent("open-approval-modal", { + detail: { targetTable: "purchase_order_mng", targetRecordId: "test-123" }, + }) + ); + }); + await page.waitForTimeout(2000); + + // 8. CustomEvent 발송 후 스크린샷 + results.push("\n=== 8. CustomEvent 발송 후 스크린샷 ==="); + await page.screenshot({ path: "/Users/gbpark/ERP-node/approval-test-3-after-customevent.png", fullPage: true }); + results.push("OK: approval-test-3-after-customevent.png 저장"); + + // 9. CustomEvent 발송 후 모달 확인 + results.push("\n=== 9. CustomEvent 발송 후 모달 확인 ==="); + const modalAfterEvent = page.locator('[role="dialog"]'); + const modalVisibleAfterEvent = (await modalAfterEvent.count()) > 0; + results.push(modalVisibleAfterEvent ? "OK: CustomEvent 발송으로 모달 열림" : "FAIL: CustomEvent 발송 후에도 모달 없음"); + + // 10. 최종 요약 + results.push("\n=== 10. 최종 요약 ==="); + results.push(`버튼 클릭 → 모달: ${modalVisibleAfterClick ? "YES" : "NO"}`); + results.push(`CustomEvent 발송 → 모달: ${modalVisibleAfterEvent ? "YES" : "NO"}`); + } catch (err: any) { + results.push(`\nERROR: ${err.message}`); + await page.screenshot({ path: "/Users/gbpark/ERP-node/approval-test-error.png", fullPage: true }).catch(() => {}); + } finally { + await browser.close(); + } + + const output = results.join("\n"); + console.log("\n" + "=".repeat(60)); + console.log("결재 모달 테스트 결과"); + console.log("=".repeat(60)); + console.log(output); + console.log("=".repeat(60)); + writeFileSync("/Users/gbpark/ERP-node/approval-modal-test-result.txt", output); +} + +main(); diff --git a/test-results/.last-run.json b/test-results/.last-run.json index 5fca3f84..cbcc1fba 100644 --- a/test-results/.last-run.json +++ b/test-results/.last-run.json @@ -1,4 +1,4 @@ { - "status": "failed", + "status": "passed", "failedTests": [] } \ No newline at end of file