diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..752bbbe3 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index e3546392..a766194f 100644 --- a/.gitignore +++ b/.gitignore @@ -218,6 +218,11 @@ docs/mes-reference/ # 테스트 결과물 frontend/test-results/ +test-output/ +test-results/ + +# 아카이브/백업 파일 +*.tar.gz # Cursor 설정 .cursor/ diff --git a/PLAN.MD b/PLAN.MD deleted file mode 100644 index 49d2d7e4..00000000 --- a/PLAN.MD +++ /dev/null @@ -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. 키패드 모달에서 포장등록 버튼이 숨겨지는지 확인 - ---- - -## 이전 완료 계획 (아카이브) - - -pop-dashboard 4가지 아이템 모드 완성 (완료) - -- [x] groupBy UI 추가 -- [x] xAxisColumn 입력 UI 추가 -- [x] 통계카드 카테고리 설정 UI 추가 -- [x] 차트 xAxisColumn 자동 보정 로직 -- [x] 통계카드 카테고리별 필터 적용 -- [x] SQL 빌더 방어 로직 -- [x] refreshInterval 최소값 강제 - - - - -POP 뷰어 스크롤 수정 (완료) - -- [x] overflow-hidden 제거 -- [x] overflow-auto 공통 적용 -- [x] 일반 모드 min-h-full 추가 - - - - -POP 뷰어 실제 컴포넌트 렌더링 (완료) - -- [x] 뷰어 페이지에 레지스트리 초기화 import 추가 -- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체 - - diff --git a/POPUPDATE.md b/POPUPDATE.md deleted file mode 100644 index 836cdb1f..00000000 --- a/POPUPDATE.md +++ /dev/null @@ -1,1041 +0,0 @@ -# POP 화면 관리 시스템 개발 기록 - -> **AI 에이전트 안내**: 이 문서는 Progressive Disclosure 방식으로 구성되어 있습니다. -> 1. 먼저 [Quick Reference](#quick-reference)에서 필요한 정보 확인 -> 2. 상세 내용이 필요하면 해당 섹션으로 이동 -> 3. 코드가 필요하면 파일 직접 참조 - ---- - -## Quick Reference - -### POP이란? -Point of Production - 현장 작업자용 모바일/태블릿 화면 시스템 - -### 핵심 결정사항 -- **분리 방식**: 레이아웃 기반 구분 (screen_layouts_pop 테이블) -- **식별 방법**: `screen_layouts_pop`에 레코드 존재 여부로 POP 화면 판별 -- **데스크톱 영향**: 없음 (모든 isPop 기본값 = false) - -### 주요 경로 - -| 용도 | 경로 | -|------|------| -| POP 뷰어 URL | `/pop/screens/{screenId}?preview=true&device=tablet` | -| POP 관리 페이지 | `/admin/screenMng/popScreenMngList` | -| POP 레이아웃 API | `/api/screen-management/layout-pop/:screenId` | - -### 파일 찾기 가이드 - -| 작업 | 파일 | -|------|------| -| POP 레이아웃 DB 스키마 | `db/migrations/052_create_screen_layouts_pop.sql` | -| POP API 서비스 로직 | `backend-node/src/services/screenManagementService.ts` (getLayoutPop, saveLayoutPop) | -| POP API 라우트 | `backend-node/src/routes/screenManagementRoutes.ts` | -| 프론트엔드 API 클라이언트 | `frontend/lib/api/screen.ts` (screenApi.getLayoutPop 등) | -| POP 화면 관리 UI | `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` | -| POP 뷰어 페이지 | `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` | -| 미리보기 URL 분기 | `frontend/components/screen/ScreenSettingModal.tsx` (PreviewTab) | -| POP 컴포넌트 설계서 | `docs/pop/components-spec.md` (13개 컴포넌트 상세) | - ---- - -## 섹션 목차 - -| # | 섹션 | 한 줄 요약 | -|---|------|----------| -| 1 | [아키텍처](#1-아키텍처) | 레이아웃 테이블로 POP/데스크톱 분리 | -| 2 | [데이터베이스](#2-데이터베이스) | screen_layouts_pop 테이블 (FK 없음) | -| 3 | [백엔드 API](#3-백엔드-api) | CRUD 4개 엔드포인트 | -| 4 | [프론트엔드 API](#4-프론트엔드-api) | screenApi에 4개 함수 추가 | -| 5 | [관리 페이지](#5-관리-페이지) | POP 화면만 필터링하여 표시 | -| 6 | [뷰어](#6-뷰어) | 모바일/태블릿 프레임 미리보기 | -| 7 | [미리보기](#7-미리보기) | isPop prop으로 URL 분기 | -| 8 | [파일 목록](#8-파일-목록) | 생성 3개, 수정 9개 | -| 9 | [반응형 전략](#9-반응형-전략-신규-결정사항) | Flow 레이아웃 (세로 쌓기) 채택 | -| 10 | [POP 사용자 앱](#10-pop-사용자-앱-구조-신규-결정사항) | 대시보드 카드 → 화면 뷰어 | -| 11 | [POP 디자이너](#11-pop-디자이너-신규-계획) | 좌(탭패널) + 우(팬캔버스), 반응형 편집 | -| 12 | [데이터 구조](#12-pop-레이아웃-데이터-구조-신규) | PopLayoutData, mobileOverride | -| 13 | [컴포넌트 재사용성](#13-컴포넌트-재사용성-분석-신규) | 2개 재사용, 4개 부분, 7개 신규 | - ---- - -## 1. 아키텍처 - -**결정**: Option B (레이아웃 기반 구분) - -``` -screen_definitions (공용) - ├── screen_layouts_v2 (데스크톱) - └── screen_layouts_pop (POP) -``` - -**선택 이유**: 기존 테이블 변경 없음, 데스크톱 영향 없음, 향후 통합 가능 - ---- - -## 2. 데이터베이스 - -**테이블**: `screen_layouts_pop` - -| 컬럼 | 타입 | 설명 | -|------|------|------| -| id | SERIAL | PK | -| screen_id | INTEGER | 화면 ID (unique) | -| layout_data | JSONB | 컴포넌트 JSON | - -**특이사항**: FK 없음 (soft-delete 지원) - -**파일**: `db/migrations/052_create_screen_layouts_pop.sql` - ---- - -## 3. 백엔드 API - -| Method | Endpoint | 용도 | -|--------|----------|------| -| GET | `/api/screen-management/layout-pop/:screenId` | 조회 | -| POST | `/api/screen-management/layout-pop/:screenId` | 저장 | -| DELETE | `/api/screen-management/layout-pop/:screenId` | 삭제 | -| GET | `/api/screen-management/pop-layout-screen-ids` | ID 목록 | - -**파일**: `backend-node/src/services/screenManagementService.ts` - ---- - -## 4. 프론트엔드 API - -**파일**: `frontend/lib/api/screen.ts` - -```typescript -screenApi.getLayoutPop(screenId) // 조회 -screenApi.saveLayoutPop(screenId, data) // 저장 -screenApi.deleteLayoutPop(screenId) // 삭제 -screenApi.getScreenIdsWithPopLayout() // ID 목록 -``` - ---- - -## 5. 관리 페이지 - -**파일**: `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` - -**핵심 로직**: -```typescript -const popIds = await screenApi.getScreenIdsWithPopLayout(); -const filteredScreens = screens.filter(s => new Set(popIds).has(s.screenId)); -``` - -**기능**: POP 화면만 표시, 새 POP 화면 생성):, 보기/설계 버튼 - ---- - -## 6. 뷰어 - -**파일**: `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` - -**URL 파라미터**: -| 파라미터 | 값 | 설명 | -|---------|---|------| -| preview | true | 툴바 표시 | -| device | mobile/tablet | 디바이스 크기 (기본: tablet) | - -**디바이스 크기**: mobile(375x812), tablet(768x1024) - ---- - -## 7. 미리보기 - -**핵심**: `isPop` prop으로 URL 분기 - -``` -popScreenMngList - └─► ScreenRelationFlow(isPop=true) - └─► ScreenSettingModal - └─► PreviewTab → /pop/screens/{id} - -screenMngList (데스크톱) - └─► ScreenRelationFlow(isPop=false 기본값) - └─► ScreenSettingModal - └─► PreviewTab → /screens/{id} -``` - -**안전성**: isPop 기본값 = false → 데스크톱 영향 없음 - ---- - -## 8. 파일 목록 - -### 생성 (3개) - -| 파일 | 용도 | -|------|------| -| `db/migrations/052_create_screen_layouts_pop.sql` | DB 스키마 | -| `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` | POP 뷰어 | -| `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` | POP 관리 | - -### 수정 (9개) - -| 파일 | 변경 내용 | -|------|----------| -| `backend-node/src/services/screenManagementService.ts` | POP CRUD 함수 | -| `backend-node/src/controllers/screenManagementController.ts` | 컨트롤러 | -| `backend-node/src/routes/screenManagementRoutes.ts` | 라우트 | -| `frontend/lib/api/screen.ts` | API 클라이언트 | -| `frontend/components/screen/CreateScreenModal.tsx` | isPop prop | -| `frontend/components/screen/ScreenSettingModal.tsx` | isPop, PreviewTab | -| `frontend/components/screen/ScreenRelationFlow.tsx` | isPop 전달 | -| `frontend/components/screen/ScreenDesigner.tsx` | isPop, 미리보기 | -| `frontend/components/screen/toolbar/SlimToolbar.tsx` | POP 미리보기 버튼 | - ---- - -## 9. 반응형 전략 (신규 결정사항) - -### 문제점 -- 데스크톱은 절대 좌표(`position: { x, y }`) 사용 -- 모바일 화면 크기가 달라지면 레이아웃 깨짐 - -### 결정: Flow 레이아웃 채택 - -| 항목 | 데스크톱 | POP | -|-----|---------|-----| -| 배치 방식 | `position: { x, y }` | `order: number` (순서) | -| 컨테이너 | 자유 배치 | 중첩 구조 (섹션 > 필드) | -| 렌더러 | 절대 좌표 계산 | Flexbox column (세로 쌓기) | - -### Flow 레이아웃 데이터 구조 -```typescript -{ - layoutMode: "flow", // flow | absolute - components: [ - { - id: "section-1", - type: "pop-section", - order: 0, // 순서로 배치 - children: [...] - } - ] -} -``` - ---- - -## 10. POP 사용자 앱 구조 (신규 결정사항) - -### 데스크톱 vs POP 진입 구조 - -| | 데스크톱 | POP | -|---|---------|-----| -| 메뉴 | 왼쪽 사이드바 | 대시보드 카드 | -| 네비게이션 | 복잡한 트리 구조 | 화면 → 뒤로가기 | -| URL | `/screens/{id}` | `/pop/screens/{id}` | - -### POP 화면 흐름 -``` -/pop/login (POP 로그인) - ↓ -/pop/dashboard (화면 목록 - 카드형) - ↓ -/pop/screens/{id} (화면 뷰어) -``` - ---- - -## 11. POP 디자이너 (신규 계획) - -### 진입 경로 -``` -popScreenMngList → [설계] 버튼 → PopDesigner 컴포넌트 -``` - -### 레이아웃 구조 (2026-02-02 수정) -데스크톱 Screen Designer와 유사하게 **좌측 탭 패널 + 우측 캔버스**: -``` -┌─────────────────────────────────────────────────────────────────┐ -│ [툴바] ← 목록 | 화면명 | 📱모바일 📱태블릿 | 🔄 | 💾저장 │ -├────────────────┬────────────────────────────────────────────────┤ -│ [패널] │ [캔버스 영역] │ -│ ◀━━━━▶ │ │ -│ (리사이즈) │ ┌────────────────────────┐ │ -│ │ │ 디바이스 프레임 │ ← 드래그로 │ -│ ┌────────────┐ │ │ │ 팬 이동 │ -│ │컴포넌트│편집│ │ │ [섹션 1] │ │ -│ └────────────┘ │ │ ├─ 필드 A │ │ -│ │ │ └─ 필드 B │ │ -│ (컴포넌트 탭) │ │ │ │ -│ 📦 섹션 │ │ [섹션 2] │ │ -│ 📝 필드 │ │ ├─ 버튼1 ─ 버튼2 │ │ -│ 🔘 버튼 │ │ │ │ -│ 📋 리스트 │ └────────────────────────┘ │ -│ 📊 인디케이터 │ │ -│ │ │ -│ (편집 탭) │ │ -│ 선택된 컴포 │ │ -│ 넌트 설정 │ │ -└────────────────┴────────────────────────────────────────────────┘ -``` - -### 패널 기능 -| 기능 | 설명 | -|-----|------| -| **리사이즈** | 드래그로 패널 너비 조절 (min: 200px, max: 400px) | -| **컴포넌트 탭** | POP 전용 컴포넌트만 표시 | -| **편집 탭** | 선택된 컴포넌트 설정 (프리셋 기반) | - -### 캔버스 기능 -| 기능 | 설명 | -|-----|------| -| **팬(Pan)** | 마우스 드래그로 보는 위치 이동 | -| **줌** | 마우스 휠로 확대/축소 (선택사항) | -| **디바이스 탭** | 📱모바일 / 📱태블릿 전환 | -| **나란히 보기** | 옵션으로 둘 다 표시 가능 | -| **실시간 미리보기** | 편집 = 미리보기 (별도 창 불필요) | - -### 캔버스 방식: 블록 쌓기 -- 섹션끼리는 위→아래로 쌓임 -- 섹션 안에서는 가로(row) 또는 세로(column) 선택 가능 -- 드래그앤드롭으로 순서 변경 -- 캔버스 자체가 실시간 미리보기 - -### 기준 해상도 -| 디바이스 | 논리적 크기 (dp) | 용도 | -|---------|-----------------|------| -| 모바일 | 360 x 640 | Zebra TC52/57 등 산업용 핸드헬드 | -| 태블릿 | 768 x 1024 | 8~10인치 산업용 태블릿 | - -### 터치 타겟 (장갑 착용 고려) -- 최소 버튼 크기: **60dp** (일반 앱 48dp보다 큼) -- 버튼 간격: **16dp** 이상 - -### 반응형 편집 방식 -| 모드 | 설명 | -|-----|------| -| **기준 디바이스** | 태블릿 (메인 편집) | -| **자동 조정** | CSS flex-wrap, grid로 모바일 자동 줄바꿈 | -| **수동 조정** | 모바일 탭에서 그리드 열 수, 숨기기 설정 | - -**흐름:** -``` -1. 태블릿 탭에서 편집 (기준) - → 모든 컴포넌트, 섹션, 순서, 데이터 바인딩 설정 - -2. 모바일 탭에서 확인 - A) 자동 조정 OK → 그대로 저장 - B) 배치 어색함 → 그리드 열 수 조정 또는 숨기기 -``` - -### 섹션 내 컴포넌트 배치 옵션 -| 설정 | 옵션 | -|-----|------| -| 배치 방향 | `row` / `column` | -| 순서 | 드래그로 변경 | -| 비율 | flex (1:1, 2:1, 1:2 등) | -| 정렬 | `start` / `center` / `end` | -| 간격 | `none` / `small` / `medium` / `large` | -| 줄바꿈 | `wrap` / `nowrap` | -| **그리드 열 수** | 태블릿용, 모바일용 각각 설정 가능 | - -### 관리자가 설정 가능한 것 -| 항목 | 설정 방식 | -|-----|----------| -| 섹션 순서 | 드래그로 위/아래 이동 | -| 섹션 내 배치 | 가로(row) / 세로(column) | -| 정렬 | 왼쪽/가운데/오른쪽, 위/가운데/아래 | -| 컴포넌트 비율 | 1:1, 2:1, 1:2 등 (flex) | -| 크기 | S/M/L/XL 프리셋 | -| 여백/간격 | 작음/보통/넓음 프리셋 | -| 아이콘 | 선택 가능 | -| 테마/색상 | 프리셋 또는 커스텀 | -| 그리드 열 수 | 태블릿/모바일 각각 | -| 모바일 숨기기 | 특정 컴포넌트 숨김 | - -### 관리자가 설정 불가능한 것 (반응형 유지) -- 정확한 x, y 좌표 -- 정확한 픽셀 크기 (예: 347px) -- 고정 위치 (예: 왼쪽에서 100px) - -### 스타일 분리 원칙 -``` -뼈대 (변경 어려움 - 처음부터 잘 설계): -- 데이터 바인딩 구조 (columnName, dataSource) -- 컴포넌트 계층 (섹션 > 필드) -- 액션 로직 - -옷 (변경 쉬움 - 나중에 조정 가능): -- 색상, 폰트 크기 → CSS 변수/테마 -- 버튼 모양 → 프리셋 -- 아이콘 → 선택 -``` - -### 다국어 연동 (준비) -- 상태: `showMultilangSettingsModal` 미리 추가 -- 버튼: 툴바에 자리만 (비활성) -- 연결: 추후 `MultilangSettingsModal` import - -### 데스크톱 시스템 재사용 -| 기능 | 재사용 | 비고 | -|-----|-------|------| -| formData 관리 | O | 그대로 | -| 필드간 연결 | O | cascading, hierarchy | -| 테이블 참조 | O | dataSource, filter | -| 저장 이벤트 | O | beforeFormSave | -| 집계 | O | 스타일만 변경 | -| 설정 패널 | O | 탭 방식 참고 | -| CRUD API | O | 그대로 | -| buttonActions | O | 그대로 | -| 다국어 | O | MultilangSettingsModal | - -### 파일 구조 (신규 생성 예정) -``` -frontend/components/pop/ -├── PopDesigner.tsx # 메인 (좌: 패널, 우: 캔버스) -├── PopCanvas.tsx # 캔버스 (팬/줌 + 프레임) -├── PopToolbar.tsx # 상단 툴바 -│ -├── panels/ -│ └── PopPanel.tsx # 통합 패널 (컴포넌트/편집 탭) -│ -├── components/ # POP 전용 컴포넌트 -│ ├── PopSection.tsx -│ ├── PopField.tsx -│ ├── PopButton.tsx -│ └── ... -│ -└── types/ - └── pop-layout.ts # PopLayoutData, PopComponentData -``` - ---- - -## 12. POP 레이아웃 데이터 구조 (신규) - -### PopLayoutData -```typescript -interface PopLayoutData { - version: "pop-1.0"; - layoutMode: "flow"; // 항상 flow (절대좌표 없음) - deviceTarget: "mobile" | "tablet" | "both"; - components: PopComponentData[]; -} -``` - -### PopComponentData -```typescript -interface PopComponentData { - id: string; - type: "pop-section" | "pop-field" | "pop-button" | "pop-list" | "pop-indicator"; - order: number; // 순서 (x, y 좌표 대신) - - // 개별 컴포넌트 flex 비율 - flex?: number; // 기본 1 - - // 섹션인 경우: 내부 레이아웃 설정 - layout?: { - direction: "row" | "column"; - justify: "start" | "center" | "end" | "between"; - align: "start" | "center" | "end"; - gap: "none" | "small" | "medium" | "large"; - wrap: boolean; - grid?: number; // 태블릿 기준 열 수 - }; - - // 크기 프리셋 - size?: "S" | "M" | "L" | "XL" | "full"; - - // 데이터 바인딩 - dataBinding?: { - tableName: string; - columnName: string; - displayField?: string; - }; - - // 스타일 프리셋 - style?: { - variant: "default" | "primary" | "success" | "warning" | "danger"; - padding: "none" | "small" | "medium" | "large"; - }; - - // 모바일 오버라이드 (선택사항) - mobileOverride?: { - grid?: number; // 모바일 열 수 (없으면 자동) - hidden?: boolean; // 모바일에서 숨기기 - }; - - // 하위 컴포넌트 (섹션 내부) - children?: PopComponentData[]; - - // 컴포넌트별 설정 - config?: Record; -} -``` - -### 데스크톱 vs POP 데이터 비교 -| 항목 | 데스크톱 (LayoutData) | POP (PopLayoutData) | -|-----|----------------------|---------------------| -| 배치 | `position: { x, y, z }` | `order: number` | -| 크기 | `size: { width, height }` (픽셀) | `size: "S" | "M" | "L"` (프리셋) | -| 컨테이너 | 없음 (자유 배치) | `layout: { direction, grid }` | -| 반응형 | 없음 | `mobileOverride` | - ---- - -## 13. 컴포넌트 재사용성 분석 - -### 최종 분류 - -| 분류 | 개수 | 컴포넌트 | -|-----|-----|---------| -| 완전 재사용 | 2 | form-field, action-button | -| 부분 재사용 | 4 | tab-panel, data-table, kpi-gauge, process-flow | -| 신규 개발 | 7 | section, card-list, status-indicator, number-pad, barcode-scanner, timer, alarm-list | - -### 핵심 컴포넌트 7개 (최소 필수) - -| 컴포넌트 | 역할 | 포함 기능 | -|---------|------|----------| -| **pop-section** | 레이아웃 컨테이너 | 카드, 그룹핑, 접기/펼치기 | -| **pop-field** | 데이터 입력/표시 | 텍스트, 숫자, 드롭다운, 바코드, 숫자패드 | -| **pop-button** | 액션 실행 | 저장, 삭제, API 호출, 화면이동 | -| **pop-list** | 데이터 목록 | 카드리스트, 선택목록, 테이블 참조 | -| **pop-indicator** | 상태/수치 표시 | KPI, 게이지, 신호등, 진행률 | -| **pop-scanner** | 바코드/QR 입력 | 카메라, 외부 스캐너 | -| **pop-numpad** | 숫자 입력 특화 | 큰 버튼, 계산기 모드 | - ---- - -## TODO - -### Phase 1: POP 디자이너 개발 (현재 진행) - -| # | 작업 | 설명 | 상태 | -|---|------|------|------| -| 1 | `PopLayoutData` 타입 정의 | order, layout, mobileOverride | 완료 | -| 2 | `PopDesigner.tsx` | 좌: 리사이즈 패널, 우: 팬 가능 캔버스 | 완료 | -| 3 | `PopPanel.tsx` | 탭 (컴포넌트/편집), POP 컴포넌트만 | 완료 | -| 4 | `PopCanvas.tsx` | 팬/줌 + 디바이스 프레임 + 블록 렌더링 | 완료 | -| 5 | `SectionGrid.tsx` | 섹션 내부 컴포넌트 배치 (react-grid-layout) | 완료 | -| 6 | 드래그앤드롭 | 팔레트→캔버스 (섹션), 팔레트→섹션 (컴포넌트) | 완료 | -| 7 | 컴포넌트 자유 배치/리사이즈 | 고정 셀 크기(40px) 기반 자동 그리드 | 완료 | -| 8 | 편집 탭 | 그리드 설정, 모바일 오버라이드 | 완료 (기본) | -| 9 | 저장/로드 | 기존 API 재사용 (saveLayoutPop) | 완료 | - -### Phase 2: POP 컴포넌트 개발 - -상세: `docs/pop/components-spec.md` - -1단계 (우선): -- [ ] pop-section (레이아웃 컨테이너) -- [ ] pop-field (범용 입력) -- [ ] pop-button (액션) - -2단계: -- [ ] pop-list (카드형 목록) -- [ ] pop-indicator (상태/KPI) -- [ ] pop-numpad (숫자패드) - -3단계: -- [ ] pop-scanner (바코드) -- [ ] pop-timer (타이머) -- [ ] pop-alarm (알람) - -### Phase 3: POP 사용자 앱 -- [ ] `/pop/login` - POP 전용 로그인 -- [ ] `/pop/dashboard` - 화면 목록 (카드형) -- [ ] `/pop/screens/[id]` - Flow 렌더러 적용 - -### 기타 -- [ ] POP 컴포넌트 레지스트리 -- [ ] POP 메뉴/폴더 관리 -- [ ] POP 인증 분리 -- [ ] 다국어 연동 - ---- - -## 핵심 파일 참조 - -### 기존 파일 (참고용) -| 파일 | 용도 | -|------|------| -| `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` | 진입점, PopDesigner 호출 위치 | -| `frontend/components/screen/ScreenDesigner.tsx` | 데스크톱 디자이너 (구조 참고) | -| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 모달 (추후 연동) | -| `frontend/lib/api/screen.ts` | API (getLayoutPop, saveLayoutPop) | -| `backend-node/src/services/screenManagementService.ts` | POP CRUD (4720~4920행) | - -### 신규 생성 예정 -| 파일 | 용도 | -|------|------| -| `frontend/components/pop/PopDesigner.tsx` | 메인 디자이너 | -| `frontend/components/pop/PopCanvas.tsx` | 캔버스 (팬/줌) | -| `frontend/components/pop/PopToolbar.tsx` | 툴바 | -| `frontend/components/pop/panels/PopPanel.tsx` | 통합 패널 | -| `frontend/components/pop/types/pop-layout.ts` | 타입 정의 | -| `frontend/components/pop/components/PopSection.tsx` | 섹션 컴포넌트 | - ---- - ---- - -## 14. 그리드 시스템 단순화 (2026-02-02 변경) - -### 기존 문제: 이중 그리드 구조 -``` -캔버스 (24열, rowHeight 20px) - └─ 섹션 (colSpan/rowSpan으로 크기 지정) - └─ 내부 그리드 (columns/rows로 컴포넌트 배치) -``` - -**문제점:** -1. 섹션 크기와 내부 그리드가 독립적이라 동기화 안됨 -2. 섹션을 늘려도 내부 그리드 점은 그대로 (비례 확대만) -3. 사용자가 두 가지 단위를 이해해야 함 - -### 변경: 단일 자동계산 그리드 - -**핵심 변경사항:** -- 그리드 점(dot) 제거 -- 고정 셀 크기(40px) 기반으로 섹션 크기에 따라 열/행 수 자동 계산 -- 컴포넌트는 react-grid-layout으로 자유롭게 드래그/리사이즈 - -**코드 (SectionGrid.tsx):** -```typescript -const CELL_SIZE = 40; -const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap))); -const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap))); -``` - -**결과:** -- 섹션 크기 변경 → 내부 셀 개수 자동 조정 -- 컴포넌트 자유 배치/리사이즈 가능 -- 직관적인 사용자 경험 - -### onLayoutChange 대신 onDragStop/onResizeStop 사용 - -**문제:** onLayoutChange는 드롭 직후에도 호출되어 섹션 크기가 자동 확대됨 - -**해결:** -```typescript -// 변경 전 - - -// 변경 후 - -``` - -상태 업데이트는 드래그/리사이즈 완료 후에만 실행 - ---- - -## POP 화면 관리 페이지 개발 (2026-02-02) - -### POP 카테고리 트리 API 구현 - -**기능:** -- POP 화면을 카테고리별로 관리하는 트리 구조 구현 -- 기존 `screen_groups` 테이블을 `hierarchy_path LIKE 'POP/%'` 조건으로 필터링하여 재사용 -- 데스크탑 화면 관리와 별도로 POP 전용 카테고리 체계 구성 - -**백엔드 API:** -- `GET /api/screen-groups/pop/groups` - POP 그룹 목록 조회 -- `POST /api/screen-groups/pop/groups` - POP 그룹 생성 -- `PUT /api/screen-groups/pop/groups/:id` - POP 그룹 수정 -- `DELETE /api/screen-groups/pop/groups/:id` - POP 그룹 삭제 -- `POST /api/screen-groups/pop/ensure-root` - POP 루트 그룹 자동 생성 - -### 트러블슈팅: API 경로 중복 문제 - -**문제:** 카테고리 생성 시 404 에러 발생 - -**원인:** -- `apiClient`의 baseURL이 이미 `http://localhost:8080/api`로 설정됨 -- API 호출 경로에 `/api/screen-groups/...`를 사용하여 최종 URL이 `/api/api/screen-groups/...`로 중복 - -**해결:** -```typescript -// 변경 전 -const response = await apiClient.post("/api/screen-groups/pop/groups", data); - -// 변경 후 -const response = await apiClient.post("/screen-groups/pop/groups", data); -``` - -### 트러블슈팅: created_by 컬럼 오류 - -**문제:** `column "created_by" of relation "screen_groups" does not exist` - -**원인:** -- 신규 작성 코드에서 `created_by` 컬럼을 사용했으나 -- 기존 `screen_groups` 테이블 스키마에는 `writer` 컬럼이 존재 - -**해결:** -```sql --- 변경 전 -INSERT INTO screen_groups (..., created_by) VALUES (..., $9) - --- 변경 후 -INSERT INTO screen_groups (..., writer) VALUES (..., $9) -``` - -### 트러블슈팅: is_active 컬럼 타입 불일치 - -**문제:** `value too long for type character varying(1)` 에러로 카테고리 생성 실패 - -**원인:** -- `is_active` 컬럼이 `VARCHAR(1)` 타입 -- INSERT 쿼리에서 `true`(boolean, 4자)를 직접 사용 - -**해결:** -```sql --- 변경 전 -INSERT INTO screen_groups (..., is_active) VALUES (..., true) - --- 변경 후 -INSERT INTO screen_groups (..., is_active) VALUES (..., 'Y') -``` - -**교훈:** -- 기존 테이블 스키마를 반드시 확인 후 쿼리 작성 -- `is_active`는 `VARCHAR(1)` 타입으로 'Y'/'N' 값 사용 -- `created_by` 대신 `writer` 컬럼명 사용 - -### 카테고리 트리 UI 개선 - -**문제:** 하위 폴더와 상위 폴더의 계층 관계가 시각적으로 불명확 - -**해결:** -1. 들여쓰기 증가: `level * 16px` → `level * 24px` -2. 트리 연결 표시: "ㄴ" 문자로 하위 항목 명시 -3. 루트 폴더 강조: 주황색 아이콘 + 볼드 텍스트, 하위는 노란색 아이콘 - -```tsx -// 하위 레벨에 연결 표시 추가 -{level > 0 && ( - ㄴ -)} - -// 루트와 하위 폴더 시각적 구분 - -{group.group_name} -``` - -### 미분류 화면 이동 기능 추가 - -**기능:** 미분류 화면을 특정 카테고리로 이동하는 드롭다운 메뉴 - -**구현:** -```tsx -// 이동 드롭다운 메뉴 - - - - - 이동 - - - - {treeData.map((g) => ( - handleMoveScreenToGroup(screen, g)}> - - {g.group_name} - - ))} - - - -// API 호출 (apiClient 사용) -const handleMoveScreenToGroup = async (screen, group) => { - await apiClient.post("/screen-groups/group-screens", { - group_id: group.id, - screen_id: screen.screenId, - screen_role: "main", - display_order: 0, - is_default: false, - }); -}; -``` - -**주의:** API 호출 시 `apiClient`를 사용해야 환경별 URL이 자동 처리됨 - -### 화면 이동 로직 수정 (복사 → 이동) - -**문제:** 화면을 다른 카테고리로 이동할 때 복사가 되어 중복 발생 - -**원인:** 기존 그룹 연결 삭제 없이 새 그룹에만 연결 추가 - -**해결:** 2단계 처리 - 기존 연결 삭제 후 새 연결 추가 - -```tsx -const handleMoveScreenToGroup = async (screen: ScreenDefinition, targetGroup: PopScreenGroup) => { - // 1. 기존 연결 찾기 및 삭제 - for (const g of groups) { - const existingLink = g.screens?.find((s) => s.screen_id === screen.screenId); - if (existingLink) { - await apiClient.delete(`/screen-groups/group-screens/${existingLink.id}`); - break; - } - } - - // 2. 새 그룹에 연결 추가 - await apiClient.post("/screen-groups/group-screens", { - group_id: targetGroup.id, - screen_id: screen.screenId, - screen_role: "main", - display_order: 0, - is_default: false, - }); - - loadGroups(); // 목록 새로고침 -}; -``` - -### 화면/카테고리 메뉴 UI 개선 - -**변경 사항:** -1. 화면에 "..." 더보기 메뉴 추가 (폴더와 동일한 스타일) -2. 메뉴 항목: 설계, 위로 이동, 아래로 이동, 다른 카테고리로 이동, 그룹에서 제거 -3. 폴더 메뉴에도 위로/아래로 이동 추가 - -**순서 변경 구현:** -```tsx -// 그룹 순서 변경 (display_order 교환) -const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => { - const siblingGroups = groups - .filter((g) => g.parent_id === targetGroup.parent_id) - .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); - - const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); - if (currentIndex <= 0) return; - - const prevGroup = siblingGroups[currentIndex - 1]; - - await Promise.all([ - apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { display_order: prevGroup.display_order }), - apiClient.put(`/screen-groups/groups/${prevGroup.id}`, { display_order: targetGroup.display_order }), - ]); - - loadGroups(); -}; - -// 화면 순서 변경 (screen_group_screens의 display_order 교환) -const handleMoveScreenUp = async (screen: ScreenDefinition, groupId: number) => { - const targetGroup = groups.find((g) => g.id === groupId); - const sortedScreens = [...targetGroup.screens].sort((a, b) => a.display_order - b.display_order); - const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); - - if (currentIndex <= 0) return; - - const currentLink = sortedScreens[currentIndex]; - const prevLink = sortedScreens[currentIndex - 1]; - - await Promise.all([ - apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { display_order: prevLink.display_order }), - apiClient.put(`/screen-groups/group-screens/${prevLink.id}`, { display_order: currentLink.display_order }), - ]); - - loadGroups(); -}; -``` - -### 카테고리 이동 모달 (서브메뉴 → 모달 방식) - -**문제:** 카테고리가 많아지면 서브메뉴 방식은 관리 어려움 - -**해결:** 검색 기능이 있는 모달로 변경 - -**구현:** -```tsx -// 이동 모달 상태 -const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); -const [movingScreen, setMovingScreen] = useState(null); -const [movingFromGroupId, setMovingFromGroupId] = useState(null); -const [moveSearchTerm, setMoveSearchTerm] = useState(""); - -// 필터링된 그룹 목록 -const filteredMoveGroups = useMemo(() => { - if (!moveSearchTerm) return flattenedGroups; - const searchLower = moveSearchTerm.toLowerCase(); - return flattenedGroups.filter((g) => - (g._displayName || g.group_name).toLowerCase().includes(searchLower) - ); -}, [flattenedGroups, moveSearchTerm]); - -// 모달 UI 특징: -// 1. 검색 입력창 (Search 아이콘 포함) -// 2. 트리 구조 표시 (depth에 따라 들여쓰기) -// 3. 현재 소속 그룹 표시 및 선택 불가 처리 -// 4. ScrollArea로 긴 목록 스크롤 지원 -``` - -**모달 구조:** -``` -┌─────────────────────────────┐ -│ 카테고리로 이동 │ -│ "화면명" 화면을 이동할... │ -├─────────────────────────────┤ -│ 🔍 카테고리 검색... │ -├─────────────────────────────┤ -│ 📁 POP 화면 │ -│ 📁 홈 관리 │ -│ 📁 출고관리 │ -│ 📁 수주관리 │ -│ 📁 생산 관리 (현재) │ -├─────────────────────────────┤ -│ [ 취소 ] │ -└─────────────────────────────┘ -``` - ---- - -## 14. 비율 기반 그리드 시스템 (2026-02-03) - -### 문제 발견 - -POP 디자이너에서 섹션을 크게 설정해도 뷰어에서 매우 얇게(약 20px) 렌더링되는 문제 발생. - -### 근본 원인 분석 - -1. **기존 구조**: `canvasGrid.rowHeight = 20` (고정 픽셀) -2. **react-grid-layout 동작**: 작은 리사이즈 → `rowSpan: 1`로 반올림 → DB 저장 -3. **뷰어 렌더링**: `gridAutoRows: 20px` → 섹션 높이 = 20px (매우 얇음) -4. **비교**: 가로(columns)는 `1fr` 비율 기반으로 잘 작동 - -### 해결책: 비율 기반 행 시스템 - -| 구분 | 이전 | 이후 | -|------|------|------| -| 타입 | `rowHeight: number` (px) | `rows: number` (개수) | -| 기본값 | `rowHeight: 20` | `rows: 24` | -| 뷰어 CSS | `gridAutoRows: 20px` | `gridTemplateRows: repeat(24, 1fr)` | -| 디자이너 계산 | 고정 20px | `resolution.height / 24` | - -### 수정된 파일 - -| 파일 | 변경 내용 | -|------|----------| -| `types/pop-layout.ts` | `PopCanvasGrid.rowHeight` → `rows`, `DEFAULT_CANVAS_GRID.rows = 24` | -| `renderers/PopLayoutRenderer.tsx` | `gridAutoRows` → `gridTemplateRows: repeat(rows, 1fr)` | -| `PopCanvas.tsx` | `rowHeight = Math.floor(resolution.height / canvasGrid.rows)` | - -### 모드별 행 높이 계산 - -| 모드 | 해상도 높이 | 행 높이 (24행 기준) | -|------|-------------|---------------------| -| tablet_landscape | 768px | 32px | -| tablet_portrait | 1024px | 42.7px | -| mobile_landscape | 375px | 15.6px | -| mobile_portrait | 667px | 27.8px | - -### 기존 데이터 호환성 - -- 기존 `rowHeight: 20` 데이터는 `rows || 24` fallback으로 처리 -- 기존 `rowSpan: 1` 데이터는 1/24 = 4.17%로 렌더링 (여전히 작음) -- **권장**: 디자이너에서 섹션 재조정 후 재저장 - ---- - -## 15. 화면 삭제 기능 추가 (2026-02-03) - -### 추가된 기능 - -POP 카테고리 트리에서 화면 자체를 삭제하는 기능 추가. - -### UI 변경 - -| 위치 | 메뉴 항목 | 동작 | -|------|----------|------| -| 그룹 내 화면 드롭다운 | "화면 삭제" | 휴지통으로 이동 | -| 미분류 화면 드롭다운 | "화면 삭제" | 휴지통으로 이동 | - -### 삭제 흐름 - -``` -1. 드롭다운 메뉴에서 "화면 삭제" 클릭 -2. 확인 다이얼로그 표시 ("삭제된 화면은 휴지통으로 이동됩니다") -3. 확인 → DELETE /api/screen-management/screens/:id -4. 화면 is_deleted = 'Y'로 변경 (soft delete) -5. 그룹 목록 새로고침 -``` - -### 완전 삭제 vs 휴지통 이동 - -| API | 동작 | 복원 가능 | -|-----|------|----------| -| `DELETE /screens/:id` | 휴지통으로 이동 (is_deleted='Y') | O | -| `DELETE /screens/:id/permanent` | DB에서 완전 삭제 | X | - -### 수정된 파일 - -| 파일 | 변경 내용 | -|------|----------| -| `PopCategoryTree.tsx` | `handleDeleteScreen`, `confirmDeleteScreen` 함수 추가 | -| `PopCategoryTree.tsx` | `isScreenDeleteDialogOpen`, `deletingScreen` 상태 추가 | -| `PopCategoryTree.tsx` | TreeNode에 `onDeleteScreen` prop 추가 | -| `PopCategoryTree.tsx` | 화면 삭제 확인 AlertDialog 추가 | - ---- - -## 16. 멀티테넌시 이슈 해결 (2026-02-03) - -### 문제 - -화면 그룹에서 제거 시 404 에러 발생. - -### 원인 - -- DB 데이터: `company_code = "*"` (최고 관리자 전용) -- 현재 세션: `company_code = "COMPANY_7"` -- 컨트롤러 WHERE 조건: `id = $1 AND company_code = $2` → 0 rows - -### 해결 - -세션 불일치 문제로 DB에서 직접 삭제 처리. - -### 교훈 - -- 최고 관리자로 생성한 데이터는 일반 회사 사용자가 삭제 불가 -- 로그인 후 토큰 갱신 필요 시 브라우저 완전 새로고침 - ---- - -## 트러블슈팅 - -### Export default doesn't exist in target module - -**문제:** `import apiClient from "@/lib/api/client"` 에러 - -**원인:** `apiClient`가 named export로 정의됨 - -**해결:** `import { apiClient } from "@/lib/api/client"` 사용 - -### 섹션이 매우 얇게 렌더링되는 문제 - -**문제:** 디자이너에서 크게 설정한 섹션이 뷰어에서 20px 높이로 표시 - -**원인:** `canvasGrid.rowHeight = 20` 고정값 + react-grid-layout의 rowSpan 반올림 - -**해결:** 비율 기반 rows 시스템으로 변경 (섹션 14 참조) - -### 화면 삭제 404 에러 - -**문제:** 화면 그룹에서 제거 시 404 에러 - -**원인:** company_code 불일치 (세션 vs DB) - -**해결:** 브라우저 새로고침으로 토큰 갱신 또는 DB 직접 처리 - -### 관련 파일 - -| 파일 | 역할 | -|------|------| -| `frontend/components/pop/management/PopCategoryTree.tsx` | POP 카테고리 트리 (전체 UI) | -| `frontend/lib/api/popScreenGroup.ts` | POP 그룹 API 클라이언트 | -| `backend-node/src/controllers/screenGroupController.ts` | 그룹 CRUD 컨트롤러 | -| `backend-node/src/routes/screenGroupRoutes.ts` | 그룹 API 라우트 | -| `frontend/components/pop/designer/types/pop-layout.ts` | POP 레이아웃 타입 정의 | -| `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx` | CSS Grid 기반 렌더러 | -| `frontend/components/pop/designer/PopCanvas.tsx` | react-grid-layout 디자이너 캔버스 | - ---- - -*최종 업데이트: 2026-02-03* diff --git a/POPUPDATE_2.md b/POPUPDATE_2.md deleted file mode 100644 index 85e20af2..00000000 --- a/POPUPDATE_2.md +++ /dev/null @@ -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` diff --git a/STATUS.md b/STATUS.md deleted file mode 100644 index 09b8da12..00000000 --- a/STATUS.md +++ /dev/null @@ -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`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 | diff --git a/approval-company7-report.txt b/approval-company7-report.txt deleted file mode 100644 index 57760435..00000000 --- a/approval-company7-report.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/approval-test-report.txt b/approval-test-report.txt deleted file mode 100644 index 4a2e6386..00000000 --- a/approval-test-report.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/backend-node/.env.shared b/backend-node/.env.shared index 3b546ed9..5cd2673f 100644 --- a/backend-node/.env.shared +++ b/backend-node/.env.shared @@ -3,25 +3,26 @@ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # # ⚠️ 주의: 이 파일은 Git에 커밋됩니다! -# 팀원들이 동일한 API 키를 사용합니다. +# 실제 API 키는 .env 파일에 설정하세요. +# 여기에는 키 형식 예시만 기록합니다. # # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 한국은행 환율 API 키 # 발급: https://www.bok.or.kr/portal/openapi/OpenApiGuide.do -BOK_API_KEY=OXIGPQXH68NUKVKL5KT9 +BOK_API_KEY=your_bok_api_key_here # 기상청 API Hub 키 # 발급: https://apihub.kma.go.kr/ -KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA +KMA_API_KEY=your_kma_api_key_here # ITS 국가교통정보센터 API 키 # 발급: https://www.its.go.kr/ -ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 +ITS_API_KEY=your_its_api_key_here # 한국도로공사 OpenOASIS API 키 # 발급: https://data.ex.co.kr/ (OpenOASIS 신청) -EXWAY_API_KEY=7820214492 +EXWAY_API_KEY=your_exway_api_key_here # ExchangeRate API 키 (백업용, 선택사항) # 발급: https://www.exchangerate-api.com/ diff --git a/backend-node/API_연동_가이드.md b/backend-node/API_연동_가이드.md index 0af08e43..d5771c03 100644 --- a/backend-node/API_연동_가이드.md +++ b/backend-node/API_연동_가이드.md @@ -6,12 +6,12 @@ ### ✅ 작동 중인 API 1. **기상청 특보 API** (완벽 작동!) - - API 키: `ogdXr2e9T4iHV69nvV-IwA` + - API 키: `${KMA_API_KEY}` - 상태: ✅ 14건 실시간 특보 수신 중 - 제공 데이터: 대설/강풍/한파/태풍/폭염 특보 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} ``` ### 문제 상황 diff --git a/backend-node/API_키_정리.md b/backend-node/API_키_정리.md index 04d8f245..f4de8d2a 100644 --- a/backend-node/API_키_정리.md +++ b/backend-node/API_키_정리.md @@ -4,13 +4,13 @@ ## ✅ 완벽 작동 중 ### 1. 기상청 API Hub -- **API 키**: `ogdXr2e9T4iHV69nvV-IwA` +- **API 키**: `${KMA_API_KEY}` - **상태**: ✅ 14건 실시간 특보 수신 중 - **제공 데이터**: 대설/강풍/한파/태풍/폭염 특보 - **코드 위치**: `backend-node/src/services/riskAlertService.ts` ### 2. 한국은행 환율 API -- **API 키**: `OXIGPQXH68NUKVKL5KT9` +- **API 키**: `${BOK_API_KEY}` - **상태**: ✅ 환율 위젯 작동 중 - **제공 데이터**: USD/EUR/JPY/CNY 환율 @@ -19,7 +19,7 @@ ## ⚠️ 연동 대기 중 ### 3. 한국도로공사 OpenOASIS API -- **API 키**: `7820214492` +- **API 키**: `${EXWAY_API_KEY}` - **상태**: ❌ 엔드포인트 URL 불명 - **문제**: - 발급 이메일에 사용법 없음 @@ -34,7 +34,7 @@ 시스템 장애: 070-8656-8771 문의 내용: -"OpenOASIS API 인증키(7820214492)를 발급받았는데 +"OpenOASIS API 인증키(${EXWAY_API_KEY})를 발급받았는데 사용 방법과 엔드포인트 URL을 알려주세요. - 돌발상황정보 API - 교통사고 정보 @@ -42,7 +42,7 @@ ``` ### 4. 국토교통부 ITS API -- **API 키**: `d6b9befec3114d648284674b8fddcc32` +- **API 키**: `${ITS_API_KEY}` - **상태**: ❌ 엔드포인트 URL 불명 - **승인 API**: - 교통소통정보 @@ -63,7 +63,7 @@ 이메일: its@ex.co.kr 문의 내용: -"ITS API 인증키(d6b9befec3114d648284674b8fddcc32)를 +"ITS API 인증키(${ITS_API_KEY})를 발급받았는데 매뉴얼에 엔드포인트 URL이 없습니다. 돌발상황정보 API의 정확한 URL과 파라미터를 알려주세요." @@ -88,8 +88,8 @@ ### 연동 방법 ```bash # .env 파일에 추가 -ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 -EXWAY_API_KEY=7820214492 +ITS_API_KEY=${ITS_API_KEY} +EXWAY_API_KEY=${EXWAY_API_KEY} # 백엔드 재시작 docker restart pms-backend-mac diff --git a/backend-node/README.md b/backend-node/README.md index 84bff2a1..8e3f5015 100644 --- a/backend-node/README.md +++ b/backend-node/README.md @@ -48,7 +48,7 @@ npm install `.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_EXPIRES_IN="24h" PORT=8080 diff --git a/backend-node/README_API_SETUP.md b/backend-node/README_API_SETUP.md index 6f4a930d..6e460d44 100644 --- a/backend-node/README_API_SETUP.md +++ b/backend-node/README_API_SETUP.md @@ -19,19 +19,19 @@ cp .env.shared .env ### ✅ 한국은행 환율 API - 용도: 환율 정보 조회 -- 키: `OXIGPQXH68NUKVKL5KT9` +- 키: `${BOK_API_KEY}` ### ✅ 기상청 API Hub - 용도: 날씨특보, 기상정보 -- 키: `ogdXr2e9T4iHV69nvV-IwA` +- 키: `${KMA_API_KEY}` ### ✅ ITS 국가교통정보센터 - 용도: 교통사고, 도로공사 정보 -- 키: `d6b9befec3114d648284674b8fddcc32` +- 키: `${ITS_API_KEY}` ### ✅ 한국도로공사 OpenOASIS - 용도: 고속도로 교통정보 -- 키: `7820214492` +- 키: `${EXWAY_API_KEY}` --- diff --git a/backend-node/scripts/add-button-webtype.js b/backend-node/scripts/add-button-webtype.js deleted file mode 100644 index 2fd68221..00000000 --- a/backend-node/scripts/add-button-webtype.js +++ /dev/null @@ -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(); diff --git a/backend-node/scripts/add-data-mapping-column.js b/backend-node/scripts/add-data-mapping-column.js deleted file mode 100644 index cd7ee154..00000000 --- a/backend-node/scripts/add-data-mapping-column.js +++ /dev/null @@ -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(); diff --git a/backend-node/scripts/add-external-db-connection.ts b/backend-node/scripts/add-external-db-connection.ts index b595168a..67788f67 100644 --- a/backend-node/scripts/add-external-db-connection.ts +++ b/backend-node/scripts/add-external-db-connection.ts @@ -27,11 +27,11 @@ async function addExternalDbConnection() { name: "운영_외부_PostgreSQL", description: "운영용 외부 PostgreSQL 데이터베이스", dbType: "postgresql", - host: "39.117.244.52", - port: 11132, - databaseName: "plm", - username: "postgres", - password: "ph0909!!", // 이 값은 암호화되어 저장됩니다 + host: process.env.EXT_DB_HOST || "localhost", + port: parseInt(process.env.EXT_DB_PORT || "5432"), + databaseName: process.env.EXT_DB_NAME || "vexplor_dev", + username: process.env.EXT_DB_USER || "postgres", + password: process.env.EXT_DB_PASSWORD || "", // 환경변수로 전달 sslEnabled: false, isActive: true, }, diff --git a/backend-node/scripts/add-missing-columns.js b/backend-node/scripts/add-missing-columns.js deleted file mode 100644 index 4b21e702..00000000 --- a/backend-node/scripts/add-missing-columns.js +++ /dev/null @@ -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(); diff --git a/backend-node/scripts/btn-bulk-update-company7.ts b/backend-node/scripts/btn-bulk-update-company7.ts deleted file mode 100644 index ee757a0c..00000000 --- a/backend-node/scripts/btn-bulk-update-company7.ts +++ /dev/null @@ -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 = { - 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(); diff --git a/backend-node/scripts/check-dashboard-structure.js b/backend-node/scripts/check-dashboard-structure.js deleted file mode 100644 index d7b9ab1d..00000000 --- a/backend-node/scripts/check-dashboard-structure.js +++ /dev/null @@ -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(); - diff --git a/backend-node/scripts/check-tables.js b/backend-node/scripts/check-tables.js deleted file mode 100644 index 68f9f687..00000000 --- a/backend-node/scripts/check-tables.js +++ /dev/null @@ -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(); - diff --git a/backend-node/scripts/create-component-table.js b/backend-node/scripts/create-component-table.js deleted file mode 100644 index e40dfbfa..00000000 --- a/backend-node/scripts/create-component-table.js +++ /dev/null @@ -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 }; diff --git a/backend-node/scripts/init-layout-standards.js b/backend-node/scripts/init-layout-standards.js deleted file mode 100644 index 688a328d..00000000 --- a/backend-node/scripts/init-layout-standards.js +++ /dev/null @@ -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 }; - diff --git a/backend-node/scripts/install-dataflow-indexes.js b/backend-node/scripts/install-dataflow-indexes.js deleted file mode 100644 index 0c62dc1a..00000000 --- a/backend-node/scripts/install-dataflow-indexes.js +++ /dev/null @@ -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 }; diff --git a/backend-node/scripts/list-components.js b/backend-node/scripts/list-components.js deleted file mode 100644 index a0ba6da4..00000000 --- a/backend-node/scripts/list-components.js +++ /dev/null @@ -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(); diff --git a/backend-node/scripts/migrate-input-type-to-web-type.ts b/backend-node/scripts/migrate-input-type-to-web-type.ts deleted file mode 100644 index 65c64b14..00000000 --- a/backend-node/scripts/migrate-input-type-to-web-type.ts +++ /dev/null @@ -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 = { - 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(); diff --git a/backend-node/scripts/run-1050-migration.js b/backend-node/scripts/run-1050-migration.js deleted file mode 100644 index aa1b3723..00000000 --- a/backend-node/scripts/run-1050-migration.js +++ /dev/null @@ -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(); diff --git a/backend-node/scripts/run-migration.js b/backend-node/scripts/run-migration.js deleted file mode 100644 index 39419ce6..00000000 --- a/backend-node/scripts/run-migration.js +++ /dev/null @@ -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(); - diff --git a/backend-node/scripts/run-notice-migration.js b/backend-node/scripts/run-notice-migration.js deleted file mode 100644 index 4b23153d..00000000 --- a/backend-node/scripts/run-notice-migration.js +++ /dev/null @@ -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(); diff --git a/backend-node/scripts/seed-templates.js b/backend-node/scripts/seed-templates.js deleted file mode 100644 index f72b53ea..00000000 --- a/backend-node/scripts/seed-templates.js +++ /dev/null @@ -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 }; diff --git a/backend-node/scripts/seed-ui-components.js b/backend-node/scripts/seed-ui-components.js deleted file mode 100644 index 78a71ead..00000000 --- a/backend-node/scripts/seed-ui-components.js +++ /dev/null @@ -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 }; diff --git a/backend-node/scripts/test-digital-twin-db.ts b/backend-node/scripts/test-digital-twin-db.ts deleted file mode 100644 index 7d0efce7..00000000 --- a/backend-node/scripts/test-digital-twin-db.ts +++ /dev/null @@ -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); - }); - - diff --git a/backend-node/scripts/test-template-creation.js b/backend-node/scripts/test-template-creation.js deleted file mode 100644 index a4879cbc..00000000 --- a/backend-node/scripts/test-template-creation.js +++ /dev/null @@ -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(); diff --git a/backend-node/scripts/verify-migration.js b/backend-node/scripts/verify-migration.js deleted file mode 100644 index 5c3b9175..00000000 --- a/backend-node/scripts/verify-migration.js +++ /dev/null @@ -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(); - diff --git a/backend-node/src/config/environment.ts b/backend-node/src/config/environment.ts index e350642d..558f3f07 100644 --- a/backend-node/src/config/environment.ts +++ b/backend-node/src/config/environment.ts @@ -93,7 +93,7 @@ const config: Config = { // 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", refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d", }, diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index dfe685ff..a95b08f1 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -7,9 +7,21 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; 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 기준 행 반환) ─── export async function getList(req: AuthenticatedRequest, res: Response) { try { + await ensureDetailRoutingColumn(); const companyCode = req.user!.companyCode; const { dateFrom, dateTo, status, progressStatus, keyword } = req.query; @@ -72,6 +84,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { d.part_code, d.source_table, d.source_id, + d.routing_version_id AS detail_routing_version_id, COALESCE(itm.item_name, '') AS item_name, COALESCE(itm.size, '') AS item_spec, 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) { try { + await ensureDetailRoutingColumn(); const companyCode = req.user!.companyCode; const userId = req.user!.userId; 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) { 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)`, - [companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", userId] + `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||"", item.routing||null, userId] ); } diff --git a/backend-node/src/services/riskAlertService.ts b/backend-node/src/services/riskAlertService.ts index 03a3fdf1..923bed52 100644 --- a/backend-node/src/services/riskAlertService.ts +++ b/backend-node/src/services/riskAlertService.ts @@ -202,7 +202,7 @@ export class RiskAlertService { } // 2순위: 한국도로공사 API (현재 차단됨) - const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492'; + const exwayApiKey = process.env.EXWAY_API_KEY || ''; try { const url = 'https://data.ex.co.kr/openapi/business/trafficFcst'; @@ -321,7 +321,7 @@ export class RiskAlertService { } // 2순위: 한국도로공사 API - const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492'; + const exwayApiKey = process.env.EXWAY_API_KEY || ''; try { const url = 'https://data.ex.co.kr/openapi/business/trafficFcst'; diff --git a/backend-node/src/tests/env.setup.ts b/backend-node/src/tests/env.setup.ts index 55263a9a..85825aeb 100644 --- a/backend-node/src/tests/env.setup.ts +++ b/backend-node/src/tests/env.setup.ts @@ -6,8 +6,7 @@ process.env.NODE_ENV = "test"; // 실제 DB 연결을 위해 운영 데이터베이스 사용 (읽기 전용 테스트만 수행) process.env.DATABASE_URL = - process.env.TEST_DATABASE_URL || - "postgresql://postgres:ph0909!!@39.117.244.52:11132/plm"; + process.env.TEST_DATABASE_URL || ""; process.env.JWT_SECRET = "test-jwt-secret-key-for-testing-only"; process.env.PORT = "3001"; process.env.DEBUG = "true"; // 테스트 시 디버그 로그 활성화 diff --git a/cursor-rules-backup-20260309.tar.gz b/cursor-rules-backup-20260309.tar.gz deleted file mode 100644 index 5f8eeb10..00000000 Binary files a/cursor-rules-backup-20260309.tar.gz and /dev/null differ diff --git a/docker-compose.backend.win.yml b/docker-compose.backend.win.yml index 35dbf42a..72e0d987 100644 --- a/docker-compose.backend.win.yml +++ b/docker-compose.backend.win.yml @@ -12,10 +12,10 @@ services: environment: - NODE_ENV=development - PORT=8080 - - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - - JWT_EXPIRES_IN=24h - - ENCRYPTION_KEY=ilshin-plm-encryption-key-2024-secure-32bytes + - DATABASE_URL=${DATABASE_URL} + - JWT_SECRET=${JWT_SECRET} + - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} - CORS_ORIGIN=http://localhost:9771 - CORS_CREDENTIALS=true - LOG_LEVEL=debug diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index efd1b961..f653f617 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -12,15 +12,15 @@ services: NODE_ENV: production PORT: "3001" HOST: 0.0.0.0 - DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor - JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024 - JWT_EXPIRES_IN: 24h - CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com + DATABASE_URL: ${DATABASE_URL} + JWT_SECRET: ${JWT_SECRET} + JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h} + CORS_ORIGIN: ${CORS_ORIGIN:-https://v1.vexplor.com,https://api.vexplor.com} CORS_CREDENTIALS: "true" LOG_LEVEL: info - ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure - KMA_API_KEY: ogdXr2e9T4iHV69nvV-IwA - ITS_API_KEY: d6b9befec3114d648284674b8fddcc32 + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + KMA_API_KEY: ${KMA_API_KEY} + ITS_API_KEY: ${ITS_API_KEY} EXPRESSWAY_API_KEY: ${EXPRESSWAY_API_KEY:-} volumes: - backend_uploads:/app/uploads diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index 4d862d9e..5002e813 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -12,15 +12,15 @@ services: environment: - NODE_ENV=development - PORT=8080 - - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - - JWT_EXPIRES_IN=24h + - DATABASE_URL=${DATABASE_URL} + - JWT_SECRET=${JWT_SECRET} + - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h} - CORS_ORIGIN=http://localhost:9771 - CORS_CREDENTIALS=true - LOG_LEVEL=debug - - ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure - - KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA - - ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 + - ENCRYPTION_KEY=${ENCRYPTION_KEY} + - KMA_API_KEY=${KMA_API_KEY} + - ITS_API_KEY=${ITS_API_KEY} - EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-} volumes: - ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영 diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml index a3327ea1..06a769f2 100644 --- a/docker/prod/docker-compose.backend.prod.yml +++ b/docker/prod/docker-compose.backend.prod.yml @@ -13,15 +13,15 @@ services: - NODE_ENV=production - PORT=8080 - HOST=0.0.0.0 # 모든 인터페이스에서 바인딩 - - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - - JWT_EXPIRES_IN=24h - - CORS_ORIGIN=http://192.168.0.70:5555,http://39.117.244.52:5555,http://localhost:9771 + - DATABASE_URL=${DATABASE_URL} + - JWT_SECRET=${JWT_SECRET} + - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h} + - CORS_ORIGIN=${CORS_ORIGIN:-http://192.168.0.70:5555,http://39.117.244.52:5555,http://localhost:9771} - CORS_CREDENTIALS=true - LOG_LEVEL=info - - ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure - - KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA - - ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 + - ENCRYPTION_KEY=${ENCRYPTION_KEY} + - KMA_API_KEY=${KMA_API_KEY} + - ITS_API_KEY=${ITS_API_KEY} - EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-} restart: unless-stopped healthcheck: diff --git a/docs/DDD1542/본서버_개발서버_마이그레이션_가이드.md b/docs/DDD1542/본서버_개발서버_마이그레이션_가이드.md index b7a0e353..dc71bd2d 100644 --- a/docs/DDD1542/본서버_개발서버_마이그레이션_가이드.md +++ b/docs/DDD1542/본서버_개발서버_마이그레이션_가이드.md @@ -23,7 +23,7 @@ screen_layouts (V1) screen_layouts_v2 (V2) docker exec pms-backend-mac node -e ' const { Pool } = require("pg"); 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 }); // 쿼리 실행 diff --git a/docs/POP_화면_배포서버_마이그레이션_가이드.md b/docs/POP_화면_배포서버_마이그레이션_가이드.md index 8711c60e..6ac2b777 100644 --- a/docs/POP_화면_배포서버_마이그레이션_가이드.md +++ b/docs/POP_화면_배포서버_마이그레이션_가이드.md @@ -177,7 +177,7 @@ CREATE TABLE backup_20260323_screen_group_screens AS ### STEP 1: 누락 테이블 생성 (배포 DB에서 psql 실행) ```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 @@ -366,7 +366,7 @@ COPY ( ) TO STDOUT WITH CSV HEADER" > /tmp/ttc_export.csv # 배포 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 ON CONFLICT DO NOTHING" < /tmp/ttc_export.csv ``` @@ -386,7 +386,7 @@ COPY ( ) 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 ON CONFLICT DO NOTHING" < /tmp/screen_def.csv @@ -400,7 +400,7 @@ COPY ( ) 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) FROM STDIN WITH CSV HEADER 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 # 배포에 삽입 -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 ON CONFLICT DO NOTHING" < /tmp/screen_groups.csv @@ -427,7 +427,7 @@ COPY ( ) 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 ON CONFLICT DO NOTHING" < /tmp/screen_group_screens.csv diff --git a/docs/leeheejin/리스크알림_API키_발급가이드.md b/docs/leeheejin/리스크알림_API키_발급가이드.md index e2a33761..a19e4eb9 100644 --- a/docs/leeheejin/리스크알림_API키_발급가이드.md +++ b/docs/leeheejin/리스크알림_API키_발급가이드.md @@ -13,7 +13,7 @@ 현재 `.env`에 설정된 키: ```bash -KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA +KMA_API_KEY=${KMA_API_KEY} ``` **사용 API:** @@ -105,7 +105,7 @@ nano .env ```bash # 기상청 API Hub 키 (기존 - 예특보 활용신청 완료 시 사용) -KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA +KMA_API_KEY=${KMA_API_KEY} # 국토교통부 도로교통 API 키 (활용신청 완료 시 추가) MOLIT_TRAFFIC_API_KEY=여기에_발급받은_교통사고_API_인증키_붙여넣기 diff --git a/docs/leeheejin/메일관리_기능_리스트.md b/docs/leeheejin/메일관리_기능_리스트.md index 9bed9d5b..6b341983 100644 --- a/docs/leeheejin/메일관리_기능_리스트.md +++ b/docs/leeheejin/메일관리_기능_리스트.md @@ -222,7 +222,7 @@ uploads/ ### 필수 환경변수 ```bash -ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure +ENCRYPTION_KEY=${ENCRYPTION_KEY} ``` ### 필수 디렉토리 diff --git a/docs/leeheejin/메일관리_시스템_구현_계획서.md b/docs/leeheejin/메일관리_시스템_구현_계획서.md index 31cd2ee9..06e67327 100644 --- a/docs/leeheejin/메일관리_시스템_구현_계획서.md +++ b/docs/leeheejin/메일관리_시스템_구현_계획서.md @@ -401,7 +401,7 @@ MailDesigner 통합 및 템플릿 저장/불러오기 ### 필수 환경변수 ```bash # docker/dev/docker-compose.backend.mac.yml -ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure +ENCRYPTION_KEY=${ENCRYPTION_KEY} ``` ### 저장 디렉토리 생성 diff --git a/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx index 6f042bd0..d9167dcb 100644 --- a/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx @@ -11,7 +11,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { Loader2, Save, RotateCcw, Plus, Trash2, Pencil, ClipboardCheck, - ChevronRight, GripVertical, AlertCircle, + GripVertical, AlertCircle, ClipboardList, } from "lucide-react"; import { toast } from "sonner"; import { @@ -30,9 +30,9 @@ interface WorkStandardEditModalProps { } const PHASES = [ - { key: "PRE", label: "사전작업" }, - { key: "MAIN", label: "본작업" }, - { key: "POST", label: "후작업" }, + { key: "PRE", label: "작업 전 (Pre-Work)" }, + { key: "IN", label: "작업 중 (In-Work)" }, + { key: "POST", label: "작업 후 (Post-Work)" }, ]; const DETAIL_TYPES = [ @@ -47,6 +47,53 @@ const DETAIL_TYPES = [ { value: "material_input", label: "자재투입" }, ]; +const INPUT_TYPES = [ + { value: "text", label: "텍스트" }, + { value: "number", label: "숫자" }, + { value: "date", label: "날짜" }, + { value: "textarea", label: "장문 텍스트" }, +]; + +const UNIT_OPTIONS = [ + "mm", "cm", "m", "μm", "℃", "℉", "bar", "Pa", "MPa", "psi", + "RPM", "kg", "N", "N·m", "m/s", "m/min", "A", "V", "kW", "%", + "L/min", "Hz", "dB", "ea", "g", "mg", "ml", "L", +]; + +const getDetailTypeLabel = (type: string) => + DETAIL_TYPES.find(d => d.value === type)?.label || type; + +const getContentSummary = (detail: WIWorkItemDetail): string => { + const type = detail.detail_type; + if (type === "inspection") { + const parts = [detail.content]; + if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`); + if (detail.base_value) { + parts.push(`(기준: ${detail.base_value}${detail.tolerance ? ` ±${detail.tolerance}` : ""} ${detail.unit || ""})`); + } + return parts.join(" "); + } + if (type === "procedure" && detail.duration_minutes) { + return `${detail.content} (${detail.duration_minutes}분)`; + } + if (type === "input" && detail.input_type) { + const typeMap: Record = { text: "텍스트", number: "숫자", date: "날짜", textarea: "장문" }; + return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`; + } + if (type === "lookup") return "품목 등록 문서 (자동 연동)"; + if (type === "equip_inspection") return detail.content || "설비점검"; + if (type === "equip_condition") { + const parts = [detail.content]; + if (detail.condition_base_value) { + parts.push(`(기준: ${detail.condition_base_value}${detail.condition_tolerance ? ` ±${detail.condition_tolerance}` : ""} ${detail.condition_unit || ""})`); + } + return parts.join(" "); + } + if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; + if (type === "material_input") return "BOM 구성 자재 (자동 연동)"; + return detail.content || "-"; +}; + export function WorkStandardEditModal({ open, onClose, @@ -61,20 +108,22 @@ export function WorkStandardEditModal({ const [processes, setProcesses] = useState([]); const [isCustom, setIsCustom] = useState(false); const [selectedProcessIdx, setSelectedProcessIdx] = useState(0); - const [selectedPhase, setSelectedPhase] = useState("PRE"); - const [selectedWorkItemId, setSelectedWorkItemId] = useState(null); + const [selectedWorkItemIdByPhase, setSelectedWorkItemIdByPhase] = useState>({}); const [dirty, setDirty] = useState(false); - // 작업항목 추가 모달 + // 작업항목 추가/수정 모달 const [addItemOpen, setAddItemOpen] = useState(false); + const [addItemPhase, setAddItemPhase] = useState("PRE"); const [addItemTitle, setAddItemTitle] = useState(""); const [addItemRequired, setAddItemRequired] = useState("Y"); + const [editingWorkItem, setEditingWorkItem] = useState(null); - // 상세 추가 모달 - const [addDetailOpen, setAddDetailOpen] = useState(false); - const [addDetailType, setAddDetailType] = useState("checklist"); - const [addDetailContent, setAddDetailContent] = useState(""); - const [addDetailRequired, setAddDetailRequired] = useState("N"); + // 상세 추가/수정 모달 + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [detailModalMode, setDetailModalMode] = useState<"add" | "edit">("add"); + const [detailModalPhase, setDetailModalPhase] = useState("PRE"); + const [detailFormData, setDetailFormData] = useState>({}); + const [editingDetail, setEditingDetail] = useState(null); // 데이터 로드 const loadData = useCallback(async () => { @@ -86,8 +135,7 @@ export function WorkStandardEditModal({ setProcesses(res.data.processes); setIsCustom(res.data.isCustom); setSelectedProcessIdx(0); - setSelectedPhase("PRE"); - setSelectedWorkItemId(null); + setSelectedWorkItemIdByPhase({}); setDirty(false); } } catch (err) { @@ -102,15 +150,18 @@ export function WorkStandardEditModal({ }, [open, loadData]); const currentProcess = processes[selectedProcessIdx] || null; - const currentWorkItems = useMemo(() => { - if (!currentProcess) return []; - return currentProcess.workItems.filter(wi => wi.work_phase === selectedPhase); - }, [currentProcess, selectedPhase]); - const selectedWorkItem = useMemo(() => { - if (!selectedWorkItemId || !currentProcess) return null; - return currentProcess.workItems.find(wi => wi.id === selectedWorkItemId) || null; - }, [selectedWorkItemId, currentProcess]); + // Phase별 작업항목 그룹핑 (MAIN을 IN으로 매핑) + const workItemsByPhase = useMemo(() => { + if (!currentProcess) return {}; + const map: Record = {}; + for (const phase of PHASES) { + map[phase.key] = currentProcess.workItems.filter( + wi => wi.work_phase === phase.key || (phase.key === "IN" && wi.work_phase === "MAIN") + ); + } + return map; + }, [currentProcess]); // 커스텀 복사 확인 후 수정 const ensureCustom = useCallback(async () => { @@ -128,40 +179,76 @@ export function WorkStandardEditModal({ return false; }, [isCustom, workInstructionNo, routingVersionId, loadData]); - // 작업항목 추가 - const handleAddWorkItem = useCallback(async () => { + // 작업항목 추가 모달 열기 + const openAddWorkItem = useCallback((phaseKey: string) => { + setAddItemPhase(phaseKey); + setAddItemTitle(""); + setAddItemRequired("Y"); + setEditingWorkItem(null); + setAddItemOpen(true); + }, []); + + // 작업항목 수정 모달 열기 + const openEditWorkItem = useCallback((item: WIWorkItem) => { + setAddItemPhase(item.work_phase); + setAddItemTitle(item.title); + setAddItemRequired(item.is_required); + setEditingWorkItem(item); + setAddItemOpen(true); + }, []); + + // 작업항목 추가/수정 처리 + const handleSaveWorkItem = useCallback(async () => { if (!addItemTitle.trim()) { toast.error("제목을 입력하세요"); return; } const ok = await ensureCustom(); if (!ok || !currentProcess) return; - const newItem: WIWorkItem = { - id: `temp-${Date.now()}`, - routing_detail_id: currentProcess.routing_detail_id, - work_phase: selectedPhase, - title: addItemTitle.trim(), - is_required: addItemRequired, - sort_order: currentWorkItems.length + 1, - details: [], - }; - - setProcesses(prev => { - const next = [...prev]; - next[selectedProcessIdx] = { - ...next[selectedProcessIdx], - workItems: [...next[selectedProcessIdx].workItems, newItem], + if (editingWorkItem) { + // 수정 + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const idx = workItems.findIndex(wi => wi.id === editingWorkItem.id); + if (idx >= 0) { + workItems[idx] = { ...workItems[idx], title: addItemTitle.trim(), is_required: addItemRequired }; + } + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + } else { + // 추가 + const phaseKey = addItemPhase === "IN" ? "IN" : addItemPhase; + const phaseItems = currentProcess.workItems.filter( + wi => wi.work_phase === phaseKey || (phaseKey === "IN" && wi.work_phase === "MAIN") + ); + const newItem: WIWorkItem = { + id: `temp-${Date.now()}`, + routing_detail_id: currentProcess.routing_detail_id, + work_phase: phaseKey, + title: addItemTitle.trim(), + is_required: addItemRequired, + sort_order: phaseItems.length + 1, + details: [], }; - return next; - }); - setAddItemTitle(""); - setAddItemRequired("Y"); + setProcesses(prev => { + const next = [...prev]; + next[selectedProcessIdx] = { + ...next[selectedProcessIdx], + workItems: [...next[selectedProcessIdx].workItems, newItem], + }; + return next; + }); + + setSelectedWorkItemIdByPhase(prev => ({ ...prev, [addItemPhase]: newItem.id! })); + } + setAddItemOpen(false); setDirty(true); - setSelectedWorkItemId(newItem.id!); - }, [addItemTitle, addItemRequired, ensureCustom, currentProcess, selectedPhase, currentWorkItems, selectedProcessIdx]); + }, [addItemTitle, addItemRequired, addItemPhase, ensureCustom, currentProcess, selectedProcessIdx, editingWorkItem]); // 작업항목 삭제 - const handleDeleteWorkItem = useCallback(async (id: string) => { + const handleDeleteWorkItem = useCallback(async (id: string, phaseKey: string) => { const ok = await ensureCustom(); if (!ok) return; @@ -173,37 +260,82 @@ export function WorkStandardEditModal({ }; return next; }); - if (selectedWorkItemId === id) setSelectedWorkItemId(null); + if (selectedWorkItemIdByPhase[phaseKey] === id) { + setSelectedWorkItemIdByPhase(prev => ({ ...prev, [phaseKey]: null })); + } setDirty(true); - }, [ensureCustom, selectedProcessIdx, selectedWorkItemId]); + }, [ensureCustom, selectedProcessIdx, selectedWorkItemIdByPhase]); - // 상세 추가 - const handleAddDetail = useCallback(async () => { - if (!addDetailContent.trim() && addDetailType !== "production_result" && addDetailType !== "material_input") { + // 상세 추가 모달 열기 + const openAddDetail = useCallback((phaseKey: string) => { + setDetailModalPhase(phaseKey); + setDetailModalMode("add"); + setEditingDetail(null); + setDetailFormData({ + detail_type: DETAIL_TYPES[0].value, + content: "", + is_required: "Y", + }); + setDetailModalOpen(true); + }, []); + + // 상세 수정 모달 열기 + const openEditDetail = useCallback((detail: WIWorkItemDetail, phaseKey: string) => { + setDetailModalPhase(phaseKey); + setDetailModalMode("edit"); + setEditingDetail(detail); + setDetailFormData({ ...detail }); + setDetailModalOpen(true); + }, []); + + // 상세 추가/수정 처리 + const handleSaveDetail = useCallback(async () => { + const type = detailFormData.detail_type || ""; + if (!type) return; + + // 유효성 검사 (내용이 필요한 유형) + const needsContent = ["checklist", "procedure", "input", "equip_condition"]; + if (needsContent.includes(type) && !detailFormData.content?.trim()) { toast.error("내용을 입력하세요"); return; } - if (!selectedWorkItemId) return; + if (type === "inspection" && !detailFormData.content?.trim()) { + toast.error("검사 항목명을 입력하세요"); + return; + } + + const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; + if (!workItemId) return; const ok = await ensureCustom(); if (!ok) return; - const content = addDetailContent.trim() || - DETAIL_TYPES.find(d => d.value === addDetailType)?.label || addDetailType; - - const newDetail: WIWorkItemDetail = { - id: `temp-detail-${Date.now()}`, - work_item_id: selectedWorkItemId, - detail_type: addDetailType, - content, - is_required: addDetailRequired, - sort_order: (selectedWorkItem?.details?.length || 0) + 1, - }; + // content 자동 설정 (UI에서 직접 입력이 없는 유형들) + const submitData = { ...detailFormData }; + if (type === "lookup") submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; + if (type === "equip_inspection") submitData.content = submitData.content || "설비점검"; + if (type === "production_result") submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; + if (type === "material_input") submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; setProcesses(prev => { const next = [...prev]; const workItems = [...next[selectedProcessIdx].workItems]; - const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId); - if (wiIdx >= 0) { + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx < 0) return prev; + + if (detailModalMode === "edit" && editingDetail) { + // 수정 + const details = (workItems[wiIdx].details || []).map(d => + d.id === editingDetail.id ? { ...d, ...submitData } : d + ); + workItems[wiIdx] = { ...workItems[wiIdx], details }; + } else { + // 추가 + const newDetail: WIWorkItemDetail = { + ...submitData, + id: `temp-detail-${Date.now()}`, + work_item_id: workItemId, + sort_order: (workItems[wiIdx].details?.length || 0) + 1, + }; workItems[wiIdx] = { ...workItems[wiIdx], details: [...(workItems[wiIdx].details || []), newDetail], @@ -214,23 +346,21 @@ export function WorkStandardEditModal({ return next; }); - setAddDetailContent(""); - setAddDetailType("checklist"); - setAddDetailRequired("N"); - setAddDetailOpen(false); + setDetailModalOpen(false); setDirty(true); - }, [addDetailContent, addDetailType, addDetailRequired, selectedWorkItemId, selectedWorkItem, ensureCustom, selectedProcessIdx]); + }, [detailFormData, detailModalPhase, detailModalMode, editingDetail, selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); // 상세 삭제 - const handleDeleteDetail = useCallback(async (detailId: string) => { - if (!selectedWorkItemId) return; + const handleDeleteDetail = useCallback(async (detailId: string, phaseKey: string) => { + const workItemId = selectedWorkItemIdByPhase[phaseKey]; + if (!workItemId) return; const ok = await ensureCustom(); if (!ok) return; setProcesses(prev => { const next = [...prev]; const workItems = [...next[selectedProcessIdx].workItems]; - const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId); + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); if (wiIdx >= 0) { workItems[wiIdx] = { ...workItems[wiIdx], @@ -242,7 +372,7 @@ export function WorkStandardEditModal({ return next; }); setDirty(true); - }, [selectedWorkItemId, ensureCustom, selectedProcessIdx]); + }, [selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); // 저장 const handleSave = useCallback(async () => { @@ -285,8 +415,9 @@ export function WorkStandardEditModal({ } }, [workInstructionNo, loadData]); - const getDetailTypeLabel = (type: string) => - DETAIL_TYPES.find(d => d.value === type)?.label || type; + const updateDetailField = (field: string, value: unknown) => { + setDetailFormData(prev => ({ ...prev, [field]: value })); + }; return ( { if (!v) onClose(); }}> @@ -324,7 +455,7 @@ export function WorkStandardEditModal({ className={cn("text-xs shrink-0 h-8", selectedProcessIdx === idx && "shadow-sm")} onClick={() => { setSelectedProcessIdx(idx); - setSelectedWorkItemId(null); + setSelectedWorkItemIdByPhase({}); }} > {proc.seq_no}. @@ -336,120 +467,185 @@ export function WorkStandardEditModal({ ))} - {/* 작업 단계 탭 */} - + {/* Phase별 세로 섹션 */} + {PHASES.map(phase => { - const count = currentProcess?.workItems.filter(wi => wi.work_phase === phase.key).length || 0; + const phaseItems = workItemsByPhase[phase.key] || []; + const selectedWiId = selectedWorkItemIdByPhase[phase.key] || null; + const selectedWi = phaseItems.find(wi => wi.id === selectedWiId) || null; + return ( - { setSelectedPhase(phase.key); setSelectedWorkItemId(null); }} - > - {phase.label} - {count > 0 && {count}} - - ); - })} - - - {/* 작업항목 + 상세 split */} - - {/* 좌측: 작업항목 목록 */} - - - 작업항목 - { setAddItemTitle(""); setAddItemOpen(true); }}> - - - - - {currentWorkItems.length === 0 ? ( - 작업항목이 없습니다 - ) : currentWorkItems.map((wi) => ( - setSelectedWorkItemId(wi.id!)} - > - - - {wi.title} - - {wi.is_required === "Y" && 필수} - 상세 {wi.details?.length || wi.detail_count || 0}건 - - - { e.stopPropagation(); handleDeleteWorkItem(wi.id!); }} - > - - + + {/* 섹션 헤더 */} + + + {phase.label} + + {phaseItems.length}개 항목 + - - ))} - - - - {/* 우측: 상세 목록 */} - - {!selectedWorkItem ? ( - - - 좌측에서 작업항목을 선택하세요 - - ) : ( - <> - - - {selectedWorkItem.title} - 상세 항목 - - { setAddDetailContent(""); setAddDetailType("checklist"); setAddDetailOpen(true); }}> - 상세 추가 + openAddWorkItem(phase.key)} + > + + 작업항목 추가 - - {(!selectedWorkItem.details || selectedWorkItem.details.length === 0) ? ( - 상세 항목이 없습니다 - ) : selectedWorkItem.details.map((detail, dIdx) => ( - - - - - - {getDetailTypeLabel(detail.detail_type || "checklist")} - - {detail.is_required === "Y" && 필수} - - {detail.content || "-"} - {detail.remark && {detail.remark}} - {detail.detail_type === "inspection" && (detail.lower_limit || detail.upper_limit) && ( - - 범위: {detail.lower_limit || "-"} ~ {detail.upper_limit || "-"} {detail.unit || ""} - - )} + + {/* 좌우 분할 */} + + {/* 좌측: 240px 작업항목 카드 */} + + {phaseItems.length === 0 ? ( + + + 등록된 항목이 없습니다 - handleDeleteDetail(detail.id!)} - > - - - - ))} + ) : ( + + {phaseItems.map(item => ( + setSelectedWorkItemIdByPhase(prev => ({ ...prev, [phase.key]: item.id! }))} + className={cn( + "group flex cursor-pointer items-start gap-2 rounded-lg border p-3 transition-all", + "hover:border-primary/30 hover:shadow-sm", + selectedWiId === item.id + ? "border-primary bg-primary/5 shadow-sm" + : "border-border bg-card" + )} + > + + + + {item.title} + + + + {item.details?.length || item.detail_count || 0}개 + + + {item.is_required === "Y" ? "필수" : "선택"} + + + + + { e.stopPropagation(); openEditWorkItem(item); }} + > + + + { e.stopPropagation(); handleDeleteWorkItem(item.id!, phase.key); }} + > + + + + + ))} + + )} + + + {/* 우측: 상세 테이블 */} + + {!selectedWi ? ( + + 왼쪽에서 항목을 선택하세요 + + ) : ( + <> + {/* 상세 헤더 */} + + + {selectedWi.title} + + {selectedWi.details?.length || 0}개 + + + openAddDetail(phase.key)} + > + + 상세 추가 + + + + {/* 테이블 */} + + + + + 순서 + 유형 + 내용 + 필수 + 관리 + + + + {(selectedWi.details || []).map((detail, idx) => ( + + {idx + 1} + + + {getDetailTypeLabel(detail.detail_type || "checklist")} + + + {getContentSummary(detail)} + + + {detail.is_required === "Y" ? "필수" : "선택"} + + + + + openEditDetail(detail, phase.key)} + > + + + handleDeleteDetail(detail.id!, phase.key)} + > + + + + + + ))} + + + + {(!selectedWi.details || selectedWi.details.length === 0) && ( + + + 상세 항목이 없습니다. "상세 추가" 버튼을 클릭하여 추가하세요. + + + )} + + > + )} + - > - )} - + + ); + })} )} @@ -471,13 +667,15 @@ export function WorkStandardEditModal({ - {/* 작업항목 추가 다이얼로그 */} + {/* 작업항목 추가/수정 다이얼로그 */} e.stopPropagation()}> - 작업항목 추가 + + 작업항목 {editingWorkItem ? "수정" : "추가"} + - {PHASES.find(p => p.key === selectedPhase)?.label} 단계에 작업항목을 추가합니다. + {PHASES.find(p => p.key === addItemPhase)?.label} 단계에 작업항목을 {editingWorkItem ? "수정" : "추가"}합니다. @@ -492,25 +690,39 @@ export function WorkStandardEditModal({ setAddItemOpen(false)}>취소 - 추가 + {editingWorkItem ? "수정" : "추가"} - {/* 상세 추가 다이얼로그 */} - - e.stopPropagation()}> + {/* 상세 추가/수정 다이얼로그 (유형별 동적 필드) */} + { if (!v) setDetailModalOpen(false); }}> + e.stopPropagation()}> - 상세 항목 추가 + + 상세 항목 {detailModalMode === "add" ? "추가" : "수정"} + - "{selectedWorkItem?.title}"에 상세 항목을 추가합니다. + 상세 항목의 유형을 선택하고 내용을 입력하세요 - + + + {/* 유형 선택 */} - 유형 - - + 유형 * + { + setDetailFormData({ + detail_type: v, + is_required: detailFormData.is_required || "Y", + }); + }} + > + + + {DETAIL_TYPES.map(dt => ( {dt.label} @@ -518,18 +730,274 @@ export function WorkStandardEditModal({ - - 내용 - setAddDetailContent(e.target.value)} placeholder="상세 내용 입력" className="h-8 text-xs mt-1" /> - - - setAddDetailRequired(v ? "Y" : "N")} /> - 필수 항목 - + + {/* 체크리스트 */} + {detailFormData.detail_type === "checklist" && ( + + 체크 내용 * + updateDetailField("content", e.target.value)} + placeholder="예: 전원 상태 확인" + className="mt-1 h-8 text-xs" + /> + + )} + + {/* 검사항목 */} + {detailFormData.detail_type === "inspection" && ( + <> + + 검사 항목명 * + updateDetailField("content", e.target.value)} + placeholder="예: 외경 치수" + className="mt-1 h-8 text-xs" + /> + + + + 검사 방법 + updateDetailField("inspection_method", e.target.value)} + placeholder="예: 마이크로미터" + className="mt-1 h-8 text-xs" + /> + + + 단위 + updateDetailField("unit", v)} + > + + + + + {UNIT_OPTIONS.map(u => ( + {u} + ))} + + + + + + + + updateDetailField("base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs" + /> + + ± + + updateDetailField("tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs" + /> + + + + > + )} + + {/* 작업절차 */} + {detailFormData.detail_type === "procedure" && ( + <> + + 작업 내용 * + updateDetailField("content", e.target.value)} + placeholder="예: 자재 투입" + className="mt-1 h-8 text-xs" + /> + + + 소요 시간 (분) + updateDetailField("duration_minutes", e.target.value ? Number(e.target.value) : undefined)} + placeholder="예: 5" + className="mt-1 h-8 text-xs" + /> + + > + )} + + {/* 직접입력 */} + {detailFormData.detail_type === "input" && ( + <> + + 입력 항목명 * + updateDetailField("content", e.target.value)} + placeholder="예: 작업자 의견" + className="mt-1 h-8 text-xs" + /> + + + 입력 타입 + updateDetailField("input_type", v)} + > + + + + + {INPUT_TYPES.map(t => ( + {t.label} + ))} + + + + > + )} + + {/* 문서참조 */} + {detailFormData.detail_type === "lookup" && ( + + + 해당 품목에 등록된 문서를 자동으로 불러옵니다. + + + )} + + {/* 설비점검 */} + {detailFormData.detail_type === "equip_inspection" && ( + + 점검 내용 + updateDetailField("content", e.target.value)} + placeholder="예: 설비 가동 전 안전 점검" + className="mt-1 h-8 text-xs" + /> + + )} + + {/* 설비조건 */} + {detailFormData.detail_type === "equip_condition" && ( + <> + + 조건명 * + updateDetailField("content", e.target.value)} + placeholder="조건명 (예: 온도, 압력, RPM)" + className="mt-1 h-8 text-xs" + /> + + + + + updateDetailField("condition_unit", v)} + > + + + + + {UNIT_OPTIONS.map(u => ( + {u} + ))} + + + + + + + updateDetailField("condition_base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs" + /> + + ± + + updateDetailField("condition_tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs" + /> + + + + > + )} + + {/* 실적등록 */} + {detailFormData.detail_type === "production_result" && ( + + 작업수량 / 불량수량 / 양품수량 + 실적 입력 항목이 자동으로 생성됩니다. + + )} + + {/* 자재투입 */} + {detailFormData.detail_type === "material_input" && ( + + BOM 구성 자재 (자동 연동) + 품목에 등록된 BOM 구성 자재가 자동으로 적용됩니다. + + )} + + {/* 필수 여부 (모든 유형 공통) */} + {detailFormData.detail_type && ( + + 필수 여부 + updateDetailField("is_required", v)} + > + + + + + 필수 + 선택 + + + + )} + + {/* 비고 (모든 유형 공통) */} + {detailFormData.detail_type && ( + + 비고 + updateDetailField("remark", e.target.value)} + placeholder="비고 입력" + className="mt-1 h-8 text-xs" + /> + + )} + - setAddDetailOpen(false)}>취소 - 추가 + setDetailModalOpen(false)}>취소 + {detailModalMode === "add" ? "추가" : "수정"} diff --git a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx index 369eac6a..d36f56ad 100644 --- a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx @@ -9,6 +9,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; 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 { cn } from "@/lib/utils"; @@ -39,6 +40,7 @@ interface EmployeeOption { user_id: string; user_name: string; dept_name: string interface SelectedItem { itemCode: string; itemName: string; spec: string; qty: number; remark: string; sourceType: SourceType; sourceTable: string; sourceId: string | number; + routing?: string; routingOptions?: RoutingVersionData[]; } export default function WorkInstructionPage() { @@ -206,14 +208,17 @@ export default function WorkInstructionPage() { setConfirmRouting(""); setConfirmRoutingOptions([]); previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)")); - // 첫 번째 품목의 라우팅 로드 - const firstItem = items.length > 0 ? items[0] : null; - if (firstItem) { - getRoutingVersions("__new__", firstItem.itemCode).then(r => { + // 품목별 라우팅 옵션 로드 + const finalItems = regMergeSameItem ? Array.from(new Map(items.map(i => [i.itemCode, i])).values()) : items; + const uniqueItemCodes = [...new Set(finalItems.map(i => i.itemCode).filter(Boolean))]; + for (const ic of uniqueItemCodes) { + getRoutingVersions("__new__", ic).then(r => { if (r.success && r.data) { - setConfirmRoutingOptions(r.data); - const defaultRouting = r.data.find(rv => rv.is_default); - if (defaultRouting) setConfirmRouting(defaultRouting.id); + setConfirmItems(prev => prev.map(it => { + if (it.itemCode !== ic) return it; + const defaultRv = r.data.find(rv => rv.is_default); + return { ...it, routingOptions: r.data, routing: defaultRv?.id || "" }; + })); } }).catch(() => {}); } @@ -242,7 +247,7 @@ export default function WorkInstructionPage() { status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate, equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker, 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); if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); } @@ -258,21 +263,35 @@ export default function WorkInstructionPage() { setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || ""); setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || ""); 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 || "", 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, 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(""); setEditRouting(order.routing_version_id || ""); setEditRoutingOptions([]); - // 라우팅 옵션 로드 - const itemCode = order.item_number || order.part_code || ""; - if (itemCode) { - getRoutingVersions(wiNo, itemCode).then(r => { - if (r.success && r.data) setEditRoutingOptions(r.data); + // 품목별 라우팅 옵션 로드 + const uniqueItemCodes = [...new Set(items.map(i => i.itemCode).filter(Boolean))]; + for (const ic of uniqueItemCodes) { + getRoutingVersions(wiNo, ic).then(r => { + 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(() => {}); } @@ -296,7 +315,7 @@ export default function WorkInstructionPage() { id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate, equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark, 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); if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); } @@ -600,13 +619,20 @@ export default function WorkInstructionPage() { {/* ── 2단계: 확인 모달 ── */} - - - - 작업지시 적용 확인 - 기본 정보를 입력하고 "최종 적용" 버튼을 눌러주세요. - - + + { setIsConfirmModalOpen(false); setIsRegModalOpen(true); }}> 이전 + setIsConfirmModalOpen(false)}>취소 + {saving ? : } 최종 적용 + > + } + > + 작업지시 기본 정보 @@ -625,27 +651,24 @@ export default function WorkInstructionPage() { 작업자 - 라우팅 - setConfirmRouting(fromNv(v))}> - - - 선택 안 함 - {confirmRoutingOptions.map(rv => ( - - {rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정 - - ))} - - - + 비고 품목 목록 - + - 순번품목코드품목명규격수량비고 + + 순번 + 품목코드 + 품목명 + 규격 + 수량 + 라우팅 + 비고 + + {confirmItems.map((item, idx) => ( @@ -655,6 +678,25 @@ export default function WorkInstructionPage() { {item.itemName || item.itemCode} {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + + { + const val = fromNv(v); + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, routing: val } : it)); + }} + > + + + 선택 안 함 + {(item.routingOptions || []).map((rv: RoutingVersionData) => ( + + {rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정 + + ))} + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> setConfirmItems(prev => prev.filter((_, i) => i !== idx))}> @@ -664,22 +706,22 @@ export default function WorkInstructionPage() { - - { setIsConfirmModalOpen(false); setIsRegModalOpen(true); }}> 이전 - setIsConfirmModalOpen(false)}>취소 - {saving ? : } 최종 적용 - - - + {/* ── 수정 모달 ── */} - - - - 작업지시 관리 - {editOrder?.work_instruction_no} - 품목을 추가/삭제하고 정보를 수정하세요. - - + { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }} + title={`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`} + description="품목을 추가/삭제하고 정보를 수정하세요." + footer={ + <> + setIsEditModalOpen(false)}>취소 + {editSaving ? : } 저장 + > + } + > + 기본 정보 @@ -691,64 +733,81 @@ export default function WorkInstructionPage() { 작업자 - 라우팅 - setEditRouting(fromNv(v))}> - - - 선택 안 함 - {editRoutingOptions.map(rv => ( - - {rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정 - - ))} - - - - 공정작업기준 - { - 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 || "" - ); - }} - > - 작업기준 수정 - - 비고 setEditRemark(e.target.value)} className="h-9" placeholder="비고" /> - {/* 품목 테이블 */} + {/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */} 작업지시 항목 {editItems.length}건 - + - 순번품목코드품목명규격수량비고 + + 순번 + 품목코드 + 품목명 + 규격 + 수량 + 라우팅 + 공정작업기준 + 비고 + + {editItems.length === 0 ? ( - 품목이 없습니다 + 품목이 없습니다 ) : editItems.map((item, idx) => ( {idx + 1} {item.itemCode} - {item.itemName || "-"} - {item.spec || "-"} + {item.itemName || "-"} + {item.spec || "-"} setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + + { + const val = fromNv(v); + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, routing: val } : it)); + }} + > + + + 선택 안 함 + {(item.routingOptions || []).map((rv: RoutingVersionData) => ( + + {rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정 + + ))} + + + + + { + 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 || "" + ); + }} + > + 수정 + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> setEditItems(prev => prev.filter((_, i) => i !== idx))}> @@ -764,12 +823,7 @@ export default function WorkInstructionPage() { )} - - setIsEditModalOpen(false)}>취소 - {editSaving ? : } 저장 - - - + {/* 공정작업기준 수정 모달 */} o.label === "영업관리"); + // division 기본값: 영업관리/제품/판매품 라벨 순서로 탐색 + 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); }; loadCategories(); diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index a0a60104..c02849f5 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -639,48 +639,68 @@ export const InteractiveDataTable: React.FC = ({ // 각 카테고리 컬럼의 값 목록 조회 const mappings: Record> = {}; + const flattenCategoryTree = (items: any[], parentLabel: string = ""): Record => { + const mapping: Record = {}; + 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) { try { - // menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용) const queryParams = menuObjid ? `?menuObjid=${menuObjid}&includeInactive=true` : "?includeInactive=true"; const response = await apiClient.get( `/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`, ); if (response.data.success && response.data.data) { - // valueCode 및 valueId -> {label, color} 매핑 생성 (트리 재귀 평탄화) - const mapping: Record = {}; - const flattenCategoryTree = (items: any[], parentLabel: string = "") => { - items.forEach((item: any) => { - const displayLabel = parentLabel - ? `${parentLabel} / ${item.valueLabel}` - : item.valueLabel; - if (item.valueCode) { - mapping[item.valueCode] = { - label: displayLabel, - color: item.color, - }; + const mapping = flattenCategoryTree(response.data.data); + if (Object.keys(mapping).length > 0) { + mappings[col.columnName] = mapping; + } else { + // 해당 테이블에 카테고리 값이 없으면 item_info에서 fallback 조회 + try { + const fbRes = await apiClient.get(`/table-categories/item_info/${col.columnName}/values?includeInactive=true`); + if (fbRes.data.success && fbRes.data.data) { + const fbMapping = flattenCategoryTree(fbRes.data.data); + if (Object.keys(fbMapping).length > 0) mappings[col.columnName] = fbMapping; } - 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) { - flattenCategoryTree(item.children, item.valueLabel); - } - }); - }; - flattenCategoryTree(response.data.data); - mappings[col.columnName] = mapping; - console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid }); + } catch { /* 무시 */ } + } + console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mappings[col.columnName], { menuObjid }); } } catch (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); setCategoryMappings(mappings); } catch (error) { @@ -2403,37 +2423,30 @@ export const InteractiveDataTable: React.FC = ({ break; default: { - // 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값) const strValue = String(value); - if (strValue.startsWith("CATEGORY_")) { - // 1. categoryMappings에서 해당 코드 검색 (색상 정보 포함) - for (const columnName of Object.keys(categoryMappings)) { - const mapping = categoryMappings[columnName]; - const categoryData = mapping?.[strValue]; - if (categoryData) { - // 색상이 있으면 배지로, 없으면 텍스트로 표시 - if (categoryData.color && categoryData.color !== "none") { - return ( - - {categoryData.label} - - ); + // 카테고리 코드 패턴 감지 (CAT_ 또는 CATEGORY_로 시작하는 값, 세미콜론 구분 다중값 포함) + const looksLikeCatCode = (v: string) => v.startsWith("CAT_") || v.startsWith("CATEGORY_"); + if (looksLikeCatCode(strValue) || (strValue.includes(";") && strValue.split(";").some(s => looksLikeCatCode(s.trim())))) { + // 세미콜론 구분 다중값 처리 + const codes = strValue.includes(";") ? strValue.split(";").map(s => s.trim()) : [strValue]; + const labels: string[] = []; + for (const code of codes) { + let found = false; + // 1. 해당 컬럼의 categoryMappings에서 먼저 검색 + const colMapping = categoryMappings[column.columnName]; + if (colMapping?.[code]) { labels.push(colMapping[code].label); found = true; } + // 2. 전체 매핑에서 검색 + if (!found) { + for (const cn of Object.keys(categoryMappings)) { + const mapping = categoryMappings[cn]; + if (mapping?.[code]) { labels.push(mapping[code].label); found = true; break; } } - return {categoryData.label}; } + // 3. categoryCodeLabels에서 검색 + if (!found && categoryCodeLabels[code]) { labels.push(categoryCodeLabels[code]); found = true; } + if (!found) labels.push(code); } - - // 2. categoryCodeLabels에서 검색 (API로 조회한 라벨) - const cachedLabel = categoryCodeLabels[strValue]; - if (cachedLabel) { - return {cachedLabel}; - } + return {labels.join(", ")}; } return strValue; } diff --git a/frontend/lib/api/workInstruction.ts b/frontend/lib/api/workInstruction.ts index 97a84c45..20db0997 100644 --- a/frontend/lib/api/workInstruction.ts +++ b/frontend/lib/api/workInstruction.ts @@ -79,10 +79,15 @@ export interface WIWorkItemDetail { unit?: string; lower_limit?: string; upper_limit?: string; + base_value?: string; + tolerance?: string; duration_minutes?: number; input_type?: string; lookup_target?: string; display_fields?: string; + condition_base_value?: string; + condition_tolerance?: string; + condition_unit?: string; } export interface WIWorkItem { diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index ff120d7d..692fe363 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -100,6 +100,38 @@ function ItemSearchModal({ const [items, setItems] = useState([]); const [selectedItems, setSelectedItems] = useState>(new Set()); const [loading, setLoading] = useState(false); + const [catLabels, setCatLabels] = useState>>({}); + + // 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 = {}; + 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( async (query: string) => { @@ -244,8 +276,8 @@ function ItemSearchModal({ {alreadyAdded && (추가됨)} {item.item_name} - {item.type} - {item.unit} + {resolveCatLabel(item.type || "", "division", "type")} + {resolveCatLabel(item.unit || "", "unit")} ); })} diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index f995f34a..ef5af8f4 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -142,24 +142,35 @@ export function BomTreeComponent({ const showHistory = features.showHistory !== false; const showVersion = features.showVersion !== false; - // 카테고리 라벨 캐시 (inputType === "category"인 모든 컬럼) + // 카테고리 라벨 캐시 const [categoryLabels, setCategoryLabels] = useState>>({}); useEffect(() => { - const categoryColumns = displayColumns.filter((c) => c.inputType === "category"); - if (categoryColumns.length === 0) return; - const loadLabels = async () => { + // inputType === "category"인 컬럼의 카테고리 로드 + const categoryColumns = displayColumns.filter((c) => c.inputType === "category"); for (const col of categoryColumns) { try { const res = await apiClient.get(`/table-categories/${detailTable}/${col.key}/values?includeInactive=true`); const vals = res.data?.data || []; if (vals.length > 0) { const map: Record = {}; - 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 })); } } 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 = {}; + 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(); }, [detailTable, displayColumns]); @@ -449,7 +460,21 @@ export function BomTreeComponent({ const getItemTypeLabel = (type: string) => { const map: Record = { 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) => { @@ -545,14 +570,15 @@ export function BomTreeComponent({ } 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 {unitLabel || "-"}; } // fallback: 카테고리 라벨이 로드된 컬럼이면 라벨로 변환 - if (categoryLabels[col.key] && value) { - const label = categoryLabels[col.key][String(value)] || String(value); - return {label || "-"}; + if (value) { + const label = categoryLabels[col.key]?.[String(value)] + || categoryLabels[`item_${col.key}`]?.[String(value)]; + if (label) return {label}; } return {value ?? "-"}; diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 4fb020f2..f2576016 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1233,22 +1233,34 @@ export const SplitPanelLayoutComponent: React.FC const strValue = String(value); - if (mapping && mapping[strValue]) { - const categoryData = mapping[strValue]; - return categoryData.label || strValue; - } - - // 전역 폴백: 컬럼명으로 매핑을 못 찾았을 때, 전체 매핑에서 값 검색 - if (!mapping && (strValue.startsWith("CAT_") || strValue.startsWith("CATEGORY_"))) { + // 카테고리 코드 라벨 변환 헬퍼 + const resolveLabel = (code: string): string | null => { + if (mapping && mapping[code]) return mapping[code].label || code; for (const key of Object.keys(categoryMappings)) { const m = categoryMappings[key]; - if (m && m[strValue]) { - const categoryData = m[strValue]; - return categoryData.label || strValue; - } + if (m && m[code]) return m[code].label || code; + } + 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 객체) if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) { return formatDateValue(value, "YYYY-MM-DD"); @@ -2429,6 +2441,31 @@ export const SplitPanelLayoutComponent: React.FC } } + // 카테고리 타입이 아닌 컬럼 중 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 = {}; + 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); } catch (error) { console.error("좌측 카테고리 매핑 로드 실패:", error); @@ -4036,7 +4073,7 @@ export const SplitPanelLayoutComponent: React.FC - + 데이터 1-1 데이터 1-2 @@ -4155,7 +4192,7 @@ export const SplitPanelLayoutComponent: React.FC )} - + {group.items.map((item, idx) => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const itemId = item[sourceColumn] || item.id || item.ID || idx; @@ -4167,9 +4204,7 @@ export const SplitPanelLayoutComponent: React.FC handleLeftItemSelect(item)} - className={`group hover:bg-accent cursor-pointer transition-colors ${ - isSelected ? "bg-primary/10" : "" - }`} + className={`group hover:bg-accent cursor-pointer transition-colors ${isSelected ? "bg-primary/10" : ""}`} > {columnsToShow.map((col, colIdx) => ( ))} {hasGroupedLeftActions && ( - + {componentConfig.leftPanel?.showEdit !== false && ( )} - + {filteredData.map((item, idx) => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const itemId = item[sourceColumn] || item.id || item.ID || idx; @@ -4310,7 +4345,7 @@ export const SplitPanelLayoutComponent: React.FC ))} {hasLeftTableActions && ( - + {componentConfig.leftPanel?.showEdit !== false && ( = ({ // 연쇄관계 매핑이 없는 경우 무시 } + // 카테고리 타입이 아닌 컬럼 중 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 = {}; + flattenTree(fbRes.data.data, fbMapping); + if (Object.keys(fbMapping).length > 0) mappings[colName] = fbMapping; + } + } catch { /* 무시 */ } + } + setCategoryMappings(mappings); if (Object.keys(mappings).length > 0) { setCategoryMappingsKey((prev) => prev + 1); diff --git a/frontend/scripts/approval-flow-test.ts b/frontend/scripts/approval-flow-test.ts deleted file mode 100644 index 8fdbfca6..00000000 --- a/frontend/scripts/approval-flow-test.ts +++ /dev/null @@ -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(); diff --git a/frontend/scripts/browser-verification.ts b/frontend/scripts/browser-verification.ts deleted file mode 100644 index 086fbb79..00000000 --- a/frontend/scripts/browser-verification.ts +++ /dev/null @@ -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(); diff --git a/frontend/scripts/company-menu-flow-verification.ts b/frontend/scripts/company-menu-flow-verification.ts deleted file mode 100644 index 33d4aba7..00000000 --- a/frontend/scripts/company-menu-flow-verification.ts +++ /dev/null @@ -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(); diff --git a/frontend/scripts/dashboard-verification.ts b/frontend/scripts/dashboard-verification.ts deleted file mode 100644 index 2a3a7bf0..00000000 --- a/frontend/scripts/dashboard-verification.ts +++ /dev/null @@ -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(); diff --git a/frontend/scripts/performance-test.ts b/frontend/scripts/performance-test.ts deleted file mode 100644 index 9446d69d..00000000 --- a/frontend/scripts/performance-test.ts +++ /dev/null @@ -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 }; diff --git a/frontend/scripts/po-approval-company7-test.ts b/frontend/scripts/po-approval-company7-test.ts deleted file mode 100644 index a1023c89..00000000 --- a/frontend/scripts/po-approval-company7-test.ts +++ /dev/null @@ -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(); diff --git a/frontend/scripts/purchase-order-approval-test.ts b/frontend/scripts/purchase-order-approval-test.ts deleted file mode 100644 index 0ea0cbe1..00000000 --- a/frontend/scripts/purchase-order-approval-test.ts +++ /dev/null @@ -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(); diff --git a/frontend/scripts/screen-approval-modal-test.ts b/frontend/scripts/screen-approval-modal-test.ts deleted file mode 100644 index cc47073c..00000000 --- a/frontend/scripts/screen-approval-modal-test.ts +++ /dev/null @@ -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(); diff --git a/frontend/scripts/screen68-verification.ts b/frontend/scripts/screen68-verification.ts deleted file mode 100644 index 231a9ab7..00000000 --- a/frontend/scripts/screen68-verification.ts +++ /dev/null @@ -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(); diff --git a/frontend/scripts/screen94-124-verification.ts b/frontend/scripts/screen94-124-verification.ts deleted file mode 100644 index f422a607..00000000 --- a/frontend/scripts/screen94-124-verification.ts +++ /dev/null @@ -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 { - 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(); diff --git a/frontend/scripts/test-card-list-e2e.ts b/frontend/scripts/test-card-list-e2e.ts deleted file mode 100644 index 6381546a..00000000 --- a/frontend/scripts/test-card-list-e2e.ts +++ /dev/null @@ -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(); diff --git a/frontend/scripts/test-formdata-logs.ts b/frontend/scripts/test-formdata-logs.ts deleted file mode 100644 index 0ea85492..00000000 --- a/frontend/scripts/test-formdata-logs.ts +++ /dev/null @@ -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); -}); diff --git a/frontend/scripts/ui-redesign-verification.ts b/frontend/scripts/ui-redesign-verification.ts deleted file mode 100644 index ceb78c3f..00000000 --- a/frontend/scripts/ui-redesign-verification.ts +++ /dev/null @@ -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(); diff --git a/frontend/scripts/verify-button-layout-screens.ts b/frontend/scripts/verify-button-layout-screens.ts deleted file mode 100644 index 34cf3cd4..00000000 --- a/frontend/scripts/verify-button-layout-screens.ts +++ /dev/null @@ -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 -) { - 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 = { 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(); diff --git a/frontend/scripts/verify-overlay-buttons.ts b/frontend/scripts/verify-overlay-buttons.ts deleted file mode 100644 index 42b24731..00000000 --- a/frontend/scripts/verify-overlay-buttons.ts +++ /dev/null @@ -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 = { 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(); diff --git a/frontend/scripts/verify-responsive-screens.ts b/frontend/scripts/verify-responsive-screens.ts deleted file mode 100644 index dbbf18e9..00000000 --- a/frontend/scripts/verify-responsive-screens.ts +++ /dev/null @@ -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 = {}; - - 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(); diff --git a/frontend/scripts/verify-screen-1053-admin.ts b/frontend/scripts/verify-screen-1053-admin.ts deleted file mode 100644 index f98bc53e..00000000 --- a/frontend/scripts/verify-screen-1053-admin.ts +++ /dev/null @@ -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 = {}; - - 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(); diff --git a/frontend/scripts/verify-screen-1053.ts b/frontend/scripts/verify-screen-1053.ts deleted file mode 100644 index 1f0cb96f..00000000 --- a/frontend/scripts/verify-screen-1053.ts +++ /dev/null @@ -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 = {}; - - 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(); diff --git a/frontend/scripts/verify-screen-1244-layout.ts b/frontend/scripts/verify-screen-1244-layout.ts deleted file mode 100644 index 361ef742..00000000 --- a/frontend/scripts/verify-screen-1244-layout.ts +++ /dev/null @@ -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 = { 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(); diff --git a/frontend/scripts/verify-screen-1244-refresh.ts b/frontend/scripts/verify-screen-1244-refresh.ts deleted file mode 100644 index aaeb7b9d..00000000 --- a/frontend/scripts/verify-screen-1244-refresh.ts +++ /dev/null @@ -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 = {}; - - 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(); diff --git a/frontend/scripts/verify-screen-1244-refresh2.ts b/frontend/scripts/verify-screen-1244-refresh2.ts deleted file mode 100644 index 0bf151a1..00000000 --- a/frontend/scripts/verify-screen-1244-refresh2.ts +++ /dev/null @@ -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(); diff --git a/frontend/scripts/verify-screen-1244.ts b/frontend/scripts/verify-screen-1244.ts deleted file mode 100644 index f21a2bc8..00000000 --- a/frontend/scripts/verify-screen-1244.ts +++ /dev/null @@ -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 = {}; - - 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(); diff --git a/frontend/scripts/verify-screen-150-tapseal.ts b/frontend/scripts/verify-screen-150-tapseal.ts deleted file mode 100644 index dbc210b1..00000000 --- a/frontend/scripts/verify-screen-150-tapseal.ts +++ /dev/null @@ -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 = {}; - - 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(); diff --git a/frontend/scripts/verify-screen-1556.ts b/frontend/scripts/verify-screen-1556.ts deleted file mode 100644 index f6a4f660..00000000 --- a/frontend/scripts/verify-screen-1556.ts +++ /dev/null @@ -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 = {}; - - 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(); diff --git a/frontend/scripts/verify-screen-156.ts b/frontend/scripts/verify-screen-156.ts deleted file mode 100644 index de202465..00000000 --- a/frontend/scripts/verify-screen-156.ts +++ /dev/null @@ -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 = {}; - - 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(); diff --git a/frontend/scripts/verify-screens-156-1053-v2.ts b/frontend/scripts/verify-screens-156-1053-v2.ts deleted file mode 100644 index 0042a99b..00000000 --- a/frontend/scripts/verify-screens-156-1053-v2.ts +++ /dev/null @@ -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 = { 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(); diff --git a/frontend/scripts/verify-split-panel-screens.ts b/frontend/scripts/verify-split-panel-screens.ts deleted file mode 100644 index db8eb227..00000000 --- a/frontend/scripts/verify-split-panel-screens.ts +++ /dev/null @@ -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 -) { - 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 = { 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(); diff --git a/frontend/scripts/verify-tapseal-screens.ts b/frontend/scripts/verify-tapseal-screens.ts deleted file mode 100644 index f9fd7bc2..00000000 --- a/frontend/scripts/verify-tapseal-screens.ts +++ /dev/null @@ -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 = {}; - - 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(); diff --git a/kubernetes-setup-guide.md b/kubernetes-setup-guide.md deleted file mode 100644 index 8f785b46..00000000 --- a/kubernetes-setup-guide.md +++ /dev/null @@ -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 -n - -# Pod 로그 확인 -kubectl logs -n -``` - -### 클러스터 리셋 (초기화 실패 시) - -```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) diff --git a/run-current-e2e.sh b/run-current-e2e.sh deleted file mode 100644 index c998006e..00000000 --- a/run-current-e2e.sh +++ /dev/null @@ -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 diff --git a/run-e2e-new-spec.sh b/run-e2e-new-spec.sh deleted file mode 100644 index f1ac6437..00000000 --- a/run-e2e-new-spec.sh +++ /dev/null @@ -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 diff --git a/run-e2e-smoke.sh b/run-e2e-smoke.sh deleted file mode 100644 index 3cfde69b..00000000 --- a/run-e2e-smoke.sh +++ /dev/null @@ -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 diff --git a/run-e2e-spec-test.sh b/run-e2e-spec-test.sh deleted file mode 100644 index 971a3e06..00000000 --- a/run-e2e-spec-test.sh +++ /dev/null @@ -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 diff --git a/run-e2e-test.sh b/run-e2e-test.sh deleted file mode 100644 index 445515c6..00000000 --- a/run-e2e-test.sh +++ /dev/null @@ -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:$?" diff --git a/run-playwright-test.sh b/run-playwright-test.sh deleted file mode 100644 index c998006e..00000000 --- a/run-playwright-test.sh +++ /dev/null @@ -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 diff --git a/run-windows.bat b/run-windows.bat deleted file mode 100644 index b5490e27..00000000 --- a/run-windows.bat +++ /dev/null @@ -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 -) \ No newline at end of file diff --git a/scripts/add-modal-ids.py b/scripts/add-modal-ids.py deleted file mode 100644 index 430809a4..00000000 --- a/scripts/add-modal-ids.py +++ /dev/null @@ -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'(? { - 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 추가 - # 패턴: (modalId가 없는 경우) - pattern = r']*?)(?' - - 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'' - - 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() - diff --git a/scripts/analyze-company-info-layout.js b/scripts/analyze-company-info-layout.js deleted file mode 100644 index 8312df97..00000000 --- a/scripts/analyze-company-info-layout.js +++ /dev/null @@ -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; -})(); diff --git a/scripts/browser-test-admin-switch-button.js b/scripts/browser-test-admin-switch-button.js deleted file mode 100644 index e43a139e..00000000 --- a/scripts/browser-test-admin-switch-button.js +++ /dev/null @@ -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); - }); diff --git a/scripts/browser-test-customer-crud.js b/scripts/browser-test-customer-crud.js deleted file mode 100644 index ebfcde0c..00000000 --- a/scripts/browser-test-customer-crud.js +++ /dev/null @@ -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); -}); diff --git a/scripts/browser-test-customer-via-menu.js b/scripts/browser-test-customer-via-menu.js deleted file mode 100644 index 7199741b..00000000 --- a/scripts/browser-test-customer-via-menu.js +++ /dev/null @@ -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); - }); diff --git a/scripts/browser-test-purchase-supplier.js b/scripts/browser-test-purchase-supplier.js deleted file mode 100644 index b2b51718..00000000 --- a/scripts/browser-test-purchase-supplier.js +++ /dev/null @@ -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); - }); diff --git a/scripts/dev/start-all-parallel.bat b/scripts/dev/start-all-parallel.bat index 08049b48..7db6d5b4 100644 --- a/scripts/dev/start-all-parallel.bat +++ b/scripts/dev/start-all-parallel.bat @@ -95,7 +95,7 @@ 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 [FRONTEND] Next.js: http://localhost:9771 echo. diff --git a/scripts/dev/start-all-parallel.ps1 b/scripts/dev/start-all-parallel.ps1 index 732106c6..40d319e3 100644 --- a/scripts/dev/start-all-parallel.ps1 +++ b/scripts/dev/start-all-parallel.ps1 @@ -150,7 +150,7 @@ Write-Host "============================================" -ForegroundColor Cyan Write-Host "[완료] 모든 서비스가 시작되었습니다!" -ForegroundColor Green Write-Host "============================================" -ForegroundColor Cyan 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 "[FRONTEND] Next.js: http://localhost:9771" -ForegroundColor White Write-Host "" diff --git a/scripts/dev/start-all-parallel.sh b/scripts/dev/start-all-parallel.sh index 3f8f0ddc..5a782ae9 100755 --- a/scripts/dev/start-all-parallel.sh +++ b/scripts/dev/start-all-parallel.sh @@ -92,7 +92,7 @@ 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 "[FRONTEND] Next.js: http://localhost:9771" echo "" diff --git a/scripts/dev/start-backend.sh b/scripts/dev/start-backend.sh index 15495268..13ce6d47 100755 --- a/scripts/dev/start-backend.sh +++ b/scripts/dev/start-backend.sh @@ -29,7 +29,7 @@ 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 "" echo "상태 확인: docker-compose -f docker/dev/docker-compose.backend.mac.yml ps" diff --git a/scripts/menu-copy-automation.ts b/scripts/menu-copy-automation.ts deleted file mode 100644 index af907d82..00000000 --- a/scripts/menu-copy-automation.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * 메뉴 복사 자동화 스크립트 - * - * 실행: npx ts-node scripts/menu-copy-automation.ts - * 또는: npx playwright test scripts/menu-copy-automation.ts (playwright test 모드) - * - * 요구사항: playwright 설치 (npm install playwright) - */ - -import { chromium, type Browser, type Page } from "playwright"; -import * as fs from "fs"; -import * as path from "path"; - -const BASE_URL = "http://localhost:9771"; -const SCREENSHOT_DIR = path.join(__dirname, "../screenshots-menu-copy"); - -// 스크린샷 저장 -async function takeScreenshot(page: Page, stepName: string): Promise { - if (!fs.existsSync(SCREENSHOT_DIR)) { - fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); - } - const filename = `${Date.now()}_${stepName}.png`; - const filepath = path.join(SCREENSHOT_DIR, filename); - await page.screenshot({ path: filepath, fullPage: true }); - console.log(`[스크린샷] ${stepName} -> ${filepath}`); - return filepath; -} - -async function main() { - let browser: Browser | null = null; - const screenshots: { step: string; path: string }[] = []; - - try { - console.log("=== 메뉴 복사 자동화 시작 ===\n"); - - browser = await chromium.launch({ headless: false }); - const context = await browser.newContext({ - viewport: { width: 1280, height: 900 }, - ignoreHTTPSErrors: true, - }); - const page = await context.newPage(); - - // 1. 로그인 - console.log("1. 로그인 페이지 이동..."); - await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle" }); - await takeScreenshot(page, "01_login_page").then((p) => - screenshots.push({ step: "로그인 페이지", path: p }) - ); - - await page.fill('#userId', "admin"); - await page.fill('#password', "1234"); - await page.click('button[type="submit"]'); - await page.waitForTimeout(3000); - - await takeScreenshot(page, "02_after_login").then((p) => - screenshots.push({ step: "로그인 후", path: p }) - ); - - // 로그인 실패 시 wace 계정 시도 (admin이 DB에 없을 수 있음) - const currentUrl = page.url(); - if (currentUrl.includes("/login")) { - console.log("admin 로그인 실패, wace 계정으로 재시도..."); - await page.fill('#userId', "wace"); - await page.fill('#password', "1234"); - await page.click('button[type="submit"]'); - await page.waitForTimeout(3000); - } - - // 2. 메뉴 관리 페이지로 이동 - console.log("2. 메뉴 관리 페이지 이동..."); - await page.goto(`${BASE_URL}/admin/menu`, { waitUntil: "networkidle" }); - await page.waitForTimeout(2000); - await takeScreenshot(page, "03_menu_page").then((p) => - screenshots.push({ step: "메뉴 관리 페이지", path: p }) - ); - - // 3. 회사 선택 - 탑씰 (COMPANY_7) - console.log("3. 회사 선택: 탑씰 (COMPANY_7)..."); - const companyDropdown = page.locator('.company-dropdown button, button:has(svg)').first(); - await companyDropdown.click(); - await page.waitForTimeout(500); - - const topsealOption = page.getByText("탑씰", { exact: false }).first(); - await topsealOption.click(); - await page.waitForTimeout(1500); - await takeScreenshot(page, "04_company_selected").then((p) => - screenshots.push({ step: "탑씰 선택 후", path: p }) - ); - - // 4. "사용자" 메뉴 찾기 및 복사 버튼 클릭 - console.log("4. 사용자 메뉴 찾기 및 복사 버튼 클릭..."); - const userMenuRow = page.locator('tr').filter({ hasText: "사용자" }).first(); - await userMenuRow.waitFor({ timeout: 10000 }); - const copyButton = userMenuRow.getByRole("button", { name: "복사" }); - await copyButton.click(); - await page.waitForTimeout(1500); - await takeScreenshot(page, "05_copy_dialog_open").then((p) => - screenshots.push({ step: "복사 다이얼로그", path: p }) - ); - - // 5. 대상 회사 선택: 두바이 강정 단단 (COMPANY_18) - console.log("5. 대상 회사 선택: 두바이 강정 단단 (COMPANY_18)..."); - const targetCompanyTrigger = page.locator('[id="company"]').or(page.getByRole("combobox")).first(); - await targetCompanyTrigger.click(); - await page.waitForTimeout(500); - - const dubaiOption = page.getByText("두바이 강정 단단", { exact: false }).first(); - await dubaiOption.click(); - await page.waitForTimeout(500); - await takeScreenshot(page, "06_target_company_selected").then((p) => - screenshots.push({ step: "대상 회사 선택 후", path: p }) - ); - - // 6. 복사 시작 버튼 클릭 - console.log("6. 복사 시작..."); - const copyStartButton = page.getByRole("button", { name: /복사 시작|확인/ }).first(); - await copyStartButton.click(); - - // 7. 복사 완료 대기 (최대 5분) - console.log("7. 복사 완료 대기 (최대 5분)..."); - try { - await page.waitForSelector('text=완료, text=성공, [role="status"]', { timeout: 300000 }); - await page.waitForTimeout(3000); - } catch { - console.log("타임아웃 또는 완료 메시지 대기 중..."); - } - await takeScreenshot(page, "07_copy_result").then((p) => - screenshots.push({ step: "복사 결과", path: p }) - ); - - // 결과 확인 - const resultText = await page.locator("body").textContent(); - if (resultText?.includes("완료") || resultText?.includes("성공")) { - console.log("\n=== 메뉴 복사 성공 ==="); - } else if (resultText?.includes("오류") || resultText?.includes("실패") || resultText?.includes("error")) { - console.log("\n=== 에러 발생 가능 - 스크린샷 확인 필요 ==="); - } - - console.log("\n=== 스크린샷 목록 ==="); - screenshots.forEach((s) => console.log(` - ${s.step}: ${s.path}`)); - } catch (error) { - console.error("오류 발생:", error); - if (browser) { - const pages = (browser as any).contexts?.()?.[0]?.pages?.() || []; - for (const p of pages) { - try { - await takeScreenshot(p, "error_state").then((path) => - screenshots.push({ step: "에러 상태", path }) - ); - } catch {} - } - } - throw error; - } finally { - if (browser) { - await browser.close(); - } - } -} - -main().catch(console.error); diff --git a/scripts/prod/start-all-linux.sh b/scripts/prod/start-all-linux.sh index bf9cb77c..6d23c9a7 100644 --- a/scripts/prod/start-all-linux.sh +++ b/scripts/prod/start-all-linux.sh @@ -91,7 +91,7 @@ echo "🎉 모든 서비스가 시작되었습니다!" echo "============================================" echo "" echo "📊 서비스 접속 정보:" -echo " [DATABASE] PostgreSQL: http://39.117.244.52:11132" +echo " [DATABASE] PostgreSQL: http://211.115.91.141:11134" echo " [BACKEND] API: https://api.vexplor.com" echo " [FRONTEND] Web: https://v1.vexplor.com" echo " [BACKEND LOCAL] http://localhost:3001/api" diff --git a/scripts/remove-logs.js b/scripts/remove-logs.js deleted file mode 100644 index 11495f4b..00000000 --- a/scripts/remove-logs.js +++ /dev/null @@ -1,60 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const filePath = path.join(__dirname, '../frontend/lib/utils/buttonActions.ts'); -let content = fs.readFileSync(filePath, 'utf8'); - -// 디버깅 console.log 제거 (전체 줄) -// console.log로 시작하는 줄만 제거 (이모지 포함) -const patterns = [ - // 디버깅 로그 (이모지 포함) - /^\s*console\.log\s*\([^)]*["'`]🔍[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]📦[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]📋[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🔗[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🔄[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🎯[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]✅[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]⏭️[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]📊[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🏗️[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]📝[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]💾[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🔐[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🔑[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🔒[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🧹[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🗑️[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]📂[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]📤[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]📥[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🔎[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🆕[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]📌[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🔥[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]⚡[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🎉[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🚀[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]📡[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🌐[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]👤[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🚫[^]*?\);\s*$/gm, - /^\s*console\.log\s*\([^)]*["'`]🔧[^]*?\);\s*$/gm, -]; - -let totalRemoved = 0; - -patterns.forEach(pattern => { - const matches = content.match(pattern); - if (matches) { - totalRemoved += matches.length; - content = content.replace(pattern, ''); - } -}); - -// 연속된 빈 줄 제거 (3개 이상의 빈 줄을 2개로) -content = content.replace(/\n\n\n+/g, '\n\n'); - -fs.writeFileSync(filePath, content, 'utf8'); -console.log(`Removed ${totalRemoved} console.log statements`); - diff --git a/scripts/run-e2e-test.js b/scripts/run-e2e-test.js deleted file mode 100644 index fc9566db..00000000 --- a/scripts/run-e2e-test.js +++ /dev/null @@ -1,151 +0,0 @@ -/** - * 결재 템플릿 관리 및 결재함 E2E 테스트 - * 실행: node scripts/run-e2e-test.js - */ -const { chromium } = require("playwright"); - -const BASE_URL = "http://localhost:9771"; -const SCREENSHOT_DIR = ".agent-pipeline/browser-tests"; - -async function runTest() { - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ - viewport: { width: 1280, height: 720 }, - }); - const page = await context.newPage(); - - const results = []; - let allPassed = true; - - function pass(name) { - results.push({ name, status: "PASS" }); - console.log(`PASS: ${name}`); - } - - function fail(name, reason) { - results.push({ name, status: "FAIL", reason }); - console.log(`FAIL: ${name} - ${reason}`); - allPassed = false; - } - - try { - // 로그인 - await page.goto(`${BASE_URL}/login`); - await page.waitForLoadState("networkidle"); - - await page.getByPlaceholder("사용자 ID를 입력하세요").fill("wace"); - await page.getByPlaceholder("비밀번호를 입력하세요").fill("qlalfqjsgh11"); - - await Promise.all([ - page.waitForURL(url => !url.toString().includes("/login"), { timeout: 30000 }), - page.getByRole("button", { name: "로그인" }).click(), - ]); - await page.waitForLoadState("networkidle"); - pass("로그인"); - - // ========================================================= - // 결재 템플릿 관리 페이지 - // ========================================================= - await page.goto(`${BASE_URL}/admin/approvalTemplate`); - await page.waitForLoadState("domcontentloaded"); - await page.waitForTimeout(3000); - - // 1. "결재 템플릿 관리" 제목 확인 - try { - await page.locator("h1").filter({ hasText: "결재 템플릿 관리" }).waitFor({ timeout: 10000 }); - pass("결재 템플릿 관리 - 제목 확인"); - } catch (e) { - fail("결재 템플릿 관리 - 제목 확인", e.message); - } - - // 2. "신규 등록" 버튼 확인 - try { - await page.getByRole("button", { name: "신규 등록" }).waitFor({ timeout: 10000 }); - pass("결재 템플릿 관리 - 신규 등록 버튼 확인"); - } catch (e) { - fail("결재 템플릿 관리 - 신규 등록 버튼 확인", e.message); - } - - // 3. 검색 입력란 확인 - try { - await page.getByPlaceholder("템플릿명 또는 설명 검색...").waitFor({ timeout: 10000 }); - pass("결재 템플릿 관리 - 검색 입력란 확인"); - } catch (e) { - // placeholder가 다를 수 있으므로 input으로 재시도 - try { - await page.locator("input[type='text']").first().waitFor({ timeout: 5000 }); - pass("결재 템플릿 관리 - 검색 입력란 확인 (input fallback)"); - } catch (e2) { - fail("결재 템플릿 관리 - 검색 입력란 확인", e.message); - } - } - - // 4. JS 에러 오버레이 확인 - const hasError1 = await page.locator('[id="__next"] .nextjs-container-errors-body').isVisible().catch(() => false); - if (!hasError1) { - pass("결재 템플릿 관리 - JS 에러 없음"); - } else { - fail("결재 템플릿 관리 - JS 에러 없음", "에러 오버레이 감지됨"); - } - - await page.screenshot({ path: `${SCREENSHOT_DIR}/result-template.png`, fullPage: true }); - - // ========================================================= - // 결재함 페이지 - // ========================================================= - await page.goto(`${BASE_URL}/admin/approvalBox`); - await page.waitForLoadState("domcontentloaded"); - await page.waitForTimeout(3000); - - // 1. 탭 확인 - try { - await page.getByRole("tab", { name: /수신함/ }).waitFor({ timeout: 10000 }); - pass("결재함 - 수신함 탭 확인"); - } catch (e) { - fail("결재함 - 수신함 탭 확인", e.message); - } - - try { - await page.getByRole("tab", { name: /상신함/ }).waitFor({ timeout: 10000 }); - pass("결재함 - 상신함 탭 확인"); - } catch (e) { - fail("결재함 - 상신함 탭 확인", e.message); - } - - // 2. JS 에러 오버레이 확인 - const hasError2 = await page.locator('[id="__next"] .nextjs-container-errors-body').isVisible().catch(() => false); - if (!hasError2) { - pass("결재함 - JS 에러 없음"); - } else { - fail("결재함 - JS 에러 없음", "에러 오버레이 감지됨"); - } - - await page.screenshot({ path: `${SCREENSHOT_DIR}/result-box.png`, fullPage: true }); - - } catch (e) { - fail("테스트 실행", e.message); - } finally { - await browser.close(); - } - - console.log("\n=== 테스트 결과 ==="); - results.forEach(r => { - const status = r.status === "PASS" ? "✓" : "✗"; - console.log(`${status} ${r.name}${r.reason ? `: ${r.reason}` : ""}`); - }); - - if (allPassed) { - console.log("\nBROWSER_TEST_RESULT: PASS"); - process.exit(0); - } else { - const failed = results.filter(r => r.status === "FAIL").map(r => r.name).join(", "); - console.log(`\nBROWSER_TEST_RESULT: FAIL - ${failed}`); - process.exit(1); - } -} - -runTest().catch(e => { - console.error("치명적 오류:", e); - console.log("BROWSER_TEST_RESULT: FAIL - 치명적 오류: " + e.message); - process.exit(1); -}); diff --git a/scripts/test-option-settings-responsive.ts b/scripts/test-option-settings-responsive.ts deleted file mode 100644 index 98ba7351..00000000 --- a/scripts/test-option-settings-responsive.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * 옵션설정 (Option Settings) 페이지 반응형 동작 테스트 - * - V2CategoryManagerComponent + ResponsiveGridRenderer - * - 화면: http://localhost:9771/screens/1421 - * - * 실행: npx tsx scripts/test-option-settings-responsive.ts - */ -import { chromium } from "playwright"; -import * as path from "path"; -import * as fs from "fs"; - -const BASE_URL = "http://localhost:9771"; -const API_URL = "http://localhost:8080/api"; -const PAGE_URL = `${BASE_URL}/screens/1421`; -const CREDENTIALS = [ - { userId: "SUPER", password: "1234" }, - { userId: "wace", password: "qlalfqjsgh11" }, -]; - -const OUTPUT_DIR = path.join(__dirname, "../test-output/option-settings-responsive"); - -async function loginViaApi(): Promise { - for (const cred of CREDENTIALS) { - const res = await fetch(`${API_URL}/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId: cred.userId, password: cred.password }), - }); - const data = await res.json(); - if (data.success && data.data?.token) { - console.log(` Using credentials: ${cred.userId}`); - return data.data.token; - } - } - throw new Error("Login failed with all credentials"); -} - -async function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - - const token = await loginViaApi(); - console.log("1. Logged in via API"); - - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ - viewport: { width: 1400, height: 900 }, - }); - const page = await context.newPage(); - - const consoleErrors: string[] = []; - const reactHookErrors: string[] = []; - - page.on("console", (msg) => { - const type = msg.type(); - const text = msg.text(); - if (type === "error") { - consoleErrors.push(text); - if (text.includes("order of Hooks") || text.includes("React has detected")) { - reactHookErrors.push(text); - } - } - }); - - try { - console.log("2. Loading login page to inject token..."); - await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded", timeout: 15000 }); - await page.evaluate((t: string) => { - localStorage.setItem("authToken", t); - document.cookie = `authToken=${t}; path=/; max-age=86400; SameSite=Lax`; - }, token); - - console.log("3. Navigating to Option Settings page (screens/1421)..."); - await page.goto(PAGE_URL, { waitUntil: "domcontentloaded", timeout: 15000 }); - await page.waitForTimeout(5000); - - const report: string[] = []; - - const widths = [ - { w: 1400, name: "1-desktop-1400px" }, - { w: 1100, name: "2-tablet-1100px" }, - { w: 900, name: "3-tablet-900px" }, - { w: 600, name: "4-mobile-600px" }, - { w: 1400, name: "5-desktop-1400px-restored" }, - ]; - - for (const { w, name } of widths) { - console.log(`\nResizing to ${w}px...`); - await page.setViewportSize({ width: w, height: w === 600 ? 812 : 900 }); - await page.waitForTimeout(1500); - - const filePath = path.join(OUTPUT_DIR, `${name}.png`); - await page.screenshot({ path: filePath, fullPage: false }); - console.log(` Saved: ${filePath}`); - - const hasCategoryColumn = (await page.locator('text=카테고리 컬럼').count()) > 0; - const hasUseStatus = (await page.locator('text=사용여부').count()) > 0; - const hasVerticalStack = (await page.locator('button:has-text("목록")').count()) > 0; - const hasLeftRightSplit = (await page.locator('[class*="cursor-col-resize"]').count()) > 0; - - report.push(`[${w}px] Category column: ${hasCategoryColumn}, Use status: ${hasUseStatus}, Vertical: ${hasVerticalStack}, Split: ${hasLeftRightSplit}`); - } - - console.log("\n" + "=".repeat(60)); - console.log("OPTION SETTINGS RESPONSIVE TEST REPORT"); - console.log("=".repeat(60)); - report.forEach((r) => console.log(r)); - - if (consoleErrors.length > 0) { - console.log("\n--- Console Errors ---"); - consoleErrors.slice(0, 10).forEach((e) => console.log(" ", e)); - } - if (reactHookErrors.length > 0) { - console.log("\n--- React Hook Errors (order of Hooks) ---"); - reactHookErrors.forEach((e) => console.log(" ", e)); - } - - fs.writeFileSync( - path.join(OUTPUT_DIR, "report.txt"), - [ - "OPTION SETTINGS RESPONSIVE TEST REPORT", - "=".repeat(50), - ...report, - "", - "Console errors: " + consoleErrors.length, - "React Hook errors: " + reactHookErrors.length, - ...reactHookErrors.map((e) => " " + e), - ].join("\n") - ); - - console.log("\nScreenshots saved to:", OUTPUT_DIR); - } catch (e) { - console.error("Error:", e); - throw e; - } finally { - await browser.close(); - } -} - -main(); diff --git a/scripts/test-responsive-split-panel.ts b/scripts/test-responsive-split-panel.ts deleted file mode 100644 index f2eac313..00000000 --- a/scripts/test-responsive-split-panel.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * ResponsiveSplitPanel 반응형 동작 테스트 - * - Desktop (>= 1280px): 좌우 분할 + 리사이저 - * - Tablet (768-1279px): 좌측 패널 자동 접힘, 아이콘 버튼만 표시 - * - Mobile (< 768px): 세로 스택 + 접기/펼치기 헤더 - * - * 실행: npx tsx scripts/test-responsive-split-panel.ts - */ -import { chromium } from "playwright"; -import * as path from "path"; -import * as fs from "fs"; - -const BASE_URL = "http://localhost:9771"; -const API_URL = "http://localhost:8080/api"; -const TABLE_MNG_PATH = "/admin/systemMng/tableMngList"; -const USER_ID = "wace"; -const PASSWORD = "qlalfqjsgh11"; - -const OUTPUT_DIR = path.join(__dirname, "../test-output/responsive-split-panel"); - -async function loginViaApi(): Promise { - const res = await fetch(`${API_URL}/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId: USER_ID, password: PASSWORD }), - }); - const data = await res.json(); - if (!data.success || !data.data?.token) throw new Error("Login failed: " + (data.message || "no token")); - return data.data.token; -} - -async function main() { - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - - const token = await loginViaApi(); - console.log("1. Logged in via API"); - - const browser = await chromium.launch({ headless: true }); - const context = await browser.newContext({ - viewport: { width: 1400, height: 900 }, - }); - const page = await context.newPage(); - - try { - // 공개 페이지에서 토큰 주입 후 테이블 페이지로 이동 - console.log("2. Loading login page to inject token..."); - await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded", timeout: 15000 }); - await page.evaluate((t: string) => { - localStorage.setItem("authToken", t); - document.cookie = `authToken=${t}; path=/; max-age=86400; SameSite=Lax`; - }, token); - - console.log("3. Navigating to table management page..."); - await page.goto(`${BASE_URL}${TABLE_MNG_PATH}`, { waitUntil: "domcontentloaded", timeout: 15000 }); - - await page.waitForTimeout(3000); - - const report: string[] = []; - - // --- 3. Desktop 1400px (default wide) --- - console.log("\n3. Desktop 1400px - taking screenshot..."); - await page.setViewportSize({ width: 1400, height: 900 }); - await page.waitForTimeout(1000); - const desktopPath = path.join(OUTPUT_DIR, "1-desktop-1400px.png"); - await page.screenshot({ path: desktopPath, fullPage: false }); - console.log(` Saved: ${desktopPath}`); - - const at1400 = { - resizer: (await page.locator('[class*="cursor-col-resize"]').count()) > 0, - leftPanelVisible: (await page.locator('text=테이블 검색').count()) > 0 || (await page.locator('input[placeholder*="검색"]').count()) > 0, - iconButtonOnly: (await page.locator('button[title*="열기"]').count()) > 0 && !(await page.locator('input[placeholder*="검색"]').isVisible()), - }; - report.push(`[1400px] Resizer: ${at1400.resizer}, Left panel visible: ${at1400.leftPanelVisible}, Icon-only: ${at1400.iconButtonOnly}`); - - // --- 4. Tablet 1000px --- - console.log("\n4. Tablet 1000px - resizing and taking screenshot..."); - await page.setViewportSize({ width: 1000, height: 900 }); - await page.waitForTimeout(1500); - const tabletPath = path.join(OUTPUT_DIR, "2-tablet-1000px.png"); - await page.screenshot({ path: tabletPath, fullPage: false }); - console.log(` Saved: ${tabletPath}`); - - const at1000 = { - resizer: (await page.locator('[class*="cursor-col-resize"]').count()) > 0, - leftPanelVisible: (await page.locator('input[placeholder*="검색"]').isVisible()), - iconButtonOnly: (await page.locator('button[title*="열기"]').count()) > 0, - verticalStack: (await page.locator('button:has-text("테이블 목록")').count()) > 0, - }; - report.push(`[1000px] Resizer: ${at1000.resizer}, Left panel visible: ${at1000.leftPanelVisible}, Icon-only: ${at1000.iconButtonOnly}, Vertical stack: ${at1000.verticalStack}`); - - // --- 5. Mobile 600px --- - console.log("\n5. Mobile 600px - resizing and taking screenshot..."); - await page.setViewportSize({ width: 600, height: 812 }); - await page.waitForTimeout(1500); - const mobilePath = path.join(OUTPUT_DIR, "3-mobile-600px.png"); - await page.screenshot({ path: mobilePath, fullPage: false }); - console.log(` Saved: ${mobilePath}`); - - const at600 = { - collapsibleHeader: (await page.locator('button:has-text("테이블 목록")').count()) > 0, - verticalStack: (await page.locator('button:has-text("테이블 목록")').count()) > 0, - leftPanelVisible: (await page.locator('input[placeholder*="검색"]').isVisible()), - }; - report.push(`[600px] Collapsible header: ${at600.collapsibleHeader}, Vertical stack: ${at600.verticalStack}, Left panel visible: ${at600.leftPanelVisible}`); - - // --- 6. Back to Desktop 1400px --- - console.log("\n6. Back to Desktop 1400px - resizing and taking screenshot..."); - await page.setViewportSize({ width: 1400, height: 900 }); - await page.waitForTimeout(1500); - const desktopAgainPath = path.join(OUTPUT_DIR, "4-desktop-1400px-again.png"); - await page.screenshot({ path: desktopAgainPath, fullPage: false }); - console.log(` Saved: ${desktopAgainPath}`); - - const at1400Again = { - resizer: (await page.locator('[class*="cursor-col-resize"]').count()) > 0, - leftPanelVisible: (await page.locator('input[placeholder*="검색"]').isVisible()), - }; - report.push(`[1400px again] Resizer: ${at1400Again.resizer}, Left panel visible: ${at1400Again.leftPanelVisible}`); - - // --- Report --- - console.log("\n" + "=".repeat(60)); - console.log("RESPONSIVE LAYOUT TEST REPORT"); - console.log("=".repeat(60)); - report.forEach((r) => console.log(r)); - console.log("\nScreenshots saved to:", OUTPUT_DIR); - } catch (e) { - console.error("Error:", e); - throw e; - } finally { - await browser.close(); - } -} - -main(); diff --git a/start-all-separated.bat b/start-all-separated.bat deleted file mode 100644 index 7c580aca..00000000 --- a/start-all-separated.bat +++ /dev/null @@ -1,71 +0,0 @@ -@echo off -chcp 65001 >nul - -REM 스크립트가 있는 디렉토리로 이동 -cd /d "%~dp0" - -echo ============================================ -echo PLM 솔루션 - 전체 서비스 시작 (분리형) -echo ============================================ - -echo. -echo 🚀 백엔드와 프론트엔드를 순차적으로 시작합니다... -echo. - -REM 백엔드 먼저 시작 -echo ============================================ -echo 1. 백엔드 서비스 시작 중... -echo ============================================ - -REM 기존 컨테이너 및 네트워크 정리 -docker-compose -f docker-compose.backend.win.yml down -v 2>nul -docker-compose -f docker-compose.frontend.win.yml down -v 2>nul -docker network rm pms-network 2>nul - -REM 백엔드 빌드 및 시작 -docker-compose -f docker-compose.backend.win.yml build --no-cache -docker-compose -f docker-compose.backend.win.yml up -d - -echo. -echo ⏳ 백엔드 서비스 안정화 대기 중... (20초) -timeout /t 20 /nobreak >nul - -REM 프론트엔드 시작 -echo. -echo ============================================ -echo 2. 프론트엔드 서비스 시작 중... -echo ============================================ - -REM 프론트엔드 빌드 및 시작 -docker-compose -f docker-compose.frontend.win.yml build --no-cache -docker-compose -f docker-compose.frontend.win.yml up -d - -echo. -echo ⏳ 프론트엔드 서비스 안정화 대기 중... (10초) -timeout /t 10 /nobreak >nul - -echo. -echo ============================================ -echo 🎉 모든 서비스가 시작되었습니다! -echo ============================================ -echo. -echo [DATABASE] PostgreSQL: http://39.117.244.52:11132 -echo [BACKEND] Spring Boot: http://localhost:8080/api -echo [FRONTEND] Next.js: http://localhost:9771 -echo. -echo 서비스 상태 확인: -echo 백엔드: docker-compose -f docker-compose.backend.win.yml ps -echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml ps -echo. -echo 로그 확인: -echo 백엔드: docker-compose -f docker-compose.backend.win.yml logs -f -echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml logs -f -echo. -echo 서비스 중지: -echo 백엔드: docker-compose -f docker-compose.backend.win.yml down -echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml down -echo 전체: stop-all-separated.bat -echo. -echo ============================================ - -pause diff --git a/start-windows-simple.bat b/start-windows-simple.bat deleted file mode 100644 index a5c96fa7..00000000 --- a/start-windows-simple.bat +++ /dev/null @@ -1,97 +0,0 @@ -@echo off -chcp 65001 >nul - -REM 스크립트가 있는 디렉토리로 이동 -cd /d "%~dp0" - -echo ============================================ -echo PLM 솔루션 - 윈도우 간편 시작 -echo ============================================ -echo. - -REM Docker Desktop 실행 확인 -echo 🔍 Docker Desktop 상태 확인 중... -docker --version >nul 2>&1 -if %errorlevel% neq 0 ( - echo ❌ Docker Desktop이 실행되지 않았습니다! - echo Docker Desktop을 먼저 실행해주세요. - echo. - pause - exit /b 1 -) - -echo ✅ Docker Desktop이 실행 중입니다. -echo. - -REM 기존 컨테이너 정리 -echo 🧹 기존 컨테이너 정리 중... -docker-compose -f docker-compose.backend.win.yml down -v 2>nul -docker-compose -f docker-compose.frontend.win.yml down -v 2>nul -docker network rm pms-network 2>nul -echo. - -REM 백엔드 시작 -echo ============================================ -echo 🚀 1단계: 백엔드 서비스 시작 중... -echo ============================================ -docker-compose -f docker-compose.backend.win.yml up -d --build - -if %errorlevel% neq 0 ( - echo ❌ 백엔드 시작 실패! - echo 로그를 확인하세요: docker-compose -f docker-compose.backend.win.yml logs - pause - exit /b 1 -) - -echo ✅ 백엔드 서비스 시작 완료 -echo ⏳ 백엔드 안정화 대기 중... (30초) -timeout /t 30 /nobreak >nul - -REM 프론트엔드 시작 -echo. -echo ============================================ -echo 🎨 2단계: 프론트엔드 서비스 시작 중... -echo ============================================ -docker-compose -f docker-compose.frontend.win.yml up -d --build - -if %errorlevel% neq 0 ( - echo ❌ 프론트엔드 시작 실패! - echo 로그를 확인하세요: docker-compose -f docker-compose.frontend.win.yml logs - pause - exit /b 1 -) - -echo ✅ 프론트엔드 서비스 시작 완료 -echo ⏳ 프론트엔드 안정화 대기 중... (15초) -timeout /t 15 /nobreak >nul - -echo. -echo ============================================ -echo 🎉 PLM 솔루션이 성공적으로 시작되었습니다! -echo ============================================ -echo. -echo 📱 접속 정보: -echo • 프론트엔드: http://localhost:9771 -echo • 백엔드 API: http://localhost:8080/api -echo • 데이터베이스: 39.117.244.52:11132 -echo. -echo 📊 서비스 상태 확인: -echo docker-compose -f docker-compose.backend.win.yml ps -echo docker-compose -f docker-compose.frontend.win.yml ps -echo. -echo 📋 로그 확인: -echo 백엔드: docker-compose -f docker-compose.backend.win.yml logs -f -echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml logs -f -echo. -echo 🛑 서비스 중지: -echo stop-all-separated.bat 실행 -echo. - -REM 브라우저 자동 열기 -echo 5초 후 브라우저에서 애플리케이션을 엽니다... -timeout /t 5 /nobreak >nul -start http://localhost:9771 - -echo. -echo 애플리케이션이 준비되었습니다! -pause diff --git a/stop-all-separated.bat b/stop-all-separated.bat deleted file mode 100644 index f28bef1f..00000000 --- a/stop-all-separated.bat +++ /dev/null @@ -1,56 +0,0 @@ -@echo off -chcp 65001 >nul - -echo ============================================ -echo PLM 솔루션 - 전체 서비스 중지 (분리형) -echo ============================================ - -echo. -echo 🛑 백엔드와 프론트엔드 서비스를 순차적으로 중지합니다... -echo. - -REM 프론트엔드 먼저 중지 -echo ============================================ -echo 1. 프론트엔드 서비스 중지 중... -echo ============================================ - -docker-compose -f docker-compose.frontend.win.yml down -v - -echo. -echo ⏳ 프론트엔드 서비스 완전 중지 대기 중... (5초) -timeout /t 5 /nobreak >nul - -REM 백엔드 중지 -echo. -echo ============================================ -echo 2. 백엔드 서비스 중지 중... -echo ============================================ - -docker-compose -f docker-compose.backend.win.yml down -v - -echo. -echo ⏳ 백엔드 서비스 완전 중지 대기 중... (5초) -timeout /t 5 /nobreak >nul - -REM 네트워크 정리 (선택사항) -echo. -echo ============================================ -echo 3. 네트워크 정리 중... -echo ============================================ - -docker network rm pms-network 2>nul || echo 네트워크가 이미 삭제되었습니다. - -echo. -echo ============================================ -echo ✅ 모든 서비스가 중지되었습니다! -echo ============================================ -echo. -echo 서비스 상태 확인: -echo docker ps -echo. -echo 서비스 시작: -echo start-all-separated.bat -echo. -echo ============================================ - -pause diff --git a/test-backend-build.bat b/test-backend-build.bat deleted file mode 100644 index dad4aaee..00000000 --- a/test-backend-build.bat +++ /dev/null @@ -1,47 +0,0 @@ -@echo off -chcp 65001 >nul - -REM 스크립트가 있는 디렉토리로 이동 -cd /d "%~dp0" - -echo ============================================ -echo 백엔드 빌드 테스트 (Windows 전용) -echo ============================================ -echo. - -echo 🔍 기존 컨테이너 정리 중... -docker-compose -f docker-compose.backend.win.yml down -v 2>nul - -echo. -echo 🚀 백엔드 빌드 시작... -docker-compose -f docker-compose.backend.win.yml build --no-cache - -if %errorlevel% equ 0 ( - echo. - echo ✅ 백엔드 빌드 성공! - echo. - echo 🚀 백엔드 시작 중... - docker-compose -f docker-compose.backend.win.yml up -d - - if %errorlevel% equ 0 ( - echo ✅ 백엔드 시작 완료! - echo. - echo 📊 컨테이너 상태: - docker-compose -f docker-compose.backend.win.yml ps - echo. - echo 📋 로그 확인: - echo docker-compose -f docker-compose.backend.win.yml logs -f - echo. - echo 🌐 헬스체크: - echo http://localhost:8080/health - ) else ( - echo ❌ 백엔드 시작 실패! - echo 로그를 확인하세요: docker-compose -f docker-compose.backend.win.yml logs - ) -) else ( - echo ❌ 백엔드 빌드 실패! - echo 위의 오류 메시지를 확인하세요. -) - -echo. -pause
좌측에서 작업항목을 선택하세요
{detail.content || "-"}
{detail.remark}
등록된 항목이 없습니다
왼쪽에서 항목을 선택하세요
+ 상세 항목이 없습니다. "상세 추가" 버튼을 클릭하여 추가하세요. +
작업수량 / 불량수량 / 양품수량
실적 입력 항목이 자동으로 생성됩니다.
BOM 구성 자재 (자동 연동)
품목에 등록된 BOM 구성 자재가 자동으로 적용됩니다.