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 ( - - ); - })} -
- - {/* 작업항목 + 상세 split */} -
- {/* 좌측: 작업항목 목록 */} -
-
- 작업항목 - -
-
- {currentWorkItems.length === 0 ? ( -
작업항목이 없습니다
- ) : currentWorkItems.map((wi) => ( -
setSelectedWorkItemId(wi.id!)} - > -
-
-
{wi.title}
-
- {wi.is_required === "Y" && 필수} - 상세 {wi.details?.length || wi.detail_count || 0}건 -
-
- +
+ {/* 섹션 헤더 */} +
+
+

{phase.label}

+ + {phaseItems.length}개 항목 +
-
- ))} -
-
- - {/* 우측: 상세 목록 */} -
- {!selectedWorkItem ? ( -
- -

좌측에서 작업항목을 선택하세요

-
- ) : ( - <> -
-
- {selectedWorkItem.title} - 상세 항목 -
-
-
- {(!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 ? ( +
+ +

등록된 항목이 없습니다

- -
- ))} + ) : ( +
+ {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" ? "필수" : "선택"} + +
+
+
+ + +
+
+ ))} +
+ )} +
+ + {/* 우측: 상세 테이블 */} +
+ {!selectedWi ? ( +
+

왼쪽에서 항목을 선택하세요

+
+ ) : ( + <> + {/* 상세 헤더 */} +
+
+ {selectedWi.title} + + {selectedWi.details?.length || 0}개 + +
+ +
+ + {/* 테이블 */} +
+ + + + + + + + + + + + {(selectedWi.details || []).map((detail, idx) => ( + + + + + + + + ))} + +
순서유형내용필수관리
{idx + 1} + + {getDetailTypeLabel(detail.detail_type || "checklist")} + + {getContentSummary(detail)} + + {detail.is_required === "Y" ? "필수" : "선택"} + + +
+ + +
+
+ + {(!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({
- +
- {/* 상세 추가 다이얼로그 */} - - 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("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" + /> +
+
+ + +
+ + )} + + {/* 문서참조 */} + {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_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 && ( +
+ + +
+ )} + + {/* 비고 (모든 유형 공통) */} + {detailFormData.detail_type && ( +
+ + updateDetailField("remark", e.target.value)} + placeholder="비고 입력" + className="mt-1 h-8 text-xs" + /> +
+ )}
+ - - + +
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단계: 확인 모달 ── */} - - - - 작업지시 적용 확인 - 기본 정보를 입력하고 "최종 적용" 버튼을 눌러주세요. - -
+ + + + + + } + > +

작업지시 기본 정보

@@ -625,27 +651,24 @@ export default function WorkInstructionPage() {
-
- -
+

품목 목록

-
+
- 순번품목코드품목명규격수량비고 + + 순번 + 품목코드 + 품목명 + 규격 + 수량 + 라우팅 + 비고 + + {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))} /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> @@ -664,22 +706,22 @@ export default function WorkInstructionPage() { - - - - - - - + {/* ── 수정 모달 ── */} - - - - 작업지시 관리 - {editOrder?.work_instruction_no} - 품목을 추가/삭제하고 정보를 수정하세요. - -
+ { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }} + title={`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`} + description="품목을 추가/삭제하고 정보를 수정하세요." + footer={ + <> + + + + } + > +

기본 정보

@@ -691,64 +733,81 @@ export default function WorkInstructionPage() {
-
- -
-
- -
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))} /> + + + + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> @@ -764,12 +823,7 @@ export default function WorkInstructionPage() { )} - - - - - - + {/* 공정작업기준 수정 모달 */} 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 && (추가됨)} - - + + ); })} 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 - + @@ -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 && ( - - + {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 && ( -
{item.item_name}{item.type}{item.unit}{resolveCatLabel(item.type || "", "division", "type")}{resolveCatLabel(item.unit || "", "unit")}
데이터 1-1 데이터 1-2
+
{componentConfig.leftPanel?.showEdit !== false && (
+
{componentConfig.leftPanel?.showEdit !== false && (