Add environment variable example and update .gitignore
- Created a new .env.example file to provide a template for environment variables, including database connection details, JWT settings, encryption keys, and external API keys. - Updated .gitignore to include additional test output directories and archive files, ensuring that unnecessary files are not tracked by Git. - Removed outdated approval test reports and scripts that are no longer needed, streamlining the project structure. These changes improve the clarity of environment configuration and maintain a cleaner repository.
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
# ERP-node 환경변수 (.env.example)
|
||||||
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
#
|
||||||
|
# 사용법:
|
||||||
|
# cp .env.example .env
|
||||||
|
# 실제 값을 채워 넣으세요
|
||||||
|
#
|
||||||
|
# ⚠️ .env 파일은 절대 git에 커밋하지 마세요!
|
||||||
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
# DB 접속
|
||||||
|
DATABASE_URL=postgresql://postgres:YOUR_PASSWORD@YOUR_HOST:YOUR_PORT/YOUR_DB
|
||||||
|
|
||||||
|
# 인증
|
||||||
|
JWT_SECRET=your-jwt-secret-here
|
||||||
|
JWT_EXPIRES_IN=24h
|
||||||
|
|
||||||
|
# 암호화
|
||||||
|
ENCRYPTION_KEY=your-32-char-encryption-key-here
|
||||||
|
|
||||||
|
# 외부 API 키
|
||||||
|
KMA_API_KEY=your_kma_api_key
|
||||||
|
ITS_API_KEY=your_its_api_key
|
||||||
|
EXWAY_API_KEY=your_exway_api_key
|
||||||
|
BOK_API_KEY=your_bok_api_key
|
||||||
|
EXPRESSWAY_API_KEY=
|
||||||
|
|
||||||
|
# CORS (프로덕션용)
|
||||||
|
CORS_ORIGIN=http://localhost:9771
|
||||||
@@ -218,6 +218,11 @@ docs/mes-reference/
|
|||||||
|
|
||||||
# 테스트 결과물
|
# 테스트 결과물
|
||||||
frontend/test-results/
|
frontend/test-results/
|
||||||
|
test-output/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# 아카이브/백업 파일
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
# Cursor 설정
|
# Cursor 설정
|
||||||
.cursor/
|
.cursor/
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
# 현재 구현 계획: pop-card-list 입력 필드/계산 필드 구조 개편
|
|
||||||
|
|
||||||
> **작성일**: 2026-02-24
|
|
||||||
> **상태**: 계획 완료, 코딩 대기
|
|
||||||
> **목적**: 입력 필드 설정 단순화 + 본문 필드에 계산식 통합 + 기존 계산 필드 섹션 제거
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 변경 개요
|
|
||||||
|
|
||||||
### 배경
|
|
||||||
- 기존: "입력 필드", "계산 필드", "담기 버튼" 3개가 별도 섹션으로 분리
|
|
||||||
- 문제: 계산 필드가 본문 필드와 동일한 위치에 표시되어야 하는데 별도 영역에 있음
|
|
||||||
- 문제: 입력 필드의 min/max 고정값은 비실용적 (실제로는 DB 컬럼 기준 제한이 필요)
|
|
||||||
- 문제: step, columnName, sourceColumns, resultColumn 등 죽은 코드 존재
|
|
||||||
|
|
||||||
### 목표
|
|
||||||
1. **본문 필드에 계산식 지원 추가** - 필드별로 "DB 컬럼" 또는 "계산식" 선택
|
|
||||||
2. **입력 필드 설정 단순화** - 고정 min/max 제거, 제한 기준 컬럼 방식으로 변경
|
|
||||||
3. **기존 "계산 필드" 섹션 제거** - 본문 필드에 통합되므로 불필요
|
|
||||||
4. **죽은 코드 정리**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 수정 대상 파일 (3개)
|
|
||||||
|
|
||||||
### 파일 A: `frontend/lib/registry/pop-components/types.ts`
|
|
||||||
|
|
||||||
#### 변경 A-1: CardFieldBinding 타입 확장
|
|
||||||
|
|
||||||
**현재 코드** (라인 367~372):
|
|
||||||
```typescript
|
|
||||||
export interface CardFieldBinding {
|
|
||||||
id: string;
|
|
||||||
columnName: string;
|
|
||||||
label: string;
|
|
||||||
textColor?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**변경 코드**:
|
|
||||||
```typescript
|
|
||||||
export interface CardFieldBinding {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
textColor?: string;
|
|
||||||
valueType: "column" | "formula"; // 값 유형: DB 컬럼 또는 계산식
|
|
||||||
columnName?: string; // valueType === "column"일 때 사용
|
|
||||||
formula?: string; // valueType === "formula"일 때 사용 (예: "$input - received_qty")
|
|
||||||
unit?: string; // 계산식일 때 단위 표시 (예: "EA")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**주의**: `columnName`이 required에서 optional로 변경됨. 기존 저장 데이터와의 하위 호환 필요.
|
|
||||||
|
|
||||||
#### 변경 A-2: CardInputFieldConfig 단순화
|
|
||||||
|
|
||||||
**현재 코드** (라인 443~453):
|
|
||||||
```typescript
|
|
||||||
export interface CardInputFieldConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
columnName?: string;
|
|
||||||
label?: string;
|
|
||||||
unit?: string;
|
|
||||||
defaultValue?: number;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
maxColumn?: string;
|
|
||||||
step?: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**변경 코드**:
|
|
||||||
```typescript
|
|
||||||
export interface CardInputFieldConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
label?: string;
|
|
||||||
unit?: string;
|
|
||||||
limitColumn?: string; // 제한 기준 컬럼 (해당 행의 이 컬럼 값이 최대값)
|
|
||||||
saveTable?: string; // 저장 대상 테이블
|
|
||||||
saveColumn?: string; // 저장 대상 컬럼
|
|
||||||
showPackageUnit?: boolean; // 포장등록 버튼 표시 여부
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**제거 항목**:
|
|
||||||
- `columnName` -> `saveTable` + `saveColumn`으로 대체 (명확한 네이밍)
|
|
||||||
- `defaultValue` -> 제거 (제한 기준 컬럼 값으로 대체)
|
|
||||||
- `min` -> 제거 (항상 0)
|
|
||||||
- `max` -> 제거 (`limitColumn`으로 대체)
|
|
||||||
- `maxColumn` -> `limitColumn`으로 이름 변경
|
|
||||||
- `step` -> 제거 (키패드 방식에서 미사용)
|
|
||||||
|
|
||||||
#### 변경 A-3: CardCalculatedFieldConfig 제거
|
|
||||||
|
|
||||||
**삭제**: `CardCalculatedFieldConfig` 인터페이스 전체 (라인 457~464)
|
|
||||||
**삭제**: `PopCardListConfig`에서 `calculatedField?: CardCalculatedFieldConfig;` 제거
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 파일 B: `frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx`
|
|
||||||
|
|
||||||
#### 변경 B-1: 본문 필드 편집기(FieldEditor)에 값 유형 선택 추가
|
|
||||||
|
|
||||||
**현재**: 필드 편집 시 라벨, 컬럼, 텍스트색상만 설정 가능
|
|
||||||
|
|
||||||
**변경**: 값 유형 라디오("DB 컬럼" / "계산식") 추가
|
|
||||||
- "DB 컬럼" 선택 시: 기존 컬럼 Select 표시
|
|
||||||
- "계산식" 선택 시: 수식 입력란 + 사용 가능한 컬럼/변수 칩 목록 표시
|
|
||||||
- 사용 가능한 변수: DB 컬럼명들 + `$input` (입력 필드 활성화 시)
|
|
||||||
|
|
||||||
**하위 호환**: 기존 저장 데이터에 `valueType`이 없으면 `"column"`으로 기본 처리
|
|
||||||
|
|
||||||
#### 변경 B-2: 입력 필드 설정 섹션 개편
|
|
||||||
|
|
||||||
**현재 설정 항목**: 라벨, 단위, 기본값, 최소/최대, 최대값 컬럼, 저장 컬럼
|
|
||||||
|
|
||||||
**변경 설정 항목**:
|
|
||||||
```
|
|
||||||
라벨 [입고 수량 ]
|
|
||||||
단위 [EA ]
|
|
||||||
제한 기준 컬럼 [ order_qty v ]
|
|
||||||
저장 대상 테이블 [ 선택 v ]
|
|
||||||
저장 대상 컬럼 [ 선택 v ]
|
|
||||||
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
|
||||||
포장등록 버튼 [on/off]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 변경 B-3: "계산 필드" 섹션 제거
|
|
||||||
|
|
||||||
**삭제**: `CalculatedFieldSettingsSection` 함수 전체
|
|
||||||
**삭제**: 카드 템플릿 탭에서 "계산 필드" CollapsibleSection 제거
|
|
||||||
|
|
||||||
#### 변경 B-4: import 정리
|
|
||||||
|
|
||||||
**삭제**: `CardCalculatedFieldConfig` import
|
|
||||||
**추가**: 없음 (기존 import 재사용)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 파일 C: `frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx`
|
|
||||||
|
|
||||||
#### 변경 C-1: FieldRow에서 계산식 필드 지원
|
|
||||||
|
|
||||||
**현재**: `const value = row[field.columnName]` 로 DB 값만 표시
|
|
||||||
|
|
||||||
**변경**:
|
|
||||||
```typescript
|
|
||||||
function FieldRow({ field, row, scaled, inputValue }: {
|
|
||||||
field: CardFieldBinding;
|
|
||||||
row: RowData;
|
|
||||||
scaled: ScaledConfig;
|
|
||||||
inputValue?: number; // 입력 필드 값 (계산식에서 $input으로 참조)
|
|
||||||
}) {
|
|
||||||
const value = field.valueType === "formula" && field.formula
|
|
||||||
? evaluateFormula(field.formula, row, inputValue ?? 0)
|
|
||||||
: row[field.columnName ?? ""];
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**주의**: `inputValue`를 FieldRow까지 전달해야 하므로 CardItem -> FieldRow 경로에 prop 추가 필요
|
|
||||||
|
|
||||||
#### 변경 C-2: 계산식 필드 실시간 갱신
|
|
||||||
|
|
||||||
**현재**: 별도 `calculatedValue` useMemo가 `[calculatedField, row, inputValue]`에 반응
|
|
||||||
|
|
||||||
**변경**: FieldRow가 `inputValue` prop을 받으므로, `inputValue`가 변경될 때 계산식 필드가 자동으로 리렌더링됨. 별도 useMemo 불필요.
|
|
||||||
|
|
||||||
#### 변경 C-3: 기존 calculatedField 관련 코드 제거
|
|
||||||
|
|
||||||
**삭제 대상**:
|
|
||||||
- `calculatedField` prop 전달 (CardItem)
|
|
||||||
- `calculatedValue` useMemo
|
|
||||||
- 계산 필드 렌더링 블록 (`{calculatedField?.enabled && calculatedValue !== null && (...)}`
|
|
||||||
|
|
||||||
#### 변경 C-4: 입력 필드 로직 단순화
|
|
||||||
|
|
||||||
**변경 대상**:
|
|
||||||
- `effectiveMax`: `limitColumn` 사용, 미설정 시 999999 폴백
|
|
||||||
- `defaultValue` 자동 초기화 로직 제거 (불필요)
|
|
||||||
- `NumberInputModal`에 포장등록 on/off 전달
|
|
||||||
|
|
||||||
#### 변경 C-5: NumberInputModal에 포장등록 on/off 전달
|
|
||||||
|
|
||||||
**현재**: 포장등록 버튼 항상 표시
|
|
||||||
**변경**: `showPackageUnit` prop 추가, false이면 포장등록 버튼 숨김
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 파일 D: `frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx`
|
|
||||||
|
|
||||||
#### 변경 D-1: showPackageUnit prop 추가
|
|
||||||
|
|
||||||
**현재 props**: open, onOpenChange, unit, initialValue, initialPackageUnit, min, maxValue, onConfirm
|
|
||||||
|
|
||||||
**추가 prop**: `showPackageUnit?: boolean` (기본값 true)
|
|
||||||
|
|
||||||
**변경**: `showPackageUnit === false`이면 포장등록 버튼 숨김
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 구현 순서 (의존성 기반)
|
|
||||||
|
|
||||||
| 순서 | 작업 | 파일 | 의존성 | 상태 |
|
|
||||||
|------|------|------|--------|------|
|
|
||||||
| 1 | A-1: CardFieldBinding 타입 확장 | types.ts | 없음 | [ ] |
|
|
||||||
| 2 | A-2: CardInputFieldConfig 단순화 | types.ts | 없음 | [ ] |
|
|
||||||
| 3 | A-3: CardCalculatedFieldConfig 제거 | types.ts | 없음 | [ ] |
|
|
||||||
| 4 | B-1: FieldEditor에 값 유형 선택 추가 | PopCardListConfig.tsx | 순서 1 | [ ] |
|
|
||||||
| 5 | B-2: 입력 필드 설정 섹션 개편 | PopCardListConfig.tsx | 순서 2 | [ ] |
|
|
||||||
| 6 | B-3: 계산 필드 섹션 제거 | PopCardListConfig.tsx | 순서 3 | [ ] |
|
|
||||||
| 7 | B-4: import 정리 | PopCardListConfig.tsx | 순서 6 | [ ] |
|
|
||||||
| 8 | D-1: NumberInputModal showPackageUnit 추가 | NumberInputModal.tsx | 없음 | [ ] |
|
|
||||||
| 9 | C-1: FieldRow 계산식 지원 | PopCardListComponent.tsx | 순서 1 | [ ] |
|
|
||||||
| 10 | C-3: calculatedField 관련 코드 제거 | PopCardListComponent.tsx | 순서 9 | [ ] |
|
|
||||||
| 11 | C-4: 입력 필드 로직 단순화 | PopCardListComponent.tsx | 순서 2, 8 | [ ] |
|
|
||||||
| 12 | 린트 검사 | 전체 | 순서 1~11 | [ ] |
|
|
||||||
|
|
||||||
순서 1, 2, 3은 독립이므로 병렬 가능.
|
|
||||||
순서 8은 독립이므로 병렬 가능.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 사전 충돌 검사 결과
|
|
||||||
|
|
||||||
### 새로 추가할 식별자 목록
|
|
||||||
|
|
||||||
| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 |
|
|
||||||
|--------|------|-----------|-----------|-----------|
|
|
||||||
| `valueType` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 |
|
|
||||||
| `formula` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 (기존 CardCalculatedFieldConfig.formula와 다른 인터페이스) |
|
|
||||||
| `limitColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 |
|
|
||||||
| `saveTable` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 |
|
|
||||||
| `saveColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 |
|
|
||||||
| `showPackageUnit` | CardInputFieldConfig 속성 / NumberInputModal prop | types.ts, NumberInputModal.tsx | PopCardListComponent.tsx | 충돌 없음 |
|
|
||||||
|
|
||||||
### 기존 타입/함수 재사용 목록
|
|
||||||
|
|
||||||
| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 |
|
|
||||||
|------------|-----------|------------------------|
|
|
||||||
| `evaluateFormula()` | PopCardListComponent.tsx 라인 1026 | C-1: FieldRow에서 호출 (기존 함수 그대로 재사용) |
|
|
||||||
| `CardFieldBinding` | types.ts 라인 367 | A-1에서 수정, B-1/C-1에서 사용 |
|
|
||||||
| `CardInputFieldConfig` | types.ts 라인 443 | A-2에서 수정, B-2/C-4에서 사용 |
|
|
||||||
| `GroupedColumnSelect` | PopCardListConfig.tsx | B-1: 계산식 모드에서 컬럼 칩 표시에 재사용 가능 |
|
|
||||||
|
|
||||||
**사용처 있는데 정의 누락된 항목: 없음**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 에러 함정 경고
|
|
||||||
|
|
||||||
### 함정 1: 기존 저장 데이터 하위 호환
|
|
||||||
기존에 저장된 `CardFieldBinding`에는 `valueType`이 없고 `columnName`이 필수였음.
|
|
||||||
**반드시** 런타임에서 `field.valueType || "column"` 폴백 처리해야 함.
|
|
||||||
Config UI에서도 `valueType` 미존재 시 `"column"` 기본값 적용 필요.
|
|
||||||
|
|
||||||
### 함정 2: CardInputFieldConfig 하위 호환
|
|
||||||
기존 `maxColumn`이 `limitColumn`으로 이름 변경됨.
|
|
||||||
기존 저장 데이터의 `maxColumn`을 `limitColumn`으로 읽어야 함.
|
|
||||||
런타임: `inputField?.limitColumn || (inputField as any)?.maxColumn` 폴백 필요.
|
|
||||||
|
|
||||||
### 함정 3: evaluateFormula의 inputValue 전달
|
|
||||||
FieldRow에 `inputValue`를 전달하려면, CardItem -> body.fields.map -> FieldRow 경로에서 `inputValue` prop을 추가해야 함.
|
|
||||||
입력 필드가 비활성화된 경우 `inputValue`는 0으로 전달.
|
|
||||||
|
|
||||||
### 함정 4: calculatedField 제거 시 기존 데이터
|
|
||||||
기존 config에 `calculatedField` 데이터가 남아 있을 수 있음.
|
|
||||||
타입에서 제거하더라도 런타임 에러는 나지 않음 (unknown 속성은 무시됨).
|
|
||||||
다만 이전에 계산 필드로 설정한 내용은 사라짐 - 마이그레이션 없이 제거.
|
|
||||||
|
|
||||||
### 함정 5: columnName optional 변경
|
|
||||||
`CardFieldBinding.columnName`이 optional이 됨.
|
|
||||||
기존에 `row[field.columnName]`으로 직접 접근하던 코드 전부 수정 필요.
|
|
||||||
`field.columnName ?? ""` 또는 valueType 분기 처리.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 검증 방법
|
|
||||||
|
|
||||||
### 시나리오 1: 기존 본문 필드 (하위 호환)
|
|
||||||
1. 기존 저장된 카드리스트 열기
|
|
||||||
2. 본문 필드에 기존 DB 컬럼 필드가 정상 표시되는지 확인
|
|
||||||
3. 설정 패널에서 기존 필드가 "DB 컬럼" 유형으로 표시되는지 확인
|
|
||||||
|
|
||||||
### 시나리오 2: 계산식 본문 필드 추가
|
|
||||||
1. 본문 필드 추가 -> 값 유형 "계산식" 선택
|
|
||||||
2. 수식: `order_qty - received_qty` 입력
|
|
||||||
3. 카드에서 계산 결과가 정상 표시되는지 확인
|
|
||||||
|
|
||||||
### 시나리오 3: $input 참조 계산식
|
|
||||||
1. 입력 필드 활성화
|
|
||||||
2. 본문 필드 추가 -> 값 유형 "계산식" -> 수식: `$input - received_qty`
|
|
||||||
3. 키패드에서 수량 입력 시 계산 결과가 실시간 갱신되는지 확인
|
|
||||||
|
|
||||||
### 시나리오 4: 제한 기준 컬럼
|
|
||||||
1. 입력 필드 -> 제한 기준 컬럼: `order_qty`
|
|
||||||
2. order_qty=1000인 카드에서 키패드 열기
|
|
||||||
3. MAX 버튼 클릭 시 1000이 입력되고, 1001 이상 입력 불가 확인
|
|
||||||
|
|
||||||
### 시나리오 5: 포장등록 on/off
|
|
||||||
1. 입력 필드 -> 포장등록 버튼: off
|
|
||||||
2. 키패드 모달에서 포장등록 버튼이 숨겨지는지 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 이전 완료 계획 (아카이브)
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>pop-dashboard 4가지 아이템 모드 완성 (완료)</summary>
|
|
||||||
|
|
||||||
- [x] groupBy UI 추가
|
|
||||||
- [x] xAxisColumn 입력 UI 추가
|
|
||||||
- [x] 통계카드 카테고리 설정 UI 추가
|
|
||||||
- [x] 차트 xAxisColumn 자동 보정 로직
|
|
||||||
- [x] 통계카드 카테고리별 필터 적용
|
|
||||||
- [x] SQL 빌더 방어 로직
|
|
||||||
- [x] refreshInterval 최소값 강제
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>POP 뷰어 스크롤 수정 (완료)</summary>
|
|
||||||
|
|
||||||
- [x] overflow-hidden 제거
|
|
||||||
- [x] overflow-auto 공통 적용
|
|
||||||
- [x] 일반 모드 min-h-full 추가
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>POP 뷰어 실제 컴포넌트 렌더링 (완료)</summary>
|
|
||||||
|
|
||||||
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가
|
|
||||||
- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체
|
|
||||||
|
|
||||||
</details>
|
|
||||||
-1041
File diff suppressed because it is too large
Load Diff
-696
@@ -1,696 +0,0 @@
|
|||||||
# POP 컴포넌트 정의서 v8.0
|
|
||||||
|
|
||||||
## POP 헌법 (공통 규칙)
|
|
||||||
|
|
||||||
### 제1조. 컴포넌트의 정의
|
|
||||||
|
|
||||||
- 컴포넌트란 디자이너가 그리드에 배치하는 것이다
|
|
||||||
- 그리드에 배치하지 않는 것은 컴포넌트가 아니다
|
|
||||||
|
|
||||||
### 제2조. 컴포넌트의 독립성
|
|
||||||
|
|
||||||
- 모든 컴포넌트는 독립적으로 동작한다
|
|
||||||
- 컴포넌트는 다른 컴포넌트의 존재를 직접 알지 못한다 (이벤트 버스로만 통신)
|
|
||||||
|
|
||||||
### 제3조. 데이터의 자유
|
|
||||||
|
|
||||||
- 모든 컴포넌트는 자신의 테이블 + 외부 테이블을 자유롭게 조인할 수 있다
|
|
||||||
- 컬럼별로 read/write/readwrite/hidden을 개별 설정할 수 있다
|
|
||||||
- 보유 데이터 중 원하는 컬럼만 골라서 저장할 수 있다
|
|
||||||
|
|
||||||
### 제4조. 통신의 규칙
|
|
||||||
|
|
||||||
- 컴포넌트 간 통신은 반드시 이벤트 버스(usePopEvent)를 통한다
|
|
||||||
- 컴포넌트가 다른 컴포넌트를 직접 참조하거나 호출하지 않는다
|
|
||||||
- 이벤트는 화면 단위로 격리된다 (다른 POP 화면의 이벤트를 받지 않는다)
|
|
||||||
- 같은 화면 안에서는 이벤트를 통해 자유롭게 데이터를 주고받을 수 있다
|
|
||||||
|
|
||||||
### 제5조. 역할의 분리
|
|
||||||
|
|
||||||
- 조회용 입력(pop-search)과 저장용 입력(pop-field)은 다른 컴포넌트다
|
|
||||||
- 이동/실행(pop-icon)과 값 선택 후 반환(pop-lookup)은 다른 컴포넌트다
|
|
||||||
- 자주 쓰는 패턴은 하나로 합치되, 흐름 자체는 강제하고 보이는 방식만 옵션으로 제공한다
|
|
||||||
|
|
||||||
### 제6조. 시스템 설정도 컴포넌트다
|
|
||||||
|
|
||||||
- 프로필, 테마, 대시보드 보이기/숨기기 같은 시스템 설정도 컴포넌트(pop-system)로 만든다
|
|
||||||
- 디자이너가 pop-system을 배치하지 않으면 해당 화면에 설정 기능이 없다
|
|
||||||
- 이를 통해 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정한다
|
|
||||||
|
|
||||||
### 제7조. 디자이너의 권한
|
|
||||||
|
|
||||||
- 디자이너는 컴포넌트를 배치하고 설정한다
|
|
||||||
- 디자이너는 사용자에게 커스텀을 허용할지 말지 결정한다 (userConfigurable)
|
|
||||||
- 디자이너가 "사용자 커스텀 허용 = OFF"로 설정하면, 사용자는 변경할 수 없다
|
|
||||||
- 컴포넌트의 옵션 설정(어떻게 저장하고 어떻게 조회하는지 등)은 디자이너가 결정한다
|
|
||||||
|
|
||||||
### 제8조. 컴포넌트의 구성
|
|
||||||
|
|
||||||
- 모든 컴포넌트는 3개 파일로 구성된다: 실제 컴포넌트, 디자인 미리보기, 설정 패널
|
|
||||||
- 모든 컴포넌트는 레지스트리에 등록해야 디자이너에 나타난다
|
|
||||||
- 모든 컴포넌트 인스턴스는 userConfigurable, displayName 공통 속성을 가진다
|
|
||||||
|
|
||||||
### 제9조. 모달 화면의 설계
|
|
||||||
|
|
||||||
- 모달은 인라인(컴포넌트 설정만으로 구성)과 외부 참조(별도 POP 화면 연결) 두 가지 방식이 있다
|
|
||||||
- 단순한 목록 선택은 인라인 모달을 사용한다 (설정만으로 완결)
|
|
||||||
- 복잡한 검색/필터가 필요하거나 여러 곳에서 재사용하는 모달은 별도 POP 화면을 만들어 참조한다
|
|
||||||
- 모달 안의 화면도 동일한 POP 컴포넌트 시스템으로 구성된다 (같은 그리드, 같은 컴포넌트)
|
|
||||||
- 모달 화면의 layout_data는 기존 screen_layouts_pop 테이블에 저장한다 (DB 변경 불필요)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 현재 상태
|
|
||||||
|
|
||||||
- 그리드 시스템 (v5.2): 완성
|
|
||||||
- 컴포넌트 레지스트리: 완성 (PopComponentRegistry.ts)
|
|
||||||
- 구현 완료: `pop-text` 1개 (pop-text.tsx)
|
|
||||||
- 기존 `components-spec.md`는 v4 기준이라 갱신 필요
|
|
||||||
|
|
||||||
## 아키텍처 개요
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TB
|
|
||||||
subgraph designer [디자이너]
|
|
||||||
Palette[컴포넌트 팔레트]
|
|
||||||
Grid[CSS Grid 캔버스]
|
|
||||||
ConfigPanel[속성 설정 패널]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph registry [레지스트리]
|
|
||||||
Registry[PopComponentRegistry]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph infra [공통 인프라]
|
|
||||||
DataSource[useDataSource 훅]
|
|
||||||
EventBus[usePopEvent 훅]
|
|
||||||
ActionRunner[usePopAction 훅]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph components [9개 컴포넌트]
|
|
||||||
Text[pop-text - 완성]
|
|
||||||
Dashboard[pop-dashboard]
|
|
||||||
Table[pop-table]
|
|
||||||
Button[pop-button]
|
|
||||||
Icon[pop-icon]
|
|
||||||
Search[pop-search]
|
|
||||||
Field[pop-field]
|
|
||||||
Lookup[pop-lookup]
|
|
||||||
System[pop-system]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph backend [기존 백엔드 API]
|
|
||||||
DataAPI[dataApi - 동적 CRUD]
|
|
||||||
DashAPI[dashboardApi - 통계 쿼리]
|
|
||||||
CodeAPI[commonCodeApi - 공통코드]
|
|
||||||
NumberAPI[numberingRuleApi - 채번]
|
|
||||||
end
|
|
||||||
|
|
||||||
Palette --> Grid
|
|
||||||
Grid --> ConfigPanel
|
|
||||||
ConfigPanel --> Registry
|
|
||||||
|
|
||||||
Registry --> components
|
|
||||||
components --> infra
|
|
||||||
infra --> backend
|
|
||||||
EventBus -.->|컴포넌트 간 통신| components
|
|
||||||
System -.->|보이기/숨기기 제어| components
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 공통 인프라 (모든 컴포넌트가 공유)
|
|
||||||
|
|
||||||
### 핵심 원칙: 모든 컴포넌트는 데이터를 자유롭게 다룬다
|
|
||||||
|
|
||||||
1. **데이터 전달**: 모든 컴포넌트는 자신이 보유한 데이터를 다른 컴포넌트에 전달/수신 가능
|
|
||||||
2. **테이블 조인**: 자신의 테이블 + 외부 테이블 자유롭게 조인하여 데이터 구성
|
|
||||||
3. **컬럼별 CRUD 제어**: 컬럼 단위로 "조회만" / "저장 대상" / "숨김"을 개별 설정 가능
|
|
||||||
4. **선택적 저장**: 보유 데이터 중 원하는 컬럼만 골라서 저장/수정/삭제 가능
|
|
||||||
|
|
||||||
### 공통 인스턴스 속성 (모든 컴포넌트 배치 시 설정 가능)
|
|
||||||
|
|
||||||
디자이너가 컴포넌트를 그리드에 배치할 때 설정하는 공통 속성:
|
|
||||||
|
|
||||||
- `userConfigurable`: boolean - 사용자가 이 컴포넌트를 숨길 수 있는지 (개인 설정 패널에 노출)
|
|
||||||
- `displayName`: string - 개인 설정 패널에 보여줄 이름 (예: "금일 생산실적")
|
|
||||||
|
|
||||||
### 1. DataSourceConfig (데이터 소스 설정 타입)
|
|
||||||
|
|
||||||
모든 데이터 연동 컴포넌트가 사용하는 표준 설정 구조:
|
|
||||||
|
|
||||||
- `tableName`: 대상 테이블
|
|
||||||
- `columns`: 컬럼 바인딩 목록 (ColumnBinding 배열)
|
|
||||||
- `filters`: 필터 조건 배열
|
|
||||||
- `sort`: 정렬 설정
|
|
||||||
- `aggregation`: 집계 함수 (count, sum, avg, min, max)
|
|
||||||
- `joins`: 테이블 조인 설정 (JoinConfig 배열)
|
|
||||||
- `refreshInterval`: 자동 새로고침 주기 (초)
|
|
||||||
- `limit`: 조회 건수 제한
|
|
||||||
|
|
||||||
### 1-1. ColumnBinding (컬럼별 읽기/쓰기 제어)
|
|
||||||
|
|
||||||
각 컬럼이 컴포넌트에서 어떤 역할을 하는지 개별 설정:
|
|
||||||
|
|
||||||
- `columnName`: 컬럼명
|
|
||||||
- `sourceTable`: 소속 테이블 (조인된 외부 테이블 포함)
|
|
||||||
- `mode`: "read" | "write" | "readwrite" | "hidden"
|
|
||||||
- read: 조회만 (화면에 표시하되 저장 안 함)
|
|
||||||
- write: 저장 대상 (사용자 입력 -> DB 저장)
|
|
||||||
- readwrite: 조회 + 저장 모두
|
|
||||||
- hidden: 내부 참조용 (화면에 안 보이지만 다른 컴포넌트에 전달 가능)
|
|
||||||
- `label`: 화면 표시 라벨
|
|
||||||
- `defaultValue`: 기본값
|
|
||||||
|
|
||||||
예시: 발주 품목 카드에서 5개 컬럼 중 3개만 저장
|
|
||||||
|
|
||||||
```
|
|
||||||
columns: [
|
|
||||||
{ columnName: "item_code", sourceTable: "order_items", mode: "read" },
|
|
||||||
{ columnName: "item_name", sourceTable: "item_info", mode: "read" },
|
|
||||||
{ columnName: "inbound_qty", sourceTable: "order_items", mode: "readwrite" },
|
|
||||||
{ columnName: "warehouse", sourceTable: "order_items", mode: "write" },
|
|
||||||
{ columnName: "memo", sourceTable: "order_items", mode: "write" },
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1-2. JoinConfig (테이블 조인 설정)
|
|
||||||
|
|
||||||
외부 테이블과 자유롭게 조인:
|
|
||||||
|
|
||||||
- `targetTable`: 조인할 외부 테이블명
|
|
||||||
- `joinType`: "inner" | "left" | "right"
|
|
||||||
- `on`: 조인 조건 { sourceColumn, targetColumn }
|
|
||||||
- `columns`: 가져올 컬럼 목록
|
|
||||||
|
|
||||||
### 2. useDataSource 훅
|
|
||||||
|
|
||||||
DataSourceConfig를 받아서 기존 API를 호출하고 결과를 반환:
|
|
||||||
|
|
||||||
- 로딩/에러/데이터 상태 관리
|
|
||||||
- 자동 새로고침 타이머
|
|
||||||
- 필터 변경 시 자동 재조회
|
|
||||||
- 기존 `dataApi`, `dashboardApi` 활용
|
|
||||||
- **CRUD 함수 제공**: save(data), update(id, data), delete(id)
|
|
||||||
- ColumnBinding의 mode가 "write" 또는 "readwrite"인 컬럼만 저장 대상에 포함
|
|
||||||
- "read" 컬럼은 저장 시 자동 제외
|
|
||||||
|
|
||||||
### 3. usePopEvent 훅 (이벤트 버스 - 데이터 전달 포함)
|
|
||||||
|
|
||||||
컴포넌트 간 통신 (단순 이벤트 + 데이터 페이로드):
|
|
||||||
|
|
||||||
- `publish(eventName, payload)`: 이벤트 발행
|
|
||||||
- `subscribe(eventName, callback)`: 이벤트 구독
|
|
||||||
- `getSharedData(key)`: 공유 데이터 직접 읽기
|
|
||||||
- `setSharedData(key, value)`: 공유 데이터 직접 쓰기
|
|
||||||
- 화면 단위 스코프 (다른 POP 화면과 격리)
|
|
||||||
|
|
||||||
### 4. PopActionConfig (액션 설정 타입)
|
|
||||||
|
|
||||||
모든 컴포넌트가 사용할 수 있는 액션 표준 구조:
|
|
||||||
|
|
||||||
- `type`: "navigate" | "modal" | "save" | "delete" | "api" | "event" | "refresh"
|
|
||||||
- `navigate`: { screenId, url }
|
|
||||||
- `modal`: { mode, title, screenId, inlineConfig, modalSize }
|
|
||||||
- mode: "inline" (설정만으로 구성) | "screen-ref" (별도 화면 참조)
|
|
||||||
- title: 모달 제목
|
|
||||||
- screenId: mode가 "screen-ref"일 때 참조할 POP 화면 ID
|
|
||||||
- inlineConfig: mode가 "inline"일 때 사용할 DataSourceConfig + 표시 설정
|
|
||||||
- modalSize: { width, height } 모달 크기
|
|
||||||
- `save`: { targetColumns }
|
|
||||||
- `delete`: { confirmMessage }
|
|
||||||
- `api`: { method, endpoint, body }
|
|
||||||
- `event`: { eventName, payload }
|
|
||||||
- `refresh`: { targetComponents }
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 컴포넌트 정의 (9개)
|
|
||||||
|
|
||||||
### 1. pop-text (완성)
|
|
||||||
|
|
||||||
- **한 줄 정의**: 보여주기만 함
|
|
||||||
- **카테고리**: display
|
|
||||||
- **역할**: 정적 표시 전용 (이벤트 없음)
|
|
||||||
- **서브타입**: text, datetime, image, title
|
|
||||||
- **데이터**: 없음 (정적 콘텐츠)
|
|
||||||
- **이벤트**: 발행 없음, 수신 없음
|
|
||||||
- **설정**: 내용, 폰트 크기/굵기, 좌우/상하 정렬, 이미지 URL/맞춤/크기, 날짜 포맷 빌더
|
|
||||||
|
|
||||||
### 2. pop-dashboard (신규 - 2026-02-09 토의 결과 반영)
|
|
||||||
|
|
||||||
- **한 줄 정의**: 여러 집계 아이템을 묶어서 다양한 방식으로 보여줌
|
|
||||||
- **카테고리**: display
|
|
||||||
- **역할**: 숫자 데이터를 집계/계산하여 시각화. 하나의 컴포넌트 안에 여러 집계 아이템을 담는 컨테이너
|
|
||||||
- **구조**: 1개 pop-dashboard = 여러 DashboardItem의 묶음. 각 아이템은 독립적으로 데이터 소스/서브타입/보이기숨기기 설정 가능
|
|
||||||
- **서브타입** (아이템별로 선택, 한 묶음에 혼합 가능):
|
|
||||||
- kpi-card: 숫자 + 단위 + 라벨 + 증감 표시
|
|
||||||
- chart: 막대/원형/라인 차트
|
|
||||||
- gauge: 게이지 (목표 대비 달성률)
|
|
||||||
- stat-card: 통계 카드 (건수 + 대기 + 링크)
|
|
||||||
- **표시 모드** (디자이너가 선택):
|
|
||||||
- arrows: 좌우 버튼으로 아이템 넘기기
|
|
||||||
- auto-slide: 전광판처럼 자동 전환 (터치 시 멈춤, 일정 시간 후 재개)
|
|
||||||
- grid: 컴포넌트 영역 내부를 행/열로 쪼개서 여러 아이템 동시 표시 (디자이너가 각 아이템 위치 직접 지정)
|
|
||||||
- scroll: 좌우 또는 상하 스와이프
|
|
||||||
- **데이터**: 각 아이템별 독립 DataSourceConfig (조인/집계 자유)
|
|
||||||
- **계산식 지원**: "생산량/총재고량", "출고량/현재고량" 같은 복합 표현 가능
|
|
||||||
- 값 A, B를 각각 다른 테이블/집계로 설정
|
|
||||||
- 표시 형태: 분수(1,234/5,678), 퍼센트(21.7%), 비율(1,234:5,678)
|
|
||||||
- **CRUD**: 주로 읽기. 목표값 수정 등 필요 시 write 컬럼으로 저장 가능
|
|
||||||
- **이벤트**:
|
|
||||||
- 수신: filter_changed, data_ready
|
|
||||||
- 발행: kpi_clicked (아이템 클릭 시 상세 데이터 전달)
|
|
||||||
- **설정**: 데이터 소스(드롭다운 기반 쉬운 집계), 집계 함수, 계산식, 라벨, 단위, 색상 구간, 차트 타입, 새로고침 주기, 목표값, 표시 모드, 아이템별 보이기/숨기기
|
|
||||||
- **보이기/숨기기**: 각 아이템별로 pop-system에서 개별 on/off 가능 (userConfigurable)
|
|
||||||
- **기존 POP 대시보드 폐기**: `frontend/components/pop/dashboard/` 폴더 전체를 이 컴포넌트로 대체 예정 (Phase 1~3 완료 후)
|
|
||||||
|
|
||||||
#### pop-dashboard 데이터 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
PopDashboardConfig {
|
|
||||||
items: DashboardItem[] // 아이템 목록 (각각 독립 설정)
|
|
||||||
displayMode: "arrows" | "auto-slide" | "grid" | "scroll"
|
|
||||||
autoSlideInterval: number // 자동 슬라이드 간격(초)
|
|
||||||
gridLayout: { columns: number, rows: number } // 행열 그리드 설정
|
|
||||||
showIndicator: boolean // 페이지 인디케이터 표시
|
|
||||||
gap: number // 아이템 간 간격
|
|
||||||
}
|
|
||||||
|
|
||||||
DashboardItem {
|
|
||||||
id: string
|
|
||||||
label: string // pop-system에서 보이기/숨기기용 이름
|
|
||||||
visible: boolean // 보이기/숨기기
|
|
||||||
subType: "kpi-card" | "chart" | "gauge" | "stat-card"
|
|
||||||
dataSource: DataSourceConfig // 각 아이템별 독립 데이터 소스
|
|
||||||
|
|
||||||
// 행열 그리드 모드에서의 위치 (디자이너가 직접 지정)
|
|
||||||
gridPosition: { col: number, row: number, colSpan: number, rowSpan: number }
|
|
||||||
|
|
||||||
// 계산식 (선택사항)
|
|
||||||
formula?: {
|
|
||||||
enabled: boolean
|
|
||||||
values: [
|
|
||||||
{ id: "A", dataSource: DataSourceConfig, label: "생산량" },
|
|
||||||
{ id: "B", dataSource: DataSourceConfig, label: "총재고량" },
|
|
||||||
]
|
|
||||||
expression: string // "A / B", "A + B", "A / B * 100"
|
|
||||||
displayFormat: "value" | "fraction" | "percent" | "ratio"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서브타입별 설정
|
|
||||||
kpiConfig?: { unit, colorRanges, showTrend, trendPeriod }
|
|
||||||
chartConfig?: { chartType, xAxis, yAxis, colors }
|
|
||||||
gaugeConfig?: { min, max, target, colorRanges }
|
|
||||||
statConfig?: { categories, showLink }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 설정 패널 흐름 (드롭다운 기반 쉬운 집계)
|
|
||||||
|
|
||||||
```
|
|
||||||
1. [+ 아이템 추가] 버튼 클릭
|
|
||||||
2. 서브타입 선택: kpi-card / chart / gauge / stat-card
|
|
||||||
3. 데이터 모드 선택: [단일 집계] 또는 [계산식]
|
|
||||||
|
|
||||||
[단일 집계]
|
|
||||||
- 테이블 선택 (table-schema API로 목록)
|
|
||||||
- 조인할 테이블 추가 (선택사항)
|
|
||||||
- 컬럼 선택 → 집계 함수 선택 (합계/건수/평균/최소/최대)
|
|
||||||
- 필터 조건 추가
|
|
||||||
|
|
||||||
[계산식] (예: 생산량/총재고량)
|
|
||||||
- 값 A: 테이블 -> 컬럼 -> 집계함수
|
|
||||||
- 값 B: 테이블 -> 컬럼 -> 집계함수 (다른 테이블도 가능)
|
|
||||||
- 계산식: A / B
|
|
||||||
- 표시 형태: 분수 / 퍼센트 / 비율
|
|
||||||
|
|
||||||
4. 라벨, 단위, 색상 등 외형 설정
|
|
||||||
5. 행열 그리드 위치 설정 (grid 모드일 때)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. pop-table (신규 - 가장 복잡)
|
|
||||||
|
|
||||||
- **한 줄 정의**: 데이터 목록을 보여주고 편집함
|
|
||||||
- **카테고리**: display
|
|
||||||
- **역할**: 데이터 목록 표시 + 편집 (카드형/테이블형)
|
|
||||||
- **서브타입**:
|
|
||||||
- card-list: 카드 형태
|
|
||||||
- table-list: 테이블 형태 (행/열 장부)
|
|
||||||
- **데이터**: DataSourceConfig (조인/컬럼별 읽기쓰기 자유)
|
|
||||||
- **CRUD**: useDataSource의 save/update/delete 사용. write/readwrite 컬럼만 자동 추출
|
|
||||||
- **카드 템플릿** (card-list 전용): 카드 내부 미니 그리드로 요소 배치, 요소별 데이터 바인딩
|
|
||||||
- **이벤트**:
|
|
||||||
- 수신: filter_changed, refresh, data_ready
|
|
||||||
- 발행: row_selected, row_action, save_complete, delete_complete
|
|
||||||
- **설정**: 데이터 소스, 표시 모드, 카드 템플릿, 컬럼 정의, 행 선택 방식, 페이징, 정렬, 인라인 편집 여부
|
|
||||||
|
|
||||||
### 4. pop-button (신규)
|
|
||||||
|
|
||||||
- **한 줄 정의**: 누르면 액션 실행 (저장, 삭제 등)
|
|
||||||
- **카테고리**: action
|
|
||||||
- **역할**: 액션 실행 (저장, 삭제, API 호출, 모달 열기 등)
|
|
||||||
- **데이터**: 이벤트로 수신한 데이터를 액션에 활용
|
|
||||||
- **CRUD**: 버튼 클릭 시 수신 데이터 기반으로 save/update/delete 실행
|
|
||||||
- **이벤트**:
|
|
||||||
- 수신: data_ready, row_selected
|
|
||||||
- 발행: save_complete, delete_complete 등
|
|
||||||
- **설정**: 라벨, 아이콘, 크기, 스타일, 액션 설정(PopActionConfig), 확인 다이얼로그, 로딩 상태
|
|
||||||
|
|
||||||
### 5. pop-icon (신규)
|
|
||||||
|
|
||||||
- **한 줄 정의**: 누르면 어딘가로 이동 (돌아오는 값 없음)
|
|
||||||
- **카테고리**: action
|
|
||||||
- **역할**: 네비게이션 (화면 이동, URL 이동)
|
|
||||||
- **데이터**: 없음
|
|
||||||
- **이벤트**: 없음 (네비게이션은 이벤트가 아닌 직접 실행)
|
|
||||||
- **설정**: 아이콘 종류(lucide-icon), 라벨, 배경색/그라디언트, 크기, 클릭 액션(PopActionConfig), 뱃지 표시
|
|
||||||
- **pop-lookup과의 차이**: pop-icon은 이동/실행만 함. 값을 선택해서 돌려주지 않음
|
|
||||||
|
|
||||||
### 6. pop-search (신규)
|
|
||||||
|
|
||||||
- **한 줄 정의**: 조건을 입력해서 다른 컴포넌트를 조회/필터링
|
|
||||||
- **카테고리**: input
|
|
||||||
- **역할**: 다른 컴포넌트에 필터 조건 전달 + 자체 데이터 조회
|
|
||||||
- **서브타입**:
|
|
||||||
- text-search: 텍스트 검색
|
|
||||||
- date-range: 날짜 범위
|
|
||||||
- select-filter: 드롭다운 선택 (공통코드 연동)
|
|
||||||
- combo-filter: 복합 필터 (여러 조건 조합)
|
|
||||||
- **실행 방식**: auto(값 변경 즉시) 또는 button(검색 버튼 클릭 시)
|
|
||||||
- **데이터**: 공통코드/카테고리 API로 선택 항목 조회
|
|
||||||
- **이벤트**:
|
|
||||||
- 수신: 없음
|
|
||||||
- 발행: filter_changed (필터 값 변경 시)
|
|
||||||
- **설정**: 필터 타입, 대상 컬럼, 공통코드 연결, 플레이스홀더, 실행 방식(auto/button), 발행할 이벤트 이름
|
|
||||||
- **pop-field와의 차이**: pop-search 입력값은 조회용(DB에 안 들어감). pop-field 입력값은 저장용(DB에 들어감)
|
|
||||||
|
|
||||||
### 7. pop-field (신규)
|
|
||||||
|
|
||||||
- **한 줄 정의**: 저장할 값을 입력
|
|
||||||
- **카테고리**: input
|
|
||||||
- **역할**: 단일 데이터 입력 (폼 필드) - 입력한 값이 DB에 저장되는 것이 목적
|
|
||||||
- **서브타입**:
|
|
||||||
- text: 텍스트 입력
|
|
||||||
- number: 숫자 입력 (수량, 금액)
|
|
||||||
- date: 날짜 선택
|
|
||||||
- select: 드롭다운 선택
|
|
||||||
- numpad: 큰 숫자패드 (현장용)
|
|
||||||
- **데이터**: DataSourceConfig (선택적)
|
|
||||||
- select 옵션을 DB에서 조회 가능
|
|
||||||
- ColumnBinding으로 입력값의 저장 대상 테이블/컬럼 지정
|
|
||||||
- **CRUD**: 자체 저장은 보통 하지 않음. value_changed 이벤트로 pop-button 등에 전달
|
|
||||||
- **이벤트**:
|
|
||||||
- 수신: set_value (외부에서 값 설정)
|
|
||||||
- 발행: value_changed (값 + 컬럼명 + 모드 정보)
|
|
||||||
- **설정**: 입력 타입, 라벨, 플레이스홀더, 필수 여부, 유효성 검증, 최소/최대값, 단위 표시, 바인딩 컬럼
|
|
||||||
|
|
||||||
### 8. pop-lookup (신규)
|
|
||||||
|
|
||||||
- **한 줄 정의**: 모달에서 값을 골라서 반환
|
|
||||||
- **카테고리**: input
|
|
||||||
- **역할**: 필드를 클릭하면 모달이 열리고, 목록에서 선택하면 값이 반환되는 컴포넌트
|
|
||||||
- **서브타입 (모달 안 표시 방식)**:
|
|
||||||
- card: 카드형 목록
|
|
||||||
- table: 테이블형 목록
|
|
||||||
- icon-grid: 아이콘 그리드 (참조 화면의 거래처 선택처럼)
|
|
||||||
- **동작 흐름**: 필드 클릭 -> 모달 열림 -> 목록에서 선택 -> 모달 닫힘 -> 필드에 값 표시 + 이벤트 발행
|
|
||||||
- **데이터**: DataSourceConfig (모달 안 목록의 데이터 소스)
|
|
||||||
- **이벤트**:
|
|
||||||
- 수신: set_value (외부에서 값 초기화)
|
|
||||||
- 발행: value_selected (선택한 레코드 전체 데이터 전달), filter_changed (선택 값을 필터로 전달)
|
|
||||||
- **설정**: 라벨, 플레이스홀더, 데이터 소스, 모달 표시 방식(card/table/icon-grid), 표시 컬럼(모달 목록에 보여줄 컬럼), 반환 컬럼(선택 시 돌려줄 값), 발행할 이벤트 이름
|
|
||||||
- **pop-icon과의 차이**: pop-icon은 이동/실행만 하고 값이 안 돌아옴. pop-lookup은 값을 골라서 돌려줌
|
|
||||||
- **pop-search와의 차이**: pop-search는 텍스트/날짜/드롭다운으로 필터링. pop-lookup은 모달을 열어서 목록에서 선택
|
|
||||||
|
|
||||||
#### pop-lookup 모달 화면 설계 방식
|
|
||||||
|
|
||||||
pop-lookup이 열리는 모달의 내부 화면은 **두 가지 방식** 중 선택할 수 있다:
|
|
||||||
|
|
||||||
**방식 A: 인라인 모달 (기본)**
|
|
||||||
- pop-lookup 컴포넌트의 설정 패널에서 직접 모달 내부 화면을 구성
|
|
||||||
- DataSourceConfig + 표시 컬럼 + 검색 필터 설정만으로 동작
|
|
||||||
- 별도 화면 생성 없이 컴포넌트 설정만으로 완결
|
|
||||||
- 적합한 경우: 단순 목록 선택 (거래처 목록, 품목 목록 등)
|
|
||||||
|
|
||||||
**방식 B: 외부 화면 참조 (고급)**
|
|
||||||
- 별도의 POP 화면(screen_id)을 모달로 연결
|
|
||||||
- 모달 안에서 검색/필터/테이블 등 복잡한 화면을 디자이너로 자유롭게 구성
|
|
||||||
- 여러 pop-lookup에서 같은 모달 화면을 재사용 가능
|
|
||||||
- 적합한 경우: 복잡한 검색/필터가 필요한 선택 화면, 여러 화면에서 공유하는 모달
|
|
||||||
|
|
||||||
**설정 구조:**
|
|
||||||
|
|
||||||
```
|
|
||||||
modalConfig: {
|
|
||||||
mode: "inline" | "screen-ref"
|
|
||||||
|
|
||||||
// mode = "inline"일 때 사용
|
|
||||||
dataSource: DataSourceConfig
|
|
||||||
displayColumns: ColumnBinding[]
|
|
||||||
searchFilter: { enabled: boolean, targetColumns: string[] }
|
|
||||||
modalSize: { width: number, height: number }
|
|
||||||
|
|
||||||
// mode = "screen-ref"일 때 사용
|
|
||||||
screenId: number // 참조할 POP 화면 ID
|
|
||||||
returnMapping: { // 모달 화면에서 선택된 값을 어떻게 매핑할지
|
|
||||||
sourceColumn: string // 모달 화면에서 반환하는 컬럼
|
|
||||||
targetField: string // pop-lookup 필드에 표시할 값
|
|
||||||
}[]
|
|
||||||
modalSize: { width: number, height: number }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**기존 시스템과의 호환성 (검증 완료):**
|
|
||||||
|
|
||||||
| 항목 | 현재 상태 | pop-lookup 지원 여부 |
|
|
||||||
|------|-----------|---------------------|
|
|
||||||
| DB: layout_data JSONB | 유연한 JSON 구조 | modalConfig를 layout_data에 저장 가능 (스키마 변경 불필요) |
|
|
||||||
| DB: screen_layouts_pop 테이블 | screen_id + company_code 기반 | 모달 화면도 별도 screen_id로 저장 가능 |
|
|
||||||
| 프론트: TabsWidget | screenId로 외부 화면 참조 지원 | 같은 패턴으로 모달에서 외부 화면 로드 가능 |
|
|
||||||
| 프론트: detectLinkedModals API | 연결된 모달 화면 감지 기능 있음 | 화면 간 참조 관계 추적에 활용 가능 |
|
|
||||||
| 백엔드: saveLayoutPop/getLayoutPop | POP 전용 저장/조회 API 있음 | 모달 화면도 동일 API로 저장/조회 가능 |
|
|
||||||
| 레이어 시스템 | layer_id 기반 다중 레이어 지원 | 모달 내부 레이아웃을 레이어로 관리 가능 |
|
|
||||||
|
|
||||||
**DB 마이그레이션 불필요**: layout_data가 JSONB이므로 modalConfig를 컴포넌트 overrides에 포함하면 됨.
|
|
||||||
**백엔드 변경 불필요**: 기존 saveLayoutPop/getLayoutPop API가 그대로 사용 가능.
|
|
||||||
**프론트엔드 참고 패턴**: TabsWidget의 screenId 참조 방식을 그대로 차용.
|
|
||||||
|
|
||||||
### 9. pop-system (신규)
|
|
||||||
|
|
||||||
- **한 줄 정의**: 시스템 설정을 하나로 통합한 컴포넌트 (프로필, 테마, 보이기/숨기기)
|
|
||||||
- **카테고리**: system
|
|
||||||
- **역할**: 사용자 개인 설정 기능을 제공하는 통합 컴포넌트
|
|
||||||
- **내부 포함 기능**:
|
|
||||||
- 프로필 표시 (사용자명, 부서)
|
|
||||||
- 테마 선택 (기본/다크/블루/그린)
|
|
||||||
- 대시보드 보이기/숨기기 체크박스 (같은 화면의 userConfigurable=true 컴포넌트를 자동 수집)
|
|
||||||
- 하단 메뉴 보이기/숨기기
|
|
||||||
- 드래그앤드롭으로 순서 변경
|
|
||||||
- **디자이너가 설정하는 것**: 크기(그리드에서 차지하는 영역), 내부 라벨/아이콘 크기와 위치
|
|
||||||
- **사용자가 하는 것**: 체크박스로 컴포넌트 보이기/숨기기, 테마 선택, 순서 변경
|
|
||||||
- **데이터**: 같은 화면의 layout_data에서 컴포넌트 목록을 자동 수집
|
|
||||||
- **저장**: 사용자별 설정을 localStorage에 저장 (데스크탑 패턴 따름)
|
|
||||||
- **이벤트**:
|
|
||||||
- 수신: 없음
|
|
||||||
- 발행: visibility_changed (컴포넌트 보이기/숨기기 변경 시), theme_changed (테마 변경 시)
|
|
||||||
- **설정**: 내부 라벨 크기, 아이콘 크기, 위치 정도만
|
|
||||||
- **특이사항**:
|
|
||||||
- 디자이너가 이 컴포넌트를 배치하지 않으면 해당 화면에 개인 설정 기능이 없다
|
|
||||||
- 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정하는 구조
|
|
||||||
- 메인 홈에는 배치, 업무 화면(입고 등)에는 안 배치하는 식으로 사용
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 컴포넌트 간 통신 예시
|
|
||||||
|
|
||||||
### 예시 1: 검색 -> 필터 연동
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant Search as pop-search
|
|
||||||
participant Dashboard as pop-dashboard
|
|
||||||
participant Table as pop-table
|
|
||||||
|
|
||||||
Note over Search: 사용자가 창고 WH01 선택
|
|
||||||
Search->>Dashboard: filter_changed
|
|
||||||
Search->>Table: filter_changed
|
|
||||||
Note over Dashboard: DataSource 재조회
|
|
||||||
Note over Table: DataSource 재조회
|
|
||||||
```
|
|
||||||
|
|
||||||
### 예시 2: 데이터 전달 + 선택적 저장
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant Table as pop-table
|
|
||||||
participant Field as pop-field
|
|
||||||
participant Button as pop-button
|
|
||||||
|
|
||||||
Note over Table: 사용자가 발주 행 선택
|
|
||||||
Table->>Field: row_selected
|
|
||||||
Table->>Button: row_selected
|
|
||||||
Note over Field: 사용자가 qty를 500으로 입력
|
|
||||||
Field->>Button: value_changed
|
|
||||||
Note over Button: 사용자가 저장 클릭
|
|
||||||
Note over Button: write/readwrite 컬럼만 추출하여 저장
|
|
||||||
Button->>Table: save_complete
|
|
||||||
Note over Table: 데이터 새로고침
|
|
||||||
```
|
|
||||||
|
|
||||||
### 예시 3: pop-lookup 거래처 선택 -> 품목 조회
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant Lookup as pop-lookup
|
|
||||||
participant Table as pop-table
|
|
||||||
|
|
||||||
Note over Lookup: 사용자가 거래처 필드 클릭
|
|
||||||
Note over Lookup: 모달 열림 - 거래처 목록 표시
|
|
||||||
Note over Lookup: 사용자가 대한금속 선택
|
|
||||||
Note over Lookup: 모달 닫힘 - 필드에 대한금속 표시
|
|
||||||
Lookup->>Table: filter_changed { company: "대한금속" }
|
|
||||||
Note over Table: company=대한금속 필터로 재조회
|
|
||||||
Note over Table: 발주 품목 3건 표시
|
|
||||||
```
|
|
||||||
|
|
||||||
### 예시 4: pop-lookup 인라인 모달 vs 외부 화면 참조
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant User as 사용자
|
|
||||||
participant Lookup as pop-lookup (거래처)
|
|
||||||
participant Modal as 모달
|
|
||||||
|
|
||||||
Note over User,Modal: [방식 A: 인라인 모달]
|
|
||||||
User->>Lookup: 거래처 필드 클릭
|
|
||||||
Lookup->>Modal: 인라인 모달 열림 (DataSourceConfig 기반)
|
|
||||||
Note over Modal: supplier 테이블에서 목록 조회
|
|
||||||
Note over Modal: 테이블형 목록 표시
|
|
||||||
User->>Modal: "대한금속" 선택
|
|
||||||
Modal->>Lookup: value_selected { supplier_code: "DH001", name: "대한금속" }
|
|
||||||
Note over Lookup: 필드에 "대한금속" 표시
|
|
||||||
|
|
||||||
Note over User,Modal: [방식 B: 외부 화면 참조]
|
|
||||||
User->>Lookup: 거래처 필드 클릭
|
|
||||||
Lookup->>Modal: 모달 열림 (screenId=42 화면 로드)
|
|
||||||
Note over Modal: 별도 POP 화면 렌더링
|
|
||||||
Note over Modal: pop-search(검색) + pop-table(목록) 등 배치된 컴포넌트 동작
|
|
||||||
User->>Modal: 검색 후 "대한금속" 선택
|
|
||||||
Modal->>Lookup: returnMapping 기반으로 값 반환
|
|
||||||
Note over Lookup: 필드에 "대한금속" 표시
|
|
||||||
```
|
|
||||||
|
|
||||||
### 예시 5: 컬럼별 읽기/쓰기 분리 동작
|
|
||||||
|
|
||||||
5개 컬럼이 있는 발주 화면:
|
|
||||||
|
|
||||||
- item_code (read) -> 화면에 표시, 저장 안 함
|
|
||||||
- item_name (read, 조인) -> item_info 테이블에서 가져옴, 저장 안 함
|
|
||||||
- inbound_qty (readwrite) -> 화면에 표시 + 사용자 수정 + 저장
|
|
||||||
- warehouse (write) -> 사용자 입력 + 저장
|
|
||||||
- memo (write) -> 사용자 입력 + 저장
|
|
||||||
|
|
||||||
저장 API 호출 시: `{ inbound_qty: 500, warehouse: "WH01", memo: "긴급" }` 만 전달
|
|
||||||
조회 API 호출 시: 5개 컬럼 전부 + 조인된 item_name까지 조회
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 구현 우선순위
|
|
||||||
|
|
||||||
- Phase 0 (공통 인프라): ColumnBinding, JoinConfig, DataSourceConfig 타입, useDataSource 훅 (CRUD 포함), usePopEvent 훅 (데이터 전달 포함), PopActionConfig 타입
|
|
||||||
- Phase 1 (기본 표시): pop-dashboard (4개 서브타입 전부 + 멀티 아이템 컨테이너 + 4개 표시 모드 + 계산식)
|
|
||||||
- Phase 2 (기본 액션): pop-button, pop-icon
|
|
||||||
- Phase 3 (데이터 목록): pop-table (테이블형부터, 카드형은 후순위)
|
|
||||||
- Phase 4 (입력/연동): pop-search, pop-field, pop-lookup
|
|
||||||
- Phase 5 (고도화): pop-table 카드 템플릿
|
|
||||||
- Phase 6 (시스템): pop-system (프로필, 테마, 대시보드 보이기/숨기기 통합)
|
|
||||||
|
|
||||||
### Phase 1 상세 변경 (2026-02-09 토의 결정)
|
|
||||||
|
|
||||||
기존 계획에서 "KPI 카드 우선"이었으나, 토의 결과 **4개 서브타입 전부를 Phase 1에서 구현**으로 변경:
|
|
||||||
- kpi-card, chart, gauge, stat-card 모두 Phase 1
|
|
||||||
- 멀티 아이템 컨테이너 (arrows, auto-slide, grid, scroll)
|
|
||||||
- 계산식 지원 (formula)
|
|
||||||
- 드롭다운 기반 쉬운 집계 설정
|
|
||||||
- 기존 `frontend/components/pop/dashboard/` 폴더는 Phase 1 완료 후 폐기/삭제
|
|
||||||
|
|
||||||
### 백엔드 API 현황 (호환성 점검 완료)
|
|
||||||
|
|
||||||
기존 백엔드에 이미 구현되어 있어 새로 만들 필요 없는 API:
|
|
||||||
|
|
||||||
| API | 용도 | 비고 |
|
|
||||||
|-----|------|------|
|
|
||||||
| `dataApi.getTableData()` | 동적 테이블 조회 | 페이징, 검색, 정렬, 필터 |
|
|
||||||
| `dataApi.getJoinedData()` | 2개 테이블 조인 | Entity 조인, 필터링, 중복제거 |
|
|
||||||
| `entityJoinApi.getTableDataWithJoins()` | Entity 조인 전용 | ID->이름 자동 변환 |
|
|
||||||
| `dataApi.createRecord/updateRecord/deleteRecord()` | 동적 CRUD | - |
|
|
||||||
| `dataApi.upsertGroupedRecords()` | 그룹 UPSERT | - |
|
|
||||||
| `dashboardApi.executeQuery()` | SELECT SQL 직접 실행 | 집계/복합조인용 |
|
|
||||||
| `dashboardApi.getTableSchema()` | 테이블/컬럼 목록 | 설정 패널 드롭다운용 |
|
|
||||||
|
|
||||||
**백엔드 신규 개발 불필요** - 기존 API만으로 모든 데이터 연동 가능
|
|
||||||
|
|
||||||
### useDataSource의 API 선택 전략
|
|
||||||
|
|
||||||
```
|
|
||||||
단순 조회 (조인/집계 없음) -> dataApi.getTableData() 또는 entityJoinApi
|
|
||||||
2개 테이블 조인 -> dataApi.getJoinedData()
|
|
||||||
3개+ 테이블 조인 또는 집계 -> DataSourceConfig를 SQL로 변환 -> dashboardApi.executeQuery()
|
|
||||||
CRUD -> dataApi.createRecord/updateRecord/deleteRecord()
|
|
||||||
```
|
|
||||||
|
|
||||||
### POP 전용 훅 분리 (2026-02-09 결정)
|
|
||||||
|
|
||||||
데스크탑과의 완전 분리를 위해 POP 전용 훅은 별도 폴더:
|
|
||||||
- `frontend/hooks/pop/usePopEvent.ts` (POP 전용)
|
|
||||||
- `frontend/hooks/pop/useDataSource.ts` (POP 전용)
|
|
||||||
|
|
||||||
## 기존 시스템 호환성 검증 결과 (v8.0 추가)
|
|
||||||
|
|
||||||
v8.0에서 추가된 모달 설계 방식에 대해 기존 시스템과의 호환성을 검증한 결과:
|
|
||||||
|
|
||||||
### DB 스키마 (변경 불필요)
|
|
||||||
|
|
||||||
| 테이블 | 현재 구조 | 호환성 |
|
|
||||||
|--------|-----------|--------|
|
|
||||||
| screen_layouts_v2 | layout_data JSONB + screen_id + company_code + layer_id | modalConfig를 컴포넌트 overrides에 포함하면 됨 |
|
|
||||||
| screen_layouts_pop | 동일 구조 (POP 전용) | 모달 화면도 별도 screen_id로 저장 가능 |
|
|
||||||
|
|
||||||
- layout_data가 JSONB 타입이므로 어떤 JSON 구조든 저장 가능
|
|
||||||
- 모달 화면을 별도 screen_id로 만들어도 기존 UNIQUE(screen_id, company_code, layer_id) 제약조건과 충돌 없음
|
|
||||||
- DB 마이그레이션 불필요
|
|
||||||
|
|
||||||
### 백엔드 API (변경 불필요)
|
|
||||||
|
|
||||||
| API | 엔드포인트 | 호환성 |
|
|
||||||
|-----|-----------|--------|
|
|
||||||
| POP 레이아웃 저장 | POST /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 저장 |
|
|
||||||
| POP 레이아웃 조회 | GET /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 조회 |
|
|
||||||
| 연결 모달 감지 | detectLinkedModals(screenId) | 화면 간 참조 관계 추적에 활용 |
|
|
||||||
|
|
||||||
### 프론트엔드 (참고 패턴 존재)
|
|
||||||
|
|
||||||
| 기존 기능 | 위치 | 활용 방안 |
|
|
||||||
|-----------|------|-----------|
|
|
||||||
| TabsWidget screenId 참조 | frontend/components/screen/widgets/TabsWidget.tsx | 같은 패턴으로 모달에서 외부 화면 로드 |
|
|
||||||
| TabsConfigPanel | frontend/components/screen/config-panels/TabsConfigPanel.tsx | pop-lookup 설정 패널의 모달 화면 선택 UI 참조 |
|
|
||||||
| ScreenDesigner 탭 내부 컴포넌트 | frontend/components/screen/ScreenDesigner.tsx | 모달 내부 컴포넌트 편집 패턴 참조 |
|
|
||||||
|
|
||||||
### 결론
|
|
||||||
|
|
||||||
- DB 마이그레이션: 불필요
|
|
||||||
- 백엔드 변경: 불필요
|
|
||||||
- 프론트엔드: pop-lookup 컴포넌트 구현 시 기존 TabsWidget의 screenId 참조 패턴을 그대로 차용
|
|
||||||
- 새로운 API: 불필요 (기존 saveLayoutPop/getLayoutPop로 충분)
|
|
||||||
|
|
||||||
## 참고 파일
|
|
||||||
|
|
||||||
- 레지스트리: `frontend/lib/registry/PopComponentRegistry.ts`
|
|
||||||
- 기존 텍스트 컴포넌트: `frontend/lib/registry/pop-components/pop-text.tsx`
|
|
||||||
- 공통 스타일 타입: `frontend/lib/registry/pop-components/types.ts`
|
|
||||||
- POP 타입 정의: `frontend/components/pop/designer/types/pop-layout.ts`
|
|
||||||
- 기존 스펙 (v4): `popdocs/components-spec.md`
|
|
||||||
- 탭 위젯 (모달 참조 패턴): `frontend/components/screen/widgets/TabsWidget.tsx`
|
|
||||||
- POP 레이아웃 API: `frontend/lib/api/screen.ts` (saveLayoutPop, getLayoutPop)
|
|
||||||
- 백엔드 화면관리: `backend-node/src/controllers/screenManagementController.ts`
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# 프로젝트 상태 추적
|
|
||||||
|
|
||||||
> **최종 업데이트**: 2026-02-11
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 현재 진행 중
|
|
||||||
|
|
||||||
### pop-dashboard 스타일 정리
|
|
||||||
**상태**: 코딩 완료, 브라우저 확인 대기
|
|
||||||
**계획서**: [popdocs/PLAN.md](./popdocs/PLAN.md)
|
|
||||||
**내용**: 글자 크기 커스텀 제거 + 라벨 정렬만 유지 + stale closure 수정
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 다음 작업
|
|
||||||
|
|
||||||
| 순서 | 작업 | 상태 |
|
|
||||||
|------|------|------|
|
|
||||||
| 1 | pop-card-list 입력 필드/계산 필드 구조 개편 (PLAN.MD 참고) | [ ] 코딩 대기 |
|
|
||||||
| 2 | pop-card-list 담기 버튼 독립화 (보류) | [ ] 대기 |
|
|
||||||
| 3 | pop-card-list 반응형 표시 런타임 적용 | [ ] 대기 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 완료된 작업 (최근)
|
|
||||||
|
|
||||||
| 날짜 | 작업 | 비고 |
|
|
||||||
|------|------|------|
|
|
||||||
| 2026-02-11 | 대시보드 스타일 정리 | FONT_SIZE_PX/글자 크기 Select 삭제, ItemStyleConfig -> labelAlign만, stale closure 수정 |
|
|
||||||
| 2026-02-10 | 디자이너 캔버스 UX 개선 | 헤더 제거, 실제 데이터 렌더링, 컴포넌트 목록 |
|
|
||||||
| 2026-02-10 | 차트/게이지/네비게이션/정렬 디자인 개선 | CartesianGrid, abbreviateNumber, 오버레이 화살표/인디케이터 |
|
|
||||||
| 2026-02-10 | 대시보드 4가지 아이템 모드 완성 | groupBy UI, xAxisColumn, 통계카드 카테고리, 필터 버그 수정 |
|
|
||||||
| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 |
|
|
||||||
| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent |
|
|
||||||
| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 알려진 이슈
|
|
||||||
|
|
||||||
| # | 이슈 | 심각도 | 상태 |
|
|
||||||
|---|------|--------|------|
|
|
||||||
| 1 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 |
|
|
||||||
| 2 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 |
|
|
||||||
| 3 | 기존 저장 데이터의 `itemStyle.align`이 `labelAlign`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 |
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
|
|
||||||
=== 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
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
|
|
||||||
=== 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
|
|
||||||
@@ -3,25 +3,26 @@
|
|||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
#
|
#
|
||||||
# ⚠️ 주의: 이 파일은 Git에 커밋됩니다!
|
# ⚠️ 주의: 이 파일은 Git에 커밋됩니다!
|
||||||
# 팀원들이 동일한 API 키를 사용합니다.
|
# 실제 API 키는 .env 파일에 설정하세요.
|
||||||
|
# 여기에는 키 형식 예시만 기록합니다.
|
||||||
#
|
#
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
# 한국은행 환율 API 키
|
# 한국은행 환율 API 키
|
||||||
# 발급: https://www.bok.or.kr/portal/openapi/OpenApiGuide.do
|
# 발급: https://www.bok.or.kr/portal/openapi/OpenApiGuide.do
|
||||||
BOK_API_KEY=OXIGPQXH68NUKVKL5KT9
|
BOK_API_KEY=your_bok_api_key_here
|
||||||
|
|
||||||
# 기상청 API Hub 키
|
# 기상청 API Hub 키
|
||||||
# 발급: https://apihub.kma.go.kr/
|
# 발급: https://apihub.kma.go.kr/
|
||||||
KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
KMA_API_KEY=your_kma_api_key_here
|
||||||
|
|
||||||
# ITS 국가교통정보센터 API 키
|
# ITS 국가교통정보센터 API 키
|
||||||
# 발급: https://www.its.go.kr/
|
# 발급: https://www.its.go.kr/
|
||||||
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
ITS_API_KEY=your_its_api_key_here
|
||||||
|
|
||||||
# 한국도로공사 OpenOASIS API 키
|
# 한국도로공사 OpenOASIS API 키
|
||||||
# 발급: https://data.ex.co.kr/ (OpenOASIS 신청)
|
# 발급: https://data.ex.co.kr/ (OpenOASIS 신청)
|
||||||
EXWAY_API_KEY=7820214492
|
EXWAY_API_KEY=your_exway_api_key_here
|
||||||
|
|
||||||
# ExchangeRate API 키 (백업용, 선택사항)
|
# ExchangeRate API 키 (백업용, 선택사항)
|
||||||
# 발급: https://www.exchangerate-api.com/
|
# 발급: https://www.exchangerate-api.com/
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
### ✅ 작동 중인 API
|
### ✅ 작동 중인 API
|
||||||
|
|
||||||
1. **기상청 특보 API** (완벽 작동!)
|
1. **기상청 특보 API** (완벽 작동!)
|
||||||
- API 키: `ogdXr2e9T4iHV69nvV-IwA`
|
- API 키: `${KMA_API_KEY}`
|
||||||
- 상태: ✅ 14건 실시간 특보 수신 중
|
- 상태: ✅ 14건 실시간 특보 수신 중
|
||||||
- 제공 데이터: 대설/강풍/한파/태풍/폭염 특보
|
- 제공 데이터: 대설/강풍/한파/태풍/폭염 특보
|
||||||
|
|
||||||
2. **한국은행 환율 API** (완벽 작동!)
|
2. **한국은행 환율 API** (완벽 작동!)
|
||||||
- API 키: `OXIGPQXH68NUKVKL5KT9`
|
- API 키: `${BOK_API_KEY}`
|
||||||
- 상태: ✅ 환율 위젯 작동 중
|
- 상태: ✅ 환율 위젯 작동 중
|
||||||
|
|
||||||
### ⚠️ 더미 데이터 사용 중
|
### ⚠️ 더미 데이터 사용 중
|
||||||
@@ -59,7 +59,7 @@ docker restart pms-backend-mac
|
|||||||
|
|
||||||
### 발급된 키
|
### 발급된 키
|
||||||
```
|
```
|
||||||
EXWAY_API_KEY=7820214492
|
EXWAY_API_KEY=${EXWAY_API_KEY}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 문제 상황
|
### 문제 상황
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
## ✅ 완벽 작동 중
|
## ✅ 완벽 작동 중
|
||||||
|
|
||||||
### 1. 기상청 API Hub
|
### 1. 기상청 API Hub
|
||||||
- **API 키**: `ogdXr2e9T4iHV69nvV-IwA`
|
- **API 키**: `${KMA_API_KEY}`
|
||||||
- **상태**: ✅ 14건 실시간 특보 수신 중
|
- **상태**: ✅ 14건 실시간 특보 수신 중
|
||||||
- **제공 데이터**: 대설/강풍/한파/태풍/폭염 특보
|
- **제공 데이터**: 대설/강풍/한파/태풍/폭염 특보
|
||||||
- **코드 위치**: `backend-node/src/services/riskAlertService.ts`
|
- **코드 위치**: `backend-node/src/services/riskAlertService.ts`
|
||||||
|
|
||||||
### 2. 한국은행 환율 API
|
### 2. 한국은행 환율 API
|
||||||
- **API 키**: `OXIGPQXH68NUKVKL5KT9`
|
- **API 키**: `${BOK_API_KEY}`
|
||||||
- **상태**: ✅ 환율 위젯 작동 중
|
- **상태**: ✅ 환율 위젯 작동 중
|
||||||
- **제공 데이터**: USD/EUR/JPY/CNY 환율
|
- **제공 데이터**: USD/EUR/JPY/CNY 환율
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
## ⚠️ 연동 대기 중
|
## ⚠️ 연동 대기 중
|
||||||
|
|
||||||
### 3. 한국도로공사 OpenOASIS API
|
### 3. 한국도로공사 OpenOASIS API
|
||||||
- **API 키**: `7820214492`
|
- **API 키**: `${EXWAY_API_KEY}`
|
||||||
- **상태**: ❌ 엔드포인트 URL 불명
|
- **상태**: ❌ 엔드포인트 URL 불명
|
||||||
- **문제**:
|
- **문제**:
|
||||||
- 발급 이메일에 사용법 없음
|
- 발급 이메일에 사용법 없음
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
시스템 장애: 070-8656-8771
|
시스템 장애: 070-8656-8771
|
||||||
|
|
||||||
문의 내용:
|
문의 내용:
|
||||||
"OpenOASIS API 인증키(7820214492)를 발급받았는데
|
"OpenOASIS API 인증키(${EXWAY_API_KEY})를 발급받았는데
|
||||||
사용 방법과 엔드포인트 URL을 알려주세요.
|
사용 방법과 엔드포인트 URL을 알려주세요.
|
||||||
- 돌발상황정보 API
|
- 돌발상황정보 API
|
||||||
- 교통사고 정보
|
- 교통사고 정보
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 4. 국토교통부 ITS API
|
### 4. 국토교통부 ITS API
|
||||||
- **API 키**: `d6b9befec3114d648284674b8fddcc32`
|
- **API 키**: `${ITS_API_KEY}`
|
||||||
- **상태**: ❌ 엔드포인트 URL 불명
|
- **상태**: ❌ 엔드포인트 URL 불명
|
||||||
- **승인 API**:
|
- **승인 API**:
|
||||||
- 교통소통정보
|
- 교통소통정보
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
이메일: its@ex.co.kr
|
이메일: its@ex.co.kr
|
||||||
|
|
||||||
문의 내용:
|
문의 내용:
|
||||||
"ITS API 인증키(d6b9befec3114d648284674b8fddcc32)를
|
"ITS API 인증키(${ITS_API_KEY})를
|
||||||
발급받았는데 매뉴얼에 엔드포인트 URL이 없습니다.
|
발급받았는데 매뉴얼에 엔드포인트 URL이 없습니다.
|
||||||
돌발상황정보 API의 정확한 URL과 파라미터를
|
돌발상황정보 API의 정확한 URL과 파라미터를
|
||||||
알려주세요."
|
알려주세요."
|
||||||
@@ -88,8 +88,8 @@
|
|||||||
### 연동 방법
|
### 연동 방법
|
||||||
```bash
|
```bash
|
||||||
# .env 파일에 추가
|
# .env 파일에 추가
|
||||||
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
ITS_API_KEY=${ITS_API_KEY}
|
||||||
EXWAY_API_KEY=7820214492
|
EXWAY_API_KEY=${EXWAY_API_KEY}
|
||||||
|
|
||||||
# 백엔드 재시작
|
# 백엔드 재시작
|
||||||
docker restart pms-backend-mac
|
docker restart pms-backend-mac
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ npm install
|
|||||||
`.env` 파일을 생성하고 다음 내용을 추가하세요:
|
`.env` 파일을 생성하고 다음 내용을 추가하세요:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
DATABASE_URL="postgresql://postgres:ph0909!!@39.117.244.52:11132/plm"
|
DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@YOUR_HOST:YOUR_PORT/YOUR_DB"
|
||||||
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
|
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
|
||||||
JWT_EXPIRES_IN="24h"
|
JWT_EXPIRES_IN="24h"
|
||||||
PORT=8080
|
PORT=8080
|
||||||
|
|||||||
@@ -19,19 +19,19 @@ cp .env.shared .env
|
|||||||
|
|
||||||
### ✅ 한국은행 환율 API
|
### ✅ 한국은행 환율 API
|
||||||
- 용도: 환율 정보 조회
|
- 용도: 환율 정보 조회
|
||||||
- 키: `OXIGPQXH68NUKVKL5KT9`
|
- 키: `${BOK_API_KEY}`
|
||||||
|
|
||||||
### ✅ 기상청 API Hub
|
### ✅ 기상청 API Hub
|
||||||
- 용도: 날씨특보, 기상정보
|
- 용도: 날씨특보, 기상정보
|
||||||
- 키: `ogdXr2e9T4iHV69nvV-IwA`
|
- 키: `${KMA_API_KEY}`
|
||||||
|
|
||||||
### ✅ ITS 국가교통정보센터
|
### ✅ ITS 국가교통정보센터
|
||||||
- 용도: 교통사고, 도로공사 정보
|
- 용도: 교통사고, 도로공사 정보
|
||||||
- 키: `d6b9befec3114d648284674b8fddcc32`
|
- 키: `${ITS_API_KEY}`
|
||||||
|
|
||||||
### ✅ 한국도로공사 OpenOASIS
|
### ✅ 한국도로공사 OpenOASIS
|
||||||
- 용도: 고속도로 교통정보
|
- 용도: 고속도로 교통정보
|
||||||
- 키: `7820214492`
|
- 키: `${EXWAY_API_KEY}`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
const { PrismaClient } = require("@prisma/client");
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function addButtonWebType() {
|
|
||||||
try {
|
|
||||||
console.log("🔍 버튼 웹타입 확인 중...");
|
|
||||||
|
|
||||||
// 기존 button 웹타입 확인
|
|
||||||
const existingButton = await prisma.web_type_standards.findUnique({
|
|
||||||
where: { web_type: "button" },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingButton) {
|
|
||||||
console.log("✅ 버튼 웹타입이 이미 존재합니다.");
|
|
||||||
console.log("📄 기존 설정:", JSON.stringify(existingButton, null, 2));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("➕ 버튼 웹타입 추가 중...");
|
|
||||||
|
|
||||||
// 버튼 웹타입 추가
|
|
||||||
const buttonWebType = await prisma.web_type_standards.create({
|
|
||||||
data: {
|
|
||||||
web_type: "button",
|
|
||||||
type_name: "버튼",
|
|
||||||
type_name_eng: "Button",
|
|
||||||
description: "클릭 가능한 버튼 컴포넌트",
|
|
||||||
category: "action",
|
|
||||||
component_name: "ButtonWidget",
|
|
||||||
config_panel: "ButtonConfigPanel",
|
|
||||||
default_config: {
|
|
||||||
actionType: "custom",
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
sort_order: 100,
|
|
||||||
is_active: "Y",
|
|
||||||
created_by: "system",
|
|
||||||
updated_by: "system",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("✅ 버튼 웹타입이 성공적으로 추가되었습니다!");
|
|
||||||
console.log("📄 추가된 설정:", JSON.stringify(buttonWebType, null, 2));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 버튼 웹타입 추가 실패:", error);
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addButtonWebType();
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
const { PrismaClient } = require("@prisma/client");
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function addDataMappingColumn() {
|
|
||||||
try {
|
|
||||||
console.log(
|
|
||||||
"🔄 external_call_configs 테이블에 data_mapping_config 컬럼 추가 중..."
|
|
||||||
);
|
|
||||||
|
|
||||||
// data_mapping_config JSONB 컬럼 추가
|
|
||||||
await prisma.$executeRaw`
|
|
||||||
ALTER TABLE external_call_configs
|
|
||||||
ADD COLUMN IF NOT EXISTS data_mapping_config JSONB
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log("✅ data_mapping_config 컬럼이 성공적으로 추가되었습니다.");
|
|
||||||
|
|
||||||
// 기존 레코드에 기본값 설정
|
|
||||||
await prisma.$executeRaw`
|
|
||||||
UPDATE external_call_configs
|
|
||||||
SET data_mapping_config = '{"direction": "none"}'::jsonb
|
|
||||||
WHERE data_mapping_config IS NULL
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log("✅ 기존 레코드에 기본값이 설정되었습니다.");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 컬럼 추가 실패:", error);
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addDataMappingColumn();
|
|
||||||
@@ -27,11 +27,11 @@ async function addExternalDbConnection() {
|
|||||||
name: "운영_외부_PostgreSQL",
|
name: "운영_외부_PostgreSQL",
|
||||||
description: "운영용 외부 PostgreSQL 데이터베이스",
|
description: "운영용 외부 PostgreSQL 데이터베이스",
|
||||||
dbType: "postgresql",
|
dbType: "postgresql",
|
||||||
host: "39.117.244.52",
|
host: process.env.EXT_DB_HOST || "localhost",
|
||||||
port: 11132,
|
port: parseInt(process.env.EXT_DB_PORT || "5432"),
|
||||||
databaseName: "plm",
|
databaseName: process.env.EXT_DB_NAME || "vexplor_dev",
|
||||||
username: "postgres",
|
username: process.env.EXT_DB_USER || "postgres",
|
||||||
password: "ph0909!!", // 이 값은 암호화되어 저장됩니다
|
password: process.env.EXT_DB_PASSWORD || "", // 환경변수로 전달
|
||||||
sslEnabled: false,
|
sslEnabled: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
const { PrismaClient } = require("@prisma/client");
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function addMissingColumns() {
|
|
||||||
try {
|
|
||||||
console.log("🔄 누락된 컬럼들을 screen_layouts 테이블에 추가 중...");
|
|
||||||
|
|
||||||
// layout_type 컬럼 추가
|
|
||||||
try {
|
|
||||||
await prisma.$executeRaw`
|
|
||||||
ALTER TABLE screen_layouts
|
|
||||||
ADD COLUMN IF NOT EXISTS layout_type VARCHAR(50);
|
|
||||||
`;
|
|
||||||
console.log("✅ layout_type 컬럼 추가 완료");
|
|
||||||
} catch (error) {
|
|
||||||
console.log(
|
|
||||||
"ℹ️ layout_type 컬럼이 이미 존재하거나 추가 중 오류:",
|
|
||||||
error.message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// layout_config 컬럼 추가
|
|
||||||
try {
|
|
||||||
await prisma.$executeRaw`
|
|
||||||
ALTER TABLE screen_layouts
|
|
||||||
ADD COLUMN IF NOT EXISTS layout_config JSONB;
|
|
||||||
`;
|
|
||||||
console.log("✅ layout_config 컬럼 추가 완료");
|
|
||||||
} catch (error) {
|
|
||||||
console.log(
|
|
||||||
"ℹ️ layout_config 컬럼이 이미 존재하거나 추가 중 오류:",
|
|
||||||
error.message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// zones_config 컬럼 추가
|
|
||||||
try {
|
|
||||||
await prisma.$executeRaw`
|
|
||||||
ALTER TABLE screen_layouts
|
|
||||||
ADD COLUMN IF NOT EXISTS zones_config JSONB;
|
|
||||||
`;
|
|
||||||
console.log("✅ zones_config 컬럼 추가 완료");
|
|
||||||
} catch (error) {
|
|
||||||
console.log(
|
|
||||||
"ℹ️ zones_config 컬럼이 이미 존재하거나 추가 중 오류:",
|
|
||||||
error.message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// zone_id 컬럼 추가
|
|
||||||
try {
|
|
||||||
await prisma.$executeRaw`
|
|
||||||
ALTER TABLE screen_layouts
|
|
||||||
ADD COLUMN IF NOT EXISTS zone_id VARCHAR(100);
|
|
||||||
`;
|
|
||||||
console.log("✅ zone_id 컬럼 추가 완료");
|
|
||||||
} catch (error) {
|
|
||||||
console.log(
|
|
||||||
"ℹ️ zone_id 컬럼이 이미 존재하거나 추가 중 오류:",
|
|
||||||
error.message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 인덱스 생성 (성능 향상)
|
|
||||||
try {
|
|
||||||
await prisma.$executeRaw`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_screen_layouts_layout_type
|
|
||||||
ON screen_layouts(layout_type);
|
|
||||||
`;
|
|
||||||
console.log("✅ layout_type 인덱스 생성 완료");
|
|
||||||
} catch (error) {
|
|
||||||
console.log("ℹ️ layout_type 인덱스 생성 중 오류:", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.$executeRaw`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_screen_layouts_zone_id
|
|
||||||
ON screen_layouts(zone_id);
|
|
||||||
`;
|
|
||||||
console.log("✅ zone_id 인덱스 생성 완료");
|
|
||||||
} catch (error) {
|
|
||||||
console.log("ℹ️ zone_id 인덱스 생성 중 오류:", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 최종 테이블 구조 확인
|
|
||||||
const columns = await prisma.$queryRaw`
|
|
||||||
SELECT column_name, data_type, is_nullable, column_default
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'screen_layouts'
|
|
||||||
ORDER BY ordinal_position
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log("\n📋 screen_layouts 테이블 최종 구조:");
|
|
||||||
console.table(columns);
|
|
||||||
|
|
||||||
console.log("\n🎉 모든 누락된 컬럼 추가 작업이 완료되었습니다!");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 컬럼 추가 중 오류 발생:", error);
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addMissingColumns();
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
/**
|
|
||||||
* 탑씰(company_7) 버튼 스타일 일괄 변경 스크립트
|
|
||||||
*
|
|
||||||
* 사용법:
|
|
||||||
* npx ts-node scripts/btn-bulk-update-company7.ts --test # 1건만 테스트 (ROLLBACK)
|
|
||||||
* npx ts-node scripts/btn-bulk-update-company7.ts --run # 전체 실행 (COMMIT)
|
|
||||||
* npx ts-node scripts/btn-bulk-update-company7.ts --backup # 백업 테이블만 생성
|
|
||||||
* npx ts-node scripts/btn-bulk-update-company7.ts --restore # 백업에서 원복
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Pool } from "pg";
|
|
||||||
|
|
||||||
// ── 배포 DB 연결 ──
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString:
|
|
||||||
"postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor",
|
|
||||||
});
|
|
||||||
|
|
||||||
const COMPANY_CODE = "COMPANY_7";
|
|
||||||
const BACKUP_TABLE = "screen_layouts_v2_backup_20260313";
|
|
||||||
|
|
||||||
// ── 액션별 기본 아이콘 매핑 (frontend/lib/button-icon-map.tsx 기준) ──
|
|
||||||
const actionIconMap: Record<string, string> = {
|
|
||||||
save: "Check",
|
|
||||||
delete: "Trash2",
|
|
||||||
edit: "Pencil",
|
|
||||||
navigate: "ArrowRight",
|
|
||||||
modal: "Maximize2",
|
|
||||||
transferData: "SendHorizontal",
|
|
||||||
excel_download: "Download",
|
|
||||||
excel_upload: "Upload",
|
|
||||||
quickInsert: "Zap",
|
|
||||||
control: "Settings",
|
|
||||||
barcode_scan: "ScanLine",
|
|
||||||
operation_control: "Truck",
|
|
||||||
event: "Send",
|
|
||||||
copy: "Copy",
|
|
||||||
};
|
|
||||||
const FALLBACK_ICON = "SquareMousePointer";
|
|
||||||
|
|
||||||
function getIconForAction(actionType?: string): string {
|
|
||||||
if (actionType && actionIconMap[actionType]) {
|
|
||||||
return actionIconMap[actionType];
|
|
||||||
}
|
|
||||||
return FALLBACK_ICON;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 버튼 컴포넌트인지 판별 (최상위 + 탭 내부 둘 다 지원) ──
|
|
||||||
function isTopLevelButton(comp: any): boolean {
|
|
||||||
return (
|
|
||||||
comp.url?.includes("v2-button-primary") ||
|
|
||||||
comp.overrides?.type === "v2-button-primary"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTabChildButton(comp: any): boolean {
|
|
||||||
return comp.componentType === "v2-button-primary";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isButtonComponent(comp: any): boolean {
|
|
||||||
return isTopLevelButton(comp) || isTabChildButton(comp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 탭 위젯인지 판별 ──
|
|
||||||
function isTabsWidget(comp: any): boolean {
|
|
||||||
return (
|
|
||||||
comp.url?.includes("v2-tabs-widget") ||
|
|
||||||
comp.overrides?.type === "v2-tabs-widget"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 버튼 스타일 변경 (최상위 버튼용: overrides 사용) ──
|
|
||||||
function applyButtonStyle(config: any, actionType: string | undefined) {
|
|
||||||
const iconName = getIconForAction(actionType);
|
|
||||||
|
|
||||||
config.displayMode = "icon-text";
|
|
||||||
|
|
||||||
config.icon = {
|
|
||||||
name: iconName,
|
|
||||||
type: "lucide",
|
|
||||||
size: "보통",
|
|
||||||
...(config.icon?.color ? { color: config.icon.color } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
config.iconTextPosition = "right";
|
|
||||||
config.iconGap = 6;
|
|
||||||
|
|
||||||
if (!config.style) config.style = {};
|
|
||||||
delete config.style.width; // 레거시 하드코딩 너비 제거 (size.width만 사용)
|
|
||||||
config.style.borderRadius = "8px";
|
|
||||||
config.style.labelColor = "#FFFFFF";
|
|
||||||
config.style.fontSize = "12px";
|
|
||||||
config.style.fontWeight = "normal";
|
|
||||||
config.style.labelTextAlign = "left";
|
|
||||||
|
|
||||||
if (actionType === "delete") {
|
|
||||||
config.style.backgroundColor = "#F04544";
|
|
||||||
} else if (actionType === "excel_upload" || actionType === "excel_download") {
|
|
||||||
config.style.backgroundColor = "#212121";
|
|
||||||
} else {
|
|
||||||
config.style.backgroundColor = "#3B83F6";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateButtonStyle(comp: any): boolean {
|
|
||||||
if (isTopLevelButton(comp)) {
|
|
||||||
const overrides = comp.overrides || {};
|
|
||||||
const actionType = overrides.action?.type;
|
|
||||||
|
|
||||||
if (!comp.size) comp.size = {};
|
|
||||||
comp.size.height = 40;
|
|
||||||
|
|
||||||
applyButtonStyle(overrides, actionType);
|
|
||||||
comp.overrides = overrides;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTabChildButton(comp)) {
|
|
||||||
const config = comp.componentConfig || {};
|
|
||||||
const actionType = config.action?.type;
|
|
||||||
|
|
||||||
if (!comp.size) comp.size = {};
|
|
||||||
comp.size.height = 40;
|
|
||||||
|
|
||||||
applyButtonStyle(config, actionType);
|
|
||||||
comp.componentConfig = config;
|
|
||||||
|
|
||||||
// 탭 내부 버튼은 렌더러가 comp.style (최상위)에서 스타일을 읽음
|
|
||||||
if (!comp.style) comp.style = {};
|
|
||||||
comp.style.borderRadius = "8px";
|
|
||||||
comp.style.labelColor = "#FFFFFF";
|
|
||||||
comp.style.fontSize = "12px";
|
|
||||||
comp.style.fontWeight = "normal";
|
|
||||||
comp.style.labelTextAlign = "left";
|
|
||||||
comp.style.backgroundColor = config.style.backgroundColor;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 백업 테이블 생성 ──
|
|
||||||
async function createBackup() {
|
|
||||||
console.log(`\n=== 백업 테이블 생성: ${BACKUP_TABLE} ===`);
|
|
||||||
|
|
||||||
const exists = await pool.query(
|
|
||||||
`SELECT to_regclass($1) AS tbl`,
|
|
||||||
[BACKUP_TABLE],
|
|
||||||
);
|
|
||||||
if (exists.rows[0].tbl) {
|
|
||||||
console.log(`백업 테이블이 이미 존재합니다: ${BACKUP_TABLE}`);
|
|
||||||
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
|
|
||||||
console.log(`기존 백업 레코드 수: ${count.rows[0].count}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await pool.query(
|
|
||||||
`CREATE TABLE ${BACKUP_TABLE} AS
|
|
||||||
SELECT * FROM screen_layouts_v2
|
|
||||||
WHERE company_code = $1`,
|
|
||||||
[COMPANY_CODE],
|
|
||||||
);
|
|
||||||
|
|
||||||
const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`);
|
|
||||||
console.log(`백업 완료. 레코드 수: ${count.rows[0].count}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 백업에서 원복 ──
|
|
||||||
async function restoreFromBackup() {
|
|
||||||
console.log(`\n=== 백업에서 원복: ${BACKUP_TABLE} ===`);
|
|
||||||
|
|
||||||
const result = await pool.query(
|
|
||||||
`UPDATE screen_layouts_v2 AS target
|
|
||||||
SET layout_data = backup.layout_data,
|
|
||||||
updated_at = backup.updated_at
|
|
||||||
FROM ${BACKUP_TABLE} AS backup
|
|
||||||
WHERE target.screen_id = backup.screen_id
|
|
||||||
AND target.company_code = backup.company_code
|
|
||||||
AND target.layer_id = backup.layer_id`,
|
|
||||||
);
|
|
||||||
console.log(`원복 완료. 변경된 레코드 수: ${result.rowCount}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 메인: 버튼 일괄 변경 ──
|
|
||||||
async function updateButtons(testMode: boolean) {
|
|
||||||
const modeLabel = testMode ? "테스트 (1건, ROLLBACK)" : "전체 실행 (COMMIT)";
|
|
||||||
console.log(`\n=== 버튼 일괄 변경 시작 [${modeLabel}] ===`);
|
|
||||||
|
|
||||||
// company_7 레코드 조회
|
|
||||||
const rows = await pool.query(
|
|
||||||
`SELECT screen_id, layer_id, company_code, layout_data
|
|
||||||
FROM screen_layouts_v2
|
|
||||||
WHERE company_code = $1
|
|
||||||
ORDER BY screen_id, layer_id`,
|
|
||||||
[COMPANY_CODE],
|
|
||||||
);
|
|
||||||
console.log(`대상 레코드 수: ${rows.rowCount}`);
|
|
||||||
|
|
||||||
if (!rows.rowCount) {
|
|
||||||
console.log("변경할 레코드가 없습니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query("BEGIN");
|
|
||||||
|
|
||||||
let totalUpdated = 0;
|
|
||||||
let totalButtons = 0;
|
|
||||||
const targetRows = testMode ? [rows.rows[0]] : rows.rows;
|
|
||||||
|
|
||||||
for (const row of targetRows) {
|
|
||||||
const layoutData = row.layout_data;
|
|
||||||
if (!layoutData?.components || !Array.isArray(layoutData.components)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let buttonsInRow = 0;
|
|
||||||
for (const comp of layoutData.components) {
|
|
||||||
// 최상위 버튼 처리
|
|
||||||
if (updateButtonStyle(comp)) {
|
|
||||||
buttonsInRow++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 탭 위젯 내부 버튼 처리
|
|
||||||
if (isTabsWidget(comp)) {
|
|
||||||
const tabs = comp.overrides?.tabs || [];
|
|
||||||
for (const tab of tabs) {
|
|
||||||
const tabComps = tab.components || [];
|
|
||||||
for (const tabComp of tabComps) {
|
|
||||||
if (updateButtonStyle(tabComp)) {
|
|
||||||
buttonsInRow++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buttonsInRow > 0) {
|
|
||||||
await client.query(
|
|
||||||
`UPDATE screen_layouts_v2
|
|
||||||
SET layout_data = $1, updated_at = NOW()
|
|
||||||
WHERE screen_id = $2 AND company_code = $3 AND layer_id = $4`,
|
|
||||||
[JSON.stringify(layoutData), row.screen_id, row.company_code, row.layer_id],
|
|
||||||
);
|
|
||||||
totalUpdated++;
|
|
||||||
totalButtons += buttonsInRow;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
` screen_id=${row.screen_id}, layer_id=${row.layer_id} → 버튼 ${buttonsInRow}개 변경`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 테스트 모드: 변경 전후 비교를 위해 첫 번째 버튼 출력
|
|
||||||
if (testMode) {
|
|
||||||
const sampleBtn = layoutData.components.find(isButtonComponent);
|
|
||||||
if (sampleBtn) {
|
|
||||||
console.log("\n--- 변경 후 샘플 버튼 ---");
|
|
||||||
console.log(JSON.stringify(sampleBtn, null, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n--- 결과 ---`);
|
|
||||||
console.log(`변경된 레코드: ${totalUpdated}개`);
|
|
||||||
console.log(`변경된 버튼: ${totalButtons}개`);
|
|
||||||
|
|
||||||
if (testMode) {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
console.log("\n[테스트 모드] ROLLBACK 완료. 실제 DB 변경 없음.");
|
|
||||||
} else {
|
|
||||||
await client.query("COMMIT");
|
|
||||||
console.log("\nCOMMIT 완료.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
console.error("\n에러 발생. ROLLBACK 완료.", err);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CLI 진입점 ──
|
|
||||||
async function main() {
|
|
||||||
const arg = process.argv[2];
|
|
||||||
|
|
||||||
if (!arg || !["--test", "--run", "--backup", "--restore"].includes(arg)) {
|
|
||||||
console.log("사용법:");
|
|
||||||
console.log(" --test : 1건 테스트 (ROLLBACK, DB 변경 없음)");
|
|
||||||
console.log(" --run : 전체 실행 (COMMIT)");
|
|
||||||
console.log(" --backup : 백업 테이블 생성");
|
|
||||||
console.log(" --restore : 백업에서 원복");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (arg === "--backup") {
|
|
||||||
await createBackup();
|
|
||||||
} else if (arg === "--restore") {
|
|
||||||
await restoreFromBackup();
|
|
||||||
} else if (arg === "--test") {
|
|
||||||
await createBackup();
|
|
||||||
await updateButtons(true);
|
|
||||||
} else if (arg === "--run") {
|
|
||||||
await createBackup();
|
|
||||||
await updateButtons(false);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("스크립트 실행 실패:", err);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* dashboards 테이블 구조 확인 스크립트
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { Pool } = require('pg');
|
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
|
||||||
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: databaseUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function checkDashboardStructure() {
|
|
||||||
const client = await pool.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('🔍 dashboards 테이블 구조 확인 중...\n');
|
|
||||||
|
|
||||||
// 컬럼 정보 조회
|
|
||||||
const columns = await client.query(`
|
|
||||||
SELECT
|
|
||||||
column_name,
|
|
||||||
data_type,
|
|
||||||
is_nullable,
|
|
||||||
column_default
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'dashboards'
|
|
||||||
ORDER BY ordinal_position
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log('📋 dashboards 테이블 컬럼:\n');
|
|
||||||
columns.rows.forEach((col, index) => {
|
|
||||||
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 샘플 데이터 조회
|
|
||||||
console.log('\n📊 샘플 데이터 (첫 1개):');
|
|
||||||
const sample = await client.query(`
|
|
||||||
SELECT * FROM dashboards LIMIT 1
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (sample.rows.length > 0) {
|
|
||||||
console.log(JSON.stringify(sample.rows[0], null, 2));
|
|
||||||
} else {
|
|
||||||
console.log('❌ 데이터가 없습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// dashboard_elements 테이블도 확인
|
|
||||||
console.log('\n🔍 dashboard_elements 테이블 구조 확인 중...\n');
|
|
||||||
|
|
||||||
const elemColumns = await client.query(`
|
|
||||||
SELECT
|
|
||||||
column_name,
|
|
||||||
data_type,
|
|
||||||
is_nullable
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'dashboard_elements'
|
|
||||||
ORDER BY ordinal_position
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log('📋 dashboard_elements 테이블 컬럼:\n');
|
|
||||||
elemColumns.rows.forEach((col, index) => {
|
|
||||||
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ 오류 발생:', error.message);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkDashboardStructure();
|
|
||||||
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* 데이터베이스 테이블 확인 스크립트
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { Pool } = require('pg');
|
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
|
||||||
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: databaseUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function checkTables() {
|
|
||||||
const client = await pool.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('🔍 데이터베이스 테이블 확인 중...\n');
|
|
||||||
|
|
||||||
// 테이블 목록 조회
|
|
||||||
const result = await client.query(`
|
|
||||||
SELECT table_name
|
|
||||||
FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
ORDER BY table_name
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log(`📊 총 ${result.rows.length}개의 테이블 발견:\n`);
|
|
||||||
result.rows.forEach((row, index) => {
|
|
||||||
console.log(`${index + 1}. ${row.table_name}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// dashboard 관련 테이블 검색
|
|
||||||
console.log('\n🔎 dashboard 관련 테이블:');
|
|
||||||
const dashboardTables = result.rows.filter(row =>
|
|
||||||
row.table_name.toLowerCase().includes('dashboard')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dashboardTables.length === 0) {
|
|
||||||
console.log('❌ dashboard 관련 테이블을 찾을 수 없습니다.');
|
|
||||||
} else {
|
|
||||||
dashboardTables.forEach(row => {
|
|
||||||
console.log(`✅ ${row.table_name}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ 오류 발생:', error.message);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkTables();
|
|
||||||
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
const { PrismaClient } = require("@prisma/client");
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function createComponentTable() {
|
|
||||||
try {
|
|
||||||
console.log("🔧 component_standards 테이블 생성 중...");
|
|
||||||
|
|
||||||
// 테이블 생성 SQL
|
|
||||||
await prisma.$executeRaw`
|
|
||||||
CREATE TABLE IF NOT EXISTS component_standards (
|
|
||||||
component_code VARCHAR(50) PRIMARY KEY,
|
|
||||||
component_name VARCHAR(100) NOT NULL,
|
|
||||||
component_name_eng VARCHAR(100),
|
|
||||||
description TEXT,
|
|
||||||
category VARCHAR(50) NOT NULL,
|
|
||||||
icon_name VARCHAR(50),
|
|
||||||
default_size JSON,
|
|
||||||
component_config JSON NOT NULL,
|
|
||||||
preview_image VARCHAR(255),
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
is_active CHAR(1) DEFAULT 'Y',
|
|
||||||
is_public CHAR(1) DEFAULT 'Y',
|
|
||||||
company_code VARCHAR(50) NOT NULL,
|
|
||||||
created_date TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by VARCHAR(50),
|
|
||||||
updated_date TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_by VARCHAR(50)
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log("✅ component_standards 테이블 생성 완료");
|
|
||||||
|
|
||||||
// 인덱스 생성
|
|
||||||
await prisma.$executeRaw`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_component_standards_category
|
|
||||||
ON component_standards (category)
|
|
||||||
`;
|
|
||||||
|
|
||||||
await prisma.$executeRaw`
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_component_standards_company
|
|
||||||
ON component_standards (company_code)
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log("✅ 인덱스 생성 완료");
|
|
||||||
|
|
||||||
// 테이블 코멘트 추가
|
|
||||||
await prisma.$executeRaw`
|
|
||||||
COMMENT ON TABLE component_standards IS 'UI 컴포넌트 표준 정보를 저장하는 테이블'
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log("✅ 테이블 코멘트 추가 완료");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 테이블 생성 실패:", error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 실행
|
|
||||||
if (require.main === module) {
|
|
||||||
createComponentTable()
|
|
||||||
.then(() => {
|
|
||||||
console.log("🎉 테이블 생성 완료!");
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("💥 테이블 생성 실패:", error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createComponentTable };
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
/**
|
|
||||||
* 레이아웃 표준 데이터 초기화 스크립트
|
|
||||||
* 기본 레이아웃들을 layout_standards 테이블에 삽입합니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { PrismaClient } = require("@prisma/client");
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 기본 레이아웃 데이터
|
|
||||||
const PREDEFINED_LAYOUTS = [
|
|
||||||
{
|
|
||||||
layout_code: "GRID_2X2_001",
|
|
||||||
layout_name: "2x2 그리드",
|
|
||||||
layout_name_eng: "2x2 Grid",
|
|
||||||
description: "2행 2열의 균등한 그리드 레이아웃입니다.",
|
|
||||||
layout_type: "grid",
|
|
||||||
category: "basic",
|
|
||||||
icon_name: "grid",
|
|
||||||
default_size: { width: 800, height: 600 },
|
|
||||||
layout_config: {
|
|
||||||
grid: { rows: 2, columns: 2, gap: 16 },
|
|
||||||
},
|
|
||||||
zones_config: [
|
|
||||||
{
|
|
||||||
id: "zone1",
|
|
||||||
name: "상단 좌측",
|
|
||||||
position: { row: 0, column: 0 },
|
|
||||||
size: { width: "50%", height: "50%" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "zone2",
|
|
||||||
name: "상단 우측",
|
|
||||||
position: { row: 0, column: 1 },
|
|
||||||
size: { width: "50%", height: "50%" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "zone3",
|
|
||||||
name: "하단 좌측",
|
|
||||||
position: { row: 1, column: 0 },
|
|
||||||
size: { width: "50%", height: "50%" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "zone4",
|
|
||||||
name: "하단 우측",
|
|
||||||
position: { row: 1, column: 1 },
|
|
||||||
size: { width: "50%", height: "50%" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sort_order: 1,
|
|
||||||
is_active: "Y",
|
|
||||||
is_public: "Y",
|
|
||||||
company_code: "DEFAULT",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
layout_code: "FORM_TWO_COLUMN_001",
|
|
||||||
layout_name: "2단 폼 레이아웃",
|
|
||||||
layout_name_eng: "Two Column Form",
|
|
||||||
description: "좌우 2단으로 구성된 폼 레이아웃입니다.",
|
|
||||||
layout_type: "grid",
|
|
||||||
category: "form",
|
|
||||||
icon_name: "columns",
|
|
||||||
default_size: { width: 800, height: 400 },
|
|
||||||
layout_config: {
|
|
||||||
grid: { rows: 1, columns: 2, gap: 24 },
|
|
||||||
},
|
|
||||||
zones_config: [
|
|
||||||
{
|
|
||||||
id: "left",
|
|
||||||
name: "좌측 입력 영역",
|
|
||||||
position: { row: 0, column: 0 },
|
|
||||||
size: { width: "50%", height: "100%" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "right",
|
|
||||||
name: "우측 입력 영역",
|
|
||||||
position: { row: 0, column: 1 },
|
|
||||||
size: { width: "50%", height: "100%" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sort_order: 2,
|
|
||||||
is_active: "Y",
|
|
||||||
is_public: "Y",
|
|
||||||
company_code: "DEFAULT",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
layout_code: "FLEXBOX_ROW_001",
|
|
||||||
layout_name: "가로 플렉스박스",
|
|
||||||
layout_name_eng: "Horizontal Flexbox",
|
|
||||||
description: "가로 방향으로 배치되는 플렉스박스 레이아웃입니다.",
|
|
||||||
layout_type: "flexbox",
|
|
||||||
category: "basic",
|
|
||||||
icon_name: "flex",
|
|
||||||
default_size: { width: 800, height: 300 },
|
|
||||||
layout_config: {
|
|
||||||
flexbox: {
|
|
||||||
direction: "row",
|
|
||||||
justify: "flex-start",
|
|
||||||
align: "stretch",
|
|
||||||
wrap: "nowrap",
|
|
||||||
gap: 16,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
zones_config: [
|
|
||||||
{
|
|
||||||
id: "left",
|
|
||||||
name: "좌측 영역",
|
|
||||||
position: {},
|
|
||||||
size: { width: "50%", height: "100%" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "right",
|
|
||||||
name: "우측 영역",
|
|
||||||
position: {},
|
|
||||||
size: { width: "50%", height: "100%" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sort_order: 3,
|
|
||||||
is_active: "Y",
|
|
||||||
is_public: "Y",
|
|
||||||
company_code: "DEFAULT",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
layout_code: "SPLIT_HORIZONTAL_001",
|
|
||||||
layout_name: "수평 분할",
|
|
||||||
layout_name_eng: "Horizontal Split",
|
|
||||||
description: "크기 조절이 가능한 수평 분할 레이아웃입니다.",
|
|
||||||
layout_type: "split",
|
|
||||||
category: "basic",
|
|
||||||
icon_name: "separator-horizontal",
|
|
||||||
default_size: { width: 800, height: 400 },
|
|
||||||
layout_config: {
|
|
||||||
split: {
|
|
||||||
direction: "horizontal",
|
|
||||||
ratio: [50, 50],
|
|
||||||
minSize: [200, 200],
|
|
||||||
resizable: true,
|
|
||||||
splitterSize: 4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
zones_config: [
|
|
||||||
{
|
|
||||||
id: "left",
|
|
||||||
name: "좌측 패널",
|
|
||||||
position: {},
|
|
||||||
size: { width: "50%", height: "100%" },
|
|
||||||
isResizable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "right",
|
|
||||||
name: "우측 패널",
|
|
||||||
position: {},
|
|
||||||
size: { width: "50%", height: "100%" },
|
|
||||||
isResizable: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sort_order: 4,
|
|
||||||
is_active: "Y",
|
|
||||||
is_public: "Y",
|
|
||||||
company_code: "DEFAULT",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
layout_code: "TABS_HORIZONTAL_001",
|
|
||||||
layout_name: "수평 탭",
|
|
||||||
layout_name_eng: "Horizontal Tabs",
|
|
||||||
description: "상단에 탭이 있는 탭 레이아웃입니다.",
|
|
||||||
layout_type: "tabs",
|
|
||||||
category: "navigation",
|
|
||||||
icon_name: "tabs",
|
|
||||||
default_size: { width: 800, height: 500 },
|
|
||||||
layout_config: {
|
|
||||||
tabs: {
|
|
||||||
position: "top",
|
|
||||||
variant: "default",
|
|
||||||
size: "md",
|
|
||||||
defaultTab: "tab1",
|
|
||||||
closable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
zones_config: [
|
|
||||||
{
|
|
||||||
id: "tab1",
|
|
||||||
name: "첫 번째 탭",
|
|
||||||
position: {},
|
|
||||||
size: { width: "100%", height: "100%" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tab2",
|
|
||||||
name: "두 번째 탭",
|
|
||||||
position: {},
|
|
||||||
size: { width: "100%", height: "100%" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tab3",
|
|
||||||
name: "세 번째 탭",
|
|
||||||
position: {},
|
|
||||||
size: { width: "100%", height: "100%" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sort_order: 5,
|
|
||||||
is_active: "Y",
|
|
||||||
is_public: "Y",
|
|
||||||
company_code: "DEFAULT",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
layout_code: "TABLE_WITH_FILTERS_001",
|
|
||||||
layout_name: "필터가 있는 테이블",
|
|
||||||
layout_name_eng: "Table with Filters",
|
|
||||||
description: "상단에 필터가 있고 하단에 테이블이 있는 레이아웃입니다.",
|
|
||||||
layout_type: "flexbox",
|
|
||||||
category: "table",
|
|
||||||
icon_name: "table",
|
|
||||||
default_size: { width: 1000, height: 600 },
|
|
||||||
layout_config: {
|
|
||||||
flexbox: {
|
|
||||||
direction: "column",
|
|
||||||
justify: "flex-start",
|
|
||||||
align: "stretch",
|
|
||||||
wrap: "nowrap",
|
|
||||||
gap: 16,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
zones_config: [
|
|
||||||
{
|
|
||||||
id: "filters",
|
|
||||||
name: "검색 필터",
|
|
||||||
position: {},
|
|
||||||
size: { width: "100%", height: "auto" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "table",
|
|
||||||
name: "데이터 테이블",
|
|
||||||
position: {},
|
|
||||||
size: { width: "100%", height: "1fr" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
sort_order: 6,
|
|
||||||
is_active: "Y",
|
|
||||||
is_public: "Y",
|
|
||||||
company_code: "DEFAULT",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function initializeLayoutStandards() {
|
|
||||||
try {
|
|
||||||
console.log("🏗️ 레이아웃 표준 데이터 초기화 시작...");
|
|
||||||
|
|
||||||
// 기존 데이터 확인
|
|
||||||
const existingLayouts = await prisma.layout_standards.count();
|
|
||||||
if (existingLayouts > 0) {
|
|
||||||
console.log(`⚠️ 이미 ${existingLayouts}개의 레이아웃이 존재합니다.`);
|
|
||||||
console.log(
|
|
||||||
"기존 데이터를 삭제하고 새로 생성하시겠습니까? (기본값: 건너뛰기)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 기존 데이터가 있으면 건너뛰기 (안전을 위해)
|
|
||||||
console.log("💡 기존 데이터를 유지하고 건너뜁니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 데이터 삽입
|
|
||||||
let insertedCount = 0;
|
|
||||||
|
|
||||||
for (const layoutData of PREDEFINED_LAYOUTS) {
|
|
||||||
try {
|
|
||||||
await prisma.layout_standards.create({
|
|
||||||
data: {
|
|
||||||
...layoutData,
|
|
||||||
created_date: new Date(),
|
|
||||||
updated_date: new Date(),
|
|
||||||
created_by: "SYSTEM",
|
|
||||||
updated_by: "SYSTEM",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`✅ ${layoutData.layout_name} 생성 완료`);
|
|
||||||
insertedCount++;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ ${layoutData.layout_name} 생성 실패:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`🎉 레이아웃 표준 데이터 초기화 완료! (${insertedCount}/${PREDEFINED_LAYOUTS.length})`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 레이아웃 표준 데이터 초기화 실패:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스크립트 실행
|
|
||||||
if (require.main === module) {
|
|
||||||
initializeLayoutStandards()
|
|
||||||
.then(() => {
|
|
||||||
console.log("✨ 스크립트 실행 완료");
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("💥 스크립트 실행 실패:", error);
|
|
||||||
process.exit(1);
|
|
||||||
})
|
|
||||||
.finally(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { initializeLayoutStandards };
|
|
||||||
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
/**
|
|
||||||
* 🔥 버튼 제어관리 성능 최적화 인덱스 설치 스크립트
|
|
||||||
*
|
|
||||||
* 사용법:
|
|
||||||
* node scripts/install-dataflow-indexes.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { PrismaClient } = require("@prisma/client");
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function installDataflowIndexes() {
|
|
||||||
try {
|
|
||||||
console.log("🔥 Starting Button Dataflow Performance Optimization...\n");
|
|
||||||
|
|
||||||
// SQL 파일 읽기
|
|
||||||
const sqlFilePath = path.join(
|
|
||||||
__dirname,
|
|
||||||
"../database/migrations/add_button_dataflow_indexes.sql"
|
|
||||||
);
|
|
||||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
|
||||||
|
|
||||||
console.log("📖 Reading SQL migration file...");
|
|
||||||
console.log(`📁 File: ${sqlFilePath}\n`);
|
|
||||||
|
|
||||||
// 데이터베이스 연결 확인
|
|
||||||
console.log("🔍 Checking database connection...");
|
|
||||||
await prisma.$queryRaw`SELECT 1`;
|
|
||||||
console.log("✅ Database connection OK\n");
|
|
||||||
|
|
||||||
// 기존 인덱스 상태 확인
|
|
||||||
console.log("🔍 Checking existing indexes...");
|
|
||||||
const existingIndexes = await prisma.$queryRaw`
|
|
||||||
SELECT indexname, tablename
|
|
||||||
FROM pg_indexes
|
|
||||||
WHERE tablename = 'dataflow_diagrams'
|
|
||||||
AND indexname LIKE 'idx_dataflow%'
|
|
||||||
ORDER BY indexname;
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (existingIndexes.length > 0) {
|
|
||||||
console.log("📋 Existing dataflow indexes:");
|
|
||||||
existingIndexes.forEach((idx) => {
|
|
||||||
console.log(` - ${idx.indexname}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log("📋 No existing dataflow indexes found");
|
|
||||||
}
|
|
||||||
console.log("");
|
|
||||||
|
|
||||||
// 테이블 상태 확인
|
|
||||||
console.log("🔍 Checking dataflow_diagrams table stats...");
|
|
||||||
const tableStats = await prisma.$queryRaw`
|
|
||||||
SELECT
|
|
||||||
COUNT(*) as total_rows,
|
|
||||||
COUNT(*) FILTER (WHERE control IS NOT NULL) as with_control,
|
|
||||||
COUNT(*) FILTER (WHERE plan IS NOT NULL) as with_plan,
|
|
||||||
COUNT(*) FILTER (WHERE category IS NOT NULL) as with_category,
|
|
||||||
COUNT(DISTINCT company_code) as companies
|
|
||||||
FROM dataflow_diagrams;
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (tableStats.length > 0) {
|
|
||||||
const stats = tableStats[0];
|
|
||||||
console.log(`📊 Table Statistics:`);
|
|
||||||
console.log(` - Total rows: ${stats.total_rows}`);
|
|
||||||
console.log(` - With control: ${stats.with_control}`);
|
|
||||||
console.log(` - With plan: ${stats.with_plan}`);
|
|
||||||
console.log(` - With category: ${stats.with_category}`);
|
|
||||||
console.log(` - Companies: ${stats.companies}`);
|
|
||||||
}
|
|
||||||
console.log("");
|
|
||||||
|
|
||||||
// SQL 실행
|
|
||||||
console.log("🚀 Installing performance indexes...");
|
|
||||||
console.log("⏳ This may take a few minutes for large datasets...\n");
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// SQL을 문장별로 나누어 실행 (PostgreSQL 함수 때문에)
|
|
||||||
const sqlStatements = sqlContent
|
|
||||||
.split(/;\s*(?=\n|$)/)
|
|
||||||
.filter(
|
|
||||||
(stmt) =>
|
|
||||||
stmt.trim().length > 0 &&
|
|
||||||
!stmt.trim().startsWith("--") &&
|
|
||||||
!stmt.trim().startsWith("/*")
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 0; i < sqlStatements.length; i++) {
|
|
||||||
const statement = sqlStatements[i].trim();
|
|
||||||
if (statement.length === 0) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// DO 블록이나 복합 문장 처리
|
|
||||||
if (
|
|
||||||
statement.includes("DO $$") ||
|
|
||||||
statement.includes("CREATE OR REPLACE VIEW")
|
|
||||||
) {
|
|
||||||
console.log(
|
|
||||||
`⚡ Executing statement ${i + 1}/${sqlStatements.length}...`
|
|
||||||
);
|
|
||||||
await prisma.$executeRawUnsafe(statement + ";");
|
|
||||||
} else if (statement.startsWith("CREATE INDEX")) {
|
|
||||||
const indexName =
|
|
||||||
statement.match(/CREATE INDEX[^"]*"?([^"\s]+)"?/)?.[1] || "unknown";
|
|
||||||
console.log(`🔧 Creating index: ${indexName}...`);
|
|
||||||
await prisma.$executeRawUnsafe(statement + ";");
|
|
||||||
} else if (statement.startsWith("ANALYZE")) {
|
|
||||||
console.log(`📊 Analyzing table statistics...`);
|
|
||||||
await prisma.$executeRawUnsafe(statement + ";");
|
|
||||||
} else {
|
|
||||||
await prisma.$executeRawUnsafe(statement + ";");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 이미 존재하는 인덱스 에러는 무시
|
|
||||||
if (error.message.includes("already exists")) {
|
|
||||||
console.log(`⚠️ Index already exists, skipping...`);
|
|
||||||
} else {
|
|
||||||
console.error(`❌ Error executing statement: ${error.message}`);
|
|
||||||
console.error(`📝 Statement: ${statement.substring(0, 100)}...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const endTime = Date.now();
|
|
||||||
const executionTime = (endTime - startTime) / 1000;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`\n✅ Index installation completed in ${executionTime.toFixed(2)} seconds!`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 설치된 인덱스 확인
|
|
||||||
console.log("\n🔍 Verifying installed indexes...");
|
|
||||||
const newIndexes = await prisma.$queryRaw`
|
|
||||||
SELECT
|
|
||||||
indexname,
|
|
||||||
pg_size_pretty(pg_relation_size(indexrelid)) as size
|
|
||||||
FROM pg_stat_user_indexes
|
|
||||||
WHERE tablename = 'dataflow_diagrams'
|
|
||||||
AND indexname LIKE 'idx_dataflow%'
|
|
||||||
ORDER BY indexname;
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (newIndexes.length > 0) {
|
|
||||||
console.log("📋 Installed indexes:");
|
|
||||||
newIndexes.forEach((idx) => {
|
|
||||||
console.log(` ✅ ${idx.indexname} (${idx.size})`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 성능 통계 조회
|
|
||||||
console.log("\n📊 Performance statistics:");
|
|
||||||
try {
|
|
||||||
const perfStats =
|
|
||||||
await prisma.$queryRaw`SELECT * FROM dataflow_performance_stats;`;
|
|
||||||
if (perfStats.length > 0) {
|
|
||||||
const stats = perfStats[0];
|
|
||||||
console.log(` - Table size: ${stats.table_size}`);
|
|
||||||
console.log(` - Total diagrams: ${stats.total_rows}`);
|
|
||||||
console.log(` - With control: ${stats.with_control}`);
|
|
||||||
console.log(` - Companies: ${stats.companies}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(" ⚠️ Performance view not available yet");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n🎯 Performance Optimization Complete!");
|
|
||||||
console.log("Expected improvements:");
|
|
||||||
console.log(" - Button dataflow lookup: 500ms+ → 10-50ms ⚡");
|
|
||||||
console.log(" - Category filtering: 200ms+ → 5-20ms ⚡");
|
|
||||||
console.log(" - Company queries: 100ms+ → 5-15ms ⚡");
|
|
||||||
|
|
||||||
console.log("\n💡 Monitor performance with:");
|
|
||||||
console.log(" SELECT * FROM dataflow_performance_stats;");
|
|
||||||
console.log(" SELECT * FROM dataflow_index_efficiency;");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("\n❌ Error installing dataflow indexes:", error);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 실행
|
|
||||||
if (require.main === module) {
|
|
||||||
installDataflowIndexes()
|
|
||||||
.then(() => {
|
|
||||||
console.log("\n🎉 Installation completed successfully!");
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("\n💥 Installation failed:", error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { installDataflowIndexes };
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
const { PrismaClient } = require("@prisma/client");
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function getComponents() {
|
|
||||||
try {
|
|
||||||
const components = await prisma.component_standards.findMany({
|
|
||||||
where: { is_active: "Y" },
|
|
||||||
select: {
|
|
||||||
component_code: true,
|
|
||||||
component_name: true,
|
|
||||||
category: true,
|
|
||||||
component_config: true,
|
|
||||||
},
|
|
||||||
orderBy: [{ category: "asc" }, { sort_order: "asc" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("📋 데이터베이스 컴포넌트 목록:");
|
|
||||||
console.log("=".repeat(60));
|
|
||||||
|
|
||||||
const grouped = components.reduce((acc, comp) => {
|
|
||||||
if (!acc[comp.category]) {
|
|
||||||
acc[comp.category] = [];
|
|
||||||
}
|
|
||||||
acc[comp.category].push(comp);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
Object.entries(grouped).forEach(([category, comps]) => {
|
|
||||||
console.log(`\n🏷️ ${category.toUpperCase()} 카테고리:`);
|
|
||||||
comps.forEach((comp) => {
|
|
||||||
const type = comp.component_config?.type || "unknown";
|
|
||||||
console.log(
|
|
||||||
` - ${comp.component_code}: ${comp.component_name} (type: ${type})`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\n총 ${components.length}개 컴포넌트 발견`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error:", error);
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getComponents();
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import { query } from "../src/database/db";
|
|
||||||
import { logger } from "../src/utils/logger";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* input_type을 web_type으로 마이그레이션하는 스크립트
|
|
||||||
*
|
|
||||||
* 목적:
|
|
||||||
* - column_labels 테이블의 input_type 값을 읽어서
|
|
||||||
* - 해당하는 기본 web_type 값으로 변환
|
|
||||||
* - web_type이 null인 경우에만 업데이트
|
|
||||||
*/
|
|
||||||
|
|
||||||
// input_type → 기본 web_type 매핑
|
|
||||||
const INPUT_TYPE_TO_WEB_TYPE: Record<string, string> = {
|
|
||||||
text: "text", // 일반 텍스트
|
|
||||||
number: "number", // 정수
|
|
||||||
date: "date", // 날짜
|
|
||||||
code: "code", // 코드 선택박스
|
|
||||||
entity: "entity", // 엔티티 참조
|
|
||||||
select: "select", // 선택박스
|
|
||||||
checkbox: "checkbox", // 체크박스
|
|
||||||
radio: "radio", // 라디오버튼
|
|
||||||
direct: "text", // direct는 text로 매핑
|
|
||||||
};
|
|
||||||
|
|
||||||
async function migrateInputTypeToWebType() {
|
|
||||||
try {
|
|
||||||
logger.info("=".repeat(60));
|
|
||||||
logger.info("input_type → web_type 마이그레이션 시작");
|
|
||||||
logger.info("=".repeat(60));
|
|
||||||
|
|
||||||
// 1. 현재 상태 확인
|
|
||||||
const stats = await query<{
|
|
||||||
total: string;
|
|
||||||
has_input_type: string;
|
|
||||||
has_web_type: string;
|
|
||||||
needs_migration: string;
|
|
||||||
}>(
|
|
||||||
`SELECT
|
|
||||||
COUNT(*) as total,
|
|
||||||
COUNT(input_type) FILTER (WHERE input_type IS NOT NULL) as has_input_type,
|
|
||||||
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type,
|
|
||||||
COUNT(*) FILTER (WHERE input_type IS NOT NULL AND web_type IS NULL) as needs_migration
|
|
||||||
FROM column_labels`
|
|
||||||
);
|
|
||||||
|
|
||||||
const stat = stats[0];
|
|
||||||
logger.info("\n📊 현재 상태:");
|
|
||||||
logger.info(` - 전체 컬럼: ${stat.total}개`);
|
|
||||||
logger.info(` - input_type 있음: ${stat.has_input_type}개`);
|
|
||||||
logger.info(` - web_type 있음: ${stat.has_web_type}개`);
|
|
||||||
logger.info(` - 마이그레이션 필요: ${stat.needs_migration}개`);
|
|
||||||
|
|
||||||
if (parseInt(stat.needs_migration) === 0) {
|
|
||||||
logger.info("\n✅ 마이그레이션이 필요한 데이터가 없습니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. input_type별 분포 확인
|
|
||||||
const distribution = await query<{
|
|
||||||
input_type: string;
|
|
||||||
count: string;
|
|
||||||
}>(
|
|
||||||
`SELECT
|
|
||||||
input_type,
|
|
||||||
COUNT(*) as count
|
|
||||||
FROM column_labels
|
|
||||||
WHERE input_type IS NOT NULL AND web_type IS NULL
|
|
||||||
GROUP BY input_type
|
|
||||||
ORDER BY input_type`
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info("\n📋 input_type별 분포:");
|
|
||||||
distribution.forEach((item) => {
|
|
||||||
const webType =
|
|
||||||
INPUT_TYPE_TO_WEB_TYPE[item.input_type] || item.input_type;
|
|
||||||
logger.info(` - ${item.input_type} → ${webType}: ${item.count}개`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. 마이그레이션 실행
|
|
||||||
logger.info("\n🔄 마이그레이션 실행 중...");
|
|
||||||
|
|
||||||
let totalUpdated = 0;
|
|
||||||
|
|
||||||
for (const [inputType, webType] of Object.entries(INPUT_TYPE_TO_WEB_TYPE)) {
|
|
||||||
const result = await query(
|
|
||||||
`UPDATE column_labels
|
|
||||||
SET
|
|
||||||
web_type = $1,
|
|
||||||
updated_date = NOW()
|
|
||||||
WHERE input_type = $2
|
|
||||||
AND web_type IS NULL
|
|
||||||
RETURNING id, table_name, column_name`,
|
|
||||||
[webType, inputType]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.length > 0) {
|
|
||||||
logger.info(
|
|
||||||
` ✓ ${inputType} → ${webType}: ${result.length}개 업데이트`
|
|
||||||
);
|
|
||||||
totalUpdated += result.length;
|
|
||||||
|
|
||||||
// 처음 5개만 출력
|
|
||||||
result.slice(0, 5).forEach((row: any) => {
|
|
||||||
logger.info(` - ${row.table_name}.${row.column_name}`);
|
|
||||||
});
|
|
||||||
if (result.length > 5) {
|
|
||||||
logger.info(` ... 외 ${result.length - 5}개`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 결과 확인
|
|
||||||
const afterStats = await query<{
|
|
||||||
total: string;
|
|
||||||
has_web_type: string;
|
|
||||||
}>(
|
|
||||||
`SELECT
|
|
||||||
COUNT(*) as total,
|
|
||||||
COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type
|
|
||||||
FROM column_labels`
|
|
||||||
);
|
|
||||||
|
|
||||||
const afterStat = afterStats[0];
|
|
||||||
|
|
||||||
logger.info("\n" + "=".repeat(60));
|
|
||||||
logger.info("✅ 마이그레이션 완료!");
|
|
||||||
logger.info("=".repeat(60));
|
|
||||||
logger.info(`📊 최종 통계:`);
|
|
||||||
logger.info(` - 전체 컬럼: ${afterStat.total}개`);
|
|
||||||
logger.info(` - web_type 설정됨: ${afterStat.has_web_type}개`);
|
|
||||||
logger.info(` - 업데이트된 컬럼: ${totalUpdated}개`);
|
|
||||||
logger.info("=".repeat(60));
|
|
||||||
|
|
||||||
// 5. 샘플 데이터 출력
|
|
||||||
logger.info("\n📝 샘플 데이터 (check_report_mng 테이블):");
|
|
||||||
const samples = await query<{
|
|
||||||
column_name: string;
|
|
||||||
input_type: string;
|
|
||||||
web_type: string;
|
|
||||||
detail_settings: string;
|
|
||||||
}>(
|
|
||||||
`SELECT
|
|
||||||
column_name,
|
|
||||||
input_type,
|
|
||||||
web_type,
|
|
||||||
detail_settings
|
|
||||||
FROM column_labels
|
|
||||||
WHERE table_name = 'check_report_mng'
|
|
||||||
ORDER BY column_name
|
|
||||||
LIMIT 10`
|
|
||||||
);
|
|
||||||
|
|
||||||
samples.forEach((sample) => {
|
|
||||||
logger.info(
|
|
||||||
` ${sample.column_name}: ${sample.input_type} → ${sample.web_type}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("❌ 마이그레이션 실패:", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스크립트 실행
|
|
||||||
migrateInputTypeToWebType();
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* system_notice 테이블 생성 마이그레이션 실행
|
|
||||||
*/
|
|
||||||
const { Pool } = require('pg');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm',
|
|
||||||
ssl: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql');
|
|
||||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
|
||||||
await client.query(sql);
|
|
||||||
console.log('OK: system_notice 테이블 생성 완료');
|
|
||||||
|
|
||||||
// 검증
|
|
||||||
const result = await client.query(
|
|
||||||
"SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position"
|
|
||||||
);
|
|
||||||
console.log('컬럼:', result.rows.map(r => r.column_name).join(', '));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('ERROR:', e.message);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* SQL 마이그레이션 실행 스크립트
|
|
||||||
* 사용법: node scripts/run-migration.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { Pool } = require('pg');
|
|
||||||
|
|
||||||
// DATABASE_URL에서 연결 정보 파싱
|
|
||||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
|
||||||
|
|
||||||
// 데이터베이스 연결 설정
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: databaseUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function runMigration() {
|
|
||||||
const client = await pool.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('🔄 마이그레이션 시작...\n');
|
|
||||||
|
|
||||||
// SQL 파일 읽기 (Docker 컨테이너 내부 경로)
|
|
||||||
const sqlPath = '/tmp/migration.sql';
|
|
||||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
|
||||||
|
|
||||||
console.log('📄 SQL 파일 로드 완료');
|
|
||||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
||||||
|
|
||||||
// SQL 실행
|
|
||||||
await client.query(sql);
|
|
||||||
|
|
||||||
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
||||||
console.log('✅ 마이그레이션 성공적으로 완료되었습니다!');
|
|
||||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
||||||
console.error('❌ 마이그레이션 실패:');
|
|
||||||
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
||||||
console.error(error);
|
|
||||||
console.error('\n💡 롤백이 필요한 경우 롤백 스크립트를 실행하세요.');
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 실행
|
|
||||||
runMigration();
|
|
||||||
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
/**
|
|
||||||
* system_notice 마이그레이션 실행 스크립트
|
|
||||||
* 사용법: node scripts/run-notice-migration.js
|
|
||||||
*/
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { Pool } = require('pg');
|
|
||||||
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm',
|
|
||||||
ssl: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql');
|
|
||||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
|
||||||
|
|
||||||
console.log('마이그레이션 실행 중...');
|
|
||||||
await client.query(sql);
|
|
||||||
console.log('마이그레이션 완료');
|
|
||||||
|
|
||||||
// 컬럼 확인
|
|
||||||
const check = await client.query(
|
|
||||||
"SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position"
|
|
||||||
);
|
|
||||||
console.log('테이블 컬럼:', check.rows.map(r => r.column_name).join(', '));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('오류:', e.message);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
const { PrismaClient } = require("@prisma/client");
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 기본 템플릿 데이터 정의
|
|
||||||
const defaultTemplates = [
|
|
||||||
{
|
|
||||||
template_code: "advanced-data-table-v2",
|
|
||||||
template_name: "고급 데이터 테이블 v2",
|
|
||||||
template_name_eng: "Advanced Data Table v2",
|
|
||||||
description:
|
|
||||||
"검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트",
|
|
||||||
category: "table",
|
|
||||||
icon_name: "table",
|
|
||||||
default_size: {
|
|
||||||
width: 1000,
|
|
||||||
height: 680,
|
|
||||||
},
|
|
||||||
layout_config: {
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
type: "datatable",
|
|
||||||
label: "고급 데이터 테이블",
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
size: { width: 1000, height: 680 },
|
|
||||||
style: {
|
|
||||||
border: "1px solid #e5e7eb",
|
|
||||||
borderRadius: "8px",
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
padding: "0",
|
|
||||||
},
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
id: "id",
|
|
||||||
label: "ID",
|
|
||||||
type: "number",
|
|
||||||
visible: true,
|
|
||||||
sortable: true,
|
|
||||||
filterable: false,
|
|
||||||
width: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "name",
|
|
||||||
label: "이름",
|
|
||||||
type: "text",
|
|
||||||
visible: true,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
width: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "email",
|
|
||||||
label: "이메일",
|
|
||||||
type: "email",
|
|
||||||
visible: true,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
width: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "status",
|
|
||||||
label: "상태",
|
|
||||||
type: "select",
|
|
||||||
visible: true,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "created_date",
|
|
||||||
label: "생성일",
|
|
||||||
type: "date",
|
|
||||||
visible: true,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
id: "status",
|
|
||||||
label: "상태",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "전체", value: "" },
|
|
||||||
{ label: "활성", value: "active" },
|
|
||||||
{ label: "비활성", value: "inactive" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ id: "name", label: "이름", type: "text" },
|
|
||||||
{ id: "email", label: "이메일", type: "text" },
|
|
||||||
],
|
|
||||||
pagination: {
|
|
||||||
enabled: true,
|
|
||||||
pageSize: 10,
|
|
||||||
pageSizeOptions: [5, 10, 20, 50, 100],
|
|
||||||
showPageSizeSelector: true,
|
|
||||||
showPageInfo: true,
|
|
||||||
showFirstLast: true,
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
showSearchButton: true,
|
|
||||||
searchButtonText: "검색",
|
|
||||||
enableExport: true,
|
|
||||||
enableRefresh: true,
|
|
||||||
enableAdd: true,
|
|
||||||
enableEdit: true,
|
|
||||||
enableDelete: true,
|
|
||||||
addButtonText: "추가",
|
|
||||||
editButtonText: "수정",
|
|
||||||
deleteButtonText: "삭제",
|
|
||||||
},
|
|
||||||
addModalConfig: {
|
|
||||||
title: "새 데이터 추가",
|
|
||||||
description: "테이블에 새로운 데이터를 추가합니다.",
|
|
||||||
width: "lg",
|
|
||||||
layout: "two-column",
|
|
||||||
gridColumns: 2,
|
|
||||||
fieldOrder: ["name", "email", "status"],
|
|
||||||
requiredFields: ["name", "email"],
|
|
||||||
hiddenFields: ["id", "created_date"],
|
|
||||||
advancedFieldConfigs: {
|
|
||||||
status: {
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "활성", value: "active" },
|
|
||||||
{ label: "비활성", value: "inactive" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
submitButtonText: "추가",
|
|
||||||
cancelButtonText: "취소",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
sort_order: 1,
|
|
||||||
is_active: "Y",
|
|
||||||
is_public: "Y",
|
|
||||||
company_code: "*",
|
|
||||||
created_by: "system",
|
|
||||||
updated_by: "system",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
template_code: "universal-button",
|
|
||||||
template_name: "범용 버튼",
|
|
||||||
template_name_eng: "Universal Button",
|
|
||||||
description:
|
|
||||||
"다양한 기능을 설정할 수 있는 범용 버튼. 상세설정에서 기능을 선택하세요.",
|
|
||||||
category: "button",
|
|
||||||
icon_name: "mouse-pointer",
|
|
||||||
default_size: {
|
|
||||||
width: 80,
|
|
||||||
height: 36,
|
|
||||||
},
|
|
||||||
layout_config: {
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
type: "widget",
|
|
||||||
widgetType: "button",
|
|
||||||
label: "버튼",
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
size: { width: 80, height: 36 },
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#3b82f6",
|
|
||||||
color: "#ffffff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: "500",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
sort_order: 2,
|
|
||||||
is_active: "Y",
|
|
||||||
is_public: "Y",
|
|
||||||
company_code: "*",
|
|
||||||
created_by: "system",
|
|
||||||
updated_by: "system",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
template_code: "file-upload",
|
|
||||||
template_name: "파일 첨부",
|
|
||||||
template_name_eng: "File Upload",
|
|
||||||
description: "드래그앤드롭 파일 업로드 영역",
|
|
||||||
category: "file",
|
|
||||||
icon_name: "upload",
|
|
||||||
default_size: {
|
|
||||||
width: 300,
|
|
||||||
height: 120,
|
|
||||||
},
|
|
||||||
layout_config: {
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
type: "widget",
|
|
||||||
widgetType: "file",
|
|
||||||
label: "파일 첨부",
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
size: { width: 300, height: 120 },
|
|
||||||
style: {
|
|
||||||
border: "2px dashed #d1d5db",
|
|
||||||
borderRadius: "8px",
|
|
||||||
backgroundColor: "#f9fafb",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#6b7280",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
sort_order: 3,
|
|
||||||
is_active: "Y",
|
|
||||||
is_public: "Y",
|
|
||||||
company_code: "*",
|
|
||||||
created_by: "system",
|
|
||||||
updated_by: "system",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
template_code: "form-container",
|
|
||||||
template_name: "폼 컨테이너",
|
|
||||||
template_name_eng: "Form Container",
|
|
||||||
description: "입력 폼을 위한 기본 컨테이너 레이아웃",
|
|
||||||
category: "form",
|
|
||||||
icon_name: "form",
|
|
||||||
default_size: {
|
|
||||||
width: 400,
|
|
||||||
height: 300,
|
|
||||||
},
|
|
||||||
layout_config: {
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
type: "container",
|
|
||||||
label: "폼 컨테이너",
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
size: { width: 400, height: 300 },
|
|
||||||
style: {
|
|
||||||
border: "1px solid #e5e7eb",
|
|
||||||
borderRadius: "8px",
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
padding: "16px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
sort_order: 4,
|
|
||||||
is_active: "Y",
|
|
||||||
is_public: "Y",
|
|
||||||
company_code: "*",
|
|
||||||
created_by: "system",
|
|
||||||
updated_by: "system",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function seedTemplates() {
|
|
||||||
console.log("🌱 템플릿 시드 데이터 삽입 시작...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 기존 템플릿이 있는지 확인하고 없는 경우에만 삽입
|
|
||||||
for (const template of defaultTemplates) {
|
|
||||||
const existing = await prisma.template_standards.findUnique({
|
|
||||||
where: { template_code: template.template_code },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
await prisma.template_standards.create({
|
|
||||||
data: template,
|
|
||||||
});
|
|
||||||
console.log(`✅ 템플릿 '${template.template_name}' 생성됨`);
|
|
||||||
} else {
|
|
||||||
console.log(`⏭️ 템플릿 '${template.template_name}' 이미 존재함`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("🎉 템플릿 시드 데이터 삽입 완료!");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 템플릿 시드 데이터 삽입 실패:", error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스크립트가 직접 실행될 때만 시드 함수 실행
|
|
||||||
if (require.main === module) {
|
|
||||||
seedTemplates().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { seedTemplates };
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
const { PrismaClient } = require("@prisma/client");
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 실제 UI 구성에 필요한 컴포넌트들
|
|
||||||
const uiComponents = [
|
|
||||||
// === 액션 컴포넌트 ===
|
|
||||||
{
|
|
||||||
component_code: "button-primary",
|
|
||||||
component_name: "기본 버튼",
|
|
||||||
component_name_eng: "Primary Button",
|
|
||||||
description: "일반적인 액션을 위한 기본 버튼 컴포넌트",
|
|
||||||
category: "action",
|
|
||||||
icon_name: "MousePointer",
|
|
||||||
default_size: { width: 100, height: 36 },
|
|
||||||
component_config: {
|
|
||||||
type: "button",
|
|
||||||
variant: "primary",
|
|
||||||
text: "버튼",
|
|
||||||
action: "custom",
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#3b82f6",
|
|
||||||
color: "#ffffff",
|
|
||||||
borderRadius: "6px",
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: "500",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sort_order: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component_code: "button-secondary",
|
|
||||||
component_name: "보조 버튼",
|
|
||||||
component_name_eng: "Secondary Button",
|
|
||||||
description: "보조 액션을 위한 버튼 컴포넌트",
|
|
||||||
category: "action",
|
|
||||||
icon_name: "MousePointer",
|
|
||||||
default_size: { width: 100, height: 36 },
|
|
||||||
component_config: {
|
|
||||||
type: "button",
|
|
||||||
variant: "secondary",
|
|
||||||
text: "취소",
|
|
||||||
action: "cancel",
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#f1f5f9",
|
|
||||||
color: "#475569",
|
|
||||||
borderRadius: "6px",
|
|
||||||
fontSize: "14px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sort_order: 11,
|
|
||||||
},
|
|
||||||
|
|
||||||
// === 레이아웃 컴포넌트 ===
|
|
||||||
{
|
|
||||||
component_code: "card-basic",
|
|
||||||
component_name: "기본 카드",
|
|
||||||
component_name_eng: "Basic Card",
|
|
||||||
description: "정보를 그룹화하는 기본 카드 컴포넌트",
|
|
||||||
category: "layout",
|
|
||||||
icon_name: "Square",
|
|
||||||
default_size: { width: 400, height: 300 },
|
|
||||||
component_config: {
|
|
||||||
type: "card",
|
|
||||||
title: "카드 제목",
|
|
||||||
showHeader: true,
|
|
||||||
showFooter: false,
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
border: "1px solid #e5e7eb",
|
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "16px",
|
|
||||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.1)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sort_order: 20,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component_code: "dashboard-grid",
|
|
||||||
component_name: "대시보드 그리드",
|
|
||||||
component_name_eng: "Dashboard Grid",
|
|
||||||
description: "대시보드를 위한 그리드 레이아웃 컴포넌트",
|
|
||||||
category: "layout",
|
|
||||||
icon_name: "LayoutGrid",
|
|
||||||
default_size: { width: 800, height: 600 },
|
|
||||||
component_config: {
|
|
||||||
type: "dashboard",
|
|
||||||
columns: 3,
|
|
||||||
gap: 16,
|
|
||||||
items: [],
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#f8fafc",
|
|
||||||
padding: "20px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sort_order: 21,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component_code: "panel-collapsible",
|
|
||||||
component_name: "접을 수 있는 패널",
|
|
||||||
component_name_eng: "Collapsible Panel",
|
|
||||||
description: "접고 펼칠 수 있는 패널 컴포넌트",
|
|
||||||
category: "layout",
|
|
||||||
icon_name: "ChevronDown",
|
|
||||||
default_size: { width: 500, height: 200 },
|
|
||||||
component_config: {
|
|
||||||
type: "panel",
|
|
||||||
title: "패널 제목",
|
|
||||||
collapsible: true,
|
|
||||||
defaultExpanded: true,
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
border: "1px solid #e5e7eb",
|
|
||||||
borderRadius: "8px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sort_order: 22,
|
|
||||||
},
|
|
||||||
|
|
||||||
// === 데이터 표시 컴포넌트 ===
|
|
||||||
{
|
|
||||||
component_code: "stats-card",
|
|
||||||
component_name: "통계 카드",
|
|
||||||
component_name_eng: "Statistics Card",
|
|
||||||
description: "수치와 통계를 표시하는 카드 컴포넌트",
|
|
||||||
category: "data",
|
|
||||||
icon_name: "BarChart3",
|
|
||||||
default_size: { width: 250, height: 120 },
|
|
||||||
component_config: {
|
|
||||||
type: "stats",
|
|
||||||
title: "총 판매량",
|
|
||||||
value: "1,234",
|
|
||||||
unit: "개",
|
|
||||||
trend: "up",
|
|
||||||
percentage: "+12.5%",
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
border: "1px solid #e5e7eb",
|
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "20px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sort_order: 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component_code: "progress-bar",
|
|
||||||
component_name: "진행률 표시",
|
|
||||||
component_name_eng: "Progress Bar",
|
|
||||||
description: "작업 진행률을 표시하는 컴포넌트",
|
|
||||||
category: "data",
|
|
||||||
icon_name: "BarChart2",
|
|
||||||
default_size: { width: 300, height: 60 },
|
|
||||||
component_config: {
|
|
||||||
type: "progress",
|
|
||||||
label: "진행률",
|
|
||||||
value: 65,
|
|
||||||
max: 100,
|
|
||||||
showPercentage: true,
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#f1f5f9",
|
|
||||||
borderRadius: "4px",
|
|
||||||
height: "8px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sort_order: 31,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component_code: "chart-basic",
|
|
||||||
component_name: "기본 차트",
|
|
||||||
component_name_eng: "Basic Chart",
|
|
||||||
description: "데이터를 시각화하는 기본 차트 컴포넌트",
|
|
||||||
category: "data",
|
|
||||||
icon_name: "TrendingUp",
|
|
||||||
default_size: { width: 500, height: 300 },
|
|
||||||
component_config: {
|
|
||||||
type: "chart",
|
|
||||||
chartType: "line",
|
|
||||||
title: "차트 제목",
|
|
||||||
data: [],
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
plugins: {
|
|
||||||
legend: { position: "top" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sort_order: 32,
|
|
||||||
},
|
|
||||||
|
|
||||||
// === 네비게이션 컴포넌트 ===
|
|
||||||
{
|
|
||||||
component_code: "breadcrumb",
|
|
||||||
component_name: "브레드크럼",
|
|
||||||
component_name_eng: "Breadcrumb",
|
|
||||||
description: "현재 위치를 표시하는 네비게이션 컴포넌트",
|
|
||||||
category: "navigation",
|
|
||||||
icon_name: "ChevronRight",
|
|
||||||
default_size: { width: 400, height: 32 },
|
|
||||||
component_config: {
|
|
||||||
type: "breadcrumb",
|
|
||||||
items: [
|
|
||||||
{ label: "홈", href: "/" },
|
|
||||||
{ label: "관리자", href: "/admin" },
|
|
||||||
{ label: "현재 페이지" },
|
|
||||||
],
|
|
||||||
separator: ">",
|
|
||||||
},
|
|
||||||
sort_order: 40,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component_code: "tabs-horizontal",
|
|
||||||
component_name: "가로 탭",
|
|
||||||
component_name_eng: "Horizontal Tabs",
|
|
||||||
description: "컨텐츠를 탭으로 구분하는 네비게이션 컴포넌트",
|
|
||||||
category: "navigation",
|
|
||||||
icon_name: "Tabs",
|
|
||||||
default_size: { width: 500, height: 300 },
|
|
||||||
component_config: {
|
|
||||||
type: "tabs",
|
|
||||||
orientation: "horizontal",
|
|
||||||
tabs: [
|
|
||||||
{ id: "tab1", label: "탭 1", content: "첫 번째 탭 내용" },
|
|
||||||
{ id: "tab2", label: "탭 2", content: "두 번째 탭 내용" },
|
|
||||||
],
|
|
||||||
defaultTab: "tab1",
|
|
||||||
},
|
|
||||||
sort_order: 41,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component_code: "pagination",
|
|
||||||
component_name: "페이지네이션",
|
|
||||||
component_name_eng: "Pagination",
|
|
||||||
description: "페이지를 나눠서 표시하는 네비게이션 컴포넌트",
|
|
||||||
category: "navigation",
|
|
||||||
icon_name: "ChevronLeft",
|
|
||||||
default_size: { width: 300, height: 40 },
|
|
||||||
component_config: {
|
|
||||||
type: "pagination",
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 10,
|
|
||||||
showFirst: true,
|
|
||||||
showLast: true,
|
|
||||||
showPrevNext: true,
|
|
||||||
},
|
|
||||||
sort_order: 42,
|
|
||||||
},
|
|
||||||
|
|
||||||
// === 피드백 컴포넌트 ===
|
|
||||||
{
|
|
||||||
component_code: "alert-info",
|
|
||||||
component_name: "정보 알림",
|
|
||||||
component_name_eng: "Info Alert",
|
|
||||||
description: "정보를 사용자에게 알리는 컴포넌트",
|
|
||||||
category: "feedback",
|
|
||||||
icon_name: "Info",
|
|
||||||
default_size: { width: 400, height: 60 },
|
|
||||||
component_config: {
|
|
||||||
type: "alert",
|
|
||||||
variant: "info",
|
|
||||||
title: "알림",
|
|
||||||
message: "중요한 정보를 확인해주세요.",
|
|
||||||
dismissible: true,
|
|
||||||
icon: true,
|
|
||||||
},
|
|
||||||
sort_order: 50,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component_code: "badge-status",
|
|
||||||
component_name: "상태 뱃지",
|
|
||||||
component_name_eng: "Status Badge",
|
|
||||||
description: "상태나 카테고리를 표시하는 뱃지 컴포넌트",
|
|
||||||
category: "feedback",
|
|
||||||
icon_name: "Tag",
|
|
||||||
default_size: { width: 80, height: 24 },
|
|
||||||
component_config: {
|
|
||||||
type: "badge",
|
|
||||||
text: "활성",
|
|
||||||
variant: "success",
|
|
||||||
size: "sm",
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#10b981",
|
|
||||||
color: "#ffffff",
|
|
||||||
borderRadius: "12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sort_order: 51,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component_code: "loading-spinner",
|
|
||||||
component_name: "로딩 스피너",
|
|
||||||
component_name_eng: "Loading Spinner",
|
|
||||||
description: "로딩 상태를 표시하는 스피너 컴포넌트",
|
|
||||||
category: "feedback",
|
|
||||||
icon_name: "RefreshCw",
|
|
||||||
default_size: { width: 100, height: 100 },
|
|
||||||
component_config: {
|
|
||||||
type: "loading",
|
|
||||||
variant: "spinner",
|
|
||||||
size: "md",
|
|
||||||
message: "로딩 중...",
|
|
||||||
overlay: false,
|
|
||||||
},
|
|
||||||
sort_order: 52,
|
|
||||||
},
|
|
||||||
|
|
||||||
// === 입력 컴포넌트 ===
|
|
||||||
{
|
|
||||||
component_code: "search-box",
|
|
||||||
component_name: "검색 박스",
|
|
||||||
component_name_eng: "Search Box",
|
|
||||||
description: "검색 기능이 있는 입력 컴포넌트",
|
|
||||||
category: "input",
|
|
||||||
icon_name: "Search",
|
|
||||||
default_size: { width: 300, height: 40 },
|
|
||||||
component_config: {
|
|
||||||
type: "search",
|
|
||||||
placeholder: "검색어를 입력하세요...",
|
|
||||||
showButton: true,
|
|
||||||
debounce: 500,
|
|
||||||
style: {
|
|
||||||
borderRadius: "20px",
|
|
||||||
border: "1px solid #d1d5db",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sort_order: 60,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
component_code: "filter-dropdown",
|
|
||||||
component_name: "필터 드롭다운",
|
|
||||||
component_name_eng: "Filter Dropdown",
|
|
||||||
description: "데이터 필터링을 위한 드롭다운 컴포넌트",
|
|
||||||
category: "input",
|
|
||||||
icon_name: "Filter",
|
|
||||||
default_size: { width: 200, height: 40 },
|
|
||||||
component_config: {
|
|
||||||
type: "filter",
|
|
||||||
label: "필터",
|
|
||||||
options: [
|
|
||||||
{ value: "all", label: "전체" },
|
|
||||||
{ value: "active", label: "활성" },
|
|
||||||
{ value: "inactive", label: "비활성" },
|
|
||||||
],
|
|
||||||
defaultValue: "all",
|
|
||||||
multiple: false,
|
|
||||||
},
|
|
||||||
sort_order: 61,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function seedUIComponents() {
|
|
||||||
try {
|
|
||||||
console.log("🚀 UI 컴포넌트 시딩 시작...");
|
|
||||||
|
|
||||||
// 기존 데이터 삭제
|
|
||||||
console.log("📝 기존 컴포넌트 데이터 삭제 중...");
|
|
||||||
await prisma.$executeRaw`DELETE FROM component_standards`;
|
|
||||||
|
|
||||||
// 새 컴포넌트 데이터 삽입
|
|
||||||
console.log("📦 새로운 UI 컴포넌트 삽입 중...");
|
|
||||||
|
|
||||||
for (const component of uiComponents) {
|
|
||||||
await prisma.component_standards.create({
|
|
||||||
data: {
|
|
||||||
...component,
|
|
||||||
company_code: "DEFAULT",
|
|
||||||
created_by: "system",
|
|
||||||
updated_by: "system",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log(`✅ ${component.component_name} 컴포넌트 생성됨`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`\n🎉 총 ${uiComponents.length}개의 UI 컴포넌트가 성공적으로 생성되었습니다!`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 카테고리별 통계
|
|
||||||
const categoryCounts = {};
|
|
||||||
uiComponents.forEach((component) => {
|
|
||||||
categoryCounts[component.category] =
|
|
||||||
(categoryCounts[component.category] || 0) + 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("\n📊 카테고리별 컴포넌트 수:");
|
|
||||||
Object.entries(categoryCounts).forEach(([category, count]) => {
|
|
||||||
console.log(` ${category}: ${count}개`);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ UI 컴포넌트 시딩 실패:", error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 실행
|
|
||||||
if (require.main === module) {
|
|
||||||
seedUIComponents()
|
|
||||||
.then(() => {
|
|
||||||
console.log("✨ UI 컴포넌트 시딩 완료!");
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("💥 시딩 실패:", error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { seedUIComponents, uiComponents };
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
/**
|
|
||||||
* 디지털 트윈 외부 DB (DO_DY) 연결 및 쿼리 테스트 스크립트
|
|
||||||
* READ-ONLY: SELECT 쿼리만 실행
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Pool } from "pg";
|
|
||||||
import mysql from "mysql2/promise";
|
|
||||||
import { CredentialEncryption } from "../src/utils/credentialEncryption";
|
|
||||||
|
|
||||||
async function testDigitalTwinDb() {
|
|
||||||
// 내부 DB 연결 (연결 정보 저장용)
|
|
||||||
const internalPool = new Pool({
|
|
||||||
host: process.env.DB_HOST || "localhost",
|
|
||||||
port: parseInt(process.env.DB_PORT || "5432"),
|
|
||||||
database: process.env.DB_NAME || "plm",
|
|
||||||
user: process.env.DB_USER || "postgres",
|
|
||||||
password: process.env.DB_PASSWORD || "ph0909!!",
|
|
||||||
});
|
|
||||||
|
|
||||||
const encryptionKey =
|
|
||||||
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
|
|
||||||
const encryption = new CredentialEncryption(encryptionKey);
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("🚀 디지털 트윈 외부 DB 연결 테스트 시작\n");
|
|
||||||
|
|
||||||
// 디지털 트윈 외부 DB 연결 정보
|
|
||||||
const digitalTwinConnection = {
|
|
||||||
name: "디지털트윈_DO_DY",
|
|
||||||
description: "디지털 트윈 후판(자재) 재고 정보 데이터베이스 (MariaDB)",
|
|
||||||
dbType: "mysql", // MariaDB는 MySQL 프로토콜 사용
|
|
||||||
host: "1.240.13.83",
|
|
||||||
port: 4307,
|
|
||||||
databaseName: "DO_DY",
|
|
||||||
username: "root",
|
|
||||||
password: "pohangms619!#",
|
|
||||||
sslEnabled: false,
|
|
||||||
isActive: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("📝 연결 정보:");
|
|
||||||
console.log(` - 이름: ${digitalTwinConnection.name}`);
|
|
||||||
console.log(` - DB 타입: ${digitalTwinConnection.dbType}`);
|
|
||||||
console.log(` - 호스트: ${digitalTwinConnection.host}:${digitalTwinConnection.port}`);
|
|
||||||
console.log(` - 데이터베이스: ${digitalTwinConnection.databaseName}\n`);
|
|
||||||
|
|
||||||
// 1. 외부 DB 직접 연결 테스트
|
|
||||||
console.log("🔍 외부 DB 직접 연결 테스트 중...");
|
|
||||||
|
|
||||||
const externalConnection = await mysql.createConnection({
|
|
||||||
host: digitalTwinConnection.host,
|
|
||||||
port: digitalTwinConnection.port,
|
|
||||||
database: digitalTwinConnection.databaseName,
|
|
||||||
user: digitalTwinConnection.username,
|
|
||||||
password: digitalTwinConnection.password,
|
|
||||||
connectTimeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("✅ 외부 DB 연결 성공!\n");
|
|
||||||
|
|
||||||
// 2. SELECT 쿼리 실행
|
|
||||||
console.log("📊 WSTKKY 테이블 쿼리 실행 중...\n");
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
SELECT
|
|
||||||
SKUMKEY -- 제품번호
|
|
||||||
, SKUDESC -- 자재명
|
|
||||||
, SKUTHIC -- 두께
|
|
||||||
, SKUWIDT -- 폭
|
|
||||||
, SKULENG -- 길이
|
|
||||||
, SKUWEIG -- 중량
|
|
||||||
, STOTQTY -- 수량
|
|
||||||
, SUOMKEY -- 단위
|
|
||||||
FROM DO_DY.WSTKKY
|
|
||||||
LIMIT 10
|
|
||||||
`;
|
|
||||||
|
|
||||||
const [rows] = await externalConnection.execute(query);
|
|
||||||
|
|
||||||
console.log("✅ 쿼리 실행 성공!\n");
|
|
||||||
console.log(`📦 조회된 데이터: ${Array.isArray(rows) ? rows.length : 0}건\n`);
|
|
||||||
|
|
||||||
if (Array.isArray(rows) && rows.length > 0) {
|
|
||||||
console.log("🔍 샘플 데이터 (첫 3건):\n");
|
|
||||||
rows.slice(0, 3).forEach((row: any, index: number) => {
|
|
||||||
console.log(`[${index + 1}]`);
|
|
||||||
console.log(` 제품번호(SKUMKEY): ${row.SKUMKEY}`);
|
|
||||||
console.log(` 자재명(SKUDESC): ${row.SKUDESC}`);
|
|
||||||
console.log(` 두께(SKUTHIC): ${row.SKUTHIC}`);
|
|
||||||
console.log(` 폭(SKUWIDT): ${row.SKUWIDT}`);
|
|
||||||
console.log(` 길이(SKULENG): ${row.SKULENG}`);
|
|
||||||
console.log(` 중량(SKUWEIG): ${row.SKUWEIG}`);
|
|
||||||
console.log(` 수량(STOTQTY): ${row.STOTQTY}`);
|
|
||||||
console.log(` 단위(SUOMKEY): ${row.SUOMKEY}\n`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 전체 데이터 JSON 출력
|
|
||||||
console.log("📄 전체 데이터 (JSON):");
|
|
||||||
console.log(JSON.stringify(rows, null, 2));
|
|
||||||
console.log("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
await externalConnection.end();
|
|
||||||
|
|
||||||
// 3. 내부 DB에 연결 정보 저장
|
|
||||||
console.log("💾 내부 DB에 연결 정보 저장 중...");
|
|
||||||
|
|
||||||
const encryptedPassword = encryption.encrypt(digitalTwinConnection.password);
|
|
||||||
|
|
||||||
// 중복 체크
|
|
||||||
const existingResult = await internalPool.query(
|
|
||||||
"SELECT id FROM flow_external_db_connection WHERE name = $1",
|
|
||||||
[digitalTwinConnection.name]
|
|
||||||
);
|
|
||||||
|
|
||||||
let connectionId: number;
|
|
||||||
|
|
||||||
if (existingResult.rows.length > 0) {
|
|
||||||
connectionId = existingResult.rows[0].id;
|
|
||||||
console.log(`⚠️ 이미 존재하는 연결 (ID: ${connectionId})`);
|
|
||||||
|
|
||||||
// 기존 연결 업데이트
|
|
||||||
await internalPool.query(
|
|
||||||
`UPDATE flow_external_db_connection
|
|
||||||
SET description = $1,
|
|
||||||
db_type = $2,
|
|
||||||
host = $3,
|
|
||||||
port = $4,
|
|
||||||
database_name = $5,
|
|
||||||
username = $6,
|
|
||||||
password_encrypted = $7,
|
|
||||||
ssl_enabled = $8,
|
|
||||||
is_active = $9,
|
|
||||||
updated_at = NOW(),
|
|
||||||
updated_by = 'system'
|
|
||||||
WHERE name = $10`,
|
|
||||||
[
|
|
||||||
digitalTwinConnection.description,
|
|
||||||
digitalTwinConnection.dbType,
|
|
||||||
digitalTwinConnection.host,
|
|
||||||
digitalTwinConnection.port,
|
|
||||||
digitalTwinConnection.databaseName,
|
|
||||||
digitalTwinConnection.username,
|
|
||||||
encryptedPassword,
|
|
||||||
digitalTwinConnection.sslEnabled,
|
|
||||||
digitalTwinConnection.isActive,
|
|
||||||
digitalTwinConnection.name,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
console.log(`✅ 연결 정보 업데이트 완료`);
|
|
||||||
} else {
|
|
||||||
// 새 연결 추가
|
|
||||||
const result = await internalPool.query(
|
|
||||||
`INSERT INTO flow_external_db_connection (
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
db_type,
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
database_name,
|
|
||||||
username,
|
|
||||||
password_encrypted,
|
|
||||||
ssl_enabled,
|
|
||||||
is_active,
|
|
||||||
created_by
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
|
|
||||||
RETURNING id`,
|
|
||||||
[
|
|
||||||
digitalTwinConnection.name,
|
|
||||||
digitalTwinConnection.description,
|
|
||||||
digitalTwinConnection.dbType,
|
|
||||||
digitalTwinConnection.host,
|
|
||||||
digitalTwinConnection.port,
|
|
||||||
digitalTwinConnection.databaseName,
|
|
||||||
digitalTwinConnection.username,
|
|
||||||
encryptedPassword,
|
|
||||||
digitalTwinConnection.sslEnabled,
|
|
||||||
digitalTwinConnection.isActive,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
connectionId = result.rows[0].id;
|
|
||||||
console.log(`✅ 새 연결 추가 완료 (ID: ${connectionId})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n✅ 모든 테스트 완료!");
|
|
||||||
console.log(`\n📌 연결 ID: ${connectionId}`);
|
|
||||||
console.log(" 이 ID를 사용하여 플로우 관리나 제어 관리에서 외부 DB를 연동할 수 있습니다.");
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("\n❌ 오류 발생:", error.message);
|
|
||||||
console.error("상세 정보:", error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await internalPool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스크립트 실행
|
|
||||||
testDigitalTwinDb()
|
|
||||||
.then(() => {
|
|
||||||
console.log("\n🎉 스크립트 완료");
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("\n💥 스크립트 실패:", error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
const { PrismaClient } = require("@prisma/client");
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function testTemplateCreation() {
|
|
||||||
console.log("🧪 템플릿 생성 테스트 시작...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 테이블 존재 여부 확인
|
|
||||||
console.log("1. 템플릿 테이블 존재 여부 확인 중...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const count = await prisma.template_standards.count();
|
|
||||||
console.log(`✅ template_standards 테이블 발견 (현재 ${count}개 레코드)`);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === "P2021") {
|
|
||||||
console.log("❌ template_standards 테이블이 존재하지 않습니다.");
|
|
||||||
console.log("👉 데이터베이스 마이그레이션이 필요합니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 샘플 템플릿 생성 테스트
|
|
||||||
console.log("2. 샘플 템플릿 생성 중...");
|
|
||||||
|
|
||||||
const sampleTemplate = {
|
|
||||||
template_code: "test-button-" + Date.now(),
|
|
||||||
template_name: "테스트 버튼",
|
|
||||||
template_name_eng: "Test Button",
|
|
||||||
description: "테스트용 버튼 템플릿",
|
|
||||||
category: "button",
|
|
||||||
icon_name: "mouse-pointer",
|
|
||||||
default_size: {
|
|
||||||
width: 80,
|
|
||||||
height: 36,
|
|
||||||
},
|
|
||||||
layout_config: {
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
type: "widget",
|
|
||||||
widgetType: "button",
|
|
||||||
label: "테스트 버튼",
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
size: { width: 80, height: 36 },
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#3b82f6",
|
|
||||||
color: "#ffffff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
sort_order: 999,
|
|
||||||
is_active: "Y",
|
|
||||||
is_public: "Y",
|
|
||||||
company_code: "*",
|
|
||||||
created_by: "test",
|
|
||||||
updated_by: "test",
|
|
||||||
};
|
|
||||||
|
|
||||||
const created = await prisma.template_standards.create({
|
|
||||||
data: sampleTemplate,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("✅ 샘플 템플릿 생성 성공:", created.template_code);
|
|
||||||
|
|
||||||
// 3. 생성된 템플릿 조회 테스트
|
|
||||||
console.log("3. 템플릿 조회 테스트 중...");
|
|
||||||
|
|
||||||
const retrieved = await prisma.template_standards.findUnique({
|
|
||||||
where: { template_code: created.template_code },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (retrieved) {
|
|
||||||
console.log("✅ 템플릿 조회 성공:", retrieved.template_name);
|
|
||||||
console.log(
|
|
||||||
"📄 Layout Config:",
|
|
||||||
JSON.stringify(retrieved.layout_config, null, 2)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 카테고리 목록 조회 테스트
|
|
||||||
console.log("4. 카테고리 목록 조회 테스트 중...");
|
|
||||||
|
|
||||||
const categories = await prisma.template_standards.findMany({
|
|
||||||
where: { is_active: "Y" },
|
|
||||||
select: { category: true },
|
|
||||||
distinct: ["category"],
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"✅ 발견된 카테고리:",
|
|
||||||
categories.map((c) => c.category)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 5. 테스트 데이터 정리
|
|
||||||
console.log("5. 테스트 데이터 정리 중...");
|
|
||||||
|
|
||||||
await prisma.template_standards.delete({
|
|
||||||
where: { template_code: created.template_code },
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("✅ 테스트 데이터 정리 완료");
|
|
||||||
|
|
||||||
console.log("🎉 모든 테스트 통과!");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 테스트 실패:", error);
|
|
||||||
console.error("📋 상세 정보:", {
|
|
||||||
message: error.message,
|
|
||||||
code: error.code,
|
|
||||||
stack: error.stack?.split("\n").slice(0, 5),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스크립트 실행
|
|
||||||
testTemplateCreation();
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* 마이그레이션 검증 스크립트
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { Pool } = require('pg');
|
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
|
||||||
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: databaseUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function verifyMigration() {
|
|
||||||
const client = await pool.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('🔍 마이그레이션 결과 검증 중...\n');
|
|
||||||
|
|
||||||
// 전체 요소 수
|
|
||||||
const total = await client.query(`
|
|
||||||
SELECT COUNT(*) as count FROM dashboard_elements
|
|
||||||
`);
|
|
||||||
|
|
||||||
// 새로운 subtype별 개수
|
|
||||||
const mapV2 = await client.query(`
|
|
||||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'map-summary-v2'
|
|
||||||
`);
|
|
||||||
|
|
||||||
const chart = await client.query(`
|
|
||||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'chart'
|
|
||||||
`);
|
|
||||||
|
|
||||||
const listV2 = await client.query(`
|
|
||||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'list-v2'
|
|
||||||
`);
|
|
||||||
|
|
||||||
const metricV2 = await client.query(`
|
|
||||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'custom-metric-v2'
|
|
||||||
`);
|
|
||||||
|
|
||||||
const alertV2 = await client.query(`
|
|
||||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'risk-alert-v2'
|
|
||||||
`);
|
|
||||||
|
|
||||||
// 테스트 subtype 남아있는지 확인
|
|
||||||
const remaining = await client.query(`
|
|
||||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype LIKE '%-test%'
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
||||||
console.log('📊 마이그레이션 결과 요약');
|
|
||||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
||||||
console.log(`전체 요소 수: ${total.rows[0].count}`);
|
|
||||||
console.log(`map-summary-v2: ${mapV2.rows[0].count}`);
|
|
||||||
console.log(`chart: ${chart.rows[0].count}`);
|
|
||||||
console.log(`list-v2: ${listV2.rows[0].count}`);
|
|
||||||
console.log(`custom-metric-v2: ${metricV2.rows[0].count}`);
|
|
||||||
console.log(`risk-alert-v2: ${alertV2.rows[0].count}`);
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
if (parseInt(remaining.rows[0].count) > 0) {
|
|
||||||
console.log(`⚠️ 테스트 subtype이 ${remaining.rows[0].count}개 남아있습니다!`);
|
|
||||||
} else {
|
|
||||||
console.log('✅ 모든 테스트 subtype이 정상적으로 변경되었습니다!');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
||||||
console.log('');
|
|
||||||
console.log('🎉 마이그레이션이 성공적으로 완료되었습니다!');
|
|
||||||
console.log('');
|
|
||||||
console.log('다음 단계:');
|
|
||||||
console.log('1. 프론트엔드 애플리케이션을 새로고침하세요');
|
|
||||||
console.log('2. 대시보드를 열어 위젯이 정상적으로 작동하는지 확인하세요');
|
|
||||||
console.log('3. 문제가 발생하면 백업에서 복원하세요');
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ 오류 발생:', error.message);
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyMigration();
|
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ const config: Config = {
|
|||||||
|
|
||||||
// JWT 설정
|
// JWT 설정
|
||||||
jwt: {
|
jwt: {
|
||||||
secret: process.env.JWT_SECRET || "ilshin-plm-super-secret-jwt-key-2024",
|
secret: process.env.JWT_SECRET || "change-this-jwt-secret-in-env",
|
||||||
expiresIn: process.env.JWT_EXPIRES_IN || "24h",
|
expiresIn: process.env.JWT_EXPIRES_IN || "24h",
|
||||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
|
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,9 +7,21 @@ import { getPool } from "../database/db";
|
|||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { numberingRuleService } from "../services/numberingRuleService";
|
import { numberingRuleService } from "../services/numberingRuleService";
|
||||||
|
|
||||||
|
// 자동 마이그레이션: work_instruction_detail에 routing_version_id 컬럼 추가
|
||||||
|
let _migrationDone = false;
|
||||||
|
async function ensureDetailRoutingColumn() {
|
||||||
|
if (_migrationDone) return;
|
||||||
|
try {
|
||||||
|
const pool = getPool();
|
||||||
|
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS routing_version_id VARCHAR(500)");
|
||||||
|
_migrationDone = true;
|
||||||
|
} catch { /* 이미 존재하거나 권한 문제 시 무시 */ }
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 작업지시 목록 조회 (detail 기준 행 반환) ───
|
// ─── 작업지시 목록 조회 (detail 기준 행 반환) ───
|
||||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
await ensureDetailRoutingColumn();
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
|
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
|
||||||
|
|
||||||
@@ -72,6 +84,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
|||||||
d.part_code,
|
d.part_code,
|
||||||
d.source_table,
|
d.source_table,
|
||||||
d.source_id,
|
d.source_id,
|
||||||
|
d.routing_version_id AS detail_routing_version_id,
|
||||||
COALESCE(itm.item_name, '') AS item_name,
|
COALESCE(itm.item_name, '') AS item_name,
|
||||||
COALESCE(itm.size, '') AS item_spec,
|
COALESCE(itm.size, '') AS item_spec,
|
||||||
COALESCE(e.equipment_name, '') AS equipment_name,
|
COALESCE(e.equipment_name, '') AS equipment_name,
|
||||||
@@ -131,6 +144,7 @@ export async function previewNextNo(req: AuthenticatedRequest, res: Response) {
|
|||||||
// ─── 작업지시 저장 (신규/수정) ───
|
// ─── 작업지시 저장 (신규/수정) ───
|
||||||
export async function save(req: AuthenticatedRequest, res: Response) {
|
export async function save(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
await ensureDetailRoutingColumn();
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body;
|
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body;
|
||||||
@@ -175,8 +189,8 @@ export async function save(req: AuthenticatedRequest, res: Response) {
|
|||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9)`,
|
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,NOW(),$10)`,
|
||||||
[companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", userId]
|
[companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", item.routing||null, userId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export class RiskAlertService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2순위: 한국도로공사 API (현재 차단됨)
|
// 2순위: 한국도로공사 API (현재 차단됨)
|
||||||
const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492';
|
const exwayApiKey = process.env.EXWAY_API_KEY || '';
|
||||||
try {
|
try {
|
||||||
const url = 'https://data.ex.co.kr/openapi/business/trafficFcst';
|
const url = 'https://data.ex.co.kr/openapi/business/trafficFcst';
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ export class RiskAlertService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2순위: 한국도로공사 API
|
// 2순위: 한국도로공사 API
|
||||||
const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492';
|
const exwayApiKey = process.env.EXWAY_API_KEY || '';
|
||||||
try {
|
try {
|
||||||
const url = 'https://data.ex.co.kr/openapi/business/trafficFcst';
|
const url = 'https://data.ex.co.kr/openapi/business/trafficFcst';
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
process.env.NODE_ENV = "test";
|
process.env.NODE_ENV = "test";
|
||||||
// 실제 DB 연결을 위해 운영 데이터베이스 사용 (읽기 전용 테스트만 수행)
|
// 실제 DB 연결을 위해 운영 데이터베이스 사용 (읽기 전용 테스트만 수행)
|
||||||
process.env.DATABASE_URL =
|
process.env.DATABASE_URL =
|
||||||
process.env.TEST_DATABASE_URL ||
|
process.env.TEST_DATABASE_URL || "";
|
||||||
"postgresql://postgres:ph0909!!@39.117.244.52:11132/plm";
|
|
||||||
process.env.JWT_SECRET = "test-jwt-secret-key-for-testing-only";
|
process.env.JWT_SECRET = "test-jwt-secret-key-for-testing-only";
|
||||||
process.env.PORT = "3001";
|
process.env.PORT = "3001";
|
||||||
process.env.DEBUG = "true"; // 테스트 시 디버그 로그 활성화
|
process.env.DEBUG = "true"; // 테스트 시 디버그 로그 활성화
|
||||||
|
|||||||
Binary file not shown.
@@ -12,10 +12,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- JWT_EXPIRES_IN=24h
|
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
|
||||||
- ENCRYPTION_KEY=ilshin-plm-encryption-key-2024-secure-32bytes
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
- CORS_ORIGIN=http://localhost:9771
|
- CORS_ORIGIN=http://localhost:9771
|
||||||
- CORS_CREDENTIALS=true
|
- CORS_CREDENTIALS=true
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
JWT_EXPIRES_IN: 24h
|
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h}
|
||||||
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com
|
CORS_ORIGIN: ${CORS_ORIGIN:-https://v1.vexplor.com,https://api.vexplor.com}
|
||||||
CORS_CREDENTIALS: "true"
|
CORS_CREDENTIALS: "true"
|
||||||
LOG_LEVEL: info
|
LOG_LEVEL: info
|
||||||
ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
KMA_API_KEY: ogdXr2e9T4iHV69nvV-IwA
|
KMA_API_KEY: ${KMA_API_KEY}
|
||||||
ITS_API_KEY: d6b9befec3114d648284674b8fddcc32
|
ITS_API_KEY: ${ITS_API_KEY}
|
||||||
EXPRESSWAY_API_KEY: ${EXPRESSWAY_API_KEY:-}
|
EXPRESSWAY_API_KEY: ${EXPRESSWAY_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- backend_uploads:/app/uploads
|
- backend_uploads:/app/uploads
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- JWT_EXPIRES_IN=24h
|
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
|
||||||
- CORS_ORIGIN=http://localhost:9771
|
- CORS_ORIGIN=http://localhost:9771
|
||||||
- CORS_CREDENTIALS=true
|
- CORS_CREDENTIALS=true
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
- ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
- KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
- KMA_API_KEY=${KMA_API_KEY}
|
||||||
- ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
- ITS_API_KEY=${ITS_API_KEY}
|
||||||
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
|
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영
|
- ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
- HOST=0.0.0.0 # 모든 인터페이스에서 바인딩
|
- HOST=0.0.0.0 # 모든 인터페이스에서 바인딩
|
||||||
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- JWT_EXPIRES_IN=24h
|
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
|
||||||
- CORS_ORIGIN=http://192.168.0.70:5555,http://39.117.244.52:5555,http://localhost:9771
|
- CORS_ORIGIN=${CORS_ORIGIN:-http://192.168.0.70:5555,http://39.117.244.52:5555,http://localhost:9771}
|
||||||
- CORS_CREDENTIALS=true
|
- CORS_CREDENTIALS=true
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
- ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
- KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
- KMA_API_KEY=${KMA_API_KEY}
|
||||||
- ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
- ITS_API_KEY=${ITS_API_KEY}
|
||||||
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
|
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ screen_layouts (V1) screen_layouts_v2 (V2)
|
|||||||
docker exec pms-backend-mac node -e '
|
docker exec pms-backend-mac node -e '
|
||||||
const { Pool } = require("pg");
|
const { Pool } = require("pg");
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
connectionString: "postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm?sslmode=disable",
|
connectionString: "postgresql://postgres:$DB_PASSWORD@211.115.91.141:11134/plm?sslmode=disable",
|
||||||
ssl: false
|
ssl: false
|
||||||
});
|
});
|
||||||
// 쿼리 실행
|
// 쿼리 실행
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ CREATE TABLE backup_20260323_screen_group_screens AS
|
|||||||
### STEP 1: 누락 테이블 생성 (배포 DB에서 psql 실행)
|
### STEP 1: 누락 테이블 생성 (배포 DB에서 psql 실행)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm
|
PGPASSWORD='$DB_PASSWORD' psql -h 211.115.91.141 -p 11134 -U postgres -d plm
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 1-1. work_order_process
|
#### 1-1. work_order_process
|
||||||
@@ -366,7 +366,7 @@ COPY (
|
|||||||
) TO STDOUT WITH CSV HEADER" > /tmp/ttc_export.csv
|
) TO STDOUT WITH CSV HEADER" > /tmp/ttc_export.csv
|
||||||
|
|
||||||
# 배포 DB에 삽입 (충돌 시 무시)
|
# 배포 DB에 삽입 (충돌 시 무시)
|
||||||
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
PGPASSWORD='$DB_PASSWORD' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
||||||
COPY table_type_columns FROM STDIN WITH CSV HEADER
|
COPY table_type_columns FROM STDIN WITH CSV HEADER
|
||||||
ON CONFLICT DO NOTHING" < /tmp/ttc_export.csv
|
ON CONFLICT DO NOTHING" < /tmp/ttc_export.csv
|
||||||
```
|
```
|
||||||
@@ -386,7 +386,7 @@ COPY (
|
|||||||
) TO STDOUT WITH CSV HEADER" > /tmp/screen_def.csv
|
) TO STDOUT WITH CSV HEADER" > /tmp/screen_def.csv
|
||||||
|
|
||||||
# 배포에 삽입
|
# 배포에 삽입
|
||||||
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
PGPASSWORD='$DB_PASSWORD' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
||||||
COPY screen_definitions FROM STDIN WITH CSV HEADER
|
COPY screen_definitions FROM STDIN WITH CSV HEADER
|
||||||
ON CONFLICT DO NOTHING" < /tmp/screen_def.csv
|
ON CONFLICT DO NOTHING" < /tmp/screen_def.csv
|
||||||
|
|
||||||
@@ -400,7 +400,7 @@ COPY (
|
|||||||
) TO STDOUT WITH CSV HEADER" > /tmp/screen_layouts.csv
|
) TO STDOUT WITH CSV HEADER" > /tmp/screen_layouts.csv
|
||||||
|
|
||||||
# 배포에 삽입
|
# 배포에 삽입
|
||||||
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
PGPASSWORD='$DB_PASSWORD' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
||||||
COPY screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
|
COPY screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
|
||||||
FROM STDIN WITH CSV HEADER
|
FROM STDIN WITH CSV HEADER
|
||||||
ON CONFLICT (screen_id, company_code) DO NOTHING" < /tmp/screen_layouts.csv
|
ON CONFLICT (screen_id, company_code) DO NOTHING" < /tmp/screen_layouts.csv
|
||||||
@@ -414,7 +414,7 @@ COPY (
|
|||||||
) TO STDOUT WITH CSV HEADER" > /tmp/screen_groups.csv
|
) TO STDOUT WITH CSV HEADER" > /tmp/screen_groups.csv
|
||||||
|
|
||||||
# 배포에 삽입
|
# 배포에 삽입
|
||||||
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
PGPASSWORD='$DB_PASSWORD' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
||||||
COPY screen_groups FROM STDIN WITH CSV HEADER
|
COPY screen_groups FROM STDIN WITH CSV HEADER
|
||||||
ON CONFLICT DO NOTHING" < /tmp/screen_groups.csv
|
ON CONFLICT DO NOTHING" < /tmp/screen_groups.csv
|
||||||
|
|
||||||
@@ -427,7 +427,7 @@ COPY (
|
|||||||
) TO STDOUT WITH CSV HEADER" > /tmp/screen_group_screens.csv
|
) TO STDOUT WITH CSV HEADER" > /tmp/screen_group_screens.csv
|
||||||
|
|
||||||
# 배포에 삽입
|
# 배포에 삽입
|
||||||
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
PGPASSWORD='$DB_PASSWORD' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
||||||
COPY screen_group_screens FROM STDIN WITH CSV HEADER
|
COPY screen_group_screens FROM STDIN WITH CSV HEADER
|
||||||
ON CONFLICT DO NOTHING" < /tmp/screen_group_screens.csv
|
ON CONFLICT DO NOTHING" < /tmp/screen_group_screens.csv
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
현재 `.env`에 설정된 키:
|
현재 `.env`에 설정된 키:
|
||||||
```bash
|
```bash
|
||||||
KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
KMA_API_KEY=${KMA_API_KEY}
|
||||||
```
|
```
|
||||||
|
|
||||||
**사용 API:**
|
**사용 API:**
|
||||||
@@ -105,7 +105,7 @@ nano .env
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 기상청 API Hub 키 (기존 - 예특보 활용신청 완료 시 사용)
|
# 기상청 API Hub 키 (기존 - 예특보 활용신청 완료 시 사용)
|
||||||
KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
KMA_API_KEY=${KMA_API_KEY}
|
||||||
|
|
||||||
# 국토교통부 도로교통 API 키 (활용신청 완료 시 추가)
|
# 국토교통부 도로교통 API 키 (활용신청 완료 시 추가)
|
||||||
MOLIT_TRAFFIC_API_KEY=여기에_발급받은_교통사고_API_인증키_붙여넣기
|
MOLIT_TRAFFIC_API_KEY=여기에_발급받은_교통사고_API_인증키_붙여넣기
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ uploads/
|
|||||||
|
|
||||||
### 필수 환경변수
|
### 필수 환경변수
|
||||||
```bash
|
```bash
|
||||||
ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 필수 디렉토리
|
### 필수 디렉토리
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ MailDesigner 통합 및 템플릿 저장/불러오기
|
|||||||
### 필수 환경변수
|
### 필수 환경변수
|
||||||
```bash
|
```bash
|
||||||
# docker/dev/docker-compose.backend.mac.yml
|
# docker/dev/docker-compose.backend.mac.yml
|
||||||
ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 저장 디렉토리 생성
|
### 저장 디렉토리 생성
|
||||||
|
|||||||
+675
-207
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Plus, Trash2, RotateCcw, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck } from "lucide-react";
|
import { Plus, Trash2, RotateCcw, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -39,6 +40,7 @@ interface EmployeeOption { user_id: string; user_name: string; dept_name: string
|
|||||||
interface SelectedItem {
|
interface SelectedItem {
|
||||||
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
|
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
|
||||||
sourceType: SourceType; sourceTable: string; sourceId: string | number;
|
sourceType: SourceType; sourceTable: string; sourceId: string | number;
|
||||||
|
routing?: string; routingOptions?: RoutingVersionData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WorkInstructionPage() {
|
export default function WorkInstructionPage() {
|
||||||
@@ -206,14 +208,17 @@ export default function WorkInstructionPage() {
|
|||||||
setConfirmRouting(""); setConfirmRoutingOptions([]);
|
setConfirmRouting(""); setConfirmRoutingOptions([]);
|
||||||
previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)"));
|
previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)"));
|
||||||
|
|
||||||
// 첫 번째 품목의 라우팅 로드
|
// 품목별 라우팅 옵션 로드
|
||||||
const firstItem = items.length > 0 ? items[0] : null;
|
const finalItems = regMergeSameItem ? Array.from(new Map(items.map(i => [i.itemCode, i])).values()) : items;
|
||||||
if (firstItem) {
|
const uniqueItemCodes = [...new Set(finalItems.map(i => i.itemCode).filter(Boolean))];
|
||||||
getRoutingVersions("__new__", firstItem.itemCode).then(r => {
|
for (const ic of uniqueItemCodes) {
|
||||||
|
getRoutingVersions("__new__", ic).then(r => {
|
||||||
if (r.success && r.data) {
|
if (r.success && r.data) {
|
||||||
setConfirmRoutingOptions(r.data);
|
setConfirmItems(prev => prev.map(it => {
|
||||||
const defaultRouting = r.data.find(rv => rv.is_default);
|
if (it.itemCode !== ic) return it;
|
||||||
if (defaultRouting) setConfirmRouting(defaultRouting.id);
|
const defaultRv = r.data.find(rv => rv.is_default);
|
||||||
|
return { ...it, routingOptions: r.data, routing: defaultRv?.id || "" };
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -242,7 +247,7 @@ export default function WorkInstructionPage() {
|
|||||||
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
|
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
|
||||||
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
|
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
|
||||||
routing: confirmRouting || null,
|
routing: confirmRouting || null,
|
||||||
items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
|
items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })),
|
||||||
};
|
};
|
||||||
const r = await saveWorkInstruction(payload);
|
const r = await saveWorkInstruction(payload);
|
||||||
if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); }
|
if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); }
|
||||||
@@ -258,21 +263,35 @@ export default function WorkInstructionPage() {
|
|||||||
setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || "");
|
setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || "");
|
||||||
setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || "");
|
setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || "");
|
||||||
setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || "");
|
setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || "");
|
||||||
setEditItems(relatedDetails.map((d: any) => ({
|
const items: SelectedItem[] = relatedDetails.map((d: any) => ({
|
||||||
itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "",
|
itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "",
|
||||||
qty: Number(d.detail_qty || 0), remark: d.detail_remark || "",
|
qty: Number(d.detail_qty || 0), remark: d.detail_remark || "",
|
||||||
sourceType: (d.source_table === "sales_order_detail" ? "order" : d.source_table === "production_plan_mng" ? "production" : "item") as SourceType,
|
sourceType: (d.source_table === "sales_order_detail" ? "order" : d.source_table === "production_plan_mng" ? "production" : "item") as SourceType,
|
||||||
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
|
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
|
||||||
})));
|
routing: d.detail_routing_version_id || order.routing_version_id || "",
|
||||||
|
routingOptions: [],
|
||||||
|
}));
|
||||||
|
setEditItems(items);
|
||||||
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
|
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
|
||||||
setEditRouting(order.routing_version_id || "");
|
setEditRouting(order.routing_version_id || "");
|
||||||
setEditRoutingOptions([]);
|
setEditRoutingOptions([]);
|
||||||
|
|
||||||
// 라우팅 옵션 로드
|
// 품목별 라우팅 옵션 로드
|
||||||
const itemCode = order.item_number || order.part_code || "";
|
const uniqueItemCodes = [...new Set(items.map(i => i.itemCode).filter(Boolean))];
|
||||||
if (itemCode) {
|
for (const ic of uniqueItemCodes) {
|
||||||
getRoutingVersions(wiNo, itemCode).then(r => {
|
getRoutingVersions(wiNo, ic).then(r => {
|
||||||
if (r.success && r.data) setEditRoutingOptions(r.data);
|
if (r.success && r.data) {
|
||||||
|
setEditItems(prev => prev.map(it => {
|
||||||
|
if (it.itemCode !== ic) return it;
|
||||||
|
const opts = r.data;
|
||||||
|
const hasRouting = it.routing && opts.some(rv => rv.id === it.routing);
|
||||||
|
return {
|
||||||
|
...it,
|
||||||
|
routingOptions: opts,
|
||||||
|
routing: hasRouting ? it.routing : (opts.find(rv => rv.is_default)?.id || it.routing || ""),
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +315,7 @@ export default function WorkInstructionPage() {
|
|||||||
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
|
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
|
||||||
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
|
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
|
||||||
routing: editRouting || null,
|
routing: editRouting || null,
|
||||||
items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
|
items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })),
|
||||||
};
|
};
|
||||||
const r = await saveWorkInstruction(payload);
|
const r = await saveWorkInstruction(payload);
|
||||||
if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); }
|
if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); }
|
||||||
@@ -600,13 +619,20 @@ export default function WorkInstructionPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* ── 2단계: 확인 모달 ── */}
|
{/* ── 2단계: 확인 모달 ── */}
|
||||||
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
|
<FullscreenDialog
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[1000px] max-h-[90vh] flex flex-col p-0 gap-0">
|
open={isConfirmModalOpen}
|
||||||
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
onOpenChange={setIsConfirmModalOpen}
|
||||||
<DialogTitle className="text-base flex items-center gap-2"><CheckCircle2 className="w-4 h-4" /> 작업지시 적용 확인</DialogTitle>
|
title="작업지시 적용 확인"
|
||||||
<DialogDescription className="text-xs">기본 정보를 입력하고 "최종 적용" 버튼을 눌러주세요.</DialogDescription>
|
description="기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요."
|
||||||
</DialogHeader>
|
footer={
|
||||||
<div className="flex-1 overflow-auto p-6 space-y-5">
|
<>
|
||||||
|
<Button variant="outline" onClick={() => { setIsConfirmModalOpen(false); setIsRegModalOpen(true); }}><ChevronLeft className="w-4 h-4 mr-1" /> 이전</Button>
|
||||||
|
<Button variant="outline" onClick={() => setIsConfirmModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={finalizeRegistration} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />} 최종 적용</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
<div className="bg-muted/30 border rounded-lg p-5">
|
<div className="bg-muted/30 border rounded-lg p-5">
|
||||||
<h4 className="text-sm font-semibold mb-4">작업지시 기본 정보</h4>
|
<h4 className="text-sm font-semibold mb-4">작업지시 기본 정보</h4>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
@@ -625,27 +651,24 @@ export default function WorkInstructionPage() {
|
|||||||
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
||||||
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
|
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5"><Label className="text-xs">라우팅</Label>
|
<div className="space-y-1.5"><Label className="text-xs">비고</Label><Input className="h-9" placeholder="비고" /></div>
|
||||||
<Select value={nv(confirmRouting)} onValueChange={v => setConfirmRouting(fromNv(v))}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">선택 안 함</SelectItem>
|
|
||||||
{confirmRoutingOptions.map(rv => (
|
|
||||||
<SelectItem key={rv.id} value={rv.id}>
|
|
||||||
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg p-5">
|
<div className="border rounded-lg p-5">
|
||||||
<h4 className="text-sm font-semibold mb-3">품목 목록</h4>
|
<h4 className="text-sm font-semibold mb-3">품목 목록</h4>
|
||||||
<div className="max-h-[300px] overflow-auto">
|
<div className="overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-background z-10">
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[100px]">수량</TableHead><TableHead>비고</TableHead><TableHead className="w-[60px]" /></TableRow>
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px]">순번</TableHead>
|
||||||
|
<TableHead className="w-[110px]">품목코드</TableHead>
|
||||||
|
<TableHead>품목명</TableHead>
|
||||||
|
<TableHead className="w-[80px]">규격</TableHead>
|
||||||
|
<TableHead className="w-[90px]">수량</TableHead>
|
||||||
|
<TableHead className="w-[180px]">라우팅</TableHead>
|
||||||
|
<TableHead>비고</TableHead>
|
||||||
|
<TableHead className="w-[40px]" />
|
||||||
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{confirmItems.map((item, idx) => (
|
{confirmItems.map((item, idx) => (
|
||||||
@@ -655,6 +678,25 @@ export default function WorkInstructionPage() {
|
|||||||
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
|
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
|
||||||
<TableCell className="text-xs">{item.spec || "-"}</TableCell>
|
<TableCell className="text-xs">{item.spec || "-"}</TableCell>
|
||||||
<TableCell><Input type="number" className="h-7 text-xs w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
<TableCell><Input type="number" className="h-7 text-xs w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
value={nv(item.routing || "")}
|
||||||
|
onValueChange={v => {
|
||||||
|
const val = fromNv(v);
|
||||||
|
setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, routing: val } : it));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="라우팅" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택 안 함</SelectItem>
|
||||||
|
{(item.routingOptions || []).map((rv: RoutingVersionData) => (
|
||||||
|
<SelectItem key={rv.id} value={rv.id}>
|
||||||
|
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||||
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -664,22 +706,22 @@ export default function WorkInstructionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
</FullscreenDialog>
|
||||||
<Button variant="outline" onClick={() => { setIsConfirmModalOpen(false); setIsRegModalOpen(true); }}><ChevronLeft className="w-4 h-4 mr-1" /> 이전</Button>
|
|
||||||
<Button variant="outline" onClick={() => setIsConfirmModalOpen(false)}>취소</Button>
|
|
||||||
<Button onClick={finalizeRegistration} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />} 최종 적용</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* ── 수정 모달 ── */}
|
{/* ── 수정 모달 ── */}
|
||||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
<FullscreenDialog
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[90vh] flex flex-col p-0 gap-0">
|
open={isEditModalOpen}
|
||||||
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
onOpenChange={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}
|
||||||
<DialogTitle className="text-base flex items-center gap-2"><Wrench className="w-4 h-4" /> 작업지시 관리 - {editOrder?.work_instruction_no}</DialogTitle>
|
title={`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}
|
||||||
<DialogDescription className="text-xs">품목을 추가/삭제하고 정보를 수정하세요.</DialogDescription>
|
description="품목을 추가/삭제하고 정보를 수정하세요."
|
||||||
</DialogHeader>
|
footer={
|
||||||
<div className="flex-1 overflow-auto p-6 space-y-5">
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={saveEdit} disabled={editSaving}>{editSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
<div className="bg-muted/30 border rounded-lg p-5">
|
<div className="bg-muted/30 border rounded-lg p-5">
|
||||||
<h4 className="text-sm font-semibold mb-4">기본 정보</h4>
|
<h4 className="text-sm font-semibold mb-4">기본 정보</h4>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
@@ -691,64 +733,81 @@ export default function WorkInstructionPage() {
|
|||||||
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
||||||
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
|
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5"><Label className="text-xs">라우팅</Label>
|
|
||||||
<Select value={nv(editRouting)} onValueChange={v => setEditRouting(fromNv(v))}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">선택 안 함</SelectItem>
|
|
||||||
{editRoutingOptions.map(rv => (
|
|
||||||
<SelectItem key={rv.id} value={rv.id}>
|
|
||||||
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5"><Label className="text-xs">공정작업기준</Label>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-9 w-full text-xs"
|
|
||||||
disabled={!editRouting}
|
|
||||||
onClick={() => {
|
|
||||||
if (!editOrder || !editRouting) return;
|
|
||||||
const rv = editRoutingOptions.find(r => r.id === editRouting);
|
|
||||||
openWorkStandardModal(
|
|
||||||
editOrder.work_instruction_no,
|
|
||||||
editRouting,
|
|
||||||
rv?.version_name || "",
|
|
||||||
editOrder.item_name || editOrder.item_number || "",
|
|
||||||
editOrder.item_number || ""
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ClipboardCheck className="w-3.5 h-3.5 mr-1.5" /> 작업기준 수정
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5 col-span-2"><Label className="text-xs">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고" /></div>
|
<div className="space-y-1.5 col-span-2"><Label className="text-xs">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고" /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 품목 테이블 */}
|
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/20 border-b">
|
<div className="flex items-center justify-between p-3 bg-muted/20 border-b">
|
||||||
<span className="text-sm font-semibold">작업지시 항목</span>
|
<span className="text-sm font-semibold">작업지시 항목</span>
|
||||||
<span className="text-xs text-muted-foreground">{editItems.length}건</span>
|
<span className="text-xs text-muted-foreground">{editItems.length}건</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[280px] overflow-auto">
|
<div className="overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-background z-10">
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[100px] text-right">수량</TableHead><TableHead>비고</TableHead><TableHead className="w-[60px]" /></TableRow>
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px]">순번</TableHead>
|
||||||
|
<TableHead className="w-[110px]">품목코드</TableHead>
|
||||||
|
<TableHead>품목명</TableHead>
|
||||||
|
<TableHead className="w-[80px]">규격</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-right">수량</TableHead>
|
||||||
|
<TableHead className="w-[180px]">라우팅</TableHead>
|
||||||
|
<TableHead className="w-[100px]">공정작업기준</TableHead>
|
||||||
|
<TableHead>비고</TableHead>
|
||||||
|
<TableHead className="w-[40px]" />
|
||||||
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{editItems.length === 0 ? (
|
{editItems.length === 0 ? (
|
||||||
<TableRow><TableCell colSpan={7} className="text-center py-8 text-muted-foreground text-sm">품목이 없습니다</TableCell></TableRow>
|
<TableRow><TableCell colSpan={9} className="text-center py-8 text-muted-foreground text-sm">품목이 없습니다</TableCell></TableRow>
|
||||||
) : editItems.map((item, idx) => (
|
) : editItems.map((item, idx) => (
|
||||||
<TableRow key={idx}>
|
<TableRow key={idx}>
|
||||||
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
||||||
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
||||||
<TableCell className="text-xs max-w-[180px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
<TableCell className="text-xs max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
||||||
<TableCell className="text-xs max-w-[100px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
|
<TableCell className="text-xs truncate" title={item.spec}>{item.spec || "-"}</TableCell>
|
||||||
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
value={nv(item.routing || "")}
|
||||||
|
onValueChange={v => {
|
||||||
|
const val = fromNv(v);
|
||||||
|
setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, routing: val } : it));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="라우팅" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택 안 함</SelectItem>
|
||||||
|
{(item.routingOptions || []).map((rv: RoutingVersionData) => (
|
||||||
|
<SelectItem key={rv.id} value={rv.id}>
|
||||||
|
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={!item.routing}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editOrder || !item.routing) return;
|
||||||
|
const rv = (item.routingOptions || []).find((r: RoutingVersionData) => r.id === item.routing);
|
||||||
|
openWorkStandardModal(
|
||||||
|
editOrder.work_instruction_no,
|
||||||
|
item.routing,
|
||||||
|
rv?.version_name || "",
|
||||||
|
item.itemName || item.itemCode || "",
|
||||||
|
item.itemCode || ""
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="w-3 h-3 mr-1" /> 수정
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||||
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -764,12 +823,7 @@ export default function WorkInstructionPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
</FullscreenDialog>
|
||||||
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}>취소</Button>
|
|
||||||
<Button onClick={saveEdit} disabled={editSaving}>{editSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* 공정작업기준 수정 모달 */}
|
{/* 공정작업기준 수정 모달 */}
|
||||||
<WorkStandardEditModal
|
<WorkStandardEditModal
|
||||||
|
|||||||
@@ -209,8 +209,11 @@ export default function SalesOrderPage() {
|
|||||||
} catch { /* skip */ }
|
} catch { /* skip */ }
|
||||||
}
|
}
|
||||||
setCategoryOptions(optMap);
|
setCategoryOptions(optMap);
|
||||||
// division 기본값: 라벨이 "영업관리"인 코드로 설정
|
// division 기본값: 영업관리/제품/판매품 라벨 순서로 탐색
|
||||||
const salesDiv = (optMap["item_division"] || []).find((o: any) => o.label === "영업관리");
|
const divs = optMap["item_division"] || [];
|
||||||
|
const salesDiv = divs.find((o: any) => o.label === "영업관리")
|
||||||
|
|| divs.find((o: any) => o.label === "제품")
|
||||||
|
|| divs.find((o: any) => o.label === "판매품");
|
||||||
if (salesDiv) setItemSearchDivision(salesDiv.code);
|
if (salesDiv) setItemSearchDivision(salesDiv.code);
|
||||||
};
|
};
|
||||||
loadCategories();
|
loadCategories();
|
||||||
|
|||||||
@@ -639,48 +639,68 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||||||
// 각 카테고리 컬럼의 값 목록 조회
|
// 각 카테고리 컬럼의 값 목록 조회
|
||||||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
||||||
|
|
||||||
|
const flattenCategoryTree = (items: any[], parentLabel: string = ""): Record<string, { label: string; color?: string }> => {
|
||||||
|
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||||
|
items.forEach((item: any) => {
|
||||||
|
const displayLabel = parentLabel
|
||||||
|
? `${parentLabel} / ${item.valueLabel}`
|
||||||
|
: item.valueLabel;
|
||||||
|
if (item.valueCode) {
|
||||||
|
mapping[item.valueCode] = { label: displayLabel, color: item.color };
|
||||||
|
}
|
||||||
|
if (item.valueId !== undefined && item.valueId !== null) {
|
||||||
|
mapping[String(item.valueId)] = { label: displayLabel, color: item.color };
|
||||||
|
}
|
||||||
|
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
||||||
|
Object.assign(mapping, flattenCategoryTree(item.children, item.valueLabel));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return mapping;
|
||||||
|
};
|
||||||
|
|
||||||
for (const col of categoryColumns) {
|
for (const col of categoryColumns) {
|
||||||
try {
|
try {
|
||||||
// menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용)
|
|
||||||
const queryParams = menuObjid ? `?menuObjid=${menuObjid}&includeInactive=true` : "?includeInactive=true";
|
const queryParams = menuObjid ? `?menuObjid=${menuObjid}&includeInactive=true` : "?includeInactive=true";
|
||||||
const response = await apiClient.get(
|
const response = await apiClient.get(
|
||||||
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`,
|
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data.success && response.data.data) {
|
if (response.data.success && response.data.data) {
|
||||||
// valueCode 및 valueId -> {label, color} 매핑 생성 (트리 재귀 평탄화)
|
const mapping = flattenCategoryTree(response.data.data);
|
||||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
if (Object.keys(mapping).length > 0) {
|
||||||
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
|
mappings[col.columnName] = mapping;
|
||||||
items.forEach((item: any) => {
|
} else {
|
||||||
const displayLabel = parentLabel
|
// 해당 테이블에 카테고리 값이 없으면 item_info에서 fallback 조회
|
||||||
? `${parentLabel} / ${item.valueLabel}`
|
try {
|
||||||
: item.valueLabel;
|
const fbRes = await apiClient.get(`/table-categories/item_info/${col.columnName}/values?includeInactive=true`);
|
||||||
if (item.valueCode) {
|
if (fbRes.data.success && fbRes.data.data) {
|
||||||
mapping[item.valueCode] = {
|
const fbMapping = flattenCategoryTree(fbRes.data.data);
|
||||||
label: displayLabel,
|
if (Object.keys(fbMapping).length > 0) mappings[col.columnName] = fbMapping;
|
||||||
color: item.color,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (item.valueId !== undefined && item.valueId !== null) {
|
} catch { /* 무시 */ }
|
||||||
mapping[String(item.valueId)] = {
|
}
|
||||||
label: displayLabel,
|
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mappings[col.columnName], { menuObjid });
|
||||||
color: item.color,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
|
||||||
flattenCategoryTree(item.children, item.valueLabel);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
flattenCategoryTree(response.data.data);
|
|
||||||
mappings[col.columnName] = mapping;
|
|
||||||
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid });
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ 카테고리 값 로드 실패 [${col.columnName}]:`, error);
|
console.error(`❌ 카테고리 값 로드 실패 [${col.columnName}]:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 카테고리 타입이 아닌 컬럼 중 division/unit/type 등 흔한 카테고리 컬럼은 item_info에서 fallback 로드
|
||||||
|
const KNOWN_CAT_COLS = ["division", "unit", "type", "material"];
|
||||||
|
const allColNames = (component.columns || []).map((c) => c.columnName);
|
||||||
|
for (const colName of allColNames) {
|
||||||
|
if (mappings[colName]) continue; // 이미 로드됨
|
||||||
|
if (!KNOWN_CAT_COLS.includes(colName)) continue;
|
||||||
|
try {
|
||||||
|
const fbRes = await apiClient.get(`/table-categories/item_info/${colName}/values?includeInactive=true`);
|
||||||
|
if (fbRes.data.success && fbRes.data.data?.length > 0) {
|
||||||
|
const fbMapping = flattenCategoryTree(fbRes.data.data);
|
||||||
|
if (Object.keys(fbMapping).length > 0) mappings[colName] = fbMapping;
|
||||||
|
}
|
||||||
|
} catch { /* 무시 */ }
|
||||||
|
}
|
||||||
|
|
||||||
console.log("📊 전체 카테고리 매핑:", mappings);
|
console.log("📊 전체 카테고리 매핑:", mappings);
|
||||||
setCategoryMappings(mappings);
|
setCategoryMappings(mappings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2403,37 +2423,30 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
// 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값)
|
|
||||||
const strValue = String(value);
|
const strValue = String(value);
|
||||||
if (strValue.startsWith("CATEGORY_")) {
|
// 카테고리 코드 패턴 감지 (CAT_ 또는 CATEGORY_로 시작하는 값, 세미콜론 구분 다중값 포함)
|
||||||
// 1. categoryMappings에서 해당 코드 검색 (색상 정보 포함)
|
const looksLikeCatCode = (v: string) => v.startsWith("CAT_") || v.startsWith("CATEGORY_");
|
||||||
for (const columnName of Object.keys(categoryMappings)) {
|
if (looksLikeCatCode(strValue) || (strValue.includes(";") && strValue.split(";").some(s => looksLikeCatCode(s.trim())))) {
|
||||||
const mapping = categoryMappings[columnName];
|
// 세미콜론 구분 다중값 처리
|
||||||
const categoryData = mapping?.[strValue];
|
const codes = strValue.includes(";") ? strValue.split(";").map(s => s.trim()) : [strValue];
|
||||||
if (categoryData) {
|
const labels: string[] = [];
|
||||||
// 색상이 있으면 배지로, 없으면 텍스트로 표시
|
for (const code of codes) {
|
||||||
if (categoryData.color && categoryData.color !== "none") {
|
let found = false;
|
||||||
return (
|
// 1. 해당 컬럼의 categoryMappings에서 먼저 검색
|
||||||
<Badge
|
const colMapping = categoryMappings[column.columnName];
|
||||||
style={{
|
if (colMapping?.[code]) { labels.push(colMapping[code].label); found = true; }
|
||||||
backgroundColor: categoryData.color,
|
// 2. 전체 매핑에서 검색
|
||||||
borderColor: categoryData.color,
|
if (!found) {
|
||||||
}}
|
for (const cn of Object.keys(categoryMappings)) {
|
||||||
className="text-white"
|
const mapping = categoryMappings[cn];
|
||||||
>
|
if (mapping?.[code]) { labels.push(mapping[code].label); found = true; break; }
|
||||||
{categoryData.label}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return <span className="text-sm">{categoryData.label}</span>;
|
|
||||||
}
|
}
|
||||||
|
// 3. categoryCodeLabels에서 검색
|
||||||
|
if (!found && categoryCodeLabels[code]) { labels.push(categoryCodeLabels[code]); found = true; }
|
||||||
|
if (!found) labels.push(code);
|
||||||
}
|
}
|
||||||
|
return <span className="text-sm">{labels.join(", ")}</span>;
|
||||||
// 2. categoryCodeLabels에서 검색 (API로 조회한 라벨)
|
|
||||||
const cachedLabel = categoryCodeLabels[strValue];
|
|
||||||
if (cachedLabel) {
|
|
||||||
return <span className="text-sm">{cachedLabel}</span>;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return strValue;
|
return strValue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,10 +79,15 @@ export interface WIWorkItemDetail {
|
|||||||
unit?: string;
|
unit?: string;
|
||||||
lower_limit?: string;
|
lower_limit?: string;
|
||||||
upper_limit?: string;
|
upper_limit?: string;
|
||||||
|
base_value?: string;
|
||||||
|
tolerance?: string;
|
||||||
duration_minutes?: number;
|
duration_minutes?: number;
|
||||||
input_type?: string;
|
input_type?: string;
|
||||||
lookup_target?: string;
|
lookup_target?: string;
|
||||||
display_fields?: string;
|
display_fields?: string;
|
||||||
|
condition_base_value?: string;
|
||||||
|
condition_tolerance?: string;
|
||||||
|
condition_unit?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WIWorkItem {
|
export interface WIWorkItem {
|
||||||
|
|||||||
@@ -100,6 +100,38 @@ function ItemSearchModal({
|
|||||||
const [items, setItems] = useState<ItemInfo[]>([]);
|
const [items, setItems] = useState<ItemInfo[]>([]);
|
||||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [catLabels, setCatLabels] = useState<Record<string, Record<string, string>>>({});
|
||||||
|
|
||||||
|
// item_info 카테고리 라벨 로드 (division, unit, type)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLabels = async () => {
|
||||||
|
for (const col of ["division", "unit", "type"]) {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/table-categories/item_info/${col}/values?includeInactive=true`);
|
||||||
|
const vals = res.data?.data || [];
|
||||||
|
if (vals.length > 0) {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
vals.forEach((v: any) => { const code = v.valueCode || v.value_code; const label = v.valueLabel || v.value_label; if (code) map[code] = label; });
|
||||||
|
setCatLabels((prev) => ({ ...prev, [col]: map }));
|
||||||
|
}
|
||||||
|
} catch { /* 무시 */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadLabels();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolveCatLabel = (value: string, ...cols: string[]) => {
|
||||||
|
if (!value) return "-";
|
||||||
|
const resolve = (code: string) => {
|
||||||
|
for (const col of cols) { if (catLabels[col]?.[code]) return catLabels[col][code]; }
|
||||||
|
return code;
|
||||||
|
};
|
||||||
|
if (value.includes(",") || value.includes(";")) {
|
||||||
|
const delim = value.includes(";") ? ";" : ",";
|
||||||
|
return value.split(delim).map(s => resolve(s.trim())).filter(Boolean).join(", ");
|
||||||
|
}
|
||||||
|
return resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
const searchItems = useCallback(
|
const searchItems = useCallback(
|
||||||
async (query: string) => {
|
async (query: string) => {
|
||||||
@@ -244,8 +276,8 @@ function ItemSearchModal({
|
|||||||
{alreadyAdded && <span className="text-muted-foreground ml-1 text-[10px]">(추가됨)</span>}
|
{alreadyAdded && <span className="text-muted-foreground ml-1 text-[10px]">(추가됨)</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">{item.item_name}</td>
|
<td className="px-3 py-2">{item.item_name}</td>
|
||||||
<td className="px-3 py-2">{item.type}</td>
|
<td className="px-3 py-2">{resolveCatLabel(item.type || "", "division", "type")}</td>
|
||||||
<td className="px-3 py-2">{item.unit}</td>
|
<td className="px-3 py-2">{resolveCatLabel(item.unit || "", "unit")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -142,24 +142,35 @@ export function BomTreeComponent({
|
|||||||
const showHistory = features.showHistory !== false;
|
const showHistory = features.showHistory !== false;
|
||||||
const showVersion = features.showVersion !== false;
|
const showVersion = features.showVersion !== false;
|
||||||
|
|
||||||
// 카테고리 라벨 캐시 (inputType === "category"인 모든 컬럼)
|
// 카테고리 라벨 캐시
|
||||||
const [categoryLabels, setCategoryLabels] = useState<Record<string, Record<string, string>>>({});
|
const [categoryLabels, setCategoryLabels] = useState<Record<string, Record<string, string>>>({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const categoryColumns = displayColumns.filter((c) => c.inputType === "category");
|
|
||||||
if (categoryColumns.length === 0) return;
|
|
||||||
|
|
||||||
const loadLabels = async () => {
|
const loadLabels = async () => {
|
||||||
|
// inputType === "category"인 컬럼의 카테고리 로드
|
||||||
|
const categoryColumns = displayColumns.filter((c) => c.inputType === "category");
|
||||||
for (const col of categoryColumns) {
|
for (const col of categoryColumns) {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get(`/table-categories/${detailTable}/${col.key}/values?includeInactive=true`);
|
const res = await apiClient.get(`/table-categories/${detailTable}/${col.key}/values?includeInactive=true`);
|
||||||
const vals = res.data?.data || [];
|
const vals = res.data?.data || [];
|
||||||
if (vals.length > 0) {
|
if (vals.length > 0) {
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
vals.forEach((v: any) => { map[v.value_code] = v.value_label; });
|
vals.forEach((v: any) => { const code = v.valueCode || v.value_code; const label = v.valueLabel || v.value_label; if (code) map[code] = label; });
|
||||||
setCategoryLabels((prev) => ({ ...prev, [col.key]: map }));
|
setCategoryLabels((prev) => ({ ...prev, [col.key]: map }));
|
||||||
}
|
}
|
||||||
} catch { /* 무시 */ }
|
} catch { /* 무시 */ }
|
||||||
}
|
}
|
||||||
|
// item_info의 division/unit/type 카테고리 항상 로드 (BOM 헤더/상세의 구분/단위 컬럼용)
|
||||||
|
for (const col of ["division", "unit", "type"]) {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/table-categories/item_info/${col}/values?includeInactive=true`);
|
||||||
|
const vals = res.data?.data || [];
|
||||||
|
if (vals.length > 0) {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
vals.forEach((v: any) => { const code = v.valueCode || v.value_code; const label = v.valueLabel || v.value_label; if (code) map[code] = label; });
|
||||||
|
setCategoryLabels((prev) => ({ ...prev, [`item_${col}`]: map }));
|
||||||
|
}
|
||||||
|
} catch { /* 무시 */ }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
loadLabels();
|
loadLabels();
|
||||||
}, [detailTable, displayColumns]);
|
}, [detailTable, displayColumns]);
|
||||||
@@ -449,7 +460,21 @@ export function BomTreeComponent({
|
|||||||
|
|
||||||
const getItemTypeLabel = (type: string) => {
|
const getItemTypeLabel = (type: string) => {
|
||||||
const map: Record<string, string> = { product: "제품", semi: "반제품", material: "원자재", part: "부품" };
|
const map: Record<string, string> = { product: "제품", semi: "반제품", material: "원자재", part: "부품" };
|
||||||
return map[type] || type || "-";
|
if (map[type]) return map[type];
|
||||||
|
// 카테고리 라벨에서 코드→라벨 변환 (division, type 모두 확인)
|
||||||
|
const fromDiv = categoryLabels["item_division"]?.[type];
|
||||||
|
if (fromDiv) return fromDiv;
|
||||||
|
const fromType = categoryLabels["item_type"]?.[type];
|
||||||
|
if (fromType) return fromType;
|
||||||
|
// 콤마/세미콜론 구분 다중값인 경우 각각 변환
|
||||||
|
if (type && (type.includes(";") || type.includes(","))) {
|
||||||
|
const delimiter = type.includes(";") ? ";" : ",";
|
||||||
|
return type.split(delimiter).map(t => {
|
||||||
|
const trimmed = t.trim();
|
||||||
|
return map[trimmed] || categoryLabels["item_division"]?.[trimmed] || categoryLabels["item_type"]?.[trimmed] || trimmed;
|
||||||
|
}).join(", ");
|
||||||
|
}
|
||||||
|
return type || "-";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getItemTypeBadge = (type: string) => {
|
const getItemTypeBadge = (type: string) => {
|
||||||
@@ -545,14 +570,15 @@ export function BomTreeComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (col.key === "unit") {
|
if (col.key === "unit") {
|
||||||
const unitLabel = categoryLabels[col.key]?.[String(value)] || value;
|
const unitLabel = categoryLabels[col.key]?.[String(value)] || categoryLabels["item_unit"]?.[String(value)] || value;
|
||||||
return <span className="text-muted-foreground">{unitLabel || "-"}</span>;
|
return <span className="text-muted-foreground">{unitLabel || "-"}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback: 카테고리 라벨이 로드된 컬럼이면 라벨로 변환
|
// fallback: 카테고리 라벨이 로드된 컬럼이면 라벨로 변환
|
||||||
if (categoryLabels[col.key] && value) {
|
if (value) {
|
||||||
const label = categoryLabels[col.key][String(value)] || String(value);
|
const label = categoryLabels[col.key]?.[String(value)]
|
||||||
return <span className="text-muted-foreground">{label || "-"}</span>;
|
|| categoryLabels[`item_${col.key}`]?.[String(value)];
|
||||||
|
if (label) return <span className="text-muted-foreground">{label}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <span className="text-muted-foreground">{value ?? "-"}</span>;
|
return <span className="text-muted-foreground">{value ?? "-"}</span>;
|
||||||
|
|||||||
+54
-19
@@ -1233,22 +1233,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||||||
|
|
||||||
const strValue = String(value);
|
const strValue = String(value);
|
||||||
|
|
||||||
if (mapping && mapping[strValue]) {
|
// 카테고리 코드 라벨 변환 헬퍼
|
||||||
const categoryData = mapping[strValue];
|
const resolveLabel = (code: string): string | null => {
|
||||||
return categoryData.label || strValue;
|
if (mapping && mapping[code]) return mapping[code].label || code;
|
||||||
}
|
|
||||||
|
|
||||||
// 전역 폴백: 컬럼명으로 매핑을 못 찾았을 때, 전체 매핑에서 값 검색
|
|
||||||
if (!mapping && (strValue.startsWith("CAT_") || strValue.startsWith("CATEGORY_"))) {
|
|
||||||
for (const key of Object.keys(categoryMappings)) {
|
for (const key of Object.keys(categoryMappings)) {
|
||||||
const m = categoryMappings[key];
|
const m = categoryMappings[key];
|
||||||
if (m && m[strValue]) {
|
if (m && m[code]) return m[code].label || code;
|
||||||
const categoryData = m[strValue];
|
}
|
||||||
return categoryData.label || strValue;
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// 콤마/세미콜론 구분 다중값 처리
|
||||||
|
const looksLikeCatCode = (v: string) => v.startsWith("CAT_") || v.startsWith("CATEGORY_");
|
||||||
|
if (looksLikeCatCode(strValue) || strValue.includes(",") || strValue.includes(";")) {
|
||||||
|
const delimiter = strValue.includes(";") ? ";" : ",";
|
||||||
|
const codes = strValue.includes(delimiter) ? strValue.split(delimiter).map(s => s.trim()).filter(Boolean) : [strValue];
|
||||||
|
if (codes.some(c => looksLikeCatCode(c))) {
|
||||||
|
const labels = codes.map(code => resolveLabel(code) || code);
|
||||||
|
return labels.join(", ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 단일값 변환
|
||||||
|
if (mapping && mapping[strValue]) {
|
||||||
|
return mapping[strValue].label || strValue;
|
||||||
|
}
|
||||||
|
const resolved = resolveLabel(strValue);
|
||||||
|
if (resolved) return resolved;
|
||||||
|
|
||||||
// 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체)
|
// 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체)
|
||||||
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) {
|
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) {
|
||||||
return formatDateValue(value, "YYYY-MM-DD");
|
return formatDateValue(value, "YYYY-MM-DD");
|
||||||
@@ -2429,6 +2441,31 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 카테고리 타입이 아닌 컬럼 중 division/unit/type 등은 item_info에서 fallback 로드
|
||||||
|
// 엔티티 조인 컬럼명은 "item_id_division" 형태이므로 끝부분으로 매칭
|
||||||
|
const KNOWN_CAT_SUFFIXES = ["division", "unit", "type", "material"];
|
||||||
|
const leftPanelCols = componentConfig.leftPanel?.columns || [];
|
||||||
|
for (const col of leftPanelCols) {
|
||||||
|
const colName = (col as any).name || (col as any).columnName || (col as any).column_name;
|
||||||
|
if (!colName || mappings[colName]) continue;
|
||||||
|
const suffix = KNOWN_CAT_SUFFIXES.find(s => colName === s || colName.endsWith(`_${s}`));
|
||||||
|
if (!suffix) continue;
|
||||||
|
try {
|
||||||
|
const fbRes = await apiClient.get(`/table-categories/item_info/${suffix}/values?includeInactive=true`);
|
||||||
|
if (fbRes.data.success && fbRes.data.data?.length > 0) {
|
||||||
|
const fbMap: Record<string, { label: string; color?: string }> = {};
|
||||||
|
const flatFb = (items: any[]) => {
|
||||||
|
items.forEach((item: any) => {
|
||||||
|
fbMap[item.value_code || item.valueCode] = { label: item.value_label || item.valueLabel, color: item.color };
|
||||||
|
if (item.children?.length) flatFb(item.children);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
flatFb(fbRes.data.data);
|
||||||
|
if (Object.keys(fbMap).length > 0) mappings[colName] = fbMap;
|
||||||
|
}
|
||||||
|
} catch { /* 무시 */ }
|
||||||
|
}
|
||||||
|
|
||||||
setLeftCategoryMappings(mappings);
|
setLeftCategoryMappings(mappings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("좌측 카테고리 매핑 로드 실패:", error);
|
console.error("좌측 카테고리 매핑 로드 실패:", error);
|
||||||
@@ -4036,7 +4073,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-border divide-y bg-white">
|
<tbody className="divide-border divide-y bg-card">
|
||||||
<tr className="hover:bg-muted cursor-pointer">
|
<tr className="hover:bg-muted cursor-pointer">
|
||||||
<td className="px-3 py-2 text-sm whitespace-nowrap">데이터 1-1</td>
|
<td className="px-3 py-2 text-sm whitespace-nowrap">데이터 1-1</td>
|
||||||
<td className="px-3 py-2 text-sm whitespace-nowrap">데이터 1-2</td>
|
<td className="px-3 py-2 text-sm whitespace-nowrap">데이터 1-2</td>
|
||||||
@@ -4155,7 +4192,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-border divide-y bg-white">
|
<tbody className="divide-border divide-y bg-card">
|
||||||
{group.items.map((item, idx) => {
|
{group.items.map((item, idx) => {
|
||||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
||||||
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
||||||
@@ -4167,9 +4204,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||||||
<tr
|
<tr
|
||||||
key={itemId}
|
key={itemId}
|
||||||
onClick={() => handleLeftItemSelect(item)}
|
onClick={() => handleLeftItemSelect(item)}
|
||||||
className={`group hover:bg-accent cursor-pointer transition-colors ${
|
className={`group hover:bg-accent cursor-pointer transition-colors ${isSelected ? "bg-primary/10" : ""}`}
|
||||||
isSelected ? "bg-primary/10" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{columnsToShow.map((col, colIdx) => (
|
{columnsToShow.map((col, colIdx) => (
|
||||||
<td
|
<td
|
||||||
@@ -4190,7 +4225,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
{hasGroupedLeftActions && (
|
{hasGroupedLeftActions && (
|
||||||
<td className="bg-card group-hover:bg-accent sticky right-0 z-10 px-3 py-2 text-right">
|
<td className={`sticky right-0 z-10 px-3 py-2 text-right ${isSelected ? "bg-transparent" : "bg-card"}`}>
|
||||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
{componentConfig.leftPanel?.showEdit !== false && (
|
{componentConfig.leftPanel?.showEdit !== false && (
|
||||||
<button
|
<button
|
||||||
@@ -4275,7 +4310,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-border divide-y bg-white">
|
<tbody className="divide-border divide-y bg-card">
|
||||||
{filteredData.map((item, idx) => {
|
{filteredData.map((item, idx) => {
|
||||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
||||||
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
||||||
@@ -4310,7 +4345,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
{hasLeftTableActions && (
|
{hasLeftTableActions && (
|
||||||
<td className="bg-card group-hover:bg-accent sticky right-0 z-10 px-3 py-2 text-right">
|
<td className={`sticky right-0 z-10 px-3 py-2 text-right ${isSelected ? "bg-transparent" : "bg-card"}`}>
|
||||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
{componentConfig.leftPanel?.showEdit !== false && (
|
{componentConfig.leftPanel?.showEdit !== false && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1775,6 +1775,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||||||
// 연쇄관계 매핑이 없는 경우 무시
|
// 연쇄관계 매핑이 없는 경우 무시
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 카테고리 타입이 아닌 컬럼 중 division/unit/type 등은 item_info에서 fallback 로드
|
||||||
|
const KNOWN_CAT_COLS = ["division", "unit", "type", "material"];
|
||||||
|
const allColNames = (tableConfig.columns || []).map((c: any) => c.columnName);
|
||||||
|
for (const colName of allColNames) {
|
||||||
|
if (mappings[colName]) continue;
|
||||||
|
if (!KNOWN_CAT_COLS.includes(colName)) continue;
|
||||||
|
try {
|
||||||
|
const fbRes = await apiClient.get(`/table-categories/item_info/${colName}/values?includeInactive=true`);
|
||||||
|
if (fbRes.data.success && fbRes.data.data?.length > 0) {
|
||||||
|
const fbMapping: Record<string, { label: string; color?: string }> = {};
|
||||||
|
flattenTree(fbRes.data.data, fbMapping);
|
||||||
|
if (Object.keys(fbMapping).length > 0) mappings[colName] = fbMapping;
|
||||||
|
}
|
||||||
|
} catch { /* 무시 */ }
|
||||||
|
}
|
||||||
|
|
||||||
setCategoryMappings(mappings);
|
setCategoryMappings(mappings);
|
||||||
if (Object.keys(mappings).length > 0) {
|
if (Object.keys(mappings).length > 0) {
|
||||||
setCategoryMappingsKey((prev) => prev + 1);
|
setCategoryMappingsKey((prev) => prev + 1);
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
/**
|
|
||||||
* 결재함 플로우 E2E 테스트 스크립트
|
|
||||||
* 실행: npx tsx scripts/approval-flow-test.ts
|
|
||||||
*/
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const LOGIN_ID = "wace";
|
|
||||||
const LOGIN_PW = "1234";
|
|
||||||
const FALLBACK_PW = "qlalfqjsgh11"; // 마스터 패스워드 (1234 실패 시)
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const results: string[] = [];
|
|
||||||
const consoleErrors: string[] = [];
|
|
||||||
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: { width: 1280, height: 800 }, // 데스크톱 뷰 (사이드바 표시)
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
// 콘솔 에러 수집
|
|
||||||
page.on("console", (msg) => {
|
|
||||||
const type = msg.type();
|
|
||||||
if (type === "error") {
|
|
||||||
const text = msg.text();
|
|
||||||
consoleErrors.push(text);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. http://localhost:9771 이동
|
|
||||||
results.push("=== 1. http://localhost:9771 이동 ===");
|
|
||||||
await page.goto(BASE_URL, { waitUntil: "networkidle", timeout: 15000 });
|
|
||||||
results.push("OK: 페이지 로드 완료");
|
|
||||||
|
|
||||||
// 2. 로그인 여부 확인
|
|
||||||
results.push("\n=== 2. 로그인 상태 확인 ===");
|
|
||||||
const isLoginPage = await page.locator('#userId, input[name="userId"]').count() > 0;
|
|
||||||
if (isLoginPage) {
|
|
||||||
results.push("로그인 페이지 감지됨. 로그인 시도...");
|
|
||||||
await page.fill('#userId', LOGIN_ID);
|
|
||||||
await page.fill('#password', LOGIN_PW);
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await page.waitForTimeout(4000);
|
|
||||||
|
|
||||||
// 여전히 로그인 페이지면 마스터 패스워드로 재시도
|
|
||||||
const stillLoginPage = await page.locator('#userId').count() > 0;
|
|
||||||
if (stillLoginPage) {
|
|
||||||
results.push("1234 로그인 실패. 마스터 패스워드로 재시도...");
|
|
||||||
await page.fill('#userId', LOGIN_ID);
|
|
||||||
await page.fill('#password', FALLBACK_PW);
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await page.waitForTimeout(4000);
|
|
||||||
}
|
|
||||||
results.push("로그인 폼 제출 완료");
|
|
||||||
} else {
|
|
||||||
results.push("이미 로그인된 상태로 판단 (로그인 폼 없음)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 사용자 프로필 아바타 클릭 (사이드바 하단)
|
|
||||||
results.push("\n=== 3. 사용자 프로필 아바타 클릭 ===");
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// 사이드바 하단 사용자 프로필 버튼 (border-t border-slate-200 내부의 button)
|
|
||||||
const sidebarAvatarBtn = page.locator('aside div.border-t.border-slate-200 button').first();
|
|
||||||
let avatarClicked = false;
|
|
||||||
if ((await sidebarAvatarBtn.count()) > 0) {
|
|
||||||
try {
|
|
||||||
// force: true - Next.js dev overlay가 클릭을 가로채는 경우 우회
|
|
||||||
await sidebarAvatarBtn.click({ timeout: 5000, force: true });
|
|
||||||
avatarClicked = true;
|
|
||||||
results.push("OK: 사이드바 하단 아바타 클릭 완료");
|
|
||||||
await page.waitForTimeout(500); // 드롭다운 열림 대기
|
|
||||||
} catch (e) {
|
|
||||||
results.push(`WARN: 사이드바 아바타 클릭 실패 - ${(e as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!avatarClicked) {
|
|
||||||
// 모바일 헤더 아바타 또는 fallback
|
|
||||||
const headerAvatar = page.locator('header button:has(div.rounded-full)').first();
|
|
||||||
if ((await headerAvatar.count()) > 0) {
|
|
||||||
await headerAvatar.click({ force: true });
|
|
||||||
avatarClicked = true;
|
|
||||||
results.push("OK: 헤더 아바타 클릭 (모바일 뷰?)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!avatarClicked) {
|
|
||||||
results.push("WARN: 아바타 클릭 실패. 직접 /admin/approvalBox로 이동하여 페이지 검증");
|
|
||||||
await page.goto(`${BASE_URL}/admin/approvalBox`, { waitUntil: "networkidle", timeout: 10000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// 4. "결재함" 메뉴 확인 (드롭다운이 열린 경우)
|
|
||||||
results.push("\n=== 4. 결재함 메뉴 확인 ===");
|
|
||||||
const approvalMenuItem = page.locator('[role="menuitem"]:has-text("결재함"), [data-radix-collection-item]:has-text("결재함")').first();
|
|
||||||
const hasApprovalMenu = (await approvalMenuItem.count()) > 0;
|
|
||||||
if (hasApprovalMenu) {
|
|
||||||
results.push("OK: 결재함 메뉴가 보입니다.");
|
|
||||||
} else {
|
|
||||||
results.push("FAIL: 결재함 메뉴를 찾을 수 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 결재함 메뉴 클릭
|
|
||||||
results.push("\n=== 5. 결재함 메뉴 클릭 ===");
|
|
||||||
if (hasApprovalMenu) {
|
|
||||||
await approvalMenuItem.click({ force: true });
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
results.push("OK: 결재함 메뉴 클릭 완료");
|
|
||||||
} else if (!avatarClicked) {
|
|
||||||
results.push("(직접 이동으로 스킵 - 이미 approvalBox 페이지)");
|
|
||||||
} else {
|
|
||||||
results.push("WARN: 드롭다운에서 결재함 메뉴 미발견. 직접 이동...");
|
|
||||||
await page.goto(`${BASE_URL}/admin/approvalBox`, { waitUntil: "networkidle", timeout: 10000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. /admin/approvalBox 페이지 렌더링 확인
|
|
||||||
results.push("\n=== 6. /admin/approvalBox 페이지 확인 ===");
|
|
||||||
const currentUrl = page.url();
|
|
||||||
const isApprovalBoxPage = currentUrl.includes("/admin/approvalBox");
|
|
||||||
results.push(`현재 URL: ${currentUrl}`);
|
|
||||||
results.push(isApprovalBoxPage ? "OK: approvalBox 페이지에 있습니다." : "FAIL: approvalBox 페이지가 아닙니다.");
|
|
||||||
|
|
||||||
// 제목 "결재함" 확인
|
|
||||||
const titleEl = page.locator('h1:has-text("결재함")');
|
|
||||||
const hasTitle = (await titleEl.count()) > 0;
|
|
||||||
results.push(hasTitle ? "OK: 제목 '결재함' 확인됨" : "FAIL: 제목 '결재함' 없음");
|
|
||||||
|
|
||||||
// 탭 확인: 수신함, 상신함
|
|
||||||
const receivedTab = page.locator('button[role="tab"], [role="tab"]').filter({ hasText: "수신함" });
|
|
||||||
const sentTab = page.locator('button[role="tab"], [role="tab"]').filter({ hasText: "상신함" });
|
|
||||||
const hasReceivedTab = (await receivedTab.count()) > 0;
|
|
||||||
const hasSentTab = (await sentTab.count()) > 0;
|
|
||||||
results.push(hasReceivedTab ? "OK: '수신함' 탭 확인됨" : "FAIL: '수신함' 탭 없음");
|
|
||||||
results.push(hasSentTab ? "OK: '상신함' 탭 확인됨" : "FAIL: '상신함' 탭 없음");
|
|
||||||
|
|
||||||
// 7. 콘솔 에러 확인
|
|
||||||
results.push("\n=== 7. 콘솔 에러 확인 ===");
|
|
||||||
if (consoleErrors.length === 0) {
|
|
||||||
results.push("OK: 콘솔 에러 없음");
|
|
||||||
} else {
|
|
||||||
results.push(`WARN: 콘솔 에러 ${consoleErrors.length}건 발견:`);
|
|
||||||
consoleErrors.slice(0, 10).forEach((err, i) => {
|
|
||||||
results.push(` [${i + 1}] ${err.substring(0, 200)}${err.length > 200 ? "..." : ""}`);
|
|
||||||
});
|
|
||||||
if (consoleErrors.length > 10) {
|
|
||||||
results.push(` ... 외 ${consoleErrors.length - 10}건`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스크린샷 저장 (프로젝트 내)
|
|
||||||
await page.screenshot({ path: "approval-box-result.png" }).catch(() => {});
|
|
||||||
} catch (err: any) {
|
|
||||||
results.push(`\nERROR: ${err.message}`);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 결과 출력
|
|
||||||
console.log("\n" + "=".repeat(60));
|
|
||||||
console.log("결재함 플로우 테스트 결과");
|
|
||||||
console.log("=".repeat(60));
|
|
||||||
results.forEach((r) => console.log(r));
|
|
||||||
console.log("\n" + "=".repeat(60));
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* 브라우저 검증 스크립트
|
|
||||||
* 1. 로그인 페이지 접속
|
|
||||||
* 2. 로그인
|
|
||||||
* 3. /screens/29 접속
|
|
||||||
* 4. 화면 렌더링 검증 (버튼, 테이블, 검색 필터)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-screenshots");
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
const results: { step: string; success: boolean; message?: string }[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 1: 로그인 페이지 접속
|
|
||||||
console.log("Step 1: 로그인 페이지 접속...");
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle", timeout: 10000 });
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "01-login-page.png"), fullPage: true });
|
|
||||||
results.push({ step: "1. 로그인 페이지 접속", success: true });
|
|
||||||
|
|
||||||
// Step 2: 로그인
|
|
||||||
console.log("Step 2: 로그인...");
|
|
||||||
await page.fill('#userId', "wace");
|
|
||||||
await page.fill('#password', "qlalfqjsgh11");
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "02-login-filled.png"), fullPage: true });
|
|
||||||
|
|
||||||
const loginButton = page.locator('button[type="submit"]').first();
|
|
||||||
await loginButton.click();
|
|
||||||
await page.waitForURL((url) => !url.pathname.includes("/login") || url.pathname === "/", { timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
const currentUrl = page.url();
|
|
||||||
if (currentUrl.includes("/login") && !currentUrl.includes("/screens")) {
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "02-login-result.png"), fullPage: true });
|
|
||||||
const errorText = await page.locator('[role="alert"], .error, .text-destructive, [class*="error"]').first().textContent().catch(() => "");
|
|
||||||
results.push({ step: "2. 로그인", success: false, message: errorText || "로그인 실패 - 여전히 로그인 페이지에 있음" });
|
|
||||||
} else {
|
|
||||||
results.push({ step: "2. 로그인", success: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: /screens/29 접속
|
|
||||||
console.log("Step 3: /screens/29 접속...");
|
|
||||||
await page.goto(`${BASE_URL}/screens/29`, { waitUntil: "networkidle", timeout: 15000 });
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "03-screen-29.png"), fullPage: true });
|
|
||||||
results.push({ step: "3. /screens/29 접속", success: true });
|
|
||||||
|
|
||||||
// Step 4: 화면 렌더링 검증
|
|
||||||
console.log("Step 4: 화면 렌더링 검증...");
|
|
||||||
const checks: { name: string; selector: string; found: boolean }[] = [];
|
|
||||||
|
|
||||||
// 버튼 확인
|
|
||||||
const buttons = page.locator("button, [role='button'], input[type='submit'], input[type='button']");
|
|
||||||
const buttonCount = await buttons.count();
|
|
||||||
checks.push({ name: "버튼", selector: "button, [role='button']", found: buttonCount > 0 });
|
|
||||||
|
|
||||||
// 테이블 확인
|
|
||||||
const tables = page.locator("table, [role='grid'], [role='table'], .ag-root");
|
|
||||||
const tableCount = await tables.count();
|
|
||||||
checks.push({ name: "테이블", selector: "table, [role='grid']", found: tableCount > 0 });
|
|
||||||
|
|
||||||
// 검색 필터 확인 (input, select 등)
|
|
||||||
const searchFilters = page.locator('input[type="text"], input[type="search"], input[placeholder*="검색"], input[placeholder*="Search"], select, [class*="filter"], [class*="search"]');
|
|
||||||
const filterCount = await searchFilters.count();
|
|
||||||
checks.push({ name: "검색/필터", selector: "input, select, filter", found: filterCount > 0 });
|
|
||||||
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "04-screen-29-verified.png"), fullPage: true });
|
|
||||||
|
|
||||||
const allPassed = checks.every((c) => c.found);
|
|
||||||
results.push({
|
|
||||||
step: "4. 화면 렌더링 검증",
|
|
||||||
success: allPassed,
|
|
||||||
message: checks.map((c) => `${c.name}: ${c.found ? "O" : "X"}`).join(", "),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 결과 출력
|
|
||||||
console.log("\n=== 검증 결과 ===");
|
|
||||||
results.forEach((r) => {
|
|
||||||
console.log(`${r.step}: ${r.success ? "성공" : "실패"}${r.message ? ` - ${r.message}` : ""}`);
|
|
||||||
});
|
|
||||||
checks.forEach((c) => {
|
|
||||||
console.log(` - ${c.name}: ${c.found ? "보임" : "없음"}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const finalSuccess = results.every((r) => r.success);
|
|
||||||
console.log(`\n최종 판정: ${finalSuccess ? "성공" : "실패"}`);
|
|
||||||
|
|
||||||
// 결과를 JSON 파일로 저장
|
|
||||||
const fs = await import("fs");
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "verification-result.json"),
|
|
||||||
JSON.stringify({ results, checks, finalSuccess: finalSuccess ? "성공" : "실패" }, null, 2)
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("오류 발생:", error.message);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "99-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
results.push({ step: "오류", success: false, message: error.message });
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
/**
|
|
||||||
* 회사 선택 → 메뉴 → 수주/구매관리 화면 검증
|
|
||||||
* 1. 로그인 (topseal7 또는 wace)
|
|
||||||
* 2. 회사 선택 → 탑씰
|
|
||||||
* 3. 영업관리 > 수주관리 또는 구매관리
|
|
||||||
* 4. 데이터 화면 스크린샷
|
|
||||||
* 5. 테이블 가로 스크롤 확인
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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 steps: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 1: 로그인 페이지
|
|
||||||
console.log("Step 1: 로그인 페이지 접속...");
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-01-login-page.png"), fullPage: true });
|
|
||||||
steps.push("01-login-page");
|
|
||||||
|
|
||||||
// Step 2: 로그인 시도 (topseal7 먼저)
|
|
||||||
console.log("Step 2: 로그인 (topseal7 시도)...");
|
|
||||||
await page.fill("#userId", "topseal7");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-02-login-topseal7.png"), fullPage: true });
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
const urlAfterLogin = page.url();
|
|
||||||
const isStillLogin = urlAfterLogin.includes("/login");
|
|
||||||
|
|
||||||
if (isStillLogin) {
|
|
||||||
console.log("topseal7 로그인 실패, wace 시도...");
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-02b-login-wace.png"), fullPage: true });
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
}
|
|
||||||
await page.waitForURL((url) => !url.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-03-after-login.png"), fullPage: true });
|
|
||||||
steps.push("03-after-login");
|
|
||||||
|
|
||||||
// Step 3: 회사 선택 → 탑씰 (SUPER_ADMIN만 보임, 메인 앱 로드 대기)
|
|
||||||
console.log("Step 3: 회사 선택 클릭...");
|
|
||||||
await page.getByText("현재 관리 회사").waitFor({ timeout: 8000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
const companyBtn = page.getByText("회사 선택").first();
|
|
||||||
if ((await companyBtn.count()) > 0) {
|
|
||||||
await companyBtn.click();
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-04-company-dropdown.png"), fullPage: true });
|
|
||||||
|
|
||||||
const tapsealOption = page.getByText("탑씰", { exact: true }).first();
|
|
||||||
if ((await tapsealOption.count()) > 0) {
|
|
||||||
await tapsealOption.click();
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
console.log("탑씰 선택됨");
|
|
||||||
} else {
|
|
||||||
console.log("탑씰 옵션 없음 - 스킵");
|
|
||||||
}
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-05-after-company.png"), fullPage: true });
|
|
||||||
} else {
|
|
||||||
console.log("회사 선택 버튼 없음");
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-05-no-company-btn.png"), fullPage: true });
|
|
||||||
}
|
|
||||||
steps.push("05-after-company");
|
|
||||||
|
|
||||||
// Step 4: 영업관리 > 수주관리 또는 구매관리
|
|
||||||
console.log("Step 4: 메뉴 클릭 (영업관리 > 수주관리)...");
|
|
||||||
const salesMgmt = page.getByText("영업관리").first();
|
|
||||||
if ((await salesMgmt.count()) > 0) {
|
|
||||||
await salesMgmt.click();
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-06-sales-expanded.png"), fullPage: true });
|
|
||||||
|
|
||||||
const orderMgmt = page.getByText("수주관리").first();
|
|
||||||
if ((await orderMgmt.count()) > 0) {
|
|
||||||
await orderMgmt.click();
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-07-order-screen.png"), fullPage: true });
|
|
||||||
} else {
|
|
||||||
const purchaseMgmt = page.getByText("구매관리").first();
|
|
||||||
if ((await purchaseMgmt.count()) > 0) {
|
|
||||||
await purchaseMgmt.click();
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-07-purchase-screen.png"), fullPage: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const purchaseMgmt = page.getByText("구매관리").first();
|
|
||||||
if ((await purchaseMgmt.count()) > 0) {
|
|
||||||
await purchaseMgmt.click();
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-07-purchase-direct.png"), fullPage: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
steps.push("07-menu-screen");
|
|
||||||
|
|
||||||
// Step 5: /screens/1244 직접 접속 시도
|
|
||||||
console.log("Step 5: /screens/1244 직접 접속...");
|
|
||||||
await page.goto(`${BASE_URL}/screens/1244`, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-08-screen-1244.png"), fullPage: true });
|
|
||||||
steps.push("08-screen-1244");
|
|
||||||
|
|
||||||
// Step 6: 테이블 가로 스크롤 확인
|
|
||||||
console.log("Step 6: 테이블 가로 스크롤 확인...");
|
|
||||||
const tableContainer = page.locator("table").locator("..").first();
|
|
||||||
const table = page.locator("table").first();
|
|
||||||
if ((await table.count()) > 0) {
|
|
||||||
const tableBox = await table.boundingBox();
|
|
||||||
const hasOverflowX = await table.evaluate((el) => {
|
|
||||||
const parent = el.closest("[style*='overflow'], [class*='overflow']");
|
|
||||||
return parent ? getComputedStyle(parent as Element).overflowX !== "visible" : false;
|
|
||||||
}).catch(() => false);
|
|
||||||
const scrollWidth = await table.evaluate((el) => el.scrollWidth);
|
|
||||||
const clientWidth = await table.evaluate((el) => el.clientWidth);
|
|
||||||
const canScroll = scrollWidth > clientWidth;
|
|
||||||
console.log(`테이블: scrollWidth=${scrollWidth}, clientWidth=${clientWidth}, 가로스크롤가능=${canScroll}`);
|
|
||||||
}
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-09-table-scroll-check.png"), fullPage: true });
|
|
||||||
steps.push("09-table-scroll");
|
|
||||||
|
|
||||||
// Step 7: 최종 스크린샷
|
|
||||||
console.log("Step 7: 최종 스크린샷...");
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-10-final.png"), fullPage: true });
|
|
||||||
steps.push("10-final");
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "flow-result.json"),
|
|
||||||
JSON.stringify({ steps, timestamp: new Date().toISOString() }, null, 2)
|
|
||||||
);
|
|
||||||
console.log("\n완료. 스크린샷:", SCREENSHOT_DIR);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("오류:", error.message);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "flow-99-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* 대시보드 검증 스크립트
|
|
||||||
* 1. 로그인
|
|
||||||
* 2. /main으로 강제 이동 (reload)
|
|
||||||
* 3. 대시보드 스크린샷
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-screenshots");
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
|
||||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 1: 로그인 페이지 접속
|
|
||||||
console.log("Step 1: 로그인 페이지 접속...");
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "commit", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// Step 2: 로그인
|
|
||||||
console.log("Step 2: 로그인...");
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
|
|
||||||
// Step 3: 리다이렉트 대기 (최대 15초)
|
|
||||||
console.log("Step 3: 페이지 로드 대기 (최대 15초)...");
|
|
||||||
await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(4000); // 쿠키/토큰 설정 완료 대기
|
|
||||||
|
|
||||||
// Step 4: /main으로 강제 이동 (reload)
|
|
||||||
console.log("Step 4: /main으로 강제 이동...");
|
|
||||||
await page.goto(`${BASE_URL}/main`, { waitUntil: "commit", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(3000); // 페이지 렌더링 대기
|
|
||||||
|
|
||||||
// Step 5: 페이지 내용 검증 및 스크린샷
|
|
||||||
const heading = await page.locator("h1").first().textContent().catch(() => "");
|
|
||||||
const url = page.url();
|
|
||||||
console.log("Step 5: 현재 URL:", url);
|
|
||||||
console.log(" -> h1 제목:", heading?.trim() || "(없음)");
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "main-dashboard.png"),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
console.log(" -> main-dashboard.png 저장됨");
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
console.log("\n검증 완료. 스크린샷:", path.join(SCREENSHOT_DIR, "main-dashboard.png"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("오류:", error);
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "dashboard-error.png"),
|
|
||||||
fullPage: true,
|
|
||||||
}).catch(() => {});
|
|
||||||
await browser.close();
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,458 +0,0 @@
|
|||||||
/**
|
|
||||||
* 🔥 버튼 제어관리 성능 검증 스크립트
|
|
||||||
*
|
|
||||||
* 실제 환경에서 성능 목표 달성 여부를 확인합니다.
|
|
||||||
*
|
|
||||||
* 사용법:
|
|
||||||
* npm run performance-test
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { optimizedButtonDataflowService } from "../lib/services/optimizedButtonDataflowService";
|
|
||||||
import { dataflowConfigCache } from "../lib/services/dataflowCache";
|
|
||||||
import { dataflowJobQueue } from "../lib/services/dataflowJobQueue";
|
|
||||||
import { PerformanceBenchmark } from "../lib/services/__tests__/buttonDataflowPerformance.test";
|
|
||||||
import { ButtonActionType, ButtonTypeConfig } from "../types/screen";
|
|
||||||
|
|
||||||
// 🔥 성능 목표 상수
|
|
||||||
const PERFORMANCE_TARGETS = {
|
|
||||||
IMMEDIATE_RESPONSE: 200, // ms
|
|
||||||
CACHE_HIT: 10, // ms
|
|
||||||
SIMPLE_VALIDATION: 50, // ms
|
|
||||||
QUEUE_ENQUEUE: 5, // ms
|
|
||||||
CACHE_HIT_RATE: 80, // %
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 🔥 메인 성능 테스트 실행
|
|
||||||
*/
|
|
||||||
async function runPerformanceTests() {
|
|
||||||
console.log("🔥 Button Dataflow Performance Verification");
|
|
||||||
console.log("==========================================\n");
|
|
||||||
|
|
||||||
const benchmark = new PerformanceBenchmark();
|
|
||||||
let totalTests = 0;
|
|
||||||
let passedTests = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 캐시 성능 테스트
|
|
||||||
console.log("📊 Testing Cache Performance...");
|
|
||||||
const cacheResults = await testCachePerformance(benchmark);
|
|
||||||
totalTests += cacheResults.total;
|
|
||||||
passedTests += cacheResults.passed;
|
|
||||||
|
|
||||||
// 2. 버튼 실행 성능 테스트
|
|
||||||
console.log("\n⚡ Testing Button Execution Performance...");
|
|
||||||
const buttonResults = await testButtonExecutionPerformance(benchmark);
|
|
||||||
totalTests += buttonResults.total;
|
|
||||||
passedTests += buttonResults.passed;
|
|
||||||
|
|
||||||
// 3. 큐 성능 테스트
|
|
||||||
console.log("\n🚀 Testing Job Queue Performance...");
|
|
||||||
const queueResults = await testJobQueuePerformance(benchmark);
|
|
||||||
totalTests += queueResults.total;
|
|
||||||
passedTests += queueResults.passed;
|
|
||||||
|
|
||||||
// 4. 통합 성능 테스트
|
|
||||||
console.log("\n🔧 Testing Integration Performance...");
|
|
||||||
const integrationResults = await testIntegrationPerformance(benchmark);
|
|
||||||
totalTests += integrationResults.total;
|
|
||||||
passedTests += integrationResults.passed;
|
|
||||||
|
|
||||||
// 최종 결과 출력
|
|
||||||
console.log("\n" + "=".repeat(50));
|
|
||||||
console.log("🎯 PERFORMANCE TEST SUMMARY");
|
|
||||||
console.log("=".repeat(50));
|
|
||||||
console.log(`Total Tests: ${totalTests}`);
|
|
||||||
console.log(`Passed: ${passedTests} (${((passedTests / totalTests) * 100).toFixed(1)}%)`);
|
|
||||||
console.log(`Failed: ${totalTests - passedTests}`);
|
|
||||||
|
|
||||||
// 벤치마크 리포트
|
|
||||||
benchmark.printReport();
|
|
||||||
|
|
||||||
// 성공/실패 판정
|
|
||||||
const successRate = (passedTests / totalTests) * 100;
|
|
||||||
if (successRate >= 90) {
|
|
||||||
console.log("\n🎉 PERFORMANCE VERIFICATION PASSED!");
|
|
||||||
console.log("All performance targets have been met.");
|
|
||||||
process.exit(0);
|
|
||||||
} else {
|
|
||||||
console.log("\n⚠️ PERFORMANCE VERIFICATION FAILED!");
|
|
||||||
console.log("Some performance targets were not met.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("\n❌ Performance test failed:", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐시 성능 테스트
|
|
||||||
*/
|
|
||||||
async function testCachePerformance(benchmark: PerformanceBenchmark) {
|
|
||||||
let total = 0;
|
|
||||||
let passed = 0;
|
|
||||||
|
|
||||||
// 캐시 초기화
|
|
||||||
dataflowConfigCache.clearAllCache();
|
|
||||||
|
|
||||||
// 1. 첫 번째 로드 성능 (서버 호출)
|
|
||||||
total++;
|
|
||||||
try {
|
|
||||||
const time = await benchmark.measure("Cache First Load", async () => {
|
|
||||||
return await dataflowConfigCache.getConfig("perf-test-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
// 첫 로드는 1초 이내면 통과
|
|
||||||
if (benchmark.getResults().details.slice(-1)[0].time < 1000) {
|
|
||||||
passed++;
|
|
||||||
console.log(" ✅ First load performance: PASSED");
|
|
||||||
} else {
|
|
||||||
console.log(" ❌ First load performance: FAILED");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(" ❌ First load test: ERROR -", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 캐시 히트 성능
|
|
||||||
total++;
|
|
||||||
try {
|
|
||||||
await benchmark.measure("Cache Hit Performance", async () => {
|
|
||||||
return await dataflowConfigCache.getConfig("perf-test-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
const hitTime = benchmark.getResults().details.slice(-1)[0].time;
|
|
||||||
if (hitTime < PERFORMANCE_TARGETS.CACHE_HIT) {
|
|
||||||
passed++;
|
|
||||||
console.log(` ✅ Cache hit performance: PASSED (${hitTime.toFixed(2)}ms < ${PERFORMANCE_TARGETS.CACHE_HIT}ms)`);
|
|
||||||
} else {
|
|
||||||
console.log(` ❌ Cache hit performance: FAILED (${hitTime.toFixed(2)}ms >= ${PERFORMANCE_TARGETS.CACHE_HIT}ms)`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(" ❌ Cache hit test: ERROR -", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 캐시 히트율 테스트
|
|
||||||
total++;
|
|
||||||
try {
|
|
||||||
// 여러 버튼에 대해 캐시 로드 및 히트 테스트
|
|
||||||
const buttonIds = Array.from({ length: 10 }, (_, i) => `perf-test-${i}`);
|
|
||||||
|
|
||||||
// 첫 번째 로드 (캐시 채우기)
|
|
||||||
await Promise.all(buttonIds.map((id) => dataflowConfigCache.getConfig(id)));
|
|
||||||
|
|
||||||
// 두 번째 로드 (캐시 히트)
|
|
||||||
await Promise.all(buttonIds.map((id) => dataflowConfigCache.getConfig(id)));
|
|
||||||
|
|
||||||
const metrics = dataflowConfigCache.getMetrics();
|
|
||||||
if (metrics.hitRate >= PERFORMANCE_TARGETS.CACHE_HIT_RATE) {
|
|
||||||
passed++;
|
|
||||||
console.log(
|
|
||||||
` ✅ Cache hit rate: PASSED (${metrics.hitRate.toFixed(1)}% >= ${PERFORMANCE_TARGETS.CACHE_HIT_RATE}%)`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
` ❌ Cache hit rate: FAILED (${metrics.hitRate.toFixed(1)}% < ${PERFORMANCE_TARGETS.CACHE_HIT_RATE}%)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(" ❌ Cache hit rate test: ERROR -", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { total, passed };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 버튼 실행 성능 테스트
|
|
||||||
*/
|
|
||||||
async function testButtonExecutionPerformance(benchmark: PerformanceBenchmark) {
|
|
||||||
let total = 0;
|
|
||||||
let passed = 0;
|
|
||||||
|
|
||||||
const mockConfig: ButtonTypeConfig = {
|
|
||||||
actionType: "save" as ButtonActionType,
|
|
||||||
enableDataflowControl: true,
|
|
||||||
dataflowTiming: "after",
|
|
||||||
dataflowConfig: {
|
|
||||||
controlMode: "simple",
|
|
||||||
selectedDiagramId: 1,
|
|
||||||
selectedRelationshipId: "rel-123",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. After 타이밍 성능 테스트
|
|
||||||
total++;
|
|
||||||
try {
|
|
||||||
await benchmark.measure("Button Execution (After)", async () => {
|
|
||||||
return await optimizedButtonDataflowService.executeButtonWithDataflow(
|
|
||||||
"perf-button-1",
|
|
||||||
"save",
|
|
||||||
mockConfig,
|
|
||||||
{ testData: "value" },
|
|
||||||
"DEFAULT",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const execTime = benchmark.getResults().details.slice(-1)[0].time;
|
|
||||||
if (execTime < PERFORMANCE_TARGETS.IMMEDIATE_RESPONSE) {
|
|
||||||
passed++;
|
|
||||||
console.log(
|
|
||||||
` ✅ After timing execution: PASSED (${execTime.toFixed(2)}ms < ${PERFORMANCE_TARGETS.IMMEDIATE_RESPONSE}ms)`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
` ❌ After timing execution: FAILED (${execTime.toFixed(2)}ms >= ${PERFORMANCE_TARGETS.IMMEDIATE_RESPONSE}ms)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(" ❌ After timing test: ERROR -", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Before 타이밍 (간단한 검증) 성능 테스트
|
|
||||||
total++;
|
|
||||||
try {
|
|
||||||
const beforeConfig = {
|
|
||||||
...mockConfig,
|
|
||||||
dataflowTiming: "before" as const,
|
|
||||||
dataflowConfig: {
|
|
||||||
controlMode: "advanced" as const,
|
|
||||||
directControl: {
|
|
||||||
sourceTable: "test_table",
|
|
||||||
triggerType: "insert" as const,
|
|
||||||
conditions: [
|
|
||||||
{
|
|
||||||
id: "cond1",
|
|
||||||
type: "condition" as const,
|
|
||||||
field: "status",
|
|
||||||
operator: "=" as const,
|
|
||||||
value: "active",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
actions: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await benchmark.measure("Button Execution (Before Simple)", async () => {
|
|
||||||
return await optimizedButtonDataflowService.executeButtonWithDataflow(
|
|
||||||
"perf-button-2",
|
|
||||||
"save",
|
|
||||||
beforeConfig,
|
|
||||||
{ status: "active" },
|
|
||||||
"DEFAULT",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const execTime = benchmark.getResults().details.slice(-1)[0].time;
|
|
||||||
if (execTime < PERFORMANCE_TARGETS.SIMPLE_VALIDATION) {
|
|
||||||
passed++;
|
|
||||||
console.log(
|
|
||||||
` ✅ Before simple validation: PASSED (${execTime.toFixed(2)}ms < ${PERFORMANCE_TARGETS.SIMPLE_VALIDATION}ms)`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
` ❌ Before simple validation: FAILED (${execTime.toFixed(2)}ms >= ${PERFORMANCE_TARGETS.SIMPLE_VALIDATION}ms)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(" ❌ Before timing test: ERROR -", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 제어관리 없는 실행 성능
|
|
||||||
total++;
|
|
||||||
try {
|
|
||||||
const noDataflowConfig = {
|
|
||||||
...mockConfig,
|
|
||||||
enableDataflowControl: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
await benchmark.measure("Button Execution (No Dataflow)", async () => {
|
|
||||||
return await optimizedButtonDataflowService.executeButtonWithDataflow(
|
|
||||||
"perf-button-3",
|
|
||||||
"save",
|
|
||||||
noDataflowConfig,
|
|
||||||
{ testData: "value" },
|
|
||||||
"DEFAULT",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const execTime = benchmark.getResults().details.slice(-1)[0].time;
|
|
||||||
if (execTime < 100) {
|
|
||||||
// 제어관리 없으면 더 빨라야 함
|
|
||||||
passed++;
|
|
||||||
console.log(` ✅ No dataflow execution: PASSED (${execTime.toFixed(2)}ms < 100ms)`);
|
|
||||||
} else {
|
|
||||||
console.log(` ❌ No dataflow execution: FAILED (${execTime.toFixed(2)}ms >= 100ms)`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(" ❌ No dataflow test: ERROR -", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { total, passed };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 작업 큐 성능 테스트
|
|
||||||
*/
|
|
||||||
async function testJobQueuePerformance(benchmark: PerformanceBenchmark) {
|
|
||||||
let total = 0;
|
|
||||||
let passed = 0;
|
|
||||||
|
|
||||||
const mockConfig: ButtonTypeConfig = {
|
|
||||||
actionType: "save" as ButtonActionType,
|
|
||||||
enableDataflowControl: true,
|
|
||||||
dataflowTiming: "after",
|
|
||||||
dataflowConfig: {
|
|
||||||
controlMode: "simple",
|
|
||||||
selectedDiagramId: 1,
|
|
||||||
selectedRelationshipId: "rel-123",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 큐 초기화
|
|
||||||
dataflowJobQueue.clearQueue();
|
|
||||||
|
|
||||||
// 1. 단일 작업 큐잉 성능
|
|
||||||
total++;
|
|
||||||
try {
|
|
||||||
await benchmark.measure("Job Queue Enqueue (Single)", async () => {
|
|
||||||
return dataflowJobQueue.enqueue("queue-perf-1", "save", mockConfig, {}, "DEFAULT", "normal");
|
|
||||||
});
|
|
||||||
|
|
||||||
const queueTime = benchmark.getResults().details.slice(-1)[0].time;
|
|
||||||
if (queueTime < PERFORMANCE_TARGETS.QUEUE_ENQUEUE) {
|
|
||||||
passed++;
|
|
||||||
console.log(
|
|
||||||
` ✅ Single job enqueue: PASSED (${queueTime.toFixed(2)}ms < ${PERFORMANCE_TARGETS.QUEUE_ENQUEUE}ms)`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
` ❌ Single job enqueue: FAILED (${queueTime.toFixed(2)}ms >= ${PERFORMANCE_TARGETS.QUEUE_ENQUEUE}ms)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(" ❌ Single enqueue test: ERROR -", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 대량 작업 큐잉 성능
|
|
||||||
total++;
|
|
||||||
try {
|
|
||||||
const jobCount = 50;
|
|
||||||
await benchmark.measure("Job Queue Enqueue (Batch)", async () => {
|
|
||||||
const promises = Array.from({ length: jobCount }, (_, i) =>
|
|
||||||
dataflowJobQueue.enqueue(`queue-perf-batch-${i}`, "save", mockConfig, {}, "DEFAULT", "normal"),
|
|
||||||
);
|
|
||||||
return Promise.resolve(promises);
|
|
||||||
});
|
|
||||||
|
|
||||||
const batchTime = benchmark.getResults().details.slice(-1)[0].time;
|
|
||||||
const averageTime = batchTime / jobCount;
|
|
||||||
|
|
||||||
if (averageTime < PERFORMANCE_TARGETS.QUEUE_ENQUEUE) {
|
|
||||||
passed++;
|
|
||||||
console.log(
|
|
||||||
` ✅ Batch job enqueue: PASSED (avg ${averageTime.toFixed(2)}ms < ${PERFORMANCE_TARGETS.QUEUE_ENQUEUE}ms)`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
` ❌ Batch job enqueue: FAILED (avg ${averageTime.toFixed(2)}ms >= ${PERFORMANCE_TARGETS.QUEUE_ENQUEUE}ms)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(" ❌ Batch enqueue test: ERROR -", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 우선순위 처리 확인
|
|
||||||
total++;
|
|
||||||
try {
|
|
||||||
// 일반 우선순위 작업들
|
|
||||||
const normalJobs = Array.from({ length: 5 }, (_, i) =>
|
|
||||||
dataflowJobQueue.enqueue(`normal-${i}`, "save", mockConfig, {}, "DEFAULT", "normal"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 높은 우선순위 작업
|
|
||||||
const highJob = dataflowJobQueue.enqueue("high-priority", "save", mockConfig, {}, "DEFAULT", "high");
|
|
||||||
|
|
||||||
const queueInfo = dataflowJobQueue.getQueueInfo();
|
|
||||||
|
|
||||||
// 높은 우선순위 작업이 맨 앞에 있는지 확인
|
|
||||||
if (queueInfo.pending[0].id === highJob && queueInfo.pending[0].priority === "high") {
|
|
||||||
passed++;
|
|
||||||
console.log(" ✅ Priority handling: PASSED");
|
|
||||||
} else {
|
|
||||||
console.log(" ❌ Priority handling: FAILED");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(" ❌ Priority test: ERROR -", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { total, passed };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 통합 성능 테스트
|
|
||||||
*/
|
|
||||||
async function testIntegrationPerformance(benchmark: PerformanceBenchmark) {
|
|
||||||
let total = 0;
|
|
||||||
let passed = 0;
|
|
||||||
|
|
||||||
// 실제 사용 시나리오 시뮬레이션
|
|
||||||
total++;
|
|
||||||
try {
|
|
||||||
const scenarios = [
|
|
||||||
{ timing: "after", count: 10, actionType: "save" },
|
|
||||||
{ timing: "before", count: 5, actionType: "delete" },
|
|
||||||
{ timing: "replace", count: 3, actionType: "submit" },
|
|
||||||
];
|
|
||||||
|
|
||||||
await benchmark.measure("Integration Load Test", async () => {
|
|
||||||
for (const scenario of scenarios) {
|
|
||||||
const promises = Array.from({ length: scenario.count }, async (_, i) => {
|
|
||||||
const config: ButtonTypeConfig = {
|
|
||||||
actionType: scenario.actionType as ButtonActionType,
|
|
||||||
enableDataflowControl: true,
|
|
||||||
dataflowTiming: scenario.timing as any,
|
|
||||||
dataflowConfig: {
|
|
||||||
controlMode: "simple",
|
|
||||||
selectedDiagramId: 1,
|
|
||||||
selectedRelationshipId: `rel-${i}`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return await optimizedButtonDataflowService.executeButtonWithDataflow(
|
|
||||||
`integration-${scenario.timing}-${i}`,
|
|
||||||
scenario.actionType as ButtonActionType,
|
|
||||||
config,
|
|
||||||
{ testData: `value-${i}` },
|
|
||||||
"DEFAULT",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalTime = benchmark.getResults().details.slice(-1)[0].time;
|
|
||||||
const totalRequests = scenarios.reduce((sum, s) => sum + s.count, 0);
|
|
||||||
const averageTime = totalTime / totalRequests;
|
|
||||||
|
|
||||||
// 통합 테스트에서는 평균 300ms 이내면 통과
|
|
||||||
if (averageTime < 300) {
|
|
||||||
passed++;
|
|
||||||
console.log(` ✅ Integration load test: PASSED (avg ${averageTime.toFixed(2)}ms < 300ms)`);
|
|
||||||
} else {
|
|
||||||
console.log(` ❌ Integration load test: FAILED (avg ${averageTime.toFixed(2)}ms >= 300ms)`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(" ❌ Integration test: ERROR -", error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { total, passed };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스크립트가 직접 실행될 때만 테스트 실행
|
|
||||||
if (require.main === module) {
|
|
||||||
runPerformanceTests();
|
|
||||||
}
|
|
||||||
|
|
||||||
export { runPerformanceTests };
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
/**
|
|
||||||
* 발주관리 화면 결재 시스템 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();
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* 결재 모달 테스트: 버튼 클릭 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();
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
/**
|
|
||||||
* 수주관리 화면(68) 검증 스크립트
|
|
||||||
* - 로그인 상태 확인 후 필요시 로그인
|
|
||||||
* - /screens/68 접속
|
|
||||||
* - 테이블, 검색 필터, 버튼 확인
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-screenshots");
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: { width: 1280, height: 900 },
|
|
||||||
storageState: undefined, // 새 세션 (쿠키 유지 안 함 - 이전 세션 로그인 상태 확인용)
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
const steps: { step: string; success: boolean; message?: string }[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 1: 로그인 페이지 접속 및 로그인 (Playwright는 매번 새 브라우저이므로 항상 로그인 필요)
|
|
||||||
console.log("Step 1: 로그인...");
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "s68-01-login-page.png"), fullPage: true });
|
|
||||||
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "s68-02-login-filled.png"), fullPage: true });
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
steps.push({ step: "로그인", success: true });
|
|
||||||
|
|
||||||
// Step 2: /screens/68 접속
|
|
||||||
console.log("Step 2: /screens/68 접속...");
|
|
||||||
await page.goto(`${BASE_URL}/screens/68`, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
||||||
|
|
||||||
// 5초 대기 (페이지 완전 로드)
|
|
||||||
console.log("Step 3: 5초 대기 (페이지 완전 로드)...");
|
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "s68-03-screen-loaded.png"), fullPage: true });
|
|
||||||
steps.push({ step: "/screens/68 접속 및 5초 대기", success: true });
|
|
||||||
|
|
||||||
// Step 3: 요소 검증
|
|
||||||
console.log("Step 3: 요소 검증...");
|
|
||||||
|
|
||||||
const hasError = await page.locator('text="화면을 찾을 수 없습니다"').count() > 0;
|
|
||||||
if (hasError) {
|
|
||||||
steps.push({ step: "화면 로드", success: false, message: "404 - 화면을 찾을 수 없습니다" });
|
|
||||||
} else {
|
|
||||||
// 테이블 (TableListComponent: role=grid, table, thead/tbody)
|
|
||||||
const tableSelectors = [
|
|
||||||
"table",
|
|
||||||
"[role='grid']",
|
|
||||||
"[role='table']",
|
|
||||||
"thead",
|
|
||||||
"tbody",
|
|
||||||
".table-mobile-fixed",
|
|
||||||
"[class*='ag-']",
|
|
||||||
"[class*='table-list']",
|
|
||||||
];
|
|
||||||
let tableFound = false;
|
|
||||||
for (const sel of tableSelectors) {
|
|
||||||
if ((await page.locator(sel).count()) > 0) {
|
|
||||||
tableFound = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 검색/필터 (input, select, 테이블 툴바 검색/필터 버튼)
|
|
||||||
const filterSelectors = [
|
|
||||||
"input",
|
|
||||||
"select",
|
|
||||||
'input[type="text"]',
|
|
||||||
'input[type="search"]',
|
|
||||||
'input[placeholder*="검색"]',
|
|
||||||
"button:has-text('검색')",
|
|
||||||
"button:has-text('필터')",
|
|
||||||
"[class*='filter']",
|
|
||||||
"[class*='search']",
|
|
||||||
];
|
|
||||||
let filterFound = false;
|
|
||||||
for (const sel of filterSelectors) {
|
|
||||||
if ((await page.locator(sel).count()) > 0) {
|
|
||||||
filterFound = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 버튼
|
|
||||||
const buttonCount = await page.locator("button, [role='button'], input[type='submit']").count();
|
|
||||||
const buttonsFound = buttonCount > 0;
|
|
||||||
|
|
||||||
steps.push({
|
|
||||||
step: "화면 요소 검증",
|
|
||||||
success: tableFound && filterFound && buttonsFound,
|
|
||||||
message: `테이블: ${tableFound ? "O" : "X"}, 검색: ${filterFound ? "O" : "X"}, 버튼: ${buttonsFound ? "O" : "X"}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "s68-04-verified.png"), fullPage: true });
|
|
||||||
|
|
||||||
const finalSuccess = tableFound && filterFound && buttonsFound && !hasError;
|
|
||||||
console.log("\n=== 검증 결과 ===");
|
|
||||||
steps.forEach((s) => console.log(`${s.step}: ${s.success ? "성공" : "실패"}${s.message ? ` - ${s.message}` : ""}`));
|
|
||||||
console.log(`\n최종 판정: ${finalSuccess ? "성공" : "실패"}`);
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "s68-result.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
steps,
|
|
||||||
checks: { table: tableFound, filter: filterFound, buttons: buttonsFound },
|
|
||||||
finalSuccess: finalSuccess ? "성공" : "실패",
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("오류:", error.message);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "s68-99-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
steps.push({ step: "오류", success: false, message: error.message });
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 94(수주), 124(수주목록 리스트) 검증 스크립트
|
|
||||||
* - 로그인 후 각 화면 접속
|
|
||||||
* - 컴포넌트 배치, 테이블/필터/버튼, 가로 레이아웃 확인
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-screenshots");
|
|
||||||
|
|
||||||
interface ScreenResult {
|
|
||||||
screenId: number;
|
|
||||||
name: string;
|
|
||||||
componentsOk: boolean;
|
|
||||||
tableVisible: boolean;
|
|
||||||
filterVisible: boolean;
|
|
||||||
buttonsVisible: boolean;
|
|
||||||
layoutHorizontal: boolean;
|
|
||||||
noError: boolean;
|
|
||||||
success: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ScreenType = "form" | "list";
|
|
||||||
|
|
||||||
async function verifyScreen(page: any, screenId: number, name: string, type: ScreenType): Promise<ScreenResult> {
|
|
||||||
console.log(`\n--- 화면 ${screenId} (${name}) 검증 ---`);
|
|
||||||
await page.goto(`${BASE_URL}/screens/${screenId}`, { waitUntil: "domcontentloaded", timeout: 20000 });
|
|
||||||
// 로딩 완료 대기: "로딩중" 텍스트 사라질 때까지 최대 12초
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 12000 }).catch(() => {});
|
|
||||||
// 리스트 화면: 테이블 로딩 대기. 폼 화면: 버튼/input 대기
|
|
||||||
if (type === "list") {
|
|
||||||
await page.waitForSelector("table, [role='grid'], thead, tbody", { timeout: 8000 }).catch(() => {});
|
|
||||||
} else {
|
|
||||||
await page.waitForSelector("button, input", { timeout: 5000 }).catch(() => {});
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
const result: ScreenResult = {
|
|
||||||
screenId,
|
|
||||||
name,
|
|
||||||
componentsOk: false,
|
|
||||||
tableVisible: false,
|
|
||||||
filterVisible: false,
|
|
||||||
buttonsVisible: false,
|
|
||||||
layoutHorizontal: false,
|
|
||||||
noError: false,
|
|
||||||
success: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 404/에러 메시지 확인
|
|
||||||
const has404 = (await page.locator('text="화면을 찾을 수 없습니다"').count()) > 0;
|
|
||||||
const hasError = (await page.locator('text="오류 발생"').count()) > 0;
|
|
||||||
result.noError = !has404;
|
|
||||||
|
|
||||||
// 테이블
|
|
||||||
const tableSelectors = ["table", "[role='grid']", "thead", "tbody", ".table-mobile-fixed"];
|
|
||||||
for (const sel of tableSelectors) {
|
|
||||||
if ((await page.locator(sel).count()) > 0) {
|
|
||||||
result.tableVisible = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 필터/검색
|
|
||||||
const filterSelectors = ["input", "select", "button:has-text('검색')", "button:has-text('필터')"];
|
|
||||||
for (const sel of filterSelectors) {
|
|
||||||
if ((await page.locator(sel).count()) > 0) {
|
|
||||||
result.filterVisible = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 버튼 (사이드바 포함, 화면에 버튼이 있으면 OK)
|
|
||||||
const buttonCount = await page.locator("button, [role='button']").count();
|
|
||||||
result.buttonsVisible = buttonCount > 0;
|
|
||||||
|
|
||||||
// 가로 레이아웃: 사이드바+메인 구조, flex/grid, 또는 테이블이 있으면 가로 배치로 간주
|
|
||||||
const hasFlexRow = (await page.locator(".flex-row, .md\\:flex-row, .flex").count()) > 0;
|
|
||||||
const hasGrid = (await page.locator(".grid, [class*='grid-cols']").count()) > 0;
|
|
||||||
const hasMain = (await page.locator("main, [role='main'], .flex-1, [class*='flex-1']").count()) > 0;
|
|
||||||
const hasSidebar = (await page.getByText("현재 관리 회사").count()) > 0 || (await page.getByText("VEXPLOR").count()) > 0;
|
|
||||||
result.layoutHorizontal = (hasMain && (hasFlexRow || hasGrid || result.tableVisible)) || hasSidebar;
|
|
||||||
|
|
||||||
// 컴포넌트 정상 배치 (테이블, 버튼, 또는 input/필터 중 하나라도 있으면 OK)
|
|
||||||
result.componentsOk = result.tableVisible || result.buttonsVisible || result.filterVisible;
|
|
||||||
|
|
||||||
// 성공: 폼 화면은 테이블 불필요, 리스트 화면은 테이블 필수
|
|
||||||
const baseOk = result.componentsOk && result.filterVisible && result.buttonsVisible && result.layoutHorizontal && result.noError;
|
|
||||||
result.success = type === "form" ? baseOk : baseOk && result.tableVisible;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 results: ScreenResult[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 로그인 (Playwright는 새 브라우저이므로)
|
|
||||||
console.log("로그인...");
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((url) => !url.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
// 화면 94
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "s94-01-before.png"),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
const r94 = await verifyScreen(page, 94, "수주", "form");
|
|
||||||
results.push(r94);
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "s94-02-after.png"),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 화면 124
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "s124-01-before.png"),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
const r124 = await verifyScreen(page, 124, "수주목록 리스트", "list");
|
|
||||||
results.push(r124);
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "s124-02-after.png"),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 결과 출력
|
|
||||||
console.log("\n=== 검증 결과 ===");
|
|
||||||
results.forEach((r) => {
|
|
||||||
console.log(
|
|
||||||
`화면 ${r.screenId} (${r.name}): ${r.success ? "성공" : "실패"}` +
|
|
||||||
` | 테이블:${r.tableVisible ? "O" : "X"} 필터:${r.filterVisible ? "O" : "X"} 버튼:${r.buttonsVisible ? "O" : "X"} 레이아웃:${r.layoutHorizontal ? "O" : "X"} 에러없음:${r.noError ? "O" : "X"}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const allSuccess = results.every((r) => r.success);
|
|
||||||
console.log(`\n최종 판정: ${allSuccess ? "성공" : "실패"}`);
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "s94-124-result.json"),
|
|
||||||
JSON.stringify({ results, finalSuccess: allSuccess ? "성공" : "실패" }, null, 2)
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("오류:", error.message);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "s94-124-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* 카드 목록 컴포넌트 E2E 테스트
|
|
||||||
* 실행: npx tsx scripts/test-card-list-e2e.ts
|
|
||||||
*/
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import path from "path";
|
|
||||||
import fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREEN_URL = "/pop/screens/4114";
|
|
||||||
const SCREENSHOT_DIR = path.join(process.cwd(), "test-screenshots");
|
|
||||||
|
|
||||||
async function ensureDir(dir: string) {
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("카드 목록 컴포넌트 E2E 테스트 시작...");
|
|
||||||
await ensureDir(SCREENSHOT_DIR);
|
|
||||||
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
const results: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. 페이지 로드
|
|
||||||
console.log("1. 페이지 로드 중...");
|
|
||||||
await page.goto(`${BASE_URL}${SCREEN_URL}`, { waitUntil: "networkidle", timeout: 15000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// 카드 목록 컴포넌트 확인
|
|
||||||
const cardContainer = await page.locator('[class*="grid"]').first();
|
|
||||||
const cardCount = await page.locator(".rounded-lg.border.bg-card").count();
|
|
||||||
const hasCards = cardCount > 0;
|
|
||||||
results.push(`1. 카드 목록 표시: ${hasCards ? "OK" : "FAIL"} (카드 ${cardCount}개)`);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "01-loaded.png") });
|
|
||||||
|
|
||||||
// 2. "더보기" 버튼 클릭
|
|
||||||
const moreBtn = page.getByRole("button", { name: /더보기/ });
|
|
||||||
const moreBtnCount = await moreBtn.count();
|
|
||||||
|
|
||||||
if (moreBtnCount > 0) {
|
|
||||||
console.log("2. 더보기 버튼 클릭...");
|
|
||||||
await moreBtn.first().click();
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
|
|
||||||
const cardCountAfter = await page.locator(".rounded-lg.border.bg-card").count();
|
|
||||||
const expanded = cardCountAfter > cardCount;
|
|
||||||
results.push(`2. 더보기 클릭 후 확장: ${expanded ? "OK" : "카드 수 변화 없음"} (${cardCount} -> ${cardCountAfter})`);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "02-expanded.png") });
|
|
||||||
|
|
||||||
// 3. 페이지네이션 확인
|
|
||||||
const prevBtn = page.getByRole("button", { name: /이전/ });
|
|
||||||
const nextBtn = page.getByRole("button", { name: /다음/ });
|
|
||||||
const hasPagination = (await prevBtn.count() > 0) || (await nextBtn.count() > 0);
|
|
||||||
results.push(`3. 페이지네이션 버튼: ${hasPagination ? "OK" : "없음 (데이터 적음 시 정상)"}`);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "03-pagination.png") });
|
|
||||||
|
|
||||||
// 4. 접기 버튼 클릭
|
|
||||||
const collapseBtn = page.getByRole("button", { name: /접기/ });
|
|
||||||
if (await collapseBtn.count() > 0) {
|
|
||||||
console.log("4. 접기 버튼 클릭...");
|
|
||||||
await collapseBtn.first().click();
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
const cardCountCollapsed = await page.locator(".rounded-lg.border.bg-card").count();
|
|
||||||
results.push(`4. 접기 후: OK (카드 ${cardCountCollapsed}개로 복원)`);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "04-collapsed.png") });
|
|
||||||
} else {
|
|
||||||
results.push("4. 접기 버튼: 없음 (확장 안됐을 수 있음)");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
results.push("2. 더보기 버튼: 없음 (카드가 적거나 모두 표시됨)");
|
|
||||||
results.push("3. 페이지네이션: N/A");
|
|
||||||
results.push("4. 접기: N/A");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 결과 출력
|
|
||||||
console.log("\n=== 테스트 결과 ===");
|
|
||||||
results.forEach((r) => console.log(r));
|
|
||||||
console.log(`\n스크린샷 저장: ${SCREENSHOT_DIR}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("테스트 실패:", err);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "error.png") });
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
/**
|
|
||||||
* formData 로그 테스트 스크립트
|
|
||||||
* - http://localhost:9771/screens/1599 접속
|
|
||||||
* - P003 행 선택 → 추가 버튼 클릭 → 장비 선택 → 저장 전/후 콘솔 로그 수집
|
|
||||||
*
|
|
||||||
* 실행: npx tsx scripts/test-formdata-logs.ts
|
|
||||||
* (Playwright 필요: npx playwright install chromium)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
|
|
||||||
const TARGET_URL = "http://localhost:9771/screens/1599?menuObjid=1762422235300";
|
|
||||||
const LOGIN = { userId: "topseal_admin", password: "1234" };
|
|
||||||
|
|
||||||
const TARGET_LOGS = ["🔵", "🟡", "🔴", "process_code", "splitPanelParentData"];
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const browser = await chromium.launch({ headless: false });
|
|
||||||
const context = await browser.newContext();
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
const consoleLogs: string[] = [];
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
page.on("console", (msg) => {
|
|
||||||
const text = msg.text();
|
|
||||||
const type = msg.type();
|
|
||||||
if (type === "error") {
|
|
||||||
errors.push(`[CONSOLE ERROR] ${text}`);
|
|
||||||
}
|
|
||||||
const hasTarget = TARGET_LOGS.some((t) => text.includes(t));
|
|
||||||
if (hasTarget || type === "error") {
|
|
||||||
consoleLogs.push(`[${type}] ${text}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("1. 페이지 이동:", TARGET_URL);
|
|
||||||
await page.goto(TARGET_URL, { waitUntil: "domcontentloaded", timeout: 20000 });
|
|
||||||
|
|
||||||
// 로그인 필요 여부 확인
|
|
||||||
const userIdInput = page.locator('input[name="userId"]').first();
|
|
||||||
if (await userIdInput.isVisible().catch(() => false)) {
|
|
||||||
console.log("2. 로그인 페이지 감지 - 로그인 진행");
|
|
||||||
await page.fill('input[name="userId"]', LOGIN.userId);
|
|
||||||
await page.fill('input[name="password"]', LOGIN.password);
|
|
||||||
await page.click('button[type="submit"]').catch(() => page.click('button:has-text("로그인")'));
|
|
||||||
await page.waitForTimeout(4000);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("3. 5초 대기 (페이지 로드)");
|
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
|
|
||||||
// 탭 확인 - 공정 마스터 (첫 번째 탭)
|
|
||||||
const firstTab = page.getByRole("tab", { name: /공정 마스터/i }).or(page.locator('button:has-text("공정 마스터")')).first();
|
|
||||||
if (await firstTab.isVisible().catch(() => false)) {
|
|
||||||
console.log("4. '공정 마스터' 탭 클릭");
|
|
||||||
await firstTab.click();
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 좌측 패널 테이블 데이터 로드 대기
|
|
||||||
console.log("5. 좌측 패널 데이터 로드 대기");
|
|
||||||
await page.locator("table tbody tr").first().waitFor({ state: "visible", timeout: 25000 }).catch(() => {
|
|
||||||
throw new Error("좌측 테이블에 데이터가 없습니다. process_mng에 P003 등 데이터가 있는지 확인하세요.");
|
|
||||||
});
|
|
||||||
|
|
||||||
// P003 행 또는 첫 번째 행 클릭
|
|
||||||
let rowToClick = page.locator('table tbody tr:has(td:has-text("P003"))').first();
|
|
||||||
const hasP003 = await rowToClick.isVisible().catch(() => false);
|
|
||||||
if (!hasP003) {
|
|
||||||
console.log(" P003 미발견 - 첫 번째 행 클릭");
|
|
||||||
rowToClick = page.locator("table tbody tr").first();
|
|
||||||
}
|
|
||||||
await rowToClick.click();
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
|
|
||||||
// 우측 패널에서 '추가' 버튼 클릭 (모달 열기)
|
|
||||||
console.log("6. '추가' 버튼 클릭");
|
|
||||||
const addBtn = page.locator('button:has-text("추가")').first();
|
|
||||||
await addBtn.click();
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// 모달이 열렸는지 확인
|
|
||||||
const modal = page.locator('[role="dialog"], [data-state="open"]').first();
|
|
||||||
await modal.waitFor({ state: "visible", timeout: 5000 }).catch(() => {});
|
|
||||||
|
|
||||||
// 모달 내 설비 드롭다운/콤보박스 선택 (v2-select, entity-search-input 등)
|
|
||||||
console.log("7. 모달 내 설비 선택");
|
|
||||||
const trigger = page.locator('[role="combobox"], button:has-text("선택"), button:has-text("설비")').first();
|
|
||||||
if (await trigger.isVisible().catch(() => false)) {
|
|
||||||
await trigger.click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
const option = page.locator('[role="option"], li[role="option"]').first();
|
|
||||||
if (await option.isVisible().catch(() => false)) {
|
|
||||||
await option.click();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// select 태그인 경우
|
|
||||||
const selectEl = page.locator('select').first();
|
|
||||||
if (await selectEl.isVisible().catch(() => false)) {
|
|
||||||
await selectEl.selectOption({ index: 1 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
|
|
||||||
// 저장 전 콘솔 스냅샷
|
|
||||||
console.log("\n=== 저장 전 콘솔 로그 (formData 관련) ===");
|
|
||||||
consoleLogs.forEach((l) => console.log(l));
|
|
||||||
if (errors.length) {
|
|
||||||
console.log("\n=== 에러 ===");
|
|
||||||
errors.forEach((e) => console.log(e));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 저장 버튼 클릭 (모달 내부의 저장 버튼)
|
|
||||||
console.log("\n8. '저장' 버튼 클릭");
|
|
||||||
const saveBtn = page.locator('[role="dialog"] button:has-text("저장"), [data-state="open"] button:has-text("저장")').first();
|
|
||||||
await saveBtn.click();
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
// 저장 후 로그 수집
|
|
||||||
console.log("\n=== 저장 후 콘솔 로그 (formData 관련) ===");
|
|
||||||
consoleLogs.forEach((l) => console.log(l));
|
|
||||||
if (errors.length) {
|
|
||||||
console.log("\n=== 에러 ===");
|
|
||||||
errors.forEach((e) => console.log(e));
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error("테스트 실패:", e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
/**
|
|
||||||
* UI 리디자인 검증 스크립트
|
|
||||||
* 1. 로그인 페이지 스크린샷
|
|
||||||
* 2. 로그인
|
|
||||||
* 3. 대시보드 스크린샷
|
|
||||||
* 4. 사이드바 스크린샷
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-screenshots");
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
|
||||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 1: 로그인 페이지 접속 및 스크린샷
|
|
||||||
console.log("Step 1: 로그인 페이지 접속...");
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "commit", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "01-login-page.png"),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
console.log(" -> 01-login-page.png 저장됨");
|
|
||||||
|
|
||||||
// Step 2: 로그인
|
|
||||||
console.log("Step 2: 로그인...");
|
|
||||||
await page.fill("#userId", "admin");
|
|
||||||
await page.fill("#password", "1234");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
const currentUrl = page.url();
|
|
||||||
if (currentUrl.includes("/login")) {
|
|
||||||
console.log(" -> 로그인 실패, 현재 URL:", currentUrl);
|
|
||||||
} else {
|
|
||||||
console.log(" -> 로그인 성공, 리다이렉트:", currentUrl);
|
|
||||||
|
|
||||||
// Step 3: 메인 페이지로 이동 (대시보드)
|
|
||||||
if (!currentUrl.includes("/main") && !currentUrl.includes("/admin")) {
|
|
||||||
await page.goto(`${BASE_URL}/main`, { waitUntil: "load", timeout: 20000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 대시보드 전체 스크린샷
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "02-dashboard.png"),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
console.log(" -> 02-dashboard.png 저장됨");
|
|
||||||
|
|
||||||
// Step 4: 사이드바 포커스 스크린샷 (좌측 영역)
|
|
||||||
const sidebar = page.locator("aside");
|
|
||||||
if ((await sidebar.count()) > 0) {
|
|
||||||
await sidebar.first().screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "03-sidebar.png"),
|
|
||||||
});
|
|
||||||
console.log(" -> 03-sidebar.png 저장됨");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: 테이블/그리드 화면으로 이동하여 스타일 확인
|
|
||||||
console.log("Step 5: 테이블 화면 탐색...");
|
|
||||||
const menuLinks = page.locator('aside a[href*="/screens/"], aside [role="button"]');
|
|
||||||
const linkCount = await menuLinks.count();
|
|
||||||
if (linkCount > 0) {
|
|
||||||
await menuLinks.first().click();
|
|
||||||
await page.waitForTimeout(2500);
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "04-table-screen.png"),
|
|
||||||
fullPage: false,
|
|
||||||
});
|
|
||||||
console.log(" -> 04-table-screen.png 저장됨");
|
|
||||||
} else {
|
|
||||||
// 메뉴 클릭으로 화면 이동 시도
|
|
||||||
const firstMenu = page.locator('aside [class*="cursor-pointer"]').first();
|
|
||||||
if ((await firstMenu.count()) > 0) {
|
|
||||||
await firstMenu.click();
|
|
||||||
await page.waitForTimeout(2500);
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "04-table-screen.png"),
|
|
||||||
fullPage: false,
|
|
||||||
});
|
|
||||||
console.log(" -> 04-table-screen.png 저장됨");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
console.log("\n검증 완료. 스크린샷:", SCREENSHOT_DIR);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("오류:", error);
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "error.png"),
|
|
||||||
fullPage: true,
|
|
||||||
}).catch(() => {});
|
|
||||||
await browser.close();
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 156, 4155, 1053 검증: 버튼 레이아웃 및 가시성
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-screenshots");
|
|
||||||
|
|
||||||
async function loginIfNeeded(page: any) {
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyScreen(
|
|
||||||
page: any,
|
|
||||||
screenId: number,
|
|
||||||
report: Record<string, any>
|
|
||||||
) {
|
|
||||||
await page.goto(`${BASE_URL}/screens/${screenId}`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await loginIfNeeded(page);
|
|
||||||
await page.goto(`${BASE_URL}/screens/${screenId}`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
|
|
||||||
const info = await page.evaluate(() => {
|
|
||||||
const buttons = Array.from(document.querySelectorAll("button"));
|
|
||||||
const buttonDetails = buttons.slice(0, 20).map((btn) => {
|
|
||||||
const text = (btn as HTMLElement).innerText?.trim() || "";
|
|
||||||
const rect = (btn as HTMLElement).getBoundingClientRect();
|
|
||||||
const style = window.getComputedStyle(btn);
|
|
||||||
return {
|
|
||||||
text: text.substring(0, 50),
|
|
||||||
hasText: text.length > 0,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
visible: rect.width > 0 && rect.height > 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const buttonsWithText = buttonDetails.filter((b) => b.hasText);
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const pagination = document.body.innerText.includes("표시") || document.body.innerText.includes("1/");
|
|
||||||
const splitPanel = document.querySelector("[class*='split'], [class*='Split'], [class*='border-r']");
|
|
||||||
return {
|
|
||||||
pageLoadsWithoutErrors: !document.body.innerText.includes("화면을 찾을 수 없습니다"),
|
|
||||||
totalButtons: buttons.length,
|
|
||||||
buttonsWithTextCount: buttonsWithText.length,
|
|
||||||
buttonsVisibleWithText: buttonsWithText.length > 0,
|
|
||||||
buttonDetails: buttonDetails.slice(0, 10),
|
|
||||||
tableVisible: !!table,
|
|
||||||
paginationVisible: !!pagination,
|
|
||||||
splitPanelVisible: !!splitPanel,
|
|
||||||
bodyScrollWidth: document.body.scrollWidth,
|
|
||||||
viewportWidth: window.innerWidth,
|
|
||||||
viewportHeight: window.innerHeight,
|
|
||||||
hasHorizontalOverflow: document.body.scrollWidth > window.innerWidth,
|
|
||||||
layoutFitsViewport: document.body.scrollWidth <= window.innerWidth,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
report.pageLoadsWithoutErrors = info.pageLoadsWithoutErrors;
|
|
||||||
report.buttonsVisibleWithText = info.buttonsVisibleWithText;
|
|
||||||
report.buttonsWithTextCount = info.buttonsWithTextCount;
|
|
||||||
report.buttonDetails = info.buttonDetails;
|
|
||||||
report.tableVisible = info.tableVisible;
|
|
||||||
report.paginationVisible = info.paginationVisible;
|
|
||||||
report.splitPanelVisible = info.splitPanelVisible;
|
|
||||||
report.layoutFitsViewport = info.layoutFitsViewport;
|
|
||||||
report.hasHorizontalOverflow = info.hasHorizontalOverflow;
|
|
||||||
report.details = {
|
|
||||||
bodyScrollWidth: info.bodyScrollWidth,
|
|
||||||
viewportWidth: info.viewportWidth,
|
|
||||||
viewportHeight: info.viewportHeight,
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, `screen-${screenId}-buttons.png`),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
console.log(`screen-${screenId}-buttons.png saved`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 report: Record<string, any> = { screen156: {}, screen4155: {}, screen1053: {} };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await loginIfNeeded(page);
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
await verifyScreen(page, 156, report.screen156);
|
|
||||||
await verifyScreen(page, 4155, report.screen4155);
|
|
||||||
await verifyScreen(page, 1053, report.screen1053);
|
|
||||||
|
|
||||||
console.log("\n=== Report ===");
|
|
||||||
console.log(JSON.stringify(report, null, 2));
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "button-layout-screens-report.json"),
|
|
||||||
JSON.stringify(report, null, 2)
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error:", error.message);
|
|
||||||
report.error = error.message;
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "button-layout-error.png"),
|
|
||||||
fullPage: true,
|
|
||||||
}).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 1053, 156 버튼 위치 검증
|
|
||||||
* 1053: overlay buttons within split panel
|
|
||||||
* 156: buttons in separate row above table
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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 report: Record<string, any> = { screen1053: {}, screen156: {} };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 20000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
await page.goto(`${BASE_URL}/screens/156`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 20000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Screen 1053
|
|
||||||
await page.goto(`${BASE_URL}/screens/1053?menuObjid=1762421920304`, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 25000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
await page.goto(`${BASE_URL}/screens/1053?menuObjid=1762421920304`, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
}
|
|
||||||
await page.getByText("화면을 불러오는 중", { exact: false }).waitFor({ state: "hidden", timeout: 35000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(40000);
|
|
||||||
|
|
||||||
const splitPanelEl = page.locator("[class*='border-r'], [class*='split']").first();
|
|
||||||
await splitPanelEl.waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
const info1053 = await page.evaluate(() => {
|
|
||||||
const splitPanel = document.querySelector("[class*='border-r']") || document.querySelector("main");
|
|
||||||
const mainContent = document.querySelector("main") || document.body;
|
|
||||||
const allBtns = Array.from(document.querySelectorAll("button"));
|
|
||||||
const buttons = allBtns.filter((b) => {
|
|
||||||
const t = (b as HTMLElement).innerText?.trim() || "";
|
|
||||||
const r = (b as HTMLElement).getBoundingClientRect();
|
|
||||||
return t.length > 1 && r.x > 250 && t.match(/등록|수정|삭제|품목|테이블|결재|수주|출하/);
|
|
||||||
});
|
|
||||||
const splitRect = splitPanel ? (splitPanel as HTMLElement).getBoundingClientRect() : null;
|
|
||||||
const mainRect = mainContent ? (mainContent as HTMLElement).getBoundingClientRect() : null;
|
|
||||||
|
|
||||||
const buttonPositions = buttons.map((b) => {
|
|
||||||
const r = (b as HTMLElement).getBoundingClientRect();
|
|
||||||
const text = (b as HTMLElement).innerText?.trim().substring(0, 20);
|
|
||||||
return {
|
|
||||||
text,
|
|
||||||
x: r.x,
|
|
||||||
y: r.y,
|
|
||||||
right: r.right,
|
|
||||||
width: r.width,
|
|
||||||
height: r.height,
|
|
||||||
isWithinSplitPanel: splitRect
|
|
||||||
? r.y >= splitRect.top - 20 && r.y <= splitRect.bottom + 20
|
|
||||||
: null,
|
|
||||||
isAboveMain: mainRect ? r.y < mainRect.top + 100 : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const tableRect = table ? (table as HTMLElement).getBoundingClientRect() : null;
|
|
||||||
const buttonsAboveTable = buttonPositions.every((p) => tableRect && p.y < tableRect.top - 10);
|
|
||||||
|
|
||||||
return {
|
|
||||||
splitPanelVisible: !!splitPanel,
|
|
||||||
splitPanelRect: splitRect ? { top: splitRect.top, bottom: splitRect.bottom, left: splitRect.left, right: splitRect.right } : null,
|
|
||||||
mainRect: mainRect ? { top: mainRect.top, bottom: mainRect.bottom } : null,
|
|
||||||
buttonCount: buttons.length,
|
|
||||||
buttonPositions,
|
|
||||||
buttonsOverlaidOnSplitPanel: buttonPositions.some((p) => p.isWithinSplitPanel),
|
|
||||||
buttonsInSeparateRowAbove: buttonsAboveTable,
|
|
||||||
tableTop: tableRect?.top ?? null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
report.screen1053 = info1053;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "overlay-1053.png"), fullPage: true });
|
|
||||||
console.log("overlay-1053.png saved");
|
|
||||||
|
|
||||||
// Screen 156
|
|
||||||
await page.goto(`${BASE_URL}/screens/156?menuObjid=1762421920156`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
await page.getByText("화면을 불러오는 중", { exact: false }).waitFor({ state: "hidden", timeout: 35000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(40000);
|
|
||||||
|
|
||||||
const table156 = page.locator("table tbody tr").first();
|
|
||||||
await table156.waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
const info156 = await page.evaluate(() => {
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const tableRect = table ? (table as HTMLElement).getBoundingClientRect() : null;
|
|
||||||
const buttons = Array.from(document.querySelectorAll("button")).filter(
|
|
||||||
(b) => ((b as HTMLElement).innerText?.trim() || "").match(/결재|수주|수정|삭제|출하|테이블/)
|
|
||||||
);
|
|
||||||
const buttonPositions = buttons.map((b) => {
|
|
||||||
const r = (b as HTMLElement).getBoundingClientRect();
|
|
||||||
const text = (b as HTMLElement).innerText?.trim().substring(0, 20);
|
|
||||||
return {
|
|
||||||
text,
|
|
||||||
x: r.x,
|
|
||||||
y: r.y,
|
|
||||||
right: r.right,
|
|
||||||
width: r.width,
|
|
||||||
isAboveTable: tableRect ? r.y < tableRect.top - 5 : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const allButtonsAboveTable = buttonPositions.every((p) => p.isAboveTable);
|
|
||||||
|
|
||||||
return {
|
|
||||||
tableVisible: !!table,
|
|
||||||
tableTop: tableRect?.top ?? null,
|
|
||||||
buttonCount: buttons.length,
|
|
||||||
buttonPositions,
|
|
||||||
buttonsInSeparateRowAboveTable: allButtonsAboveTable,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
report.screen156 = info156;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "overlay-156.png"), fullPage: true });
|
|
||||||
console.log("overlay-156.png saved");
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "overlay-report.json"),
|
|
||||||
JSON.stringify(report, null, 2)
|
|
||||||
);
|
|
||||||
console.log("\n=== Report ===");
|
|
||||||
console.log(JSON.stringify(report, null, 2));
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error:", error.message);
|
|
||||||
report.error = error.message;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "overlay-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* 반응형 렌더링 검증: 화면 1053, 2089, 156, 4155
|
|
||||||
* 로그인: admin / wace1234!
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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 report: Record<string, any> = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1-3: Login
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
await page.fill("#userId", "admin");
|
|
||||||
await page.fill("#password", "wace1234!");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
}
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
|
|
||||||
async function captureAndVerify(screenId: number, screenName: string) {
|
|
||||||
await page.goto(`${BASE_URL}/screens/${screenId}`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.goto(`${BASE_URL}/screens/${screenId}`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
await page.getByText("화면을 불러오는 중", { exact: false }).waitFor({ state: "hidden", timeout: 25000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
const info = await page.evaluate(() => {
|
|
||||||
const buttons = Array.from(document.querySelectorAll("button"));
|
|
||||||
const btnWithText = buttons.filter((b) => (b as HTMLElement).innerText?.trim().length > 0);
|
|
||||||
const splitPanel = document.querySelector("[class*='split'], [class*='Split'], [class*='border-r']");
|
|
||||||
const leftPanel = document.querySelector("[class*='border-r']");
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const thead = document.querySelector("thead");
|
|
||||||
const tbody = document.querySelector("tbody");
|
|
||||||
const pagination = document.body.innerText.includes("표시") || document.body.innerText.includes("1/");
|
|
||||||
const bodyText = document.body.innerText;
|
|
||||||
const hasOverlap = bodyText.includes("화면을 찾을 수 없습니다") ? false : null;
|
|
||||||
|
|
||||||
const btnDetails = btnWithText.slice(0, 5).map((b) => ({
|
|
||||||
text: (b as HTMLElement).innerText?.trim().substring(0, 30),
|
|
||||||
rect: (b as HTMLElement).getBoundingClientRect(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageLoadsWithoutErrors: !bodyText.includes("화면을 찾을 수 없습니다"),
|
|
||||||
buttonsVisible: btnWithText.length > 0,
|
|
||||||
buttonsCount: btnWithText.length,
|
|
||||||
buttonDetails: btnDetails,
|
|
||||||
splitPanelVisible: !!splitPanel,
|
|
||||||
leftPanelVisible: !!leftPanel,
|
|
||||||
tableVisible: !!table && !!thead && !!tbody,
|
|
||||||
paginationVisible: !!pagination,
|
|
||||||
bodyScrollWidth: document.body.scrollWidth,
|
|
||||||
viewportWidth: window.innerWidth,
|
|
||||||
viewportHeight: window.innerHeight,
|
|
||||||
hasHorizontalOverflow: document.body.scrollWidth > window.innerWidth,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, `responsive-${screenId}.png`),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
console.log(`responsive-${screenId}.png saved`);
|
|
||||||
|
|
||||||
return { screenId, screenName, ...info };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4: Screen 1053 - 거래처관리
|
|
||||||
report.screen1053 = await captureAndVerify(1053, "거래처관리 - split panel custom mode");
|
|
||||||
|
|
||||||
// 5: Screen 2089 - BOM관리
|
|
||||||
report.screen2089 = await captureAndVerify(2089, "BOM관리 - split panel");
|
|
||||||
|
|
||||||
// 6: Screen 156 - 수주관리
|
|
||||||
report.screen156 = await captureAndVerify(156, "수주관리 - regular screen");
|
|
||||||
|
|
||||||
// 7: Screen 4155 - 작업지시
|
|
||||||
report.screen4155 = await captureAndVerify(4155, "작업지시 - buttons at bottom");
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "responsive-report.json"),
|
|
||||||
JSON.stringify(report, null, 2)
|
|
||||||
);
|
|
||||||
console.log("\n=== Report ===");
|
|
||||||
console.log(JSON.stringify(report, null, 2));
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error:", error.message);
|
|
||||||
report.error = error.message;
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "responsive-error.png"),
|
|
||||||
fullPage: true,
|
|
||||||
}).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 1053 검증 - admin/1234 로그인
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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 report: Record<string, any> = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
await page.fill("#userId", "admin");
|
|
||||||
await page.fill("#password", "1234");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 20000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 20000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.goto(`${BASE_URL}/screens/1053`, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 20000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
await page.goto(`${BASE_URL}/screens/1053`, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByText("화면을 불러오는 중", { exact: false }).waitFor({ state: "hidden", timeout: 35000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(30000);
|
|
||||||
|
|
||||||
const table = page.locator("table tbody tr").first();
|
|
||||||
await table.waitFor({ state: "visible", timeout: 20000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
const info = await page.evaluate(() => {
|
|
||||||
const buttons = Array.from(document.querySelectorAll("button")).filter((b) => {
|
|
||||||
const t = (b as HTMLElement).innerText?.trim() || "";
|
|
||||||
const r = (b as HTMLElement).getBoundingClientRect();
|
|
||||||
return t.length > 1 && r.x > 250;
|
|
||||||
});
|
|
||||||
const leftPanel = document.querySelector("[class*='border-r']");
|
|
||||||
const tables = document.querySelectorAll("table");
|
|
||||||
const bodyText = document.body.innerText;
|
|
||||||
|
|
||||||
return {
|
|
||||||
buttonCount: buttons.length,
|
|
||||||
buttonDetails: buttons.slice(0, 15).map((b) => {
|
|
||||||
const r = (b as HTMLElement).getBoundingClientRect();
|
|
||||||
return {
|
|
||||||
text: (b as HTMLElement).innerText?.trim().substring(0, 30),
|
|
||||||
x: Math.round(r.x),
|
|
||||||
y: Math.round(r.y),
|
|
||||||
width: Math.round(r.width),
|
|
||||||
height: Math.round(r.height),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
splitPanelVisible: !!leftPanel || bodyText.includes("공급처") || bodyText.includes("좌측에서"),
|
|
||||||
tableCount: tables.length,
|
|
||||||
hasExcelDownload: bodyText.includes("엑셀") || bodyText.includes("다운로드") || bodyText.includes("업로드"),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
report.screen1053 = info;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "screen-1053-admin.png"), fullPage: true });
|
|
||||||
console.log("screen-1053-admin.png saved");
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "screen-1053-admin-report.json"),
|
|
||||||
JSON.stringify(report, null, 2)
|
|
||||||
);
|
|
||||||
console.log("\n=== Report ===");
|
|
||||||
console.log(JSON.stringify(report, null, 2));
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error:", error.message);
|
|
||||||
report.error = error.message;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "screen-1053-admin-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 1053 검증: split-panel 레이아웃
|
|
||||||
* - 좌/우 패널, 버튼 오버레이, 높이 채움, overflow 확인
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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 report: Record<string, any> = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.goto(`${BASE_URL}/screens/1053`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u) => !u.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.goto(`${BASE_URL}/screens/1053`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
|
|
||||||
report.pageLoadsWithoutErrors = (await page.locator('text="화면을 찾을 수 없습니다"').count()) === 0;
|
|
||||||
|
|
||||||
const splitPanel = await page.evaluate(() => {
|
|
||||||
const leftPanel = document.querySelector("[class*='split'][class*='left'], [data-panel='left'], [class*='left-panel']");
|
|
||||||
const rightPanel = document.querySelector("[class*='split'][class*='right'], [data-panel='right'], [class*='right-panel']");
|
|
||||||
const resizable = document.querySelector("[class*='resize'], [class*='ResizablePanel]");
|
|
||||||
const panels = document.querySelectorAll("[class*='panel'], [data-panel]");
|
|
||||||
return {
|
|
||||||
hasLeftPanel: !!leftPanel || document.body.innerText.includes("left") || panels.length >= 2,
|
|
||||||
hasRightPanel: !!rightPanel || panels.length >= 2,
|
|
||||||
panelCount: panels.length,
|
|
||||||
resizableCount: document.querySelectorAll("[class*='ResizablePanel'], [class*='resize']").length,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
report.splitPanelVisible = splitPanel.panelCount >= 2 || splitPanel.resizableCount > 0;
|
|
||||||
|
|
||||||
const twoPanels = await page.locator("[class*='panel'], [data-panel], [class*='split']").count();
|
|
||||||
report.twoPanelsFound = twoPanels >= 2;
|
|
||||||
|
|
||||||
const buttons = await page.locator("button").count();
|
|
||||||
const buttonsTopRight = await page.evaluate(() => {
|
|
||||||
const btns = Array.from(document.querySelectorAll("button"));
|
|
||||||
const viewportW = window.innerWidth;
|
|
||||||
return btns.filter((b) => {
|
|
||||||
const r = b.getBoundingClientRect();
|
|
||||||
return r.right > viewportW * 0.5 && r.top < 200;
|
|
||||||
}).length;
|
|
||||||
});
|
|
||||||
report.buttonsVisible = buttons > 0;
|
|
||||||
report.buttonsInTopRightArea = buttonsTopRight;
|
|
||||||
|
|
||||||
const layoutFillsHeight = await page.evaluate(() => {
|
|
||||||
const main = document.querySelector("main") || document.body;
|
|
||||||
const h = (main as HTMLElement).offsetHeight;
|
|
||||||
return h >= window.innerHeight * 0.8;
|
|
||||||
});
|
|
||||||
report.layoutFillsHeight = layoutFillsHeight;
|
|
||||||
|
|
||||||
const overflow = await page.evaluate(() => ({
|
|
||||||
bodyScrollWidth: document.body.scrollWidth,
|
|
||||||
bodyScrollHeight: document.body.scrollHeight,
|
|
||||||
viewportWidth: window.innerWidth,
|
|
||||||
viewportHeight: window.innerHeight,
|
|
||||||
hasHorizontalOverflow: document.body.scrollWidth > window.innerWidth,
|
|
||||||
hasVerticalOverflow: document.body.scrollHeight > window.innerHeight,
|
|
||||||
}));
|
|
||||||
report.noContentOverflowsViewport = !overflow.hasHorizontalOverflow;
|
|
||||||
report.overflowDetails = overflow;
|
|
||||||
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "screen-1053-snapshot.png"), fullPage: true });
|
|
||||||
console.log("screen-1053-snapshot.png saved");
|
|
||||||
|
|
||||||
console.log("\n=== Report ===");
|
|
||||||
console.log(JSON.stringify(report, null, 2));
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "screen-1053-report.json"),
|
|
||||||
JSON.stringify(report, null, 2)
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error:", error.message);
|
|
||||||
report.error = error.message;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "screen-1053-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 1244 검증: table-list 레이아웃 (데스크톱 + 모바일)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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 report: Record<string, any> = { desktop: {}, mobile: {} };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.goto(`${BASE_URL}/screens/1244`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u) => !u.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.goto(`${BASE_URL}/screens/1244`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
report.desktop.pageLoadsWithoutErrors =
|
|
||||||
(await page.locator('text="화면을 찾을 수 없습니다"').count()) === 0;
|
|
||||||
|
|
||||||
const desktopInfo = await page.evaluate(() => {
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const thead = document.querySelector("thead");
|
|
||||||
const tbody = document.querySelector("tbody");
|
|
||||||
const ths = document.querySelectorAll("thead th");
|
|
||||||
const buttons = document.querySelectorAll("button");
|
|
||||||
const searchInputs = document.querySelectorAll('input[type="text"], input[type="search"], select');
|
|
||||||
const pagination = document.body.innerText.includes("표시") ||
|
|
||||||
document.body.innerText.includes("1/") ||
|
|
||||||
document.body.innerText.includes("페이지") ||
|
|
||||||
document.querySelector("[class*='pagination'], [class*='Pagination']");
|
|
||||||
|
|
||||||
let buttonsBetweenSearchAndTable = 0;
|
|
||||||
const searchY = searchInputs.length > 0
|
|
||||||
? (searchInputs[0] as HTMLElement).getBoundingClientRect().bottom
|
|
||||||
: 0;
|
|
||||||
const tableY = table ? (table as HTMLElement).getBoundingClientRect().top : 0;
|
|
||||||
|
|
||||||
buttons.forEach((btn) => {
|
|
||||||
const rect = (btn as HTMLElement).getBoundingClientRect();
|
|
||||||
if (rect.top >= searchY - 20 && rect.top <= tableY + 100) buttonsBetweenSearchAndTable++;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
tableVisible: !!table && !!thead && !!tbody,
|
|
||||||
columnCount: ths.length,
|
|
||||||
buttonsVisible: buttons.length > 0,
|
|
||||||
buttonsBetweenSearchAndTable,
|
|
||||||
paginationVisible: !!pagination,
|
|
||||||
bodyScrollWidth: document.body.scrollWidth,
|
|
||||||
viewportWidth: window.innerWidth,
|
|
||||||
hasHorizontalOverflow: document.body.scrollWidth > window.innerWidth,
|
|
||||||
tableScrollWidth: table ? (table as HTMLElement).scrollWidth : 0,
|
|
||||||
tableClientWidth: table ? (table as HTMLElement).clientWidth : 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
report.desktop.buttonsVisible = desktopInfo.buttonsVisible;
|
|
||||||
report.desktop.buttonsBetweenSearchAndTable = desktopInfo.buttonsBetweenSearchAndTable;
|
|
||||||
report.desktop.tableVisible = desktopInfo.tableVisible;
|
|
||||||
report.desktop.columnCount = desktopInfo.columnCount;
|
|
||||||
report.desktop.paginationVisible = desktopInfo.paginationVisible;
|
|
||||||
report.desktop.noHorizontalOverflow = !desktopInfo.hasHorizontalOverflow;
|
|
||||||
report.desktop.overflowDetails = {
|
|
||||||
bodyScrollWidth: desktopInfo.bodyScrollWidth,
|
|
||||||
viewportWidth: desktopInfo.viewportWidth,
|
|
||||||
tableScrollWidth: desktopInfo.tableScrollWidth,
|
|
||||||
tableClientWidth: desktopInfo.tableClientWidth,
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "screen-1244-desktop.png"),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
console.log("screen-1244-desktop.png saved");
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 768, height: 900 });
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
|
|
||||||
const mobileInfo = await page.evaluate(() => {
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const thead = document.querySelector("thead");
|
|
||||||
const tbody = document.querySelector("tbody");
|
|
||||||
const ths = document.querySelectorAll("thead th");
|
|
||||||
const buttons = document.querySelectorAll("button");
|
|
||||||
const pagination = document.body.innerText.includes("표시") ||
|
|
||||||
document.body.innerText.includes("1/") ||
|
|
||||||
document.body.innerText.includes("페이지") ||
|
|
||||||
document.querySelector("[class*='pagination'], [class*='Pagination']");
|
|
||||||
|
|
||||||
return {
|
|
||||||
tableVisible: !!table && !!thead && !!tbody,
|
|
||||||
columnCount: ths.length,
|
|
||||||
buttonsVisible: buttons.length > 0,
|
|
||||||
paginationVisible: !!pagination,
|
|
||||||
bodyScrollWidth: document.body.scrollWidth,
|
|
||||||
viewportWidth: window.innerWidth,
|
|
||||||
hasHorizontalOverflow: document.body.scrollWidth > window.innerWidth,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
report.mobile.pageLoadsWithoutErrors = report.desktop.pageLoadsWithoutErrors;
|
|
||||||
report.mobile.buttonsVisible = mobileInfo.buttonsVisible;
|
|
||||||
report.mobile.tableVisible = mobileInfo.tableVisible;
|
|
||||||
report.mobile.columnCount = mobileInfo.columnCount;
|
|
||||||
report.mobile.paginationVisible = mobileInfo.paginationVisible;
|
|
||||||
report.mobile.noHorizontalOverflow = !mobileInfo.hasHorizontalOverflow;
|
|
||||||
report.mobile.overflowDetails = {
|
|
||||||
bodyScrollWidth: mobileInfo.bodyScrollWidth,
|
|
||||||
viewportWidth: mobileInfo.viewportWidth,
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "screen-1244-mobile-768.png"),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
console.log("screen-1244-mobile-768.png saved");
|
|
||||||
|
|
||||||
console.log("\n=== Report ===");
|
|
||||||
console.log(JSON.stringify(report, null, 2));
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "screen-1244-layout-report.json"),
|
|
||||||
JSON.stringify(report, null, 2)
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error:", error.message);
|
|
||||||
report.error = error.message;
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "screen-1244-error.png"),
|
|
||||||
fullPage: true,
|
|
||||||
}).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 1244 새로고침 후 상세 검증
|
|
||||||
* - data-screen-runtime, 테이블, body의 scrollWidth/clientWidth 확인
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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 results: Record<string, number | string | null> = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 로그인
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u) => !u.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// /screens/1244 접속
|
|
||||||
await page.goto(`${BASE_URL}/screens/1244`, { waitUntil: "load", timeout: 45000 });
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Step 1: 새로고침 (Ctrl+Shift+R - hard refresh)
|
|
||||||
console.log("Step 1: 새로고침 (Ctrl+Shift+R)...");
|
|
||||||
await page.keyboard.press("Control+Shift+r");
|
|
||||||
await page.waitForLoadState("load");
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
|
|
||||||
// Step 2: 첫 스크린샷
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-06.png"), fullPage: true });
|
|
||||||
console.log("verify-06.png 저장");
|
|
||||||
|
|
||||||
// Step 3: JavaScript로 dimension 확인
|
|
||||||
const dims = await page.evaluate(() => {
|
|
||||||
const screenRuntime = document.querySelector("[data-screen-runtime]");
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const body = document.body;
|
|
||||||
const html = document.documentElement;
|
|
||||||
|
|
||||||
const tableContainer = table?.closest("[style*='overflow'], [class*='overflow']") || table?.parentElement;
|
|
||||||
|
|
||||||
return {
|
|
||||||
screenRuntime: screenRuntime
|
|
||||||
? {
|
|
||||||
offsetWidth: (screenRuntime as HTMLElement).offsetWidth,
|
|
||||||
scrollWidth: (screenRuntime as HTMLElement).scrollWidth,
|
|
||||||
clientWidth: (screenRuntime as HTMLElement).clientWidth,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
table: table
|
|
||||||
? {
|
|
||||||
offsetWidth: table.offsetWidth,
|
|
||||||
scrollWidth: table.scrollWidth,
|
|
||||||
clientWidth: table.clientWidth,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
tableContainer: tableContainer
|
|
||||||
? {
|
|
||||||
clientWidth: (tableContainer as HTMLElement).clientWidth,
|
|
||||||
scrollWidth: (tableContainer as HTMLElement).scrollWidth,
|
|
||||||
offsetWidth: (tableContainer as HTMLElement).offsetWidth,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
body: {
|
|
||||||
scrollWidth: body.scrollWidth,
|
|
||||||
clientWidth: body.clientWidth,
|
|
||||||
offsetWidth: body.offsetWidth,
|
|
||||||
},
|
|
||||||
html: {
|
|
||||||
scrollWidth: html.scrollWidth,
|
|
||||||
clientWidth: html.clientWidth,
|
|
||||||
},
|
|
||||||
viewport: {
|
|
||||||
innerWidth: window.innerWidth,
|
|
||||||
innerHeight: window.innerHeight,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
results.screenRuntime_offsetWidth = dims.screenRuntime?.offsetWidth ?? null;
|
|
||||||
results.screenRuntime_scrollWidth = dims.screenRuntime?.scrollWidth ?? null;
|
|
||||||
results.screenRuntime_clientWidth = dims.screenRuntime?.clientWidth ?? null;
|
|
||||||
results.table_offsetWidth = dims.table?.offsetWidth ?? null;
|
|
||||||
results.table_scrollWidth = dims.table?.scrollWidth ?? null;
|
|
||||||
results.table_clientWidth = dims.table?.clientWidth ?? null;
|
|
||||||
results.tableContainer_clientWidth = dims.tableContainer?.clientWidth ?? null;
|
|
||||||
results.tableContainer_scrollWidth = dims.tableContainer?.scrollWidth ?? null;
|
|
||||||
results.body_scrollWidth = dims.body.scrollWidth;
|
|
||||||
results.body_clientWidth = dims.body.clientWidth;
|
|
||||||
results.viewport_innerWidth = dims.viewport.innerWidth;
|
|
||||||
|
|
||||||
// Step 4: 가로 overflow 확인
|
|
||||||
const hasOverflow = dims.body.scrollWidth > dims.viewport.innerWidth;
|
|
||||||
results.bodyOverflowX = hasOverflow;
|
|
||||||
|
|
||||||
// Step 5: 두 번째 스크린샷
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-07.png"), fullPage: true });
|
|
||||||
console.log("verify-07.png 저장");
|
|
||||||
|
|
||||||
console.log("\n=== 검증 결과 ===");
|
|
||||||
console.log("data-screen-runtime div:");
|
|
||||||
console.log(" offsetWidth:", results.screenRuntime_offsetWidth);
|
|
||||||
console.log(" scrollWidth:", results.screenRuntime_scrollWidth);
|
|
||||||
console.log(" clientWidth:", results.screenRuntime_clientWidth);
|
|
||||||
console.log("테이블:");
|
|
||||||
console.log(" offsetWidth:", results.table_offsetWidth);
|
|
||||||
console.log(" scrollWidth:", results.table_scrollWidth);
|
|
||||||
console.log(" clientWidth:", results.table_clientWidth);
|
|
||||||
console.log("테이블 컨테이너:");
|
|
||||||
console.log(" clientWidth:", results.tableContainer_clientWidth);
|
|
||||||
console.log(" scrollWidth:", results.tableContainer_scrollWidth);
|
|
||||||
console.log("body:");
|
|
||||||
console.log(" scrollWidth:", results.body_scrollWidth);
|
|
||||||
console.log(" clientWidth:", results.body_clientWidth);
|
|
||||||
console.log("뷰포트 innerWidth:", results.viewport_innerWidth);
|
|
||||||
console.log("가로 overflow:", results.bodyOverflowX);
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "verify-refresh-result.json"),
|
|
||||||
JSON.stringify({ ...results, rawDims: dims }, null, 2)
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("오류:", error.message);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-99-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 1244 새로고침 검증 (2차)
|
|
||||||
* - 3초 대기 후 스크린샷
|
|
||||||
* - data-screen-runtime, 테이블 관련 div width 확인
|
|
||||||
* - 가로 스크롤 가능 여부 확인
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 로그인
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u) => !u.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// /screens/1244 접속
|
|
||||||
await page.goto(`${BASE_URL}/screens/1244`, { waitUntil: "load", timeout: 45000 });
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Step 1: 새로고침 (Ctrl+Shift+R)
|
|
||||||
console.log("Step 1: 새로고침 (Ctrl+Shift+R)...");
|
|
||||||
await page.keyboard.press("Control+Shift+r");
|
|
||||||
await page.waitForLoadState("load");
|
|
||||||
console.log("Step 2: 3초 대기...");
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 8000 }).catch(() => {});
|
|
||||||
|
|
||||||
// Step 3: 첫 스크린샷
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-08.png"), fullPage: true });
|
|
||||||
console.log("verify-08.png 저장");
|
|
||||||
|
|
||||||
// Step 4: JavaScript로 width 확인 (순수 함수로 작성)
|
|
||||||
const dims = await page.evaluate(() => {
|
|
||||||
const screenRuntime = document.querySelector("[data-screen-runtime]");
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const tableContainer = table?.closest("[style*='overflow'], [class*='overflow']");
|
|
||||||
const overflowHiddenDiv = table?.closest("[style*='overflow-hidden'], [class*='overflow-hidden']");
|
|
||||||
|
|
||||||
const tableAncestors: Array<{ level: number; tag: string; class: string; offsetWidth: number; scrollWidth: number; clientWidth: number; overflowX: string }> = [];
|
|
||||||
let p = table?.parentElement;
|
|
||||||
let idx = 0;
|
|
||||||
while (p && idx < 6) {
|
|
||||||
const s = window.getComputedStyle(p);
|
|
||||||
tableAncestors.push({
|
|
||||||
level: idx,
|
|
||||||
tag: p.tagName,
|
|
||||||
class: (p.className && typeof p.className === "string" ? p.className : "").slice(0, 60),
|
|
||||||
offsetWidth: p.offsetWidth,
|
|
||||||
scrollWidth: p.scrollWidth,
|
|
||||||
clientWidth: p.clientWidth,
|
|
||||||
overflowX: s.overflowX,
|
|
||||||
});
|
|
||||||
p = p.parentElement;
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
screenRuntime: screenRuntime
|
|
||||||
? { offsetWidth: (screenRuntime as HTMLElement).offsetWidth, scrollWidth: (screenRuntime as HTMLElement).scrollWidth, clientWidth: (screenRuntime as HTMLElement).clientWidth }
|
|
||||||
: null,
|
|
||||||
table: table
|
|
||||||
? { offsetWidth: (table as HTMLElement).offsetWidth, scrollWidth: (table as HTMLElement).scrollWidth, clientWidth: (table as HTMLElement).clientWidth }
|
|
||||||
: null,
|
|
||||||
tableContainer: tableContainer
|
|
||||||
? { offsetWidth: (tableContainer as HTMLElement).offsetWidth, scrollWidth: (tableContainer as HTMLElement).scrollWidth, clientWidth: (tableContainer as HTMLElement).clientWidth }
|
|
||||||
: null,
|
|
||||||
overflowHiddenDiv: overflowHiddenDiv
|
|
||||||
? { offsetWidth: (overflowHiddenDiv as HTMLElement).offsetWidth, scrollWidth: (overflowHiddenDiv as HTMLElement).scrollWidth, clientWidth: (overflowHiddenDiv as HTMLElement).clientWidth }
|
|
||||||
: null,
|
|
||||||
tableAncestors,
|
|
||||||
viewport: { innerWidth: window.innerWidth },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("\n=== JavaScript 실행 결과 ===");
|
|
||||||
console.log("data-screen-runtime div:");
|
|
||||||
if (dims.screenRuntime) {
|
|
||||||
console.log(" offsetWidth:", dims.screenRuntime.offsetWidth);
|
|
||||||
console.log(" scrollWidth:", dims.screenRuntime.scrollWidth);
|
|
||||||
console.log(" clientWidth:", dims.screenRuntime.clientWidth);
|
|
||||||
} else {
|
|
||||||
console.log(" (없음)");
|
|
||||||
}
|
|
||||||
console.log("\n테이블:");
|
|
||||||
if (dims.table) {
|
|
||||||
console.log(" offsetWidth:", dims.table.offsetWidth);
|
|
||||||
console.log(" scrollWidth:", dims.table.scrollWidth);
|
|
||||||
}
|
|
||||||
console.log("\n테이블 컨테이너 (overflow):");
|
|
||||||
if (dims.tableContainer) {
|
|
||||||
console.log(" offsetWidth:", dims.tableContainer.offsetWidth);
|
|
||||||
console.log(" scrollWidth:", dims.tableContainer.scrollWidth);
|
|
||||||
console.log(" clientWidth:", dims.tableContainer.clientWidth);
|
|
||||||
}
|
|
||||||
console.log("\noverflow-hidden div:");
|
|
||||||
if (dims.overflowHiddenDiv) {
|
|
||||||
console.log(" offsetWidth:", dims.overflowHiddenDiv.offsetWidth);
|
|
||||||
console.log(" scrollWidth:", dims.overflowHiddenDiv.scrollWidth);
|
|
||||||
} else {
|
|
||||||
console.log(" (없음)");
|
|
||||||
}
|
|
||||||
console.log("\n테이블 조상 div들 (width):");
|
|
||||||
dims.tableAncestors?.forEach((a) => {
|
|
||||||
console.log(` L${a.level} ${a.tag} overflow=${a.overflowX} offsetW=${a.offsetWidth} scrollW=${a.scrollWidth} clientW=${a.clientWidth}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 5: 가로 스크롤 가능 여부
|
|
||||||
const canScroll = dims.table && dims.tableContainer && dims.table.scrollWidth > dims.tableContainer.clientWidth;
|
|
||||||
console.log("\n가로 스크롤 가능:", canScroll, "(테이블 scrollWidth > 컨테이너 clientWidth)");
|
|
||||||
|
|
||||||
// Step 6: 최종 스크린샷
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-09.png"), fullPage: true });
|
|
||||||
console.log("\nverify-09.png 저장");
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "verify-refresh2-result.json"),
|
|
||||||
JSON.stringify({ ...dims, canScroll }, null, 2)
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("오류:", error.message);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-99-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 1244 검증: 테이블, 가로 스크롤, 페이지네이션 확인
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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 results: Record<string, boolean | string> = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 1: 로그인 먼저 (Playwright는 새 브라우저)
|
|
||||||
console.log("Step 1: 로그인...");
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u) => !u.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Step 2: /screens/1244 접속
|
|
||||||
console.log("Step 2: /screens/1244 접속...");
|
|
||||||
await page.goto(`${BASE_URL}/screens/1244`, { waitUntil: "load", timeout: 45000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Step 2: 화면 로드 스크린샷
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-01-initial.png"), fullPage: true });
|
|
||||||
console.log("verify-01-initial.png 저장");
|
|
||||||
|
|
||||||
// Step 3: 테이블 확인
|
|
||||||
const table = page.locator("table").first();
|
|
||||||
const tableCount = await table.count();
|
|
||||||
results.tableVisible = tableCount > 0;
|
|
||||||
console.log("테이블 보임:", results.tableVisible);
|
|
||||||
|
|
||||||
// Step 4: 가로 스크롤바 확인
|
|
||||||
const scrollContainer = page.locator("[class*='overflow'], .overflow-x-auto, [style*='overflow']").first();
|
|
||||||
const hasScrollContainer = (await scrollContainer.count()) > 0;
|
|
||||||
let scrollWidth = 0;
|
|
||||||
let clientWidth = 0;
|
|
||||||
if (results.tableVisible) {
|
|
||||||
scrollWidth = await table.evaluate((el) => el.scrollWidth);
|
|
||||||
clientWidth = await table.evaluate((el) => el.clientWidth);
|
|
||||||
results.tableScrollWidth = String(scrollWidth);
|
|
||||||
results.tableClientWidth = String(clientWidth);
|
|
||||||
results.horizontalScrollNeeded = scrollWidth > clientWidth;
|
|
||||||
}
|
|
||||||
console.log("테이블 scrollWidth:", scrollWidth, "clientWidth:", clientWidth);
|
|
||||||
|
|
||||||
// Step 5: 테이블 영역 오른쪽 스크롤 시도 (overflow-auto인 조상 요소 찾기)
|
|
||||||
if (results.tableVisible) {
|
|
||||||
try {
|
|
||||||
const scrollableAncestor = await table.evaluateHandle((el) => {
|
|
||||||
let parent: HTMLElement | null = el.parentElement;
|
|
||||||
while (parent) {
|
|
||||||
const style = getComputedStyle(parent);
|
|
||||||
if (style.overflowX === "auto" || style.overflowX === "scroll" || style.overflow === "auto") {
|
|
||||||
return parent;
|
|
||||||
}
|
|
||||||
parent = parent.parentElement;
|
|
||||||
}
|
|
||||||
return el.parentElement;
|
|
||||||
});
|
|
||||||
const scrollEl = scrollableAncestor.asElement();
|
|
||||||
if (scrollEl) {
|
|
||||||
await scrollEl.evaluate((el) => (el.scrollLeft = 300));
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-02-after-scroll.png"), fullPage: true });
|
|
||||||
console.log("verify-02-after-scroll.png 저장");
|
|
||||||
|
|
||||||
// Step 6: 페이지네이션 확인
|
|
||||||
const paginationText = page.getByText("표시", { exact: false }).or(page.getByText("1/1", { exact: false }));
|
|
||||||
results.paginationVisible = (await paginationText.count()) > 0;
|
|
||||||
console.log("페이지네이션 보임:", results.paginationVisible);
|
|
||||||
|
|
||||||
// Step 7: 테이블이 뷰포트에 맞는지 (overflow 확인)
|
|
||||||
const bodyOverflow = await page.evaluate(() => {
|
|
||||||
const main = document.querySelector("main") || document.body;
|
|
||||||
return window.getComputedStyle(main).overflowX;
|
|
||||||
});
|
|
||||||
results.bodyOverflowX = bodyOverflow;
|
|
||||||
|
|
||||||
// Step 8: 중간 스크린샷 (테이블 + 페이지네이션 영역)
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-03-mid.png"), fullPage: true });
|
|
||||||
console.log("verify-03-mid.png 저장");
|
|
||||||
|
|
||||||
// Step 9: 페이지 하단으로 스크롤 (페이지네이션 바 확인)
|
|
||||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-04-pagination-area.png"), fullPage: true });
|
|
||||||
console.log("verify-04-pagination-area.png 저장");
|
|
||||||
|
|
||||||
// Step 10: 최종 스크린샷
|
|
||||||
await page.evaluate(() => window.scrollTo(0, 0));
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-05-final.png"), fullPage: true });
|
|
||||||
console.log("verify-05-final.png 저장");
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "verify-result.json"),
|
|
||||||
JSON.stringify(results, null, 2)
|
|
||||||
);
|
|
||||||
console.log("\n검증 결과:", results);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("오류:", error.message);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-99-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 150 검증 - 탑씰 영업 거래처관리
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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 report: Record<string, any> = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "load", timeout: 90000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
await page.fill("#userId", "admin");
|
|
||||||
await page.fill("#password", "1234");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 20000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 20000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const companyBtn = page.getByText("회사 선택").first();
|
|
||||||
if ((await companyBtn.count()) > 0) {
|
|
||||||
const currentCompany = await page.getByText("현재 관리 회사").locator("..").textContent().catch(() => "");
|
|
||||||
if (!currentCompany?.includes("탑씰") && !currentCompany?.includes("COMPANY_7")) {
|
|
||||||
await companyBtn.click();
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
const tapseal = page.getByText("탑씰", { exact: true }).first();
|
|
||||||
const company7 = page.getByText("COMPANY_7", { exact: true }).first();
|
|
||||||
if ((await tapseal.count()) > 0) {
|
|
||||||
await tapseal.click();
|
|
||||||
} else if ((await company7.count()) > 0) {
|
|
||||||
await company7.click();
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.goto(`${BASE_URL}/screens/150`, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 20000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
await page.goto(`${BASE_URL}/screens/150`, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByText("화면을 불러오는 중", { exact: false }).waitFor({ state: "hidden", timeout: 25000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(8000);
|
|
||||||
|
|
||||||
const info = await page.evaluate(() => {
|
|
||||||
const buttons = Array.from(document.querySelectorAll("button")).filter((b) => {
|
|
||||||
const t = (b as HTMLElement).innerText?.trim() || "";
|
|
||||||
const r = (b as HTMLElement).getBoundingClientRect();
|
|
||||||
return t.length > 1 && r.x > 250 && r.width > 0;
|
|
||||||
});
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const leftThird = viewportWidth * 0.33;
|
|
||||||
const rightThird = viewportWidth * 0.66;
|
|
||||||
|
|
||||||
const btnDetails = buttons.map((b) => {
|
|
||||||
const r = (b as HTMLElement).getBoundingClientRect();
|
|
||||||
const text = (b as HTMLElement).innerText?.trim().substring(0, 40);
|
|
||||||
let group = "center";
|
|
||||||
if (r.x < leftThird) group = "left";
|
|
||||||
else if (r.x > rightThird) group = "right";
|
|
||||||
return {
|
|
||||||
text,
|
|
||||||
x: Math.round(r.x),
|
|
||||||
y: Math.round(r.y),
|
|
||||||
width: Math.round(r.width),
|
|
||||||
height: Math.round(r.height),
|
|
||||||
right: Math.round(r.right),
|
|
||||||
group,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const leftPanel = document.querySelector("[class*='border-r']");
|
|
||||||
const tables = document.querySelectorAll("table");
|
|
||||||
const rightPanel = document.querySelector("main")?.querySelectorAll("[class*='overflow'], [style*='overflow']");
|
|
||||||
const leftRect = leftPanel ? (leftPanel as HTMLElement).getBoundingClientRect() : null;
|
|
||||||
const mainRect = document.querySelector("main")?.getBoundingClientRect();
|
|
||||||
const contentWidth = mainRect ? mainRect.width : viewportWidth;
|
|
||||||
const leftWidthPercent = leftRect && contentWidth > 0 ? (leftRect.width / contentWidth) * 100 : null;
|
|
||||||
|
|
||||||
let overlaps = false;
|
|
||||||
for (let i = 0; i < btnDetails.length; i++) {
|
|
||||||
for (let j = i + 1; j < btnDetails.length; j++) {
|
|
||||||
const a = btnDetails[i];
|
|
||||||
const b = btnDetails[j];
|
|
||||||
if (Math.abs(a.y - b.y) < 30 && !(a.right < b.x || b.right < a.x)) overlaps = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rightTable = tables.length > 1 ? tables[1] : tables[0];
|
|
||||||
const rightTableRect = rightTable ? (rightTable as HTMLElement).getBoundingClientRect() : null;
|
|
||||||
const rightTableScrollable = rightTable
|
|
||||||
? (() => {
|
|
||||||
let el: Element | null = rightTable;
|
|
||||||
while (el) {
|
|
||||||
const s = window.getComputedStyle(el);
|
|
||||||
if (s.overflowY === "auto" || s.overflowY === "scroll" || s.overflow === "auto") return true;
|
|
||||||
el = el.parentElement;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
buttonCount: buttons.length,
|
|
||||||
buttonDetails: btnDetails,
|
|
||||||
leftGroup: btnDetails.filter((b) => b.group === "left"),
|
|
||||||
centerGroup: btnDetails.filter((b) => b.group === "center"),
|
|
||||||
rightGroup: btnDetails.filter((b) => b.group === "right"),
|
|
||||||
splitPanelVisible: !!leftPanel,
|
|
||||||
leftWidthPercent: leftWidthPercent ? Math.round(leftWidthPercent) : null,
|
|
||||||
rightWidthPercent: leftWidthPercent ? Math.round(100 - leftWidthPercent) : null,
|
|
||||||
tableCount: tables.length,
|
|
||||||
rightPanelHasTable: !!rightTable,
|
|
||||||
rightTableScrollable,
|
|
||||||
buttonsOverlap: overlaps,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
report.screen150 = info;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "screen-150-tapseal.png"), fullPage: true });
|
|
||||||
console.log("screen-150-tapseal.png saved");
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "screen-150-tapseal-report.json"),
|
|
||||||
JSON.stringify(report, null, 2)
|
|
||||||
);
|
|
||||||
console.log("\n=== Report ===");
|
|
||||||
console.log(JSON.stringify(report, null, 2));
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error:", error.message);
|
|
||||||
report.error = error.message;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "screen-150-tapseal-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 1556 검증: tabs-widget 레이아웃
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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 report: Record<string, any> = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.goto(`${BASE_URL}/screens/1556`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u) => !u.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.goto(`${BASE_URL}/screens/1556`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
report.pageLoadsWithoutErrors = (await page.locator('text="화면을 찾을 수 없습니다"').count()) === 0;
|
|
||||||
|
|
||||||
const tabInfo = await page.evaluate(() => {
|
|
||||||
const tabs = document.querySelectorAll("[role='tab'], [data-state='active'], [class*='TabsTrigger'], [class*='tab']");
|
|
||||||
const tabList = document.querySelector("[role='tablist']");
|
|
||||||
const loading = document.body.innerText.includes("로딩중") || document.body.innerText.includes("로딩 중");
|
|
||||||
return {
|
|
||||||
tabCount: tabs.length,
|
|
||||||
hasTabList: !!tabList,
|
|
||||||
tabHeadersVisible: tabs.length > 0 || !!tabList,
|
|
||||||
stuckOnLoading: loading,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
report.tabHeadersVisible = tabInfo.tabHeadersVisible;
|
|
||||||
report.tabCount = tabInfo.tabCount;
|
|
||||||
report.tabContentLoadsProperly = !tabInfo.stuckOnLoading;
|
|
||||||
|
|
||||||
const loadingText = await page.getByText("로딩중", { exact: false }).count();
|
|
||||||
report.stuckOnLoading = loadingText > 0;
|
|
||||||
|
|
||||||
const layoutFillsHeight = await page.evaluate(() => {
|
|
||||||
const main = document.querySelector("main") || document.body;
|
|
||||||
const h = (main as HTMLElement).offsetHeight;
|
|
||||||
return h >= window.innerHeight * 0.8;
|
|
||||||
});
|
|
||||||
report.layoutFillsHeight = layoutFillsHeight;
|
|
||||||
|
|
||||||
const overflow = await page.evaluate(() => ({
|
|
||||||
bodyScrollWidth: document.body.scrollWidth,
|
|
||||||
bodyScrollHeight: document.body.scrollHeight,
|
|
||||||
viewportWidth: window.innerWidth,
|
|
||||||
viewportHeight: window.innerHeight,
|
|
||||||
hasHorizontalOverflow: document.body.scrollWidth > window.innerWidth,
|
|
||||||
hasVerticalOverflow: document.body.scrollHeight > window.innerHeight,
|
|
||||||
}));
|
|
||||||
report.noContentOverflowsViewport = !overflow.hasHorizontalOverflow;
|
|
||||||
report.overflowDetails = overflow;
|
|
||||||
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "screen-1556-snapshot.png"), fullPage: true });
|
|
||||||
console.log("screen-1556-snapshot.png saved");
|
|
||||||
|
|
||||||
console.log("\n=== Report ===");
|
|
||||||
console.log(JSON.stringify(report, null, 2));
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "screen-1556-report.json"),
|
|
||||||
JSON.stringify(report, null, 2)
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error:", error.message);
|
|
||||||
report.error = error.message;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "screen-1556-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 156 검증: 로드, 버튼, 테이블, 페이지네이션, 레이아웃
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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 report: Record<string, any> = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.goto(`${BASE_URL}/screens/156`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
const url = page.url();
|
|
||||||
if (url.includes("/login")) {
|
|
||||||
report.loginRequired = true;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "screen-156-login.png"), fullPage: true });
|
|
||||||
console.log("Login page - logging in with wace...");
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u) => !u.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.goto(`${BASE_URL}/screens/156`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentUrl = page.url();
|
|
||||||
report.loginRequired = currentUrl.includes("/login");
|
|
||||||
if (!currentUrl.includes("/login")) {
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
const hasError = (await page.locator('text="화면을 찾을 수 없습니다"').count()) > 0;
|
|
||||||
report.pageLoadsWithoutErrors = !hasError;
|
|
||||||
|
|
||||||
const buttonCount = await page.locator("button").count();
|
|
||||||
const buttonsBetween = await page.evaluate(() => {
|
|
||||||
const searchWidget = document.querySelector("[class*='search'], [class*='filter']");
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const buttons = document.querySelectorAll("button");
|
|
||||||
let between = 0;
|
|
||||||
buttons.forEach((btn) => {
|
|
||||||
const rect = btn.getBoundingClientRect();
|
|
||||||
if (searchWidget && table) {
|
|
||||||
const sRect = searchWidget.getBoundingClientRect();
|
|
||||||
const tRect = table.getBoundingClientRect();
|
|
||||||
if (rect.top > sRect.bottom && rect.top < tRect.top) between++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return between;
|
|
||||||
});
|
|
||||||
report.buttonsVisible = buttonCount > 0;
|
|
||||||
report.buttonsBetweenSearchAndTable = buttonsBetween;
|
|
||||||
|
|
||||||
const table = page.locator("table").first();
|
|
||||||
const tableVisible = (await table.count()) > 0;
|
|
||||||
report.tableVisible = tableVisible;
|
|
||||||
|
|
||||||
let tableOverflow = false;
|
|
||||||
if (tableVisible) {
|
|
||||||
const dims = await table.evaluate((el) => ({
|
|
||||||
scrollWidth: el.scrollWidth,
|
|
||||||
clientWidth: el.clientWidth,
|
|
||||||
}));
|
|
||||||
tableOverflow = dims.scrollWidth > dims.clientWidth;
|
|
||||||
report.tableScrollWidth = dims.scrollWidth;
|
|
||||||
report.tableClientWidth = dims.clientWidth;
|
|
||||||
}
|
|
||||||
report.tableOverflowsHorizontally = tableOverflow;
|
|
||||||
|
|
||||||
const paginationVisible = (await page.getByText("표시", { exact: false }).count()) > 0 ||
|
|
||||||
(await page.getByText("1/", { exact: false }).count()) > 0;
|
|
||||||
report.paginationBarVisible = paginationVisible;
|
|
||||||
|
|
||||||
const bodyScrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
|
||||||
const viewportWidth = 1280;
|
|
||||||
report.bodyScrollWidth = bodyScrollWidth;
|
|
||||||
report.hasHorizontalScrollbar = bodyScrollWidth > viewportWidth;
|
|
||||||
report.layoutResponsive = !report.hasHorizontalScrollbar;
|
|
||||||
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "screen-156-snapshot.png"), fullPage: true });
|
|
||||||
console.log("screen-156-snapshot.png saved");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n=== Report ===");
|
|
||||||
console.log(JSON.stringify(report, null, 2));
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "screen-156-report.json"),
|
|
||||||
JSON.stringify(report, null, 2)
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error:", error.message);
|
|
||||||
report.error = error.message;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "screen-156-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 156, 1053 재검증 - 로딩 완료 후 스크린샷
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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 report: Record<string, any> = { screen156: {}, screen1053: {} };
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1-2: Ensure logged in - goto login first, then login
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 20000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
await page.goto(`${BASE_URL}/screens/156`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 20000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3: Company selection if present
|
|
||||||
const companyBtn = page.getByText("회사 선택").first();
|
|
||||||
if ((await companyBtn.count()) > 0) {
|
|
||||||
await companyBtn.click();
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
const companyOption = page.getByText("company7", { exact: true }).first();
|
|
||||||
if ((await companyOption.count()) > 0) {
|
|
||||||
await companyOption.click();
|
|
||||||
} else {
|
|
||||||
const anyOption = page.locator("[role='menuitem'], [role='option'], button").filter({ hasText: /회사|company/i }).first();
|
|
||||||
if ((await anyOption.count()) > 0) await anyOption.click();
|
|
||||||
}
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4: Screen 156 with menuObjid
|
|
||||||
await page.goto(`${BASE_URL}/screens/156?menuObjid=1762421920156`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.goto(`${BASE_URL}/screens/156?menuObjid=1762421920156`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByText("화면을 불러오는 중", { exact: false }).waitFor({ state: "hidden", timeout: 35000 }).catch(() => {});
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(30000);
|
|
||||||
|
|
||||||
const table156 = page.locator("table tbody tr");
|
|
||||||
await table156.first().waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
const info156 = await page.evaluate(() => {
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const tbody = document.querySelector("tbody");
|
|
||||||
const rows = document.querySelectorAll("tbody tr");
|
|
||||||
const buttons = Array.from(document.querySelectorAll("button")).filter((b) => (b as HTMLElement).innerText?.trim().length > 2);
|
|
||||||
const pagination = document.body.innerText.includes("표시") || document.body.innerText.includes("1/");
|
|
||||||
return {
|
|
||||||
tableVisible: !!table && !!tbody,
|
|
||||||
dataRowCount: rows.length,
|
|
||||||
buttonsWithText: buttons.length,
|
|
||||||
paginationVisible: !!pagination,
|
|
||||||
buttonLabels: buttons.slice(0, 8).map((b) => (b as HTMLElement).innerText?.trim().substring(0, 20)),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
report.screen156 = info156;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "responsive-156-v2.png"), fullPage: true });
|
|
||||||
console.log("responsive-156-v2.png saved");
|
|
||||||
|
|
||||||
// 5: Screen 1053 with menuObjid
|
|
||||||
await page.goto(`${BASE_URL}/screens/1053?menuObjid=1762421920304`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
await page.getByText("화면을 불러오는 중", { exact: false }).waitFor({ state: "hidden", timeout: 35000 }).catch(() => {});
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(30000);
|
|
||||||
|
|
||||||
const splitPanel = page.locator("[class*='border-r'], [class*='split']").first();
|
|
||||||
await splitPanel.waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
const info1053 = await page.evaluate(() => {
|
|
||||||
const leftPanel = document.querySelector("[class*='border-r']");
|
|
||||||
const rightPanel = document.querySelector("main")?.querySelectorAll("div") || [];
|
|
||||||
const buttons = Array.from(document.querySelectorAll("button")).filter((b) => (b as HTMLElement).innerText?.trim().length > 2);
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const bodyText = document.body.innerText;
|
|
||||||
const hasSplitContent = bodyText.includes("좌측에서") || bodyText.includes("공급처") || bodyText.includes("품목");
|
|
||||||
return {
|
|
||||||
splitPanelVisible: !!leftPanel || hasSplitContent,
|
|
||||||
buttonsWithText: buttons.length,
|
|
||||||
tableVisible: !!table,
|
|
||||||
buttonLabels: buttons.slice(0, 8).map((b) => (b as HTMLElement).innerText?.trim().substring(0, 25)),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
report.screen1053 = info1053;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "responsive-1053-v2.png"), fullPage: true });
|
|
||||||
console.log("responsive-1053-v2.png saved");
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "verify-156-1053-v2-report.json"),
|
|
||||||
JSON.stringify(report, null, 2)
|
|
||||||
);
|
|
||||||
console.log("\n=== Report ===");
|
|
||||||
console.log(JSON.stringify(report, null, 2));
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error:", error.message);
|
|
||||||
report.error = error.message;
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-v2-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
/**
|
|
||||||
* 화면 1722, 2089 검증: split-panel-layout2
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-screenshots");
|
|
||||||
|
|
||||||
async function loginIfNeeded(page: any) {
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u: URL) => !u.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyScreen(
|
|
||||||
page: any,
|
|
||||||
screenId: number,
|
|
||||||
report: Record<string, any>
|
|
||||||
) {
|
|
||||||
await page.goto(`${BASE_URL}/screens/${screenId}`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
if (page.url().includes("/login")) {
|
|
||||||
await loginIfNeeded(page);
|
|
||||||
await page.goto(`${BASE_URL}/screens/${screenId}`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
const info = await page.evaluate(() => {
|
|
||||||
const splitPanels = document.querySelectorAll("[class*='split'], [class*='Split'], [data-panel], [class*='panel']");
|
|
||||||
const hasSplitLayout = document.querySelector("[class*='split-panel'], [class*='SplitPanel'], [data-split]") !== null;
|
|
||||||
const panels = document.querySelectorAll("[class*='panel'], [class*='Panel'], [class*='resize']");
|
|
||||||
const leftPanelBorder = document.querySelectorAll("[class*='border-r']");
|
|
||||||
const bodyText = document.body.innerText;
|
|
||||||
const hasLeftRightPanels = bodyText.includes("왼쪽 목록에서") || bodyText.includes("품목 목록") || bodyText.includes("선택하세요");
|
|
||||||
const buttons = document.querySelectorAll("button");
|
|
||||||
const main = document.querySelector("main") || document.body;
|
|
||||||
const mainHeight = (main as HTMLElement).offsetHeight;
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageLoadsWithoutErrors: !document.body.innerText.includes("화면을 찾을 수 없습니다"),
|
|
||||||
splitPanelCount: splitPanels.length,
|
|
||||||
panelCount: panels.length,
|
|
||||||
leftPanelBorderCount: leftPanelBorder.length,
|
|
||||||
bothPanelsVisible: panels.length >= 2 || splitPanels.length >= 2 || hasSplitLayout || (leftPanelBorder.length >= 1 && hasLeftRightPanels),
|
|
||||||
buttonsVisible: buttons.length > 0,
|
|
||||||
layoutFillsHeight: mainHeight >= window.innerHeight * 0.7,
|
|
||||||
bodyScrollWidth: document.body.scrollWidth,
|
|
||||||
bodyScrollHeight: document.body.scrollHeight,
|
|
||||||
viewportWidth: window.innerWidth,
|
|
||||||
viewportHeight: window.innerHeight,
|
|
||||||
hasHorizontalOverflow: document.body.scrollWidth > window.innerWidth,
|
|
||||||
hasVerticalOverflow: document.body.scrollHeight > window.innerHeight,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
report.pageLoadsWithoutErrors = info.pageLoadsWithoutErrors;
|
|
||||||
report.splitPanelVisible = info.bothPanelsVisible || info.splitPanelCount > 0 || info.panelCount >= 2 || (info.leftPanelBorderCount >= 1 && info.pageLoadsWithoutErrors);
|
|
||||||
report.buttonsVisible = info.buttonsVisible;
|
|
||||||
report.layoutFillsHeight = info.layoutFillsHeight;
|
|
||||||
report.noContentOverflowsViewport = !info.hasHorizontalOverflow;
|
|
||||||
report.details = info;
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, `screen-${screenId}-snapshot.png`),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
console.log(`screen-${screenId}-snapshot.png saved`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 report: Record<string, any> = { screen1722: {}, screen2089: {} };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.goto(`${BASE_URL}/login`, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
await loginIfNeeded(page);
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
await verifyScreen(page, 1722, report.screen1722);
|
|
||||||
await verifyScreen(page, 2089, report.screen2089);
|
|
||||||
|
|
||||||
console.log("\n=== Report ===");
|
|
||||||
console.log(JSON.stringify(report, null, 2));
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "split-panel-screens-report.json"),
|
|
||||||
JSON.stringify(report, null, 2)
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error:", error.message);
|
|
||||||
report.error = error.message;
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(SCREENSHOT_DIR, "split-panel-error.png"),
|
|
||||||
fullPage: true,
|
|
||||||
}).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
/**
|
|
||||||
* 탑씰 회사 실제 데이터 화면 검증
|
|
||||||
* - 회사 선택 → 탑씰
|
|
||||||
* - 구매관리 > 발주관리
|
|
||||||
* - 수주관리
|
|
||||||
* - 테이블 가로 스크롤, 페이지네이션 확인
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
const BASE_URL = "http://localhost:9771";
|
|
||||||
const SCREENSHOT_DIR = path.join(__dirname, "../verification-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 results: Record<string, any> = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 1: 접속
|
|
||||||
console.log("Step 1: 접속...");
|
|
||||||
await page.goto(BASE_URL, { waitUntil: "load", timeout: 30000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
const url = page.url();
|
|
||||||
if (url.includes("/login")) {
|
|
||||||
console.log("로그인 필요...");
|
|
||||||
await page.fill("#userId", "wace");
|
|
||||||
await page.fill("#password", "qlalfqjsgh11");
|
|
||||||
await page.locator('button[type="submit"]').first().click();
|
|
||||||
await page.waitForURL((u) => !u.includes("/login"), { timeout: 15000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByText("현재 관리 회사").waitFor({ timeout: 8000 }).catch(() => {});
|
|
||||||
|
|
||||||
// Step 2: 회사 선택 → 탑씰
|
|
||||||
console.log("Step 2: 회사 선택 → 탑씰...");
|
|
||||||
const companyBtn = page.getByText("회사 선택").first();
|
|
||||||
if ((await companyBtn.count()) > 0) {
|
|
||||||
await companyBtn.click();
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
const tapseal = page.getByText("탑씰", { exact: true }).first();
|
|
||||||
if ((await tapseal.count()) > 0) {
|
|
||||||
await tapseal.click();
|
|
||||||
await page.waitForTimeout(2500);
|
|
||||||
console.log("탑씰 선택됨");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: 구매관리 > 발주관리
|
|
||||||
console.log("Step 3: 구매관리 > 발주관리 클릭...");
|
|
||||||
const purchaseMgmt = page.getByText("구매관리").first();
|
|
||||||
if ((await purchaseMgmt.count()) > 0) {
|
|
||||||
await purchaseMgmt.click();
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
const orderMgmt = page.getByText("발주관리").first();
|
|
||||||
if ((await orderMgmt.count()) > 0) {
|
|
||||||
await orderMgmt.click();
|
|
||||||
await page.waitForTimeout(4000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Step 4: 발주관리 스크린샷
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-10.png"), fullPage: true });
|
|
||||||
console.log("verify-10.png 저장 (발주관리)");
|
|
||||||
|
|
||||||
// Step 5: 발주관리 - 테이블/스크롤/페이지네이션 확인
|
|
||||||
const orderDims = await page.evaluate(() => {
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const tableContainer = table?.closest("[style*='overflow'], [class*='overflow']");
|
|
||||||
const pagination = document.body.innerText.includes("표시") || document.body.innerText.includes("1/");
|
|
||||||
return {
|
|
||||||
tableScrollWidth: table ? (table as HTMLElement).scrollWidth : 0,
|
|
||||||
tableClientWidth: table ? (table as HTMLElement).clientWidth : 0,
|
|
||||||
containerClientWidth: tableContainer ? (tableContainer as HTMLElement).clientWidth : 0,
|
|
||||||
hasPagination: pagination,
|
|
||||||
dataRows: table ? table.querySelectorAll("tbody tr").length : 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
results.orderMgmt = orderDims;
|
|
||||||
console.log("발주관리 - 테이블:", orderDims.tableScrollWidth, "x", orderDims.tableClientWidth, "데이터행:", orderDims.dataRows, "페이지네이션:", orderDims.hasPagination);
|
|
||||||
|
|
||||||
// Step 6: 테이블 가로 스크롤 시도
|
|
||||||
const scrollResult = await page.evaluate(() => {
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const scrollable = table?.closest("[style*='overflow'], [class*='overflow']") as HTMLElement;
|
|
||||||
if (scrollable && scrollable.scrollWidth > scrollable.clientWidth) {
|
|
||||||
scrollable.scrollLeft = 200;
|
|
||||||
return { scrolled: true, scrollLeft: scrollable.scrollLeft };
|
|
||||||
}
|
|
||||||
return { scrolled: false };
|
|
||||||
});
|
|
||||||
results.orderScroll = scrollResult;
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Step 7: 수주관리 메뉴 클릭
|
|
||||||
console.log("Step 7: 수주관리 클릭...");
|
|
||||||
const salesMgmt = page.getByText("영업관리").first();
|
|
||||||
if ((await salesMgmt.count()) > 0) {
|
|
||||||
await salesMgmt.click();
|
|
||||||
await page.waitForTimeout(600);
|
|
||||||
}
|
|
||||||
const orderScreen = page.getByText("수주관리").first();
|
|
||||||
if ((await orderScreen.count()) > 0) {
|
|
||||||
await orderScreen.click();
|
|
||||||
await page.waitForTimeout(4000);
|
|
||||||
}
|
|
||||||
await page.getByText("로딩중", { exact: false }).waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Step 8: 수주관리 스크린샷
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-11.png"), fullPage: true });
|
|
||||||
console.log("verify-11.png 저장 (수주관리)");
|
|
||||||
|
|
||||||
// Step 9: 수주관리 - 테이블/페이지네이션 확인
|
|
||||||
const salesDims = await page.evaluate(() => {
|
|
||||||
const table = document.querySelector("table");
|
|
||||||
const pagination = document.body.innerText.includes("표시") || document.body.innerText.includes("1/");
|
|
||||||
return {
|
|
||||||
tableScrollWidth: table ? (table as HTMLElement).scrollWidth : 0,
|
|
||||||
tableClientWidth: table ? (table as HTMLElement).clientWidth : 0,
|
|
||||||
hasPagination: pagination,
|
|
||||||
dataRows: table ? table.querySelectorAll("tbody tr").length : 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
results.salesOrderMgmt = salesDims;
|
|
||||||
console.log("수주관리 - 테이블:", salesDims.tableScrollWidth, "x", salesDims.tableClientWidth, "데이터행:", salesDims.dataRows, "페이지네이션:", salesDims.hasPagination);
|
|
||||||
|
|
||||||
// 이전 문제 해결 여부
|
|
||||||
const orderTableFits = orderDims.tableScrollWidth <= (orderDims.containerClientWidth || orderDims.tableClientWidth + 100);
|
|
||||||
const salesTableFits = salesDims.tableScrollWidth <= salesDims.tableClientWidth + 100;
|
|
||||||
results.issuesResolved = {
|
|
||||||
orderTableOverflow: orderTableFits,
|
|
||||||
orderPaginationVisible: orderDims.hasPagination,
|
|
||||||
salesTableOverflow: salesTableFits,
|
|
||||||
salesPaginationVisible: salesDims.hasPagination,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("\n=== 이전 문제 해결 여부 ===");
|
|
||||||
console.log("발주관리 - 테이블 넘침 해결:", orderTableFits);
|
|
||||||
console.log("발주관리 - 페이지네이션 보임:", orderDims.hasPagination);
|
|
||||||
console.log("수주관리 - 테이블 넘침 해결:", salesTableFits);
|
|
||||||
console.log("수주관리 - 페이지네이션 보임:", salesDims.hasPagination);
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(SCREENSHOT_DIR, "verify-tapseal-result.json"),
|
|
||||||
JSON.stringify(results, null, 2)
|
|
||||||
);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("오류:", error.message);
|
|
||||||
await page.screenshot({ path: path.join(SCREENSHOT_DIR, "verify-99-error.png"), fullPage: true }).catch(() => {});
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
# 쿠버네티스 클러스터 구축 가이드
|
|
||||||
|
|
||||||
## 📋 개요
|
|
||||||
|
|
||||||
이 문서는 Digital Twin 프로젝트의 쿠버네티스 클러스터 구축 과정을 정리한 가이드입니다.
|
|
||||||
|
|
||||||
**작성일**: 2024년 12월 22일
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🖥️ 서버 정보
|
|
||||||
|
|
||||||
### 기존 서버 (참조용)
|
|
||||||
|
|
||||||
| 항목 | 값 |
|
|
||||||
| --------------- | ------------------ |
|
|
||||||
| IP | 211.115.91.170 |
|
|
||||||
| SSH 포트 | 12991 |
|
|
||||||
| 사용자 | geonhee |
|
|
||||||
| OS | Ubuntu 24.04.3 LTS |
|
|
||||||
| K8s 버전 | v1.28.0 |
|
|
||||||
| 컨테이너 런타임 | containerd 1.7.28 |
|
|
||||||
|
|
||||||
### 새 서버 (구축 완료)
|
|
||||||
|
|
||||||
| 항목 | 값 |
|
|
||||||
| --------------- | ------------------ |
|
|
||||||
| IP | 112.168.212.142 |
|
|
||||||
| SSH 포트 | 22 |
|
|
||||||
| 사용자 | wace |
|
|
||||||
| 호스트명 | waceserver |
|
|
||||||
| OS | Ubuntu 24.04.3 LTS |
|
|
||||||
| K8s 버전 | v1.28.15 |
|
|
||||||
| 컨테이너 런타임 | containerd 1.7.28 |
|
|
||||||
| 내부 IP | 10.10.0.74 |
|
|
||||||
| CPU | 20코어 |
|
|
||||||
| 메모리 | 31GB |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 SSH 접속 설정
|
|
||||||
|
|
||||||
### SSH 키 기반 인증 설정
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 로컬에서 SSH 키 확인
|
|
||||||
ls -la ~/.ssh/
|
|
||||||
|
|
||||||
# 2. 공개키를 서버에 복사
|
|
||||||
ssh-copy-id -p 12991 geonhee@211.115.91.170 # 기존 서버
|
|
||||||
ssh-copy-id -p 22 wace@112.168.212.142 # 새 서버
|
|
||||||
|
|
||||||
# 3. 비밀번호 없이 접속 테스트
|
|
||||||
ssh -p 12991 geonhee@211.115.91.170
|
|
||||||
ssh -p 22 wace@112.168.212.142
|
|
||||||
```
|
|
||||||
|
|
||||||
### SSH Config 설정 (선택사항)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ~/.ssh/config 파일에 추가
|
|
||||||
Host wace-old
|
|
||||||
HostName 211.115.91.170
|
|
||||||
Port 12991
|
|
||||||
User geonhee
|
|
||||||
|
|
||||||
Host wace-new
|
|
||||||
HostName 112.168.212.142
|
|
||||||
Port 22
|
|
||||||
User wace
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 쿠버네티스 클러스터 구축 과정
|
|
||||||
|
|
||||||
### 1단계: Swap 비활성화
|
|
||||||
|
|
||||||
쿠버네티스는 swap이 활성화되어 있으면 제대로 동작하지 않습니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# swap 비활성화
|
|
||||||
sudo swapoff -a
|
|
||||||
|
|
||||||
# 영구적으로 비활성화 (재부팅 후에도 유지)
|
|
||||||
sudo sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab
|
|
||||||
|
|
||||||
# 확인 (아무것도 출력되지 않으면 성공)
|
|
||||||
swapon --show
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2단계: containerd 설정
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# containerd 기본 설정 생성
|
|
||||||
sudo containerd config default | sudo tee /etc/containerd/config.toml
|
|
||||||
|
|
||||||
# SystemdCgroup 활성화 (중요!)
|
|
||||||
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/g' /etc/containerd/config.toml
|
|
||||||
|
|
||||||
# containerd 재시작
|
|
||||||
sudo systemctl restart containerd
|
|
||||||
|
|
||||||
# 상태 확인
|
|
||||||
sudo systemctl is-active containerd
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3단계: kubeadm init (클러스터 초기화)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo kubeadm init --pod-network-cidr=10.244.0.0/16
|
|
||||||
```
|
|
||||||
|
|
||||||
**출력 결과 (중요 정보)**:
|
|
||||||
|
|
||||||
- 클러스터 초기화 성공
|
|
||||||
- API 서버: https://10.10.0.74:6443
|
|
||||||
- 워커 노드 조인 토큰 생성됨
|
|
||||||
|
|
||||||
### 4단계: kubectl 설정
|
|
||||||
|
|
||||||
일반 사용자가 kubectl을 사용할 수 있도록 설정합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p $HOME/.kube
|
|
||||||
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
|
|
||||||
sudo chown $(id -u):$(id -g) $HOME/.kube/config
|
|
||||||
|
|
||||||
# 확인
|
|
||||||
kubectl cluster-info
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5단계: 네트워크 플러그인 설치 (Flannel)
|
|
||||||
|
|
||||||
Pod 간 통신을 위한 네트워크 플러그인을 설치합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6단계: 단일 노드 설정
|
|
||||||
|
|
||||||
마스터 노드에서도 워크로드를 실행할 수 있도록 taint를 제거합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl taint nodes --all node-role.kubernetes.io/control-plane-
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 구축 결과
|
|
||||||
|
|
||||||
### 클러스터 상태
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl get nodes -o wide
|
|
||||||
```
|
|
||||||
|
|
||||||
| NAME | STATUS | ROLES | VERSION | INTERNAL-IP | OS-IMAGE | CONTAINER-RUNTIME |
|
|
||||||
| ---------- | ------ | ------------- | -------- | ----------- | ------------------ | ------------------- |
|
|
||||||
| waceserver | Ready | control-plane | v1.28.15 | 10.10.0.74 | Ubuntu 24.04.3 LTS | containerd://1.7.28 |
|
|
||||||
|
|
||||||
### 시스템 Pod 상태
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl get pods -n kube-system
|
|
||||||
kubectl get pods -n kube-flannel
|
|
||||||
```
|
|
||||||
|
|
||||||
| 컴포넌트 | 상태 |
|
|
||||||
| ----------------------- | ---------- |
|
|
||||||
| etcd | ✅ Running |
|
|
||||||
| kube-apiserver | ✅ Running |
|
|
||||||
| kube-controller-manager | ✅ Running |
|
|
||||||
| kube-scheduler | ✅ Running |
|
|
||||||
| kube-proxy | ✅ Running |
|
|
||||||
| coredns (x2) | ✅ Running |
|
|
||||||
| kube-flannel | ✅ Running |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📌 워커 노드 추가 (필요 시)
|
|
||||||
|
|
||||||
다른 서버를 워커 노드로 추가하려면:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubeadm join 10.10.0.74:6443 --token 4lfga6.luad9f367uxh0rlq \
|
|
||||||
--discovery-token-ca-cert-hash sha256:9bea59b6fd34115c3f893a4b10bacc0a5409192b288564dc055251210081c86e
|
|
||||||
```
|
|
||||||
|
|
||||||
**토큰 만료 시 새 토큰 생성**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubeadm token create --print-join-command
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 유용한 명령어
|
|
||||||
|
|
||||||
### 클러스터 정보 확인
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 노드 상태
|
|
||||||
kubectl get nodes -o wide
|
|
||||||
|
|
||||||
# 모든 Pod 상태
|
|
||||||
kubectl get pods -A
|
|
||||||
|
|
||||||
# 클러스터 정보
|
|
||||||
kubectl cluster-info
|
|
||||||
|
|
||||||
# 컴포넌트 상태
|
|
||||||
kubectl get componentstatuses
|
|
||||||
```
|
|
||||||
|
|
||||||
### 문제 해결
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# kubelet 로그 확인
|
|
||||||
sudo journalctl -u kubelet -f
|
|
||||||
|
|
||||||
# containerd 로그 확인
|
|
||||||
sudo journalctl -u containerd -f
|
|
||||||
|
|
||||||
# Pod 상세 정보
|
|
||||||
kubectl describe pod <pod-name> -n <namespace>
|
|
||||||
|
|
||||||
# Pod 로그 확인
|
|
||||||
kubectl logs <pod-name> -n <namespace>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 클러스터 리셋 (초기화 실패 시)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo kubeadm reset
|
|
||||||
sudo rm -rf /etc/cni/net.d
|
|
||||||
sudo rm -rf $HOME/.kube
|
|
||||||
sudo iptables -F && sudo iptables -t nat -F && sudo iptables -t mangle -F && sudo iptables -X
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📂 다음 단계: 자동 배포 설정
|
|
||||||
|
|
||||||
쿠버네티스 클러스터 구축이 완료되었습니다. 다음 단계로 진행할 사항:
|
|
||||||
|
|
||||||
1. **Ingress Controller 설치** (외부 트래픽 라우팅) ✅ 완료
|
|
||||||
2. **Cert-Manager 설치** (SSL 인증서 자동 관리)
|
|
||||||
3. **Harbor/Registry 연동** (컨테이너 이미지 저장소)
|
|
||||||
4. **CI/CD 파이프라인 구성** (Gitea Actions) ✅ 완료
|
|
||||||
5. **Helm 설치** (패키지 관리)
|
|
||||||
6. **애플리케이션 배포** (Deployment, Service, Ingress) ✅ 완료
|
|
||||||
|
|
||||||
### Gitea Actions 자동 배포 설정 완료
|
|
||||||
|
|
||||||
자세한 설정 방법은 [KUBERNETES_DEPLOYMENT_GUIDE.md](docs/KUBERNETES_DEPLOYMENT_GUIDE.md) 참조
|
|
||||||
|
|
||||||
#### 생성된 파일 목록
|
|
||||||
|
|
||||||
```
|
|
||||||
.gitea/workflows/deploy.yml # Gitea Actions 워크플로우
|
|
||||||
k8s/
|
|
||||||
├── namespace.yaml # 네임스페이스 정의
|
|
||||||
├── vexplor-config.yaml # ConfigMap
|
|
||||||
├── vexplor-secret.yaml.template # Secret 템플릿
|
|
||||||
├── vexplor-backend-deployment.yaml # 백엔드 Deployment/Service/PVC
|
|
||||||
├── vexplor-frontend-deployment.yaml# 프론트엔드 Deployment/Service
|
|
||||||
├── vexplor-ingress.yaml # Ingress 설정
|
|
||||||
├── local-path-provisioner.yaml # 스토리지 프로비저너
|
|
||||||
└── ingress-nginx.yaml # Ingress 컨트롤러 패치
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Gitea Repository Secrets 설정 필요
|
|
||||||
|
|
||||||
| Secret 이름 | 설명 |
|
|
||||||
| ------------------- | --------------------------------- |
|
|
||||||
| `HARBOR_USERNAME` | Harbor 사용자명 |
|
|
||||||
| `HARBOR_PASSWORD` | Harbor 비밀번호 |
|
|
||||||
| `KUBECONFIG` | base64 인코딩된 Kubernetes config |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# KUBECONFIG 생성 방법 (K8s 서버에서 실행)
|
|
||||||
cat ~/.kube/config | base64 -w 0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 참고 정보
|
|
||||||
|
|
||||||
### 서버 접속
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 새 서버 (쿠버네티스 클러스터)
|
|
||||||
ssh -p 22 wace@112.168.212.142
|
|
||||||
|
|
||||||
# 기존 서버 (참조용)
|
|
||||||
ssh -p 12991 geonhee@211.115.91.170
|
|
||||||
```
|
|
||||||
|
|
||||||
### 관련 문서
|
|
||||||
|
|
||||||
- [Kubernetes 공식 문서](https://kubernetes.io/docs/)
|
|
||||||
- [kubeadm 설치 가이드](https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/)
|
|
||||||
- [Flannel 네트워크 플러그인](https://github.com/flannel-io/flannel)
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
cd /Users/gbpark/ERP-node
|
|
||||||
./node_modules/.bin/playwright test ".agent-pipeline/browser-tests/e2e-test.spec.ts" --config=".agent-pipeline/browser-tests/playwright.config.ts" --reporter=line 2>&1 | tee /tmp/playwright-result.txt
|
|
||||||
echo "EXIT_CODE: $?" >> /tmp/playwright-result.txt
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$HOME/.nvm/versions/node/$(ls $HOME/.nvm/versions/node/ 2>/dev/null | tail -1)/bin"
|
|
||||||
|
|
||||||
cd /Users/gbpark/ERP-node
|
|
||||||
|
|
||||||
# Node 경로 찾기
|
|
||||||
NODE_BIN=""
|
|
||||||
if command -v node &>/dev/null; then
|
|
||||||
NODE_BIN=$(command -v node)
|
|
||||||
elif [ -f "$HOME/.nvm/nvm.sh" ]; then
|
|
||||||
source "$HOME/.nvm/nvm.sh"
|
|
||||||
NODE_BIN=$(command -v node)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$NODE_BIN" ]; then
|
|
||||||
echo "BROWSER_TEST_RESULT: FAIL - node not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Using node: $NODE_BIN"
|
|
||||||
|
|
||||||
# playwright가 루트 node_modules에 있으므로 그걸로 실행
|
|
||||||
PLAYWRIGHT_BIN="/Users/gbpark/ERP-node/node_modules/.bin/playwright"
|
|
||||||
|
|
||||||
if [ -f "$PLAYWRIGHT_BIN" ]; then
|
|
||||||
echo "playwright binary found: $PLAYWRIGHT_BIN"
|
|
||||||
"$PLAYWRIGHT_BIN" test .agent-pipeline/browser-tests/e2e-test.spec.ts \
|
|
||||||
--config=.agent-pipeline/browser-tests/playwright.config.ts \
|
|
||||||
--reporter=line
|
|
||||||
EXIT=$?
|
|
||||||
if [ $EXIT -eq 0 ]; then
|
|
||||||
echo "BROWSER_TEST_RESULT: PASS"
|
|
||||||
else
|
|
||||||
echo "BROWSER_TEST_RESULT: FAIL - test failed with exit code $EXIT"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "playwright binary not found, falling back to mjs runner"
|
|
||||||
$NODE_BIN /Users/gbpark/ERP-node/run-e2e-runtime-test.mjs
|
|
||||||
fi
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
cd /Users/gbpark/ERP-node
|
|
||||||
node run-e2e-test.mjs 2>&1 | tee /tmp/e2e-smoke-result.txt
|
|
||||||
echo "EXIT_CODE: $?" >> /tmp/e2e-smoke-result.txt
|
|
||||||
cat /tmp/e2e-smoke-result.txt
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$HOME/.nvm/versions/node/$(ls $HOME/.nvm/versions/node/ 2>/dev/null | tail -1)/bin"
|
|
||||||
|
|
||||||
cd /Users/gbpark/ERP-node
|
|
||||||
|
|
||||||
# Node 경로 찾기
|
|
||||||
NODE_BIN=""
|
|
||||||
if command -v node &>/dev/null; then
|
|
||||||
NODE_BIN=$(command -v node)
|
|
||||||
elif [ -f "$HOME/.nvm/nvm.sh" ]; then
|
|
||||||
source "$HOME/.nvm/nvm.sh"
|
|
||||||
NODE_BIN=$(command -v node)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$NODE_BIN" ]; then
|
|
||||||
echo "RESULT: FAIL - node not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Using node: $NODE_BIN"
|
|
||||||
$NODE_BIN /Users/gbpark/ERP-node/run-e2e-runtime-test.mjs
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
cd /Users/gbpark/ERP-node
|
|
||||||
node_modules/.bin/playwright test ".agent-pipeline/browser-tests/e2e-test.spec.ts" --config=".agent-pipeline/browser-tests/playwright.config.ts" --reporter=line
|
|
||||||
echo "PLAYWRIGHT_EXIT:$?"
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
cd /Users/gbpark/ERP-node
|
|
||||||
./node_modules/.bin/playwright test ".agent-pipeline/browser-tests/e2e-test.spec.ts" --config=".agent-pipeline/browser-tests/playwright.config.ts" --reporter=line 2>&1 | tee /tmp/playwright-result.txt
|
|
||||||
echo "EXIT_CODE: $?" >> /tmp/playwright-result.txt
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
@echo off
|
|
||||||
|
|
||||||
REM 스크립트가 있는 디렉토리로 이동
|
|
||||||
cd /d "%~dp0"
|
|
||||||
|
|
||||||
echo =====================================
|
|
||||||
echo PLM 솔루션 - Windows 시작
|
|
||||||
echo =====================================
|
|
||||||
|
|
||||||
echo 기존 컨테이너 및 네트워크 정리 중...
|
|
||||||
docker-compose -f docker-compose.win.yml down -v 2>nul
|
|
||||||
docker network rm plm-network 2>nul
|
|
||||||
|
|
||||||
echo PLM 서비스 시작 중...
|
|
||||||
docker-compose -f docker-compose.win.yml build --no-cache
|
|
||||||
docker-compose -f docker-compose.win.yml up -d
|
|
||||||
|
|
||||||
if %errorlevel% equ 0 (
|
|
||||||
echo.
|
|
||||||
echo ✅ PLM 서비스가 성공적으로 시작되었습니다!
|
|
||||||
echo.
|
|
||||||
echo 🌐 접속 URL:
|
|
||||||
echo • 프론트엔드 (Next.js): http://localhost:3000
|
|
||||||
echo • 백엔드 (Spring/JSP): http://localhost:9090
|
|
||||||
echo.
|
|
||||||
echo 📋 서비스 상태 확인:
|
|
||||||
echo docker-compose -f docker-compose.win.yml ps
|
|
||||||
echo.
|
|
||||||
echo 📊 로그 확인:
|
|
||||||
echo docker-compose -f docker-compose.win.yml logs
|
|
||||||
echo.
|
|
||||||
echo 5초 후 프론트엔드 페이지를 자동으로 엽니다...
|
|
||||||
timeout /t 5 /nobreak >nul
|
|
||||||
start http://localhost:3000
|
|
||||||
) else (
|
|
||||||
echo.
|
|
||||||
echo ❌ PLM 서비스 시작에 실패했습니다!
|
|
||||||
echo.
|
|
||||||
echo 🔍 문제 해결 방법:
|
|
||||||
echo 1. Docker Desktop이 실행 중인지 확인
|
|
||||||
echo 2. 포트가 사용 중인지 확인 (3000, 9090)
|
|
||||||
echo 3. 로그 확인: docker-compose -f docker-compose.win.yml logs
|
|
||||||
echo.
|
|
||||||
pause
|
|
||||||
)
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
모든 ResizableDialogContent에 modalId와 userId를 추가하는 스크립트
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
def process_file(file_path):
|
|
||||||
"""파일을 처리하여 modalId와 userId를 추가"""
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
original_content = content
|
|
||||||
modified = False
|
|
||||||
|
|
||||||
# 파일명에서 modalId 생성 (예: UserFormModal.tsx -> user-form-modal)
|
|
||||||
file_name = Path(file_path).stem
|
|
||||||
modal_id = re.sub(r'(?<!^)(?=[A-Z])', '-', file_name).lower()
|
|
||||||
|
|
||||||
# useAuth import 확인
|
|
||||||
has_use_auth = 'useAuth' in content
|
|
||||||
|
|
||||||
# useAuth import 추가 (없으면)
|
|
||||||
if not has_use_auth and 'ResizableDialogContent' in content:
|
|
||||||
# import 섹션 찾기
|
|
||||||
import_match = re.search(r'(import.*from.*;\n)', content)
|
|
||||||
if import_match:
|
|
||||||
last_import_pos = content.rfind('import')
|
|
||||||
next_newline = content.find('\n', last_import_pos)
|
|
||||||
if next_newline != -1:
|
|
||||||
content = (
|
|
||||||
content[:next_newline + 1] +
|
|
||||||
'import { useAuth } from "@/hooks/useAuth";\n' +
|
|
||||||
content[next_newline + 1:]
|
|
||||||
)
|
|
||||||
modified = True
|
|
||||||
|
|
||||||
# 함수 컴포넌트 내부에 useAuth 추가
|
|
||||||
# 패턴: export default function ComponentName() { 또는 const ComponentName = () => {
|
|
||||||
if 'ResizableDialogContent' in content and 'const { user } = useAuth();' not in content:
|
|
||||||
# 함수 시작 부분 찾기
|
|
||||||
patterns = [
|
|
||||||
r'(export default function \w+\([^)]*\)\s*\{)',
|
|
||||||
r'(export function \w+\([^)]*\)\s*\{)',
|
|
||||||
r'(const \w+ = \([^)]*\)\s*=>\s*\{)',
|
|
||||||
r'(function \w+\([^)]*\)\s*\{)',
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in patterns:
|
|
||||||
match = re.search(pattern, content)
|
|
||||||
if match:
|
|
||||||
insert_pos = match.end()
|
|
||||||
# 이미 useAuth가 있는지 확인
|
|
||||||
next_100_chars = content[insert_pos:insert_pos + 200]
|
|
||||||
if 'useAuth' not in next_100_chars:
|
|
||||||
content = (
|
|
||||||
content[:insert_pos] +
|
|
||||||
'\n const { user } = useAuth();' +
|
|
||||||
content[insert_pos:]
|
|
||||||
)
|
|
||||||
modified = True
|
|
||||||
break
|
|
||||||
|
|
||||||
# ResizableDialogContent에 modalId와 userId 추가
|
|
||||||
# 패턴: <ResizableDialogContent ... > (modalId가 없는 경우)
|
|
||||||
pattern = r'<ResizableDialogContent\s+([^>]*?)(?<!modalId=")>'
|
|
||||||
|
|
||||||
def add_props(match):
|
|
||||||
nonlocal modified
|
|
||||||
props = match.group(1).strip()
|
|
||||||
|
|
||||||
# 이미 modalId가 있는지 확인
|
|
||||||
if 'modalId=' in props:
|
|
||||||
return match.group(0)
|
|
||||||
|
|
||||||
# props가 있으면 끝에 추가, 없으면 새로 추가
|
|
||||||
if props:
|
|
||||||
if not props.endswith(' '):
|
|
||||||
props += ' '
|
|
||||||
new_props = f'{props}modalId="{modal_id}" userId={{user?.userId}}'
|
|
||||||
else:
|
|
||||||
new_props = f'modalId="{modal_id}" userId={{user?.userId}}'
|
|
||||||
|
|
||||||
modified = True
|
|
||||||
return f'<ResizableDialogContent {new_props}>'
|
|
||||||
|
|
||||||
content = re.sub(pattern, add_props, content)
|
|
||||||
|
|
||||||
# 변경사항이 있으면 파일 저장
|
|
||||||
if modified and content != original_content:
|
|
||||||
with open(file_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(content)
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""메인 함수"""
|
|
||||||
frontend_dir = Path('frontend/components')
|
|
||||||
|
|
||||||
if not frontend_dir.exists():
|
|
||||||
print(f"❌ 디렉토리를 찾을 수 없습니다: {frontend_dir}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 모든 .tsx 파일 찾기
|
|
||||||
tsx_files = list(frontend_dir.rglob('*.tsx'))
|
|
||||||
|
|
||||||
modified_files = []
|
|
||||||
|
|
||||||
for file_path in tsx_files:
|
|
||||||
# ResizableDialogContent가 있는 파일만 처리
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
if 'ResizableDialogContent' not in content:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if process_file(file_path):
|
|
||||||
modified_files.append(file_path)
|
|
||||||
print(f"✅ {file_path}")
|
|
||||||
|
|
||||||
print(f"\n🎉 총 {len(modified_files)}개 파일 수정 완료!")
|
|
||||||
|
|
||||||
if modified_files:
|
|
||||||
print("\n수정된 파일 목록:")
|
|
||||||
for f in modified_files:
|
|
||||||
print(f" - {f}")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* 회사 기본정보 화면 - 컴포넌트 렌더링 비율 정밀 분석
|
|
||||||
*
|
|
||||||
* 사용법: 브라우저에서 회사 기본정보 화면을 연 상태에서
|
|
||||||
* F12 → Console 탭 → 이 스크립트 전체를 붙여넣고 Enter
|
|
||||||
*/
|
|
||||||
|
|
||||||
(function analyzeLayout() {
|
|
||||||
const results = { part1: null, part2: null };
|
|
||||||
|
|
||||||
// ========== Part 1: DesktopCanvasRenderer 구조 확인 ==========
|
|
||||||
const runtime = document.querySelector('[data-screen-runtime="true"]');
|
|
||||||
if (!runtime) {
|
|
||||||
console.warn('⚠️ [data-screen-runtime="true"] 요소를 찾을 수 없습니다.');
|
|
||||||
console.log('대안: ScreenModal 기반 렌더링이거나 다른 구조일 수 있습니다.');
|
|
||||||
results.part1 = { error: 'Runtime not found' };
|
|
||||||
} else {
|
|
||||||
const rect = runtime.getBoundingClientRect();
|
|
||||||
const inner = runtime.firstElementChild;
|
|
||||||
|
|
||||||
results.part1 = {
|
|
||||||
runtimeContainer: { width: rect.width, height: rect.height },
|
|
||||||
innerDiv: null,
|
|
||||||
components: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (inner) {
|
|
||||||
const style = inner.style;
|
|
||||||
results.part1.innerDiv = {
|
|
||||||
width: style.width,
|
|
||||||
height: style.height,
|
|
||||||
transform: style.transform,
|
|
||||||
transformOrigin: style.transformOrigin,
|
|
||||||
position: style.position,
|
|
||||||
};
|
|
||||||
|
|
||||||
const comps = inner.querySelectorAll('[data-component-id]');
|
|
||||||
comps.forEach((comp) => {
|
|
||||||
const s = comp.style;
|
|
||||||
const r = comp.getBoundingClientRect();
|
|
||||||
results.part1.components.push({
|
|
||||||
type: comp.getAttribute('data-component-type'),
|
|
||||||
id: comp.getAttribute('data-component-id'),
|
|
||||||
stylePos: `(${s.left}, ${s.top})`,
|
|
||||||
styleSize: `${s.width} x ${s.height}`,
|
|
||||||
renderedSize: `${Math.round(r.width)} x ${Math.round(r.height)}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// ResponsiveGridRenderer (flex 기반) 구조일 수 있음 - 행 단위로 확인
|
|
||||||
const rows = runtime.querySelectorAll(':scope > div');
|
|
||||||
results.part1.rows = [];
|
|
||||||
rows.forEach((row, i) => {
|
|
||||||
const children = row.children;
|
|
||||||
const rowData = { rowIndex: i, childCount: children.length, children: [] };
|
|
||||||
Array.from(children).forEach((child, j) => {
|
|
||||||
const cs = window.getComputedStyle(child);
|
|
||||||
const r = child.getBoundingClientRect();
|
|
||||||
rowData.children.push({
|
|
||||||
type: child.getAttribute('data-component-type') || 'unknown',
|
|
||||||
width: Math.round(r.width),
|
|
||||||
height: Math.round(r.height),
|
|
||||||
flexGrow: cs.flexGrow,
|
|
||||||
flexBasis: cs.flexBasis,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
results.part1.rows.push(rowData);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Part 2: wrapper vs child 크기 확인 ==========
|
|
||||||
const comps = document.querySelectorAll('[data-component-id]');
|
|
||||||
results.part2 = [];
|
|
||||||
comps.forEach((comp) => {
|
|
||||||
const type = comp.getAttribute('data-component-type');
|
|
||||||
const child = comp.firstElementChild;
|
|
||||||
if (child) {
|
|
||||||
const childRect = child.getBoundingClientRect();
|
|
||||||
const compRect = comp.getBoundingClientRect();
|
|
||||||
results.part2.push({
|
|
||||||
type,
|
|
||||||
wrapper: `${Math.round(compRect.width)}x${Math.round(compRect.height)}`,
|
|
||||||
child: `${Math.round(childRect.width)}x${Math.round(childRect.height)}`,
|
|
||||||
overflow: childRect.width > compRect.width ? 'YES' : 'no',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== 결과 출력 ==========
|
|
||||||
console.log('========== Part 1: Runtime 구조 ==========');
|
|
||||||
console.log(JSON.stringify(results.part1, null, 2));
|
|
||||||
|
|
||||||
console.log('\n========== Part 2: Wrapper vs Child ==========');
|
|
||||||
results.part2.forEach((r) => {
|
|
||||||
console.log(`${r.type}: wrapper=${r.wrapper}, child=${r.child}, overflow=${r.overflow}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// scale 값 추출 (transform에서)
|
|
||||||
if (results.part1?.innerDiv?.transform) {
|
|
||||||
const m = results.part1.innerDiv.transform.match(/scale\(([^)]+)\)/);
|
|
||||||
if (m) console.log('\n📐 Scale 값:', m[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
})();
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
/**
|
|
||||||
* 프로덕션에서 "관리자 메뉴로 전환" 버튼 가시성 테스트
|
|
||||||
* 두 계정 (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);
|
|
||||||
});
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
/**
|
|
||||||
* 거래처관리 화면 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);
|
|
||||||
});
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
/**
|
|
||||||
* 거래처관리 메뉴 경유 브라우저 테스트
|
|
||||||
* 영업관리 > 거래처관리 메뉴 클릭 후 상세 화면 진입
|
|
||||||
* 실행: 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);
|
|
||||||
});
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
/**
|
|
||||||
* 구매관리 - 공급업체관리 / 구매품목정보 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);
|
|
||||||
});
|
|
||||||
@@ -95,7 +95,7 @@ echo ============================================
|
|||||||
echo [완료] 모든 서비스가 시작되었습니다!
|
echo [완료] 모든 서비스가 시작되었습니다!
|
||||||
echo ============================================
|
echo ============================================
|
||||||
echo.
|
echo.
|
||||||
echo [DATABASE] PostgreSQL: http://39.117.244.52:11132
|
echo [DATABASE] PostgreSQL: http://211.115.91.141:11134
|
||||||
echo [BACKEND] Node.js API: http://localhost:8080/api
|
echo [BACKEND] Node.js API: http://localhost:8080/api
|
||||||
echo [FRONTEND] Next.js: http://localhost:9771
|
echo [FRONTEND] Next.js: http://localhost:9771
|
||||||
echo.
|
echo.
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ Write-Host "============================================" -ForegroundColor Cyan
|
|||||||
Write-Host "[완료] 모든 서비스가 시작되었습니다!" -ForegroundColor Green
|
Write-Host "[완료] 모든 서비스가 시작되었습니다!" -ForegroundColor Green
|
||||||
Write-Host "============================================" -ForegroundColor Cyan
|
Write-Host "============================================" -ForegroundColor Cyan
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "[DATABASE] PostgreSQL: http://39.117.244.52:11132" -ForegroundColor White
|
Write-Host "[DATABASE] PostgreSQL: http://211.115.91.141:11134" -ForegroundColor White
|
||||||
Write-Host "[BACKEND] Node.js API: http://localhost:8080/api" -ForegroundColor White
|
Write-Host "[BACKEND] Node.js API: http://localhost:8080/api" -ForegroundColor White
|
||||||
Write-Host "[FRONTEND] Next.js: http://localhost:9771" -ForegroundColor White
|
Write-Host "[FRONTEND] Next.js: http://localhost:9771" -ForegroundColor White
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user