Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
commit
95c8148787
|
|
@ -13,9 +13,13 @@ import {
|
|||
PoolClient,
|
||||
QueryResult as PgQueryResult,
|
||||
QueryResultRow,
|
||||
types,
|
||||
} from "pg";
|
||||
import config from "../config/environment";
|
||||
|
||||
// DATE 타입(OID 1082)을 문자열로 반환 (타임존 변환에 의한 -1day 버그 방지)
|
||||
types.setTypeParser(1082, (val: string) => val);
|
||||
|
||||
// PostgreSQL 연결 풀
|
||||
let pool: Pool | null = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
/**
|
||||
* /screen/COMPANY_7_167 → /screens/4153 리다이렉트
|
||||
* 메뉴 URL이 screenCode 기반이므로, screenId로 변환 후 이동
|
||||
*/
|
||||
export default function ScreenCodeRedirectPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const screenCode = params.screenCode as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (!screenCode) return;
|
||||
|
||||
const numericId = parseInt(screenCode);
|
||||
if (!isNaN(numericId)) {
|
||||
router.replace(`/screens/${numericId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolve = async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/screen-management/screens", {
|
||||
params: { screenCode },
|
||||
});
|
||||
const screens = res.data?.data || [];
|
||||
if (screens.length > 0) {
|
||||
const id = screens[0].screenId || screens[0].screen_id;
|
||||
router.replace(`/screens/${id}`);
|
||||
} else {
|
||||
router.replace("/");
|
||||
}
|
||||
} catch {
|
||||
router.replace("/");
|
||||
}
|
||||
};
|
||||
resolve();
|
||||
}, [screenCode, router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -93,9 +93,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
[config, component.config, component.id],
|
||||
);
|
||||
|
||||
// 소스 테이블의 키 필드명 (기본값: "item_id" → 하위 호환)
|
||||
// 예: item_info 기반이면 "item_id", customer_mng 기반이면 "customer_id"
|
||||
const sourceKeyField = componentConfig.sourceKeyField || "item_id";
|
||||
// 소스 테이블의 키 필드명
|
||||
// 우선순위: 1) config에서 명시적 설정 → 2) additionalFields에서 autoFillFrom:"id" 필드 감지 → 3) 하위 호환 "item_id"
|
||||
const sourceKeyField = useMemo(() => {
|
||||
// sourceKeyField는 config에서 직접 지정 (ConfigPanel 자동 감지에서 설정됨)
|
||||
return componentConfig.sourceKeyField || "item_id";
|
||||
}, [componentConfig.sourceKeyField]);
|
||||
|
||||
// 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id
|
||||
const dataSourceId = useMemo(
|
||||
|
|
@ -472,10 +475,16 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
if (allGroupsEmpty) {
|
||||
// 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지)
|
||||
// autoFillFrom 필드 (item_id 등)는 반드시 포함시켜야 나중에 식별 가능
|
||||
const baseRecord: Record<string, any> = {};
|
||||
|
||||
// sourceKeyField 자동 매핑 (item_id = originalData.id)
|
||||
if (sourceKeyField && item.originalData?.id) {
|
||||
baseRecord[sourceKeyField] = item.originalData.id;
|
||||
}
|
||||
|
||||
// 나머지 autoFillFrom 필드 (sourceKeyField 제외)
|
||||
additionalFields.forEach((f) => {
|
||||
if (f.autoFillFrom && item.originalData) {
|
||||
if (f.name !== sourceKeyField && f.autoFillFrom && item.originalData) {
|
||||
const value = item.originalData[f.autoFillFrom];
|
||||
if (value !== undefined && value !== null) {
|
||||
baseRecord[f.name] = value;
|
||||
|
|
@ -530,7 +539,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
return allRecords;
|
||||
},
|
||||
[componentConfig.fieldGroups, componentConfig.additionalFields],
|
||||
[componentConfig.fieldGroups, componentConfig.additionalFields, sourceKeyField],
|
||||
);
|
||||
|
||||
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
|
||||
|
|
@ -677,27 +686,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
for (const item of items) {
|
||||
// sourceKeyField 값 추출 (예: item_id 또는 customer_id)
|
||||
// (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지)
|
||||
let sourceKeyValue: string | null = null;
|
||||
|
||||
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드에서 정확한 값)
|
||||
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드)
|
||||
if (item.originalData && item.originalData[sourceKeyField]) {
|
||||
sourceKeyValue = item.originalData[sourceKeyField];
|
||||
}
|
||||
|
||||
// 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용)
|
||||
if (!sourceKeyValue) {
|
||||
mainGroups.forEach((group) => {
|
||||
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
|
||||
groupFields.forEach((field) => {
|
||||
if (field.name === sourceKeyField && field.autoFillFrom && item.originalData) {
|
||||
sourceKeyValue = item.originalData[field.autoFillFrom] || null;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3순위: fallback (최후의 수단)
|
||||
// 2순위: 원본 데이터의 id를 sourceKeyField 값으로 사용 (신규 등록 모드)
|
||||
if (!sourceKeyValue && item.originalData) {
|
||||
sourceKeyValue = item.originalData.id || null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import React, { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
|
@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Plus, X, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat } from "./types";
|
||||
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat, AutoDetectedFk } from "./types";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
|
|
@ -97,7 +97,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
|
||||
// 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드)
|
||||
const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string }>>([]);
|
||||
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string }>>([]);
|
||||
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string; referenceTable?: string; referenceColumn?: string }>>([]);
|
||||
|
||||
// FK 자동 감지 결과
|
||||
const [autoDetectedFks, setAutoDetectedFks] = useState<AutoDetectedFk[]>([]);
|
||||
|
||||
// 🆕 원본 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -130,10 +133,11 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
loadColumns();
|
||||
}, [config.sourceTable]);
|
||||
|
||||
// 🆕 대상 테이블 컬럼 로드
|
||||
// 🆕 대상 테이블 컬럼 로드 (referenceTable/referenceColumn 포함)
|
||||
useEffect(() => {
|
||||
if (!config.targetTable) {
|
||||
setLoadedTargetTableColumns([]);
|
||||
setAutoDetectedFks([]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +153,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
inputType: col.inputType, // 🔧 inputType 추가
|
||||
inputType: col.inputType,
|
||||
codeCategory: col.codeCategory,
|
||||
referenceTable: col.referenceTable,
|
||||
referenceColumn: col.referenceColumn,
|
||||
})));
|
||||
console.log("✅ 대상 테이블 컬럼 로드 성공:", columns.length);
|
||||
}
|
||||
|
|
@ -161,6 +168,76 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
loadColumns();
|
||||
}, [config.targetTable]);
|
||||
|
||||
// FK 자동 감지 (ref로 무한 루프 방지)
|
||||
const fkAutoAppliedRef = useRef(false);
|
||||
|
||||
// targetTable 컬럼이 로드되면 entity FK 컬럼 감지
|
||||
const detectedFks = useMemo<AutoDetectedFk[]>(() => {
|
||||
if (!config.targetTable || loadedTargetTableColumns.length === 0) return [];
|
||||
|
||||
const entityFkColumns = loadedTargetTableColumns.filter(
|
||||
(col) => col.inputType === "entity" && col.referenceTable
|
||||
);
|
||||
if (entityFkColumns.length === 0) return [];
|
||||
|
||||
return entityFkColumns.map((col) => {
|
||||
let mappingType: "source" | "parent" | "unknown" = "unknown";
|
||||
if (config.sourceTable && col.referenceTable === config.sourceTable) {
|
||||
mappingType = "source";
|
||||
} else if (config.sourceTable && col.referenceTable !== config.sourceTable) {
|
||||
mappingType = "parent";
|
||||
}
|
||||
return {
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.columnLabel,
|
||||
referenceTable: col.referenceTable!,
|
||||
referenceColumn: col.referenceColumn || "id",
|
||||
mappingType,
|
||||
};
|
||||
});
|
||||
}, [config.targetTable, config.sourceTable, loadedTargetTableColumns]);
|
||||
|
||||
// 감지 결과를 state에 반영
|
||||
useEffect(() => {
|
||||
setAutoDetectedFks(detectedFks);
|
||||
}, [detectedFks]);
|
||||
|
||||
// 자동 매핑 적용 (최초 1회만, targetTable 변경 시 리셋)
|
||||
useEffect(() => {
|
||||
fkAutoAppliedRef.current = false;
|
||||
}, [config.targetTable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fkAutoAppliedRef.current || detectedFks.length === 0) return;
|
||||
|
||||
const sourceFk = detectedFks.find((fk) => fk.mappingType === "source");
|
||||
const parentFks = detectedFks.filter((fk) => fk.mappingType === "parent");
|
||||
let changed = false;
|
||||
|
||||
// sourceKeyField 자동 설정
|
||||
if (sourceFk && !config.sourceKeyField) {
|
||||
console.log("🔗 sourceKeyField 자동 설정:", sourceFk.columnName);
|
||||
handleChange("sourceKeyField", sourceFk.columnName);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// parentDataMapping 자동 생성 (기존에 없을 때만)
|
||||
if (parentFks.length > 0 && (!config.parentDataMapping || config.parentDataMapping.length === 0)) {
|
||||
const autoMappings = parentFks.map((fk) => ({
|
||||
sourceTable: fk.referenceTable,
|
||||
sourceField: "id",
|
||||
targetField: fk.columnName,
|
||||
}));
|
||||
console.log("🔗 parentDataMapping 자동 생성:", autoMappings);
|
||||
handleChange("parentDataMapping", autoMappings);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fkAutoAppliedRef.current = true;
|
||||
}
|
||||
}, [detectedFks]);
|
||||
|
||||
// 🆕 필드 그룹 변경 시 로컬 입력 상태 동기화
|
||||
useEffect(() => {
|
||||
setLocalFieldGroups(config.fieldGroups || []);
|
||||
|
|
@ -898,6 +975,37 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<p className="text-[10px] text-gray-500 sm:text-xs">최종 데이터를 저장할 테이블</p>
|
||||
</div>
|
||||
|
||||
{/* FK 자동 감지 결과 표시 */}
|
||||
{autoDetectedFks.length > 0 && (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950">
|
||||
<p className="mb-2 text-xs font-medium text-blue-700 dark:text-blue-300">
|
||||
FK 자동 감지됨 ({autoDetectedFks.length}건)
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{autoDetectedFks.map((fk) => (
|
||||
<div key={fk.columnName} className="flex items-center gap-2 text-[10px] sm:text-xs">
|
||||
<span className={cn(
|
||||
"rounded px-1.5 py-0.5 font-mono text-[9px]",
|
||||
fk.mappingType === "source"
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
|
||||
: fk.mappingType === "parent"
|
||||
? "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300"
|
||||
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
)}>
|
||||
{fk.mappingType === "source" ? "원본" : fk.mappingType === "parent" ? "부모" : "미분류"}
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground">{fk.columnName}</span>
|
||||
<span className="text-muted-foreground">-></span>
|
||||
<span className="font-mono">{fk.referenceTable}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[9px] text-blue-600 dark:text-blue-400">
|
||||
엔티티 설정 기반 자동 매핑. sourceKeyField와 parentDataMapping이 자동으로 설정됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 표시할 원본 데이터 컬럼 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold sm:text-sm">표시할 원본 데이터 컬럼</Label>
|
||||
|
|
@ -961,7 +1069,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold sm:text-sm">추가 입력 필드 정의</Label>
|
||||
|
||||
{localFields.map((field, index) => (
|
||||
{localFields.map((field, index) => {
|
||||
return (
|
||||
<Card key={index} className="border-2">
|
||||
<CardContent className="space-y-2 pt-3 sm:space-y-3 sm:pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -1255,7 +1364,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -2392,9 +2502,18 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(config.parentDataMapping || []).map((mapping, index) => (
|
||||
<Card key={index} className="p-3">
|
||||
{(config.parentDataMapping || []).map((mapping, index) => {
|
||||
const isAutoDetected = autoDetectedFks.some(
|
||||
(fk) => fk.mappingType === "parent" && fk.columnName === mapping.targetField
|
||||
);
|
||||
return (
|
||||
<Card key={index} className={cn("p-3", isAutoDetected && "border-orange-200 bg-orange-50/30 dark:border-orange-800 dark:bg-orange-950/30")}>
|
||||
<div className="space-y-2">
|
||||
{isAutoDetected && (
|
||||
<span className="inline-block rounded bg-orange-100 px-1.5 py-0.5 text-[9px] font-medium text-orange-700 dark:bg-orange-900 dark:text-orange-300">
|
||||
FK 자동 감지
|
||||
</span>
|
||||
)}
|
||||
{/* 소스 테이블 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">소스 테이블</Label>
|
||||
|
|
@ -2637,7 +2756,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -139,6 +139,23 @@ export interface ParentDataMapping {
|
|||
defaultValue?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동 감지된 FK 매핑 정보
|
||||
* table_type_columns의 entity 설정을 기반으로 자동 감지
|
||||
*/
|
||||
export interface AutoDetectedFk {
|
||||
/** 대상 테이블의 FK 컬럼명 (예: item_id, customer_id) */
|
||||
columnName: string;
|
||||
/** 컬럼 라벨 (예: 품목 ID) */
|
||||
columnLabel?: string;
|
||||
/** 참조 테이블명 (예: item_info, customer_mng) */
|
||||
referenceTable: string;
|
||||
/** 참조 컬럼명 (예: item_number, customer_code) */
|
||||
referenceColumn: string;
|
||||
/** 매핑 유형: source(원본 데이터 FK) 또는 parent(부모 화면 FK) */
|
||||
mappingType: "source" | "parent" | "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 컴포넌트 설정 타입
|
||||
*/
|
||||
|
|
@ -155,6 +172,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
|||
*/
|
||||
sourceTable?: string;
|
||||
|
||||
/**
|
||||
* 원본 데이터의 키 필드명 (대상 테이블에서 원본을 참조하는 FK 컬럼)
|
||||
* 예: item_info 기반이면 "item_id", customer_mng 기반이면 "customer_id"
|
||||
* 미설정 시 엔티티 설정에서 자동 감지
|
||||
*/
|
||||
sourceKeyField?: string;
|
||||
|
||||
/**
|
||||
* 표시할 원본 데이터 컬럼들 (name, label, width)
|
||||
* 원본 데이터 테이블의 컬럼을 표시
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* 프로덕션에서 "관리자 메뉴로 전환" 버튼 가시성 테스트
|
||||
* 두 계정 (topseal_admin, rsw1206)으로 로그인하여 버튼 표시 여부 확인
|
||||
*
|
||||
* 실행: node scripts/browser-test-admin-switch-button.js
|
||||
* 브라우저 표시: HEADLESS=0 node scripts/browser-test-admin-switch-button.js
|
||||
*/
|
||||
const { chromium } = require("playwright");
|
||||
const fs = require("fs");
|
||||
|
||||
const BASE_URL = "https://v1.vexplor.com";
|
||||
const SCREENSHOT_DIR = "test-screenshots/admin-switch-test";
|
||||
|
||||
const ACCOUNTS = [
|
||||
{ userId: "topseal_admin", password: "qlalfqjsgh11", name: "topseal_admin" },
|
||||
{ userId: "rsw1206", password: "qlalfqjsgh11", name: "rsw1206" },
|
||||
];
|
||||
|
||||
async function runTest() {
|
||||
const results = { topseal_admin: {}, rsw1206: {} };
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADLESS !== "0",
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const screenshot = async (name) => {
|
||||
const path = `${SCREENSHOT_DIR}/${name}.png`;
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
console.log(` [스크린샷] ${path}`);
|
||||
return path;
|
||||
};
|
||||
|
||||
for (let i = 0; i < ACCOUNTS.length; i++) {
|
||||
const acc = ACCOUNTS[i];
|
||||
console.log(`\n========== ${acc.name} 테스트 (${i + 1}/${ACCOUNTS.length}) ==========\n`);
|
||||
|
||||
// 로그인 페이지로 이동
|
||||
await page.goto(`${BASE_URL}/login`, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 20000,
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 로그인
|
||||
await page.fill("#userId", acc.userId);
|
||||
await page.fill("#password", acc.password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// 로그인 성공 시 대시보드 또는 메인으로 리다이렉트될 것임
|
||||
const currentUrl = page.url();
|
||||
if (currentUrl.includes("/login") && !currentUrl.includes("error")) {
|
||||
// 아직 로그인 페이지에 있다면 조금 더 대기
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
const afterLoginUrl = page.url();
|
||||
const screenshotPath = await screenshot(`01_${acc.name}_after_login`);
|
||||
|
||||
// "관리자 메뉴로 전환" 버튼 찾기
|
||||
const buttonSelectors = [
|
||||
'button:has-text("관리자 메뉴로 전환")',
|
||||
'[class*="button"]:has-text("관리자 메뉴로 전환")',
|
||||
'button >> text=관리자 메뉴로 전환',
|
||||
];
|
||||
|
||||
let buttonVisible = false;
|
||||
for (const sel of buttonSelectors) {
|
||||
try {
|
||||
const btn = page.locator(sel).first();
|
||||
const count = await btn.count();
|
||||
if (count > 0) {
|
||||
const isVisible = await btn.isVisible();
|
||||
if (isVisible) {
|
||||
buttonVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 추가: 페이지 내 텍스트로 버튼 존재 여부 확인
|
||||
if (!buttonVisible) {
|
||||
const pageText = await page.textContent("body");
|
||||
buttonVisible = pageText && pageText.includes("관리자 메뉴로 전환");
|
||||
}
|
||||
|
||||
results[acc.name] = {
|
||||
buttonVisible,
|
||||
screenshotPath,
|
||||
afterLoginUrl,
|
||||
};
|
||||
|
||||
console.log(` 버튼 가시성: ${buttonVisible ? "표시됨" : "표시 안 됨"}`);
|
||||
console.log(` URL: ${afterLoginUrl}`);
|
||||
|
||||
// 로그아웃 (다음 계정 테스트 전)
|
||||
if (i < ACCOUNTS.length - 1) {
|
||||
console.log("\n 로그아웃 중...");
|
||||
try {
|
||||
// 프로필 드롭다운 클릭 (좌측 하단)
|
||||
const profileBtn = page.locator(
|
||||
'button:has-text("로그아웃"), [class*="dropdown"]:has-text("로그아웃"), [data-radix-collection-item]:has-text("로그아웃")'
|
||||
);
|
||||
const profileTrigger = page.locator(
|
||||
'button[class*="flex w-full"][class*="gap-3"]'
|
||||
).first();
|
||||
if (await profileTrigger.count() > 0) {
|
||||
await profileTrigger.click();
|
||||
await page.waitForTimeout(500);
|
||||
const logoutItem = page.locator('text=로그아웃').first();
|
||||
if (await logoutItem.count() > 0) {
|
||||
await logoutItem.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
}
|
||||
// 또는 직접 로그아웃 URL
|
||||
if (page.url().includes("/login") === false) {
|
||||
await page.goto(`${BASE_URL}/api/auth/logout`, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 5000,
|
||||
}).catch(() => {});
|
||||
await page.goto(`${BASE_URL}/login`, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(" 로그아웃 대체: 로그인 페이지로 직접 이동");
|
||||
await page.goto(`${BASE_URL}/login`, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n========== 최종 결과 ==========\n");
|
||||
console.log("topseal_admin: 관리자 메뉴로 전환 버튼 =", results.topseal_admin.buttonVisible ? "표시됨" : "표시 안 됨");
|
||||
console.log("rsw1206: 관리자 메뉴로 전환 버튼 =", results.rsw1206.buttonVisible ? "표시됨" : "표시 안 됨");
|
||||
console.log("\n스크린샷:", SCREENSHOT_DIR);
|
||||
|
||||
return results;
|
||||
} catch (err) {
|
||||
console.error("테스트 오류:", err);
|
||||
throw err;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
runTest()
|
||||
.then((r) => {
|
||||
console.log("\n테스트 완료.");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* 거래처관리 화면 CRUD 브라우저 테스트
|
||||
* 실행: node scripts/browser-test-customer-crud.js
|
||||
* 브라우저 표시: HEADLESS=0 node scripts/browser-test-customer-crud.js
|
||||
*/
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = "http://localhost:9771";
|
||||
const SCREENSHOT_DIR = "test-screenshots";
|
||||
|
||||
async function runTest() {
|
||||
const results = { success: [], failed: [], screenshots: [] };
|
||||
const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
// 스크린샷 디렉토리
|
||||
const fs = require("fs");
|
||||
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
|
||||
const screenshot = async (name) => {
|
||||
const path = `${SCREENSHOT_DIR}/${name}.png`;
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
results.screenshots.push(path);
|
||||
console.log(` [스크린샷] ${path}`);
|
||||
};
|
||||
|
||||
console.log("\n=== 1단계: 로그인 ===\n");
|
||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle", timeout: 15000 });
|
||||
await page.fill('#userId', 'topseal_admin');
|
||||
await page.fill('#password', 'qlalfqjsgh11');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
await screenshot("01_after_login");
|
||||
results.success.push("로그인 완료");
|
||||
|
||||
console.log("\n=== 2단계: 거래처관리 화면 이동 ===\n");
|
||||
await page.goto(`${BASE_URL}/screens/227`, { waitUntil: "domcontentloaded", timeout: 20000 });
|
||||
// 테이블 또는 메인 콘텐츠 로딩 대기 (API 호출 후 React 렌더링)
|
||||
try {
|
||||
await page.waitForSelector('table, tbody, [role="row"], .rt-tbody', { timeout: 25000 });
|
||||
results.success.push("테이블 로드 감지");
|
||||
} catch (e) {
|
||||
console.log(" [경고] 테이블 대기 타임아웃, 계속 진행");
|
||||
}
|
||||
await page.waitForTimeout(3000);
|
||||
await screenshot("02_screen_227");
|
||||
results.success.push("화면 227 로드");
|
||||
|
||||
console.log("\n=== 3단계: 거래처 선택 (READ 테스트) ===\n");
|
||||
// 좌측 테이블 행 선택 - 다양한 레이아웃 대응
|
||||
const rowSelectors = [
|
||||
'table tbody tr.cursor-pointer',
|
||||
'tbody tr.hover\\:bg-accent',
|
||||
'table tbody tr:has(td)',
|
||||
'tbody tr',
|
||||
];
|
||||
let rows = [];
|
||||
for (const sel of rowSelectors) {
|
||||
rows = await page.$$(sel);
|
||||
if (rows.length > 0) break;
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
await rows[0].click();
|
||||
results.success.push("거래처 행 클릭");
|
||||
} else {
|
||||
results.failed.push("거래처 테이블 행을 찾을 수 없음");
|
||||
// 디버그: 페이지 구조 저장
|
||||
const bodyHtml = await page.evaluate(() => {
|
||||
const tables = document.querySelectorAll('table, tbody, [role="grid"], [role="table"]');
|
||||
return `Tables found: ${tables.length}\n` + document.body.innerHTML.slice(0, 8000);
|
||||
});
|
||||
require("fs").writeFileSync(`${SCREENSHOT_DIR}/debug_body.html`, bodyHtml);
|
||||
console.log(" [디버그] body HTML 일부 저장: debug_body.html");
|
||||
}
|
||||
await page.waitForTimeout(3000);
|
||||
await screenshot("03_after_customer_select");
|
||||
|
||||
// SelectedItemsDetailInput 영역 확인
|
||||
const detailArea = await page.$('[data-component="selected-items-detail-input"], [class*="selected-items"], .selected-items-detail');
|
||||
if (detailArea) {
|
||||
results.success.push("SelectedItemsDetailInput 컴포넌트 렌더링 확인");
|
||||
} else {
|
||||
// 품목/입력 관련 영역이 있는지
|
||||
const hasInputArea = await page.$('input[placeholder*="품번"], input[placeholder*="품목"], [class*="detail"]');
|
||||
results.success.push(hasInputArea ? "입력 영역 확인됨" : "SelectedItemsDetailInput 영역 미확인");
|
||||
}
|
||||
|
||||
console.log("\n=== 4단계: 품목 추가 (CREATE 테스트) ===\n");
|
||||
const addBtnLoc = page.locator('button').filter({ hasText: /추가|품목/ }).first();
|
||||
const addBtnExists = await addBtnLoc.count() > 0;
|
||||
if (addBtnExists) {
|
||||
await addBtnLoc.click();
|
||||
await page.waitForTimeout(1500);
|
||||
await screenshot("04_after_add_click");
|
||||
|
||||
// 모달/팝업에서 품목 선택
|
||||
const modalItem = await page.$('[role="dialog"] tr, [role="listbox"] [role="option"], .modal tbody tr');
|
||||
if (modalItem) {
|
||||
await modalItem.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// 필수 필드 입력
|
||||
const itemCodeInput = await page.$('input[name*="품번"], input[placeholder*="품번"], input[id*="item"]');
|
||||
if (itemCodeInput) {
|
||||
await itemCodeInput.fill("TEST_BROWSER");
|
||||
}
|
||||
await screenshot("04_before_save");
|
||||
|
||||
const saveBtnLoc = page.locator('button').filter({ hasText: /저장/ }).first();
|
||||
if (await saveBtnLoc.count() > 0) {
|
||||
await saveBtnLoc.click();
|
||||
await page.waitForTimeout(3000);
|
||||
await screenshot("05_after_save");
|
||||
results.success.push("저장 버튼 클릭");
|
||||
|
||||
const toast = await page.$('[data-sonner-toast], .toast, [role="alert"]');
|
||||
if (toast) {
|
||||
const toastText = await toast.textContent();
|
||||
results.success.push(`토스트 메시지: ${toastText?.slice(0, 50)}`);
|
||||
}
|
||||
} else {
|
||||
results.failed.push("저장 버튼을 찾을 수 없음");
|
||||
}
|
||||
} else {
|
||||
results.failed.push("품목 추가/추가 버튼을 찾을 수 없음");
|
||||
await screenshot("04_no_add_button");
|
||||
}
|
||||
|
||||
console.log("\n=== 5단계: 최종 결과 ===\n");
|
||||
await screenshot("06_final_state");
|
||||
|
||||
// 콘솔 에러 수집
|
||||
const consoleErrors = [];
|
||||
page.on("console", (msg) => {
|
||||
const type = msg.type();
|
||||
if (type === "error") {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
results.failed.push(`예외: ${err.message}`);
|
||||
try {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/error.png`, fullPage: true });
|
||||
results.screenshots.push(`${SCREENSHOT_DIR}/error.png`);
|
||||
} catch (_) {}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// 결과 출력
|
||||
console.log("\n========== 테스트 결과 ==========\n");
|
||||
console.log("성공:", results.success);
|
||||
console.log("실패:", results.failed);
|
||||
console.log("스크린샷:", results.screenshots);
|
||||
return results;
|
||||
}
|
||||
|
||||
runTest().then((r) => {
|
||||
process.exit(r.failed.length > 0 ? 1 : 0);
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* 거래처관리 메뉴 경유 브라우저 테스트
|
||||
* 영업관리 > 거래처관리 메뉴 클릭 후 상세 화면 진입
|
||||
* 실행: node scripts/browser-test-customer-via-menu.js
|
||||
* 브라우저 표시: HEADLESS=0 node scripts/browser-test-customer-via-menu.js
|
||||
*/
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = "http://localhost:9771";
|
||||
const SCREENSHOT_DIR = "test-screenshots";
|
||||
const CREDENTIALS = { userId: "topseal_admin", password: "qlalfqjsgh11" };
|
||||
|
||||
async function runTest() {
|
||||
const results = { success: [], failed: [], screenshots: [] };
|
||||
const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
const fs = require("fs");
|
||||
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
|
||||
const screenshot = async (name) => {
|
||||
const path = `${SCREENSHOT_DIR}/${name}.png`;
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
results.screenshots.push(path);
|
||||
console.log(` [스크린샷] ${path}`);
|
||||
};
|
||||
|
||||
try {
|
||||
// 로그인 (이미 로그인된 상태면 자동 리다이렉트됨)
|
||||
console.log("\n=== 로그인 확인 ===\n");
|
||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle", timeout: 15000 });
|
||||
const currentUrl = page.url();
|
||||
if (currentUrl.includes("/login") && !(await page.$('input#userId'))) {
|
||||
// 로그인 폼이 있으면 로그인
|
||||
await page.fill("#userId", CREDENTIALS.userId);
|
||||
await page.fill("#password", CREDENTIALS.password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
} else if (currentUrl.includes("/login")) {
|
||||
await page.fill("#userId", CREDENTIALS.userId);
|
||||
await page.fill("#password", CREDENTIALS.password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
results.success.push("로그인/세션 확인");
|
||||
|
||||
// 단계 1: 영업관리 메뉴 클릭
|
||||
console.log("\n=== 단계 1: 영업관리 메뉴 클릭 ===\n");
|
||||
const salesMenu = page.locator('nav, aside').getByText('영업관리', { exact: true }).first();
|
||||
if (await salesMenu.count() > 0) {
|
||||
await salesMenu.click();
|
||||
await page.waitForTimeout(2000);
|
||||
results.success.push("영업관리 메뉴 클릭");
|
||||
} else {
|
||||
const salesAlt = page.getByRole('button', { name: /영업관리/ }).or(page.getByText('영업관리').first());
|
||||
if (await salesAlt.count() > 0) {
|
||||
await salesAlt.first().click();
|
||||
await page.waitForTimeout(2000);
|
||||
results.success.push("영업관리 메뉴 클릭 (대안)");
|
||||
} else {
|
||||
results.failed.push("영업관리 메뉴를 찾을 수 없음");
|
||||
}
|
||||
}
|
||||
await screenshot("01_after_sales_menu");
|
||||
|
||||
// 단계 2: 거래처관리 서브메뉴 클릭
|
||||
console.log("\n=== 단계 2: 거래처관리 서브메뉴 클릭 ===\n");
|
||||
const customerMenu = page.getByText("거래처관리", { exact: true }).first();
|
||||
if (await customerMenu.count() > 0) {
|
||||
await customerMenu.click();
|
||||
await page.waitForTimeout(5000);
|
||||
results.success.push("거래처관리 메뉴 클릭");
|
||||
} else {
|
||||
results.failed.push("거래처관리 메뉴를 찾을 수 없음");
|
||||
}
|
||||
await screenshot("02_after_customer_menu");
|
||||
|
||||
// 단계 3: 거래처 목록 확인 및 행 클릭
|
||||
console.log("\n=== 단계 3: 거래처 목록 확인 ===\n");
|
||||
const rows = await page.$$('tbody tr, table tr, [role="row"]');
|
||||
const clickableRows = rows.length > 0 ? rows : [];
|
||||
if (clickableRows.length > 0) {
|
||||
await clickableRows[0].click();
|
||||
await page.waitForTimeout(5000);
|
||||
results.success.push(`거래처 행 클릭 (${clickableRows.length}개 행 중 첫 번째)`);
|
||||
} else {
|
||||
results.failed.push("거래처 테이블 행을 찾을 수 없음");
|
||||
}
|
||||
await screenshot("03_after_row_click");
|
||||
|
||||
// 단계 4: 편집/수정 버튼 또는 더블클릭 (분할 패널이면 행 선택만으로 우측에 상세 표시될 수 있음)
|
||||
console.log("\n=== 단계 4: 상세 화면 진입 시도 ===\n");
|
||||
const editBtn = page.locator('button').filter({ hasText: /편집|수정|상세/ }).first();
|
||||
let editEnabled = false;
|
||||
try {
|
||||
if (await editBtn.count() > 0) {
|
||||
editEnabled = !(await editBtn.isDisabled());
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (editEnabled) {
|
||||
await editBtn.click();
|
||||
results.success.push("편집/수정 버튼 클릭");
|
||||
} else {
|
||||
const row = await page.$('tbody tr, table tr');
|
||||
if (row) {
|
||||
await row.dblclick();
|
||||
results.success.push("행 더블클릭 시도");
|
||||
} else if (await editBtn.count() > 0) {
|
||||
results.success.push("수정 버튼 비활성화 - 분할 패널 우측 상세 확인");
|
||||
} else {
|
||||
results.failed.push("편집 버튼/행을 찾을 수 없음");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
results.success.push("상세 진입 스킵 - 우측 패널에 상세 표시 여부 확인");
|
||||
}
|
||||
await page.waitForTimeout(5000);
|
||||
await screenshot("04_after_detail_enter");
|
||||
|
||||
// 단계 5: 품목 관련 영역 확인
|
||||
console.log("\n=== 단계 5: 품목 관련 영역 확인 ===\n");
|
||||
const hasItemSection = await page.getByText(/품목|납품품목|거래처 품번|거래처 품명/).first().count() > 0;
|
||||
const hasDetailInput = await page.$('input[placeholder*="품번"], input[name*="품번"], [class*="selected-items"]');
|
||||
if (hasItemSection || hasDetailInput) {
|
||||
results.success.push("품목 관련 UI 확인됨");
|
||||
} else {
|
||||
results.failed.push("품목 관련 영역 미확인");
|
||||
}
|
||||
await screenshot("05_item_section");
|
||||
|
||||
console.log("\n========== 테스트 결과 ==========\n");
|
||||
console.log("성공:", results.success);
|
||||
console.log("실패:", results.failed);
|
||||
console.log("스크린샷:", results.screenshots);
|
||||
|
||||
} catch (err) {
|
||||
results.failed.push(`예외: ${err.message}`);
|
||||
try {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/error.png`, fullPage: true });
|
||||
results.screenshots.push(`${SCREENSHOT_DIR}/error.png`);
|
||||
} catch (_) {}
|
||||
console.error(err);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
runTest()
|
||||
.then((r) => process.exit(r.failed.length > 0 ? 1 : 0))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* 구매관리 - 공급업체관리 / 구매품목정보 CRUD 브라우저 테스트
|
||||
* 실행: node scripts/browser-test-purchase-supplier.js
|
||||
* 브라우저 표시: HEADLESS=0 node scripts/browser-test-purchase-supplier.js
|
||||
*/
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = "http://localhost:9771";
|
||||
const SCREENSHOT_DIR = "test-screenshots";
|
||||
const CREDENTIALS = { userId: "topseal_admin", password: "qlalfqjsgh11" };
|
||||
|
||||
async function runTest() {
|
||||
const results = { success: [], failed: [], screenshots: [] };
|
||||
const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
const fs = require("fs");
|
||||
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
|
||||
const screenshot = async (name) => {
|
||||
const path = `${SCREENSHOT_DIR}/${name}.png`;
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
results.screenshots.push(path);
|
||||
console.log(` [스크린샷] ${path}`);
|
||||
return path;
|
||||
};
|
||||
|
||||
const clickMenu = async (text) => {
|
||||
const loc = page.getByText(text, { exact: true }).first();
|
||||
if ((await loc.count()) > 0) {
|
||||
await loc.click();
|
||||
return true;
|
||||
}
|
||||
const alt = page.getByRole("link", { name: text }).or(page.locator(`a:has-text("${text}")`)).first();
|
||||
if ((await alt.count()) > 0) {
|
||||
await alt.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const clickRow = async () => {
|
||||
const rows = await page.$$('tbody tr, table tr, [role="row"]');
|
||||
for (const r of rows) {
|
||||
const t = await r.textContent();
|
||||
if (t && !t.includes("데이터가 없습니다") && !t.includes("로딩")) {
|
||||
await r.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
await rows[0].click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const clickButton = async (regex) => {
|
||||
const btn = page.locator("button").filter({ hasText: regex }).first();
|
||||
try {
|
||||
if ((await btn.count()) > 0 && !(await btn.isDisabled())) {
|
||||
await btn.click();
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
};
|
||||
|
||||
try {
|
||||
console.log("\n=== 로그인 확인 ===\n");
|
||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle", timeout: 15000 });
|
||||
if (page.url().includes("/login")) {
|
||||
await page.fill("#userId", CREDENTIALS.userId);
|
||||
await page.fill("#password", CREDENTIALS.password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
results.success.push("세션 확인");
|
||||
|
||||
// ========== 테스트 1: 공급업체관리 ==========
|
||||
console.log("\n=== 테스트 1: 공급업체관리 ===\n");
|
||||
|
||||
console.log("단계 1: 구매관리 메뉴 열기");
|
||||
if (await clickMenu("구매관리")) {
|
||||
await page.waitForTimeout(3000);
|
||||
results.success.push("구매관리 메뉴 클릭");
|
||||
} else {
|
||||
results.failed.push("구매관리 메뉴 미발견");
|
||||
}
|
||||
await screenshot("p1_01_purchase_menu");
|
||||
|
||||
console.log("단계 2: 공급업체관리 서브메뉴 클릭");
|
||||
if (await clickMenu("공급업체관리")) {
|
||||
await page.waitForTimeout(8000);
|
||||
results.success.push("공급업체관리 메뉴 클릭");
|
||||
} else {
|
||||
results.failed.push("공급업체관리 메뉴 미발견");
|
||||
}
|
||||
await screenshot("p1_02_supplier_screen");
|
||||
|
||||
console.log("단계 3: 공급업체 선택");
|
||||
if (await clickRow()) {
|
||||
await page.waitForTimeout(5000);
|
||||
results.success.push("공급업체 행 클릭");
|
||||
} else {
|
||||
results.failed.push("공급업체 테이블 행 미발견");
|
||||
}
|
||||
await screenshot("p1_03_after_supplier_select");
|
||||
|
||||
console.log("단계 4: 납품품목 탭/영역 확인");
|
||||
const itemTab = page.getByText(/납품품목|품목/).first();
|
||||
if ((await itemTab.count()) > 0) {
|
||||
await itemTab.click();
|
||||
await page.waitForTimeout(3000);
|
||||
results.success.push("납품품목/품목 탭 클릭");
|
||||
} else {
|
||||
results.failed.push("납품품목 탭 미발견");
|
||||
}
|
||||
await screenshot("p1_04_item_tab");
|
||||
|
||||
console.log("단계 5: 품목 추가 시도");
|
||||
const addBtn = page.locator("button").filter({ hasText: /추가|\+ 추가/ }).first();
|
||||
let addBtnEnabled = false;
|
||||
try {
|
||||
addBtnEnabled = (await addBtn.count()) > 0 && !(await addBtn.isDisabled());
|
||||
} catch (_) {}
|
||||
if (addBtnEnabled) {
|
||||
await addBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
const modal = await page.$('[role="dialog"], .modal, [class*="modal"]');
|
||||
if (modal) {
|
||||
const modalRow = await page.$('[role="dialog"] tbody tr, .modal tbody tr');
|
||||
if (modalRow) {
|
||||
await modalRow.click();
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
results.success.push("추가 버튼 클릭 및 품목 선택 시도");
|
||||
} else {
|
||||
results.failed.push("추가 버튼 미발견 또는 비활성화");
|
||||
}
|
||||
await screenshot("p1_05_add_item");
|
||||
|
||||
// ========== 테스트 2: 구매품목정보 ==========
|
||||
console.log("\n=== 테스트 2: 구매품목정보 ===\n");
|
||||
|
||||
console.log("단계 6: 구매품목정보 메뉴 클릭");
|
||||
if (await clickMenu("구매품목정보")) {
|
||||
await page.waitForTimeout(8000);
|
||||
results.success.push("구매품목정보 메뉴 클릭");
|
||||
} else {
|
||||
results.failed.push("구매품목정보 메뉴 미발견");
|
||||
}
|
||||
await screenshot("p2_01_item_screen");
|
||||
|
||||
console.log("단계 7: 품목 선택 및 공급업체 확인");
|
||||
if (await clickRow()) {
|
||||
await page.waitForTimeout(5000);
|
||||
results.success.push("구매품목 행 클릭");
|
||||
} else {
|
||||
results.failed.push("구매품목 테이블 행 미발견");
|
||||
}
|
||||
await screenshot("p2_02_after_item_select");
|
||||
|
||||
// SelectedItemsDetailInput 컴포넌트 확인
|
||||
const hasDetailInput = await page.$('input[placeholder*="품번"], [class*="selected-items"], input[name*="품번"]');
|
||||
results.success.push(hasDetailInput ? "SelectedItemsDetailInput 렌더링 확인" : "SelectedItemsDetailInput 미확인");
|
||||
await screenshot("p2_03_final");
|
||||
|
||||
console.log("\n========== 테스트 결과 ==========\n");
|
||||
console.log("성공:", results.success);
|
||||
console.log("실패:", results.failed);
|
||||
console.log("스크린샷:", results.screenshots);
|
||||
|
||||
} catch (err) {
|
||||
results.failed.push(`예외: ${err.message}`);
|
||||
try {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/error.png`, fullPage: true });
|
||||
results.screenshots.push(`${SCREENSHOT_DIR}/error.png`);
|
||||
} catch (_) {}
|
||||
console.error(err);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
runTest()
|
||||
.then((r) => process.exit(r.failed.length > 0 ? 1 : 0))
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in New Issue