From ccb0c8df4c6b9d812f5c69b27f20716ad9f4b15f Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 1 Apr 2026 12:12:15 +0900 Subject: [PATCH] Add environment variable example and update .gitignore - Created a new .env.example file to provide a template for environment variables, including database connection details, JWT settings, encryption keys, and external API keys. - Updated .gitignore to include additional test output directories and archive files, ensuring that unnecessary files are not tracked by Git. - Removed outdated approval test reports and scripts that are no longer needed, streamlining the project structure. These changes improve the clarity of environment configuration and maintain a cleaner repository. --- .env.example | 30 + .gitignore | 5 + PLAN.MD | 337 ------ POPUPDATE.md | 1041 ----------------- POPUPDATE_2.md | 696 ----------- STATUS.md | 46 - approval-company7-report.txt | 33 - approval-test-report.txt | 29 - backend-node/.env.shared | 11 +- backend-node/API_연동_가이드.md | 6 +- backend-node/API_키_정리.md | 16 +- backend-node/README.md | 2 +- backend-node/README_API_SETUP.md | 8 +- backend-node/scripts/add-button-webtype.js | 52 - .../scripts/add-data-mapping-column.js | 34 - .../scripts/add-external-db-connection.ts | 10 +- backend-node/scripts/add-missing-columns.js | 105 -- .../scripts/btn-bulk-update-company7.ts | 318 ----- .../scripts/check-dashboard-structure.js | 75 -- backend-node/scripts/check-tables.js | 55 - .../scripts/create-component-table.js | 74 -- backend-node/scripts/init-layout-standards.js | 309 ----- .../scripts/install-dataflow-indexes.js | 200 ---- backend-node/scripts/list-components.js | 46 - .../scripts/migrate-input-type-to-web-type.ts | 168 --- backend-node/scripts/run-1050-migration.js | 35 - backend-node/scripts/run-migration.js | 53 - backend-node/scripts/run-notice-migration.js | 38 - backend-node/scripts/seed-templates.js | 294 ----- backend-node/scripts/seed-ui-components.js | 411 ------- backend-node/scripts/test-digital-twin-db.ts | 209 ---- .../scripts/test-template-creation.js | 121 -- backend-node/scripts/verify-migration.js | 86 -- backend-node/src/config/environment.ts | 2 +- .../controllers/workInstructionController.ts | 18 +- backend-node/src/services/riskAlertService.ts | 4 +- backend-node/src/tests/env.setup.ts | 3 +- cursor-rules-backup-20260309.tar.gz | Bin 51923 -> 0 bytes docker-compose.backend.win.yml | 8 +- docker/deploy/docker-compose.yml | 14 +- docker/dev/docker-compose.backend.mac.yml | 12 +- docker/prod/docker-compose.backend.prod.yml | 14 +- .../본서버_개발서버_마이그레이션_가이드.md | 2 +- docs/POP_화면_배포서버_마이그레이션_가이드.md | 12 +- docs/leeheejin/리스크알림_API키_발급가이드.md | 4 +- docs/leeheejin/메일관리_기능_리스트.md | 2 +- docs/leeheejin/메일관리_시스템_구현_계획서.md | 2 +- .../WorkStandardEditModal.tsx | 882 ++++++++++---- .../production/work-instruction/page.tsx | 248 ++-- .../app/(main)/COMPANY_7/sales/order/page.tsx | 7 +- .../screen/InteractiveDataTable.tsx | 121 +- frontend/lib/api/workInstruction.ts | 5 + .../BomItemEditorComponent.tsx | 36 +- .../v2-bom-tree/BomTreeComponent.tsx | 46 +- .../SplitPanelLayoutComponent.tsx | 73 +- .../v2-table-list/TableListComponent.tsx | 16 + frontend/scripts/approval-flow-test.ts | 171 --- frontend/scripts/browser-verification.ts | 111 -- .../scripts/company-menu-flow-verification.ts | 156 --- frontend/scripts/dashboard-verification.ts | 71 -- frontend/scripts/performance-test.ts | 458 -------- frontend/scripts/po-approval-company7-test.ts | 179 --- .../scripts/purchase-order-approval-test.ts | 174 --- .../scripts/screen-approval-modal-test.ts | 101 -- frontend/scripts/screen68-verification.ts | 135 --- frontend/scripts/screen94-124-verification.ts | 163 --- frontend/scripts/test-card-list-e2e.ts | 94 -- frontend/scripts/test-formdata-logs.ts | 135 --- frontend/scripts/ui-redesign-verification.ts | 112 -- .../scripts/verify-button-layout-screens.ts | 130 -- frontend/scripts/verify-overlay-buttons.ts | 166 --- frontend/scripts/verify-responsive-screens.ts | 126 -- frontend/scripts/verify-screen-1053-admin.ts | 103 -- frontend/scripts/verify-screen-1053.ts | 105 -- frontend/scripts/verify-screen-1244-layout.ts | 156 --- .../scripts/verify-screen-1244-refresh.ts | 143 --- .../scripts/verify-screen-1244-refresh2.ts | 142 --- frontend/scripts/verify-screen-1244.ts | 127 -- frontend/scripts/verify-screen-150-tapseal.ts | 162 --- frontend/scripts/verify-screen-1556.ts | 93 -- frontend/scripts/verify-screen-156.ts | 111 -- .../scripts/verify-screens-156-1053-v2.ts | 141 --- .../scripts/verify-split-panel-screens.ts | 115 -- frontend/scripts/verify-tapseal-screens.ts | 164 --- kubernetes-setup-guide.md | 305 ----- run-current-e2e.sh | 4 - run-e2e-new-spec.sh | 39 - run-e2e-smoke.sh | 5 - run-e2e-spec-test.sh | 21 - run-e2e-test.sh | 4 - run-playwright-test.sh | 4 - run-windows.bat | 45 - scripts/add-modal-ids.py | 132 --- scripts/analyze-company-info-layout.js | 106 -- scripts/browser-test-admin-switch-button.js | 170 --- scripts/browser-test-customer-crud.js | 167 --- scripts/browser-test-customer-via-menu.js | 157 --- scripts/browser-test-purchase-supplier.js | 196 ---- scripts/dev/start-all-parallel.bat | 2 +- scripts/dev/start-all-parallel.ps1 | 2 +- scripts/dev/start-all-parallel.sh | 2 +- scripts/dev/start-backend.sh | 2 +- scripts/menu-copy-automation.ts | 161 --- scripts/prod/start-all-linux.sh | 2 +- scripts/remove-logs.js | 60 - scripts/run-e2e-test.js | 151 --- scripts/test-option-settings-responsive.ts | 139 --- scripts/test-responsive-split-panel.ts | 134 --- start-all-separated.bat | 71 -- start-windows-simple.bat | 97 -- stop-all-separated.bat | 56 - test-backend-build.bat | 47 - 112 files changed, 1165 insertions(+), 11644 deletions(-) create mode 100644 .env.example delete mode 100644 PLAN.MD delete mode 100644 POPUPDATE.md delete mode 100644 POPUPDATE_2.md delete mode 100644 STATUS.md delete mode 100644 approval-company7-report.txt delete mode 100644 approval-test-report.txt delete mode 100644 backend-node/scripts/add-button-webtype.js delete mode 100644 backend-node/scripts/add-data-mapping-column.js delete mode 100644 backend-node/scripts/add-missing-columns.js delete mode 100644 backend-node/scripts/btn-bulk-update-company7.ts delete mode 100644 backend-node/scripts/check-dashboard-structure.js delete mode 100644 backend-node/scripts/check-tables.js delete mode 100644 backend-node/scripts/create-component-table.js delete mode 100644 backend-node/scripts/init-layout-standards.js delete mode 100644 backend-node/scripts/install-dataflow-indexes.js delete mode 100644 backend-node/scripts/list-components.js delete mode 100644 backend-node/scripts/migrate-input-type-to-web-type.ts delete mode 100644 backend-node/scripts/run-1050-migration.js delete mode 100644 backend-node/scripts/run-migration.js delete mode 100644 backend-node/scripts/run-notice-migration.js delete mode 100644 backend-node/scripts/seed-templates.js delete mode 100644 backend-node/scripts/seed-ui-components.js delete mode 100644 backend-node/scripts/test-digital-twin-db.ts delete mode 100644 backend-node/scripts/test-template-creation.js delete mode 100644 backend-node/scripts/verify-migration.js delete mode 100644 cursor-rules-backup-20260309.tar.gz delete mode 100644 frontend/scripts/approval-flow-test.ts delete mode 100644 frontend/scripts/browser-verification.ts delete mode 100644 frontend/scripts/company-menu-flow-verification.ts delete mode 100644 frontend/scripts/dashboard-verification.ts delete mode 100644 frontend/scripts/performance-test.ts delete mode 100644 frontend/scripts/po-approval-company7-test.ts delete mode 100644 frontend/scripts/purchase-order-approval-test.ts delete mode 100644 frontend/scripts/screen-approval-modal-test.ts delete mode 100644 frontend/scripts/screen68-verification.ts delete mode 100644 frontend/scripts/screen94-124-verification.ts delete mode 100644 frontend/scripts/test-card-list-e2e.ts delete mode 100644 frontend/scripts/test-formdata-logs.ts delete mode 100644 frontend/scripts/ui-redesign-verification.ts delete mode 100644 frontend/scripts/verify-button-layout-screens.ts delete mode 100644 frontend/scripts/verify-overlay-buttons.ts delete mode 100644 frontend/scripts/verify-responsive-screens.ts delete mode 100644 frontend/scripts/verify-screen-1053-admin.ts delete mode 100644 frontend/scripts/verify-screen-1053.ts delete mode 100644 frontend/scripts/verify-screen-1244-layout.ts delete mode 100644 frontend/scripts/verify-screen-1244-refresh.ts delete mode 100644 frontend/scripts/verify-screen-1244-refresh2.ts delete mode 100644 frontend/scripts/verify-screen-1244.ts delete mode 100644 frontend/scripts/verify-screen-150-tapseal.ts delete mode 100644 frontend/scripts/verify-screen-1556.ts delete mode 100644 frontend/scripts/verify-screen-156.ts delete mode 100644 frontend/scripts/verify-screens-156-1053-v2.ts delete mode 100644 frontend/scripts/verify-split-panel-screens.ts delete mode 100644 frontend/scripts/verify-tapseal-screens.ts delete mode 100644 kubernetes-setup-guide.md delete mode 100644 run-current-e2e.sh delete mode 100644 run-e2e-new-spec.sh delete mode 100644 run-e2e-smoke.sh delete mode 100644 run-e2e-spec-test.sh delete mode 100644 run-e2e-test.sh delete mode 100644 run-playwright-test.sh delete mode 100644 run-windows.bat delete mode 100644 scripts/add-modal-ids.py delete mode 100644 scripts/analyze-company-info-layout.js delete mode 100644 scripts/browser-test-admin-switch-button.js delete mode 100644 scripts/browser-test-customer-crud.js delete mode 100644 scripts/browser-test-customer-via-menu.js delete mode 100644 scripts/browser-test-purchase-supplier.js delete mode 100644 scripts/menu-copy-automation.ts delete mode 100644 scripts/remove-logs.js delete mode 100644 scripts/run-e2e-test.js delete mode 100644 scripts/test-option-settings-responsive.ts delete mode 100644 scripts/test-responsive-split-panel.ts delete mode 100644 start-all-separated.bat delete mode 100644 start-windows-simple.bat delete mode 100644 stop-all-separated.bat delete mode 100644 test-backend-build.bat 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 5f8eeb10b9dadcd126b4db463077213380bf09e2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51923 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�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 /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 && (