Enhance approval process by adding after approval flow ID to templates and implementing user selection via Combobox in the Approval Request Modal.

This commit is contained in:
DDD1542 2026-03-07 03:02:36 +09:00
parent 2439951fab
commit 542fab2140
9 changed files with 814 additions and 166 deletions

View File

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

29
approval-test-report.txt Normal file
View File

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

View File

@ -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<void> {
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<string, string> = {
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<void> {
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<any>(
"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<any>(
`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]
);

View File

@ -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<ApprovalRequestModalProps> = ({
const [showTemplatePopover, setShowTemplatePopover] = useState(false);
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
// 사용자 검색 상태
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<UserSearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
// 결재자 Combobox 상태
const [comboboxOpen, setComboboxOpen] = useState(false);
const [allUsers, setAllUsers] = useState<UserSearchResult[]>([]);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
// 모달 닫힐 때 초기화
useEffect(() => {
@ -115,21 +114,43 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
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<ApprovalRequestModalProps> = ({
}
};
// 사용자 검색 (디바운스)
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<ApprovalRequestModalProps> = ({
dept_name: user.deptName || "",
},
]);
setSearchQuery("");
setSearchResults([]);
setSearchOpen(false);
setComboboxOpen(false);
};
const removeApprover = (id: string) => {
@ -277,6 +258,7 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
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<ApprovalRequestModalProps> = ({
</span>
</div>
{/* 검색 입력 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
ref={searchInputRef}
value={searchQuery}
onChange={(e) => {
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() && (
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
{isSearching ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs"> ...</span>
</div>
) : searchResults.length === 0 ? (
<div className="p-4 text-center">
<p className="text-muted-foreground text-xs"> .</p>
</div>
{/* 결재자 Combobox (Select + 검색) */}
<Popover open={comboboxOpen} onOpenChange={setComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={comboboxOpen}
disabled={isLoadingUsers}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{isLoadingUsers ? (
<span className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
...
</span>
) : (
<div className="max-h-48 overflow-y-auto">
{searchResults.map((user) => (
<button
<span className="text-muted-foreground"> ...</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput
placeholder="이름 또는 사번으로 검색..."
className="text-xs sm:text-sm"
/>
<CommandList>
<CommandEmpty className="py-4 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
{availableUsers.map((user) => (
<CommandItem
key={user.userId}
type="button"
onClick={() => 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"
>
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
<Users className="h-4 w-4" />
<div className="bg-muted flex h-7 w-7 shrink-0 items-center justify-center rounded-full">
<Users className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium sm:text-sm">
<p className="truncate font-medium">
{user.userName}
<span className="text-muted-foreground ml-1 text-[10px]">
({user.userId})
@ -542,26 +531,18 @@ export const ApprovalRequestModal: React.FC<ApprovalRequestModalProps> = ({
</p>
</div>
<Plus className="text-muted-foreground h-4 w-4 shrink-0" />
</button>
</CommandItem>
))}
</div>
)}
</div>
)}
</div>
{/* 클릭 외부 영역 닫기 */}
{searchOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setSearchOpen(false)}
/>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 선택된 결재자 목록 */}
{approvers.length === 0 ? (
<p className="text-muted-foreground mt-3 rounded-md border border-dashed p-4 text-center text-xs">
</p>
) : (
<div className="mt-3 space-y-2">

View File

@ -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<string, any>;

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
{
"status": "failed",
"status": "passed",
"failedTests": []
}