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:
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
|
||||
179
frontend/scripts/po-approval-company7-test.ts
Normal file
179
frontend/scripts/po-approval-company7-test.ts
Normal 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();
|
||||
174
frontend/scripts/purchase-order-approval-test.ts
Normal file
174
frontend/scripts/purchase-order-approval-test.ts
Normal 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();
|
||||
101
frontend/scripts/screen-approval-modal-test.ts
Normal file
101
frontend/scripts/screen-approval-modal-test.ts
Normal 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();
|
||||
Reference in New Issue
Block a user