From a238ba36231e35f1be5c4803bb38cd6deed5490b Mon Sep 17 00:00:00 2001 From: Johngreen Date: Fri, 20 Mar 2026 13:56:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20V2=20WebView=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20+=20SSO=20=EC=97=B0=EB=8F=99=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V2WebView 컴포넌트: iframe 기반 외부 웹 임베딩 - SSO 연동: 현재 로그인 JWT를 sso_token 파라미터로 자동 전달 - /api/system/raw-token: 범용 JWT 토큰 조회 API - V2WebViewConfigPanel: URL, SSO, sandbox 등 설정 UI + 개발자 가이드 Made-with: Cursor --- .omc/project-memory.json | 328 +++++++++++++ .../591d357c-df9d-4bbc-8dfa-1b98a9184e23.json | 8 + .omc/state/hud-state.json | 6 + .omc/state/hud-stdin-cache.json | 1 + .omc/state/idle-notif-cooldown.json | 3 + docs/AI_화면생성_시스템_설계서.md | 451 ++++++++++++++++++ frontend/app/api/fleet-sso/token/route.ts | 66 +++ frontend/app/api/system/raw-token/route.ts | 18 + .../v2/config-panels/V2WebViewConfigPanel.tsx | 236 +++++++++ frontend/lib/registry/components/index.ts | 1 + .../v2-web-view/V2WebViewComponent.tsx | 195 ++++++++ .../v2-web-view/V2WebViewRenderer.tsx | 16 + .../registry/components/v2-web-view/index.ts | 34 ++ .../registry/components/v2-web-view/types.ts | 13 + frontend/package-lock.json | 42 +- 15 files changed, 1381 insertions(+), 37 deletions(-) create mode 100644 .omc/project-memory.json create mode 100644 .omc/sessions/591d357c-df9d-4bbc-8dfa-1b98a9184e23.json create mode 100644 .omc/state/hud-state.json create mode 100644 .omc/state/hud-stdin-cache.json create mode 100644 .omc/state/idle-notif-cooldown.json create mode 100644 docs/AI_화면생성_시스템_설계서.md create mode 100644 frontend/app/api/fleet-sso/token/route.ts create mode 100644 frontend/app/api/system/raw-token/route.ts create mode 100644 frontend/components/v2/config-panels/V2WebViewConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-web-view/V2WebViewComponent.tsx create mode 100644 frontend/lib/registry/components/v2-web-view/V2WebViewRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-web-view/index.ts create mode 100644 frontend/lib/registry/components/v2-web-view/types.ts diff --git a/.omc/project-memory.json b/.omc/project-memory.json new file mode 100644 index 00000000..9a424223 --- /dev/null +++ b/.omc/project-memory.json @@ -0,0 +1,328 @@ +{ + "version": "1.0.0", + "lastScanned": 1772609393905, + "projectRoot": "/Users/johngreen/Dev/vexplor", + "techStack": { + "languages": [ + { + "name": "JavaScript/TypeScript", + "version": null, + "confidence": "high", + "markers": [ + "package.json" + ] + } + ], + "frameworks": [], + "packageManager": "npm", + "runtime": null + }, + "build": { + "buildCommand": null, + "testCommand": null, + "lintCommand": null, + "devCommand": null, + "scripts": {} + }, + "conventions": { + "namingStyle": null, + "importStyle": null, + "testPattern": null, + "fileOrganization": "type-based" + }, + "structure": { + "isMonorepo": false, + "workspaces": [], + "mainDirectories": [ + "docs", + "lib", + "scripts", + "src" + ], + "gitBranches": { + "defaultBranch": "main", + "branchingStrategy": null + } + }, + "customNotes": [], + "directoryMap": { + "WebContent": { + "path": "WebContent", + "purpose": null, + "fileCount": 5, + "lastAccessed": 1772609393856, + "keyFiles": [ + "init.jsp", + "init_jqGrid.jsp", + "init_no_login.jsp", + "init_toastGrid.jsp", + "viewImage.jsp" + ] + }, + "backend": { + "path": "backend", + "purpose": null, + "fileCount": 6, + "lastAccessed": 1772609393857, + "keyFiles": [ + "Dockerfile", + "Dockerfile.mac", + "build.gradle", + "gradlew", + "gradlew.bat" + ] + }, + "backend-node": { + "path": "backend-node", + "purpose": null, + "fileCount": 14, + "lastAccessed": 1772609393872, + "keyFiles": [ + "API_연동_가이드.md", + "API_키_정리.md", + "Dockerfile.win", + "PHASE1_USAGE_GUIDE.md", + "README.md" + ] + }, + "db": { + "path": "db", + "purpose": null, + "fileCount": 2, + "lastAccessed": 1772609393873, + "keyFiles": [ + "00-create-roles.sh", + "migrate_company13_export.sh" + ] + }, + "deploy": { + "path": "deploy", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1772609393873, + "keyFiles": [] + }, + "docker": { + "path": "docker", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1772609393873, + "keyFiles": [] + }, + "docs": { + "path": "docs", + "purpose": "Documentation", + "fileCount": 23, + "lastAccessed": 1772609393873, + "keyFiles": [ + "AI_화면생성_시스템_설계서.md", + "DB_ARCHITECTURE_ANALYSIS.md", + "DB_STRUCTURE_DIAGRAM.html", + "DB_WORKFLOW_ANALYSIS.md", + "KUBERNETES_DEPLOYMENT_GUIDE.md" + ] + }, + "frontend": { + "path": "frontend", + "purpose": null, + "fileCount": 14, + "lastAccessed": 1772609393873, + "keyFiles": [ + "MODAL_REPEATER_TABLE_DEBUG.md", + "README.md", + "components.json", + "eslint.config.mjs", + "middleware.ts" + ] + }, + "hooks": { + "path": "hooks", + "purpose": null, + "fileCount": 1, + "lastAccessed": 1772609393879, + "keyFiles": [ + "useScreenStandards.ts" + ] + }, + "k8s": { + "path": "k8s", + "purpose": null, + "fileCount": 7, + "lastAccessed": 1772609393882, + "keyFiles": [ + "local-path-provisioner.yaml", + "namespace.yaml", + "vexplor-backend-deployment.yaml", + "vexplor-config.yaml", + "vexplor-frontend-deployment.yaml" + ] + }, + "lib": { + "path": "lib", + "purpose": "Library code", + "fileCount": 0, + "lastAccessed": 1772609393883, + "keyFiles": [] + }, + "mcp-agent-orchestrator": { + "path": "mcp-agent-orchestrator", + "purpose": null, + "fileCount": 4, + "lastAccessed": 1772609393883, + "keyFiles": [ + "README.md", + "package-lock.json", + "package.json", + "tsconfig.json" + ] + }, + "popdocs": { + "path": "popdocs", + "purpose": null, + "fileCount": 12, + "lastAccessed": 1772609393884, + "keyFiles": [ + "ARCHITECTURE.md", + "CHANGELOG.md", + "FILES.md", + "INDEX.md", + "PLAN.md" + ] + }, + "scripts": { + "path": "scripts", + "purpose": "Build/utility scripts", + "fileCount": 2, + "lastAccessed": 1772609393884, + "keyFiles": [ + "add-modal-ids.py", + "remove-logs.js" + ] + }, + "src": { + "path": "src", + "purpose": "Source code", + "fileCount": 0, + "lastAccessed": 1772609393884, + "keyFiles": [] + }, + "tomcat-conf": { + "path": "tomcat-conf", + "purpose": null, + "fileCount": 1, + "lastAccessed": 1772609393884, + "keyFiles": [ + "context.xml" + ] + }, + "backend/build": { + "path": "backend/build", + "purpose": "Build output", + "fileCount": 0, + "lastAccessed": 1772609393884, + "keyFiles": [] + }, + "backend/src": { + "path": "backend/src", + "purpose": "Source code", + "fileCount": 0, + "lastAccessed": 1772609393884, + "keyFiles": [] + }, + "backend-node/data": { + "path": "backend-node/data", + "purpose": "Data files", + "fileCount": 0, + "lastAccessed": 1772609393884, + "keyFiles": [] + }, + "db/migrations": { + "path": "db/migrations", + "purpose": "Database migrations", + "fileCount": 16, + "lastAccessed": 1772609393884, + "keyFiles": [ + "046_MIGRATION_FIX.md", + "046_QUICK_FIX.md", + "README_1003.md" + ] + }, + "db/scripts": { + "path": "db/scripts", + "purpose": "Build/utility scripts", + "fileCount": 1, + "lastAccessed": 1772609393884, + "keyFiles": [ + "README_cleanup.md" + ] + }, + "frontend/app": { + "path": "frontend/app", + "purpose": "Application code", + "fileCount": 5, + "lastAccessed": 1772609393885, + "keyFiles": [ + "favicon.ico", + "globals.css", + "layout.tsx" + ] + }, + "frontend/components": { + "path": "frontend/components", + "purpose": "UI components", + "fileCount": 1, + "lastAccessed": 1772609393885, + "keyFiles": [ + "GlobalFileViewer.tsx" + ] + }, + "mcp-agent-orchestrator/src": { + "path": "mcp-agent-orchestrator/src", + "purpose": "Source code", + "fileCount": 1, + "lastAccessed": 1772609393885, + "keyFiles": [ + "index.ts" + ] + }, + "src/controllers": { + "path": "src/controllers", + "purpose": "Controllers", + "fileCount": 1, + "lastAccessed": 1772609393885, + "keyFiles": [ + "dataflowDiagramController.ts" + ] + }, + "src/routes": { + "path": "src/routes", + "purpose": "Route handlers", + "fileCount": 1, + "lastAccessed": 1772609393885, + "keyFiles": [ + "dataflowDiagramRoutes.ts" + ] + }, + "src/services": { + "path": "src/services", + "purpose": "Business logic services", + "fileCount": 1, + "lastAccessed": 1772609393885, + "keyFiles": [ + "dataflowDiagramService.ts" + ] + }, + "src/utils": { + "path": "src/utils", + "purpose": "Utility functions", + "fileCount": 2, + "lastAccessed": 1772609393885, + "keyFiles": [ + "databaseValidator.ts", + "queryBuilder.ts" + ] + } + }, + "hotPaths": [], + "userDirectives": [] +} \ No newline at end of file diff --git a/.omc/sessions/591d357c-df9d-4bbc-8dfa-1b98a9184e23.json b/.omc/sessions/591d357c-df9d-4bbc-8dfa-1b98a9184e23.json new file mode 100644 index 00000000..ec93e466 --- /dev/null +++ b/.omc/sessions/591d357c-df9d-4bbc-8dfa-1b98a9184e23.json @@ -0,0 +1,8 @@ +{ + "session_id": "591d357c-df9d-4bbc-8dfa-1b98a9184e23", + "ended_at": "2026-03-04T08:10:16.810Z", + "reason": "other", + "agents_spawned": 0, + "agents_completed": 0, + "modes_used": [] +} \ No newline at end of file diff --git a/.omc/state/hud-state.json b/.omc/state/hud-state.json new file mode 100644 index 00000000..5fbc9b8f --- /dev/null +++ b/.omc/state/hud-state.json @@ -0,0 +1,6 @@ +{ + "timestamp": "2026-03-04T07:29:57.315Z", + "backgroundTasks": [], + "sessionStartTimestamp": "2026-03-04T07:29:53.176Z", + "sessionId": "591d357c-df9d-4bbc-8dfa-1b98a9184e23" +} \ No newline at end of file diff --git a/.omc/state/hud-stdin-cache.json b/.omc/state/hud-stdin-cache.json new file mode 100644 index 00000000..d5a8e668 --- /dev/null +++ b/.omc/state/hud-stdin-cache.json @@ -0,0 +1 @@ +{"session_id":"591d357c-df9d-4bbc-8dfa-1b98a9184e23","transcript_path":"/Users/johngreen/.claude/projects/-Users-johngreen-Dev-vexplor/591d357c-df9d-4bbc-8dfa-1b98a9184e23.jsonl","cwd":"/Users/johngreen/Dev/vexplor","model":{"id":"claude-opus-4-6","display_name":"Opus 4.6"},"workspace":{"current_dir":"/Users/johngreen/Dev/vexplor","project_dir":"/Users/johngreen/Dev/vexplor","added_dirs":[]},"version":"2.1.66","output_style":{"name":"default"},"cost":{"total_cost_usd":0.516748,"total_duration_ms":65256,"total_api_duration_ms":28107,"total_lines_added":0,"total_lines_removed":0},"context_window":{"total_input_tokens":604,"total_output_tokens":838,"context_window_size":200000,"current_usage":{"input_tokens":1,"output_tokens":277,"cache_creation_input_tokens":1836,"cache_read_input_tokens":55498},"used_percentage":29,"remaining_percentage":71},"exceeds_200k_tokens":false} \ No newline at end of file diff --git a/.omc/state/idle-notif-cooldown.json b/.omc/state/idle-notif-cooldown.json new file mode 100644 index 00000000..84ff7ebe --- /dev/null +++ b/.omc/state/idle-notif-cooldown.json @@ -0,0 +1,3 @@ +{ + "lastSentAt": "2026-03-04T07:30:30.883Z" +} \ No newline at end of file diff --git a/docs/AI_화면생성_시스템_설계서.md b/docs/AI_화면생성_시스템_설계서.md new file mode 100644 index 00000000..b7873615 --- /dev/null +++ b/docs/AI_화면생성_시스템_설계서.md @@ -0,0 +1,451 @@ +# AI 기반 화면 자동 생성 시스템 설계서 + +## 1. 개요 + +### 1.1 목표 + +사용자가 자연어로 "자재입고페이지 만들어줘"라고 요청하면, AI가 자동으로 완성된 화면과 워크플로우를 생성 + +### 1.2 핵심 가치 + +- **제로 코딩**: 대화만으로 화면 생성 +- **워크플로우 포함**: 저장 시 재고 업데이트 등 자동 설정 +- **기존 시스템 활용**: 새 인프라 최소화, 기존 API 재사용 + +### 1.3 예상 시나리오 + +``` +사용자: "자재입고페이지 만들어줘" + +AI: "입고관리 화면 41개를 분석했습니다. + + ✅ 자동 적용 (90%+ 사용) + - 레이아웃: 분할화면 (좌: 목록, 우: 상세) + - 필수 필드: 입고번호, 입고일자, 품목코드, 수량 + + 💡 추천 기능 (60% 사용) + - 저장 시 재고 자동 증가 + [추가하기] + + ➕ 선택 필드 + [ ] 창고 (68%) [ ] 공급업체 (61%) + + [화면 생성하기]" + +사용자: "재고 연동 추가하고, 창고 필드도 넣어줘" + +AI: "화면을 생성했습니다. + - 화면명: 자재 입고관리 + - 레이아웃: 분할화면 + - 필드: 입고번호, 입고일자, 품목, 수량, 창고 + - 워크플로우: 저장 시 inventory_stock +qty + + [미리보기] [메뉴에 추가]" +``` + +--- + +## 2. 시스템 아키텍처 + +``` + ┌───────────────────────────────────────────────────────────────┬───────────────────────────────┐ +│ 신규 개발 (AI 담당) │ 기존 드래그앤드랍 시스템 │ +│ ┌─────────────┐ ┌──────────────────────────────┐ │ ┌─────────────────────┐ │ +│ │ Chat UI │────▶│ AI 서비스 │ │ │ D&D UI Builder │ │ +│ └─────────────┘ │ • LLM 호출 (Claude/GPT) │ │ └─────────┬──────────┘ │ +│ │ • 패턴 분석 + RAG 검색 │ │ │ │ +│ │ • JSON 생성 │ │ ▼ │ +│ └──────────────┬──────────────┘ │ ┌─────────────────────┐ │ +└─────────────────────────────────────┼─────────────────────---──┴─┼─────────────────────┬─────┘ + │ 기존 API 호출 │ 기존 API 호출 │ + ▼ ┼─────────────────────┘ + ▼ +┌-------------------------------------------------------------------------------┐ +│ 기존 시스템 (vexplor) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Screen API │ │ Flow API │ │ Dataflow API│ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ │ +│ ┌──────┴──────┐ │ +│ │ PostgreSQL │ │ +│ └─────────────┘ │ +└-------------------------------------------------------------------------------┘ +``` + +### 설계 원칙 + +| 원칙 | 설명 | +| --------------------- | --------------------------------- | +| 기존 코드 변경 최소화 | 기존 백엔드/프론트 수정 없음 | +| 기존 API 재사용 | AI가 기존 API를 "사용자처럼" 호출 | +| RAG 기반 지식 주입 | 필요한 패턴만 동적으로 LLM에 주입 | +| **Assistive AI** | AI는 결정하지 않고, 사용자의 결정을 돕는다 | + +--- + +## 3. AI가 화면을 "알아서" 만드는 방법 + +### 3.1 지식 소스 + +| 데이터 | 테이블 | 활용 | +| --------------- | ---------------------------------- | ------------------- | +| 기존 화면 | screen_definitions, screen_layouts | 패턴 학습 | +| 테이블 라벨 | table_labels | 테이블 검색 | +| 컬럼 라벨 | column_labels | 필드→컴포넌트 매핑 | +| 워크플로우 패턴 | workflow_patterns (신규) | 비즈니스 로직 | + +### 3.2 통계 기반 패턴 분석 + +> **핵심 아이디어**: 사용자들이 이미 만든 화면들을 분석해서 "입고 화면은 보통 이렇게 생겼더라"를 알아내는 것 + +#### 예시: "입고페이지 만들어줘" 요청 시 + +**Step 1. 테이블 찾기** + +```sql +-- "입고"라는 단어로 table_labels 검색 +SELECT table_name, table_label FROM table_labels +WHERE table_label LIKE '%입고%'; + +-- 결과: inbound_mng (입고관리) +``` + +**Step 2. 이 테이블을 쓰는 기존 화면 찾기** + +```sql +-- inbound_mng 테이블을 사용하는 화면들 조회 +SELECT * FROM screen_definitions +WHERE table_name = 'inbound_mng'; + +-- 결과: 41개 화면 발견! +``` + +**Step 3. 41개 화면의 "공통점" 분석** + +레이아웃 통계: + +| 레이아웃 | 개수 | 비율 | +| -------------------------------- | ----- | -------- | +| split-panel (좌: 목록, 우: 상세) | 32개 | **78%** | +| table-list (목록만) | 6개 | 15% | +| form (폼만) | 3개 | 7% | + +필드 사용 통계: + +| 필드 | 사용 화면 수 | 비율 | +| ---------------------- | ------------ | -------- | +| inbound_no (입고번호) | 41개 | **100%** | +| inbound_date (입고일자)| 41개 | **100%** | +| item_code (품목코드) | 40개 | **98%** | +| qty (수량) | 39개 | **95%** | +| warehouse_code (창고) | 28개 | 68% | +| supplier_code (공급업체)| 25개 | 61% | + +**Step 4. 확신도(Confidence)에 따라 다르게 처리** + +AI는 통계 결과의 확신도에 따라 행동을 다르게 합니다: + +| 확신도 | 기준 | AI 행동 | 예시 | +| ------ | ---- | ------- | ---- | +| **높음** | 90%+ | 자동 적용 | 필수 필드(입고번호, 일자) 자동 추가 | +| **중간** | 60~90% | 추천하며 확인 | "분할화면으로 만들까요? (78% 사용)" | +| **낮음** | 60% 미만 | 옵션 나열 | "창고 필드 추가할까요? (68%)" | + +``` +AI 판단 예시: +- 레이아웃: split-panel (78%) → "분할화면으로 생성합니다" +- 필수 필드: 입고번호, 입고일자 (100%) → 자동 추가 +- 워크플로우: 입고→재고 (60%) → "💡 재고 자동 연동 추가할까요?" +- 선택 필드: 창고 (68%) → "추가 필드: [ ] 창고 [ ] 공급업체" +``` + +**핵심**: 확실한 것은 빠르게 처리하고, 애매한 것은 사용자에게 물어본다. + +#### 비유: "맛집 추천 AI"와 같은 원리 + +| 맛집 추천 AI | 화면 생성 AI | +| ------------ | ------------ | +| "강남에서 점심 뭐 먹지?" | "입고페이지 만들어줘" | +| 강남 식당 1000개 리뷰 분석 | 기존 입고 화면 41개 분석 | +| "70%가 파스타집, 평균 1.5만원" | "78%가 분할화면, 100%가 입고번호 사용" | +| "파스타집 추천드릴까요?" | "분할화면으로 만들까요?" | + +### 3.3 RAG 기반 동적 지식 주입 (핵심) + +> **문제**: 모든 도메인 지식을 프롬프트에 넣으면 Context Window 초과 + +> **해결**: 필요한 지식만 검색해서 동적 주입 + +``` +사용자: "입고페이지 만들어줘" + ↓ +1. 키워드 추출: "입고" + ↓ +2. workflow_patterns 검색 → "입고→재고 증가" 패턴 발견 + ↓ +3. LLM 프롬프트에 해당 패턴만 주입 + ↓ +4. 재고 증가 로직 포함된 화면 생성 +``` + +#### 장점 + +| 장점 | 설명 | +| ------------- | ---------------------------------- | +| 토큰 절약 | 관련 패턴 1-2개만 주입 | +| 확장성 | 패턴 1000개여도 프롬프트 길이 동일 | +| 회사별 커스텀 | company_code로 회사별 패턴 적용 | + +### 3.4 멀티테넌트 & Fallback 전략 + +회사마다 테이블명이 다르거나, 신규 회사라 기존 화면이 없을 수 있습니다. + +**데이터 검색 우선순위:** + +``` +1순위: 해당 회사의 기존 화면 (가장 정확) + ↓ 없거나 부족하면 +2순위: 전체 회사의 익명화된 통계 (company_code 제외, 패턴만) + ↓ 그래도 부족하면 +3순위: vexplor 표준 템플릿 (기본 레이아웃 + 필수 필드) +``` + +**여러 테이블이 검색될 때:** + +``` +사용자: "입고페이지 만들어줘" + +AI: "입고 관련 테이블이 3개 있습니다: + 1. 자재입고관리 (material_inbound) + 2. 제품입고관리 (product_inbound) + 3. 반품입고관리 (return_inbound) + + 어떤 테이블로 만들까요?" +``` + +### 3.5 기존 시스템 분석 결과 + +**발견된 학습 가능 데이터:** + +- `transferData` 액션: 14개 (발주→입고, 수주→출고 등) +- 제어관리 프레임워크: `dataflowControlService.ts` 존재 +- 입고→재고 규칙: 아직 정의되지 않음 → AI가 생성하면 됨 + +--- + +## 4. 개발 범위 + +### 4.1 AI 담당 (신규) + +| 작업 | 파일 | 우선순위 | +| ------------------------------------ | ----------------------------- | ------------ | +| AI 채팅 API | `aiRoutes.ts` | P0 | +| 화면 패턴 분석 | `screenAnalyzer.ts` | P0 | +| LLM 호출 | `llmService.ts` | P0 | +| **워크플로우 패턴 검색 (RAG)** | `workflowPatternService.ts` | **P0** | +| 채팅 UI | `AIChatPanel.tsx` | P0 | +| **workflow_patterns 테이블** | DB | **P0** | + +```typescript +// 패턴 검색 핵심 로직 +export async function searchWorkflowPatterns(userIntent: string, companyCode: string) { + const keywords = extractKeywords(userIntent); + + return await query(` + SELECT * FROM workflow_patterns + WHERE intent_keywords && $1::text[] + AND (company_code = $2 OR company_code = '*') + ORDER BY priority DESC + LIMIT 3 + `, [keywords, companyCode]); +} +``` + +### 4.2 vexplor 담당 (기존 보완) + +| 작업 | 현재 상태 | 필요 작업 | +| -------------- | --------- | ------------------- | +| 화면 생성 API | ✅ 존재 | 문서화 | +| 워크플로우 API | ✅ 존재 | 문서화 | +| 제어관리 API | ✅ 존재 | AI 활용 가능 | +| table_labels | 부분 존재 | 주요 테이블 한글 라벨 | +| column_labels | 부분 존재 | web_type 보완 | + +#### 4.2.1 table_labels가 필요한 이유 + +AI가 "입고"라는 단어로 테이블을 찾으려면 한글 라벨이 있어야 합니다. + +**현재 상태:** + +| table_name | table_label | AI 검색 | +| ---------- | ----------- | ------- | +| inbound_mng | **입고관리** | ✅ "입고" 검색 가능 | +| outbound_mng | **출고관리** | ✅ "출고" 검색 가능 | +| production_record | production_record | ❌ "생산" 검색 불가 | +| purchase_order_master | purchase_order_master | ❌ "발주" 검색 불가 | + +**필요 작업**: 주요 업무 테이블에 한글 라벨 추가 + +```sql +UPDATE table_labels SET table_label = '생산실적' WHERE table_name = 'production_record'; +UPDATE table_labels SET table_label = '발주관리' WHERE table_name = 'purchase_order_master'; +``` + +#### 4.2.2 column_labels의 web_type이 필요한 이유 + +AI가 컬럼을 보고 **어떤 컴포넌트를 생성할지** 결정해야 합니다. + +**현재 상태** (inbound_mng): + +| column_name | column_label | web_type | +| ----------- | ------------ | -------- | +| inbound_date | 입고일 | **null** | +| inbound_qty | 입고수량 | **null** | +| item_code | 품목코드 | **null** | + +**web_type이 null이면?** → AI가 모든 필드를 text-input으로 만들어버림 + +**web_type이 있으면:** + +| column_name | web_type | AI가 생성할 컴포넌트 | +| ----------- | -------- | ------------------- | +| inbound_date | **date** | 📅 날짜 선택기 | +| inbound_qty | **number** | 🔢 숫자 입력 | +| item_code | **entity** | 🔍 품목 검색 팝업 | +| memo | **textarea** | 📝 여러 줄 텍스트 | + +**필요 작업**: 주요 테이블 컬럼에 web_type 추가 + +```sql +UPDATE column_labels SET web_type = 'date' WHERE column_name LIKE '%_date'; +UPDATE column_labels SET web_type = 'number' WHERE column_name LIKE '%_qty'; +UPDATE column_labels SET web_type = 'entity' WHERE column_name LIKE '%_code' AND column_name != 'company_code'; +UPDATE column_labels SET web_type = 'textarea' WHERE column_name = 'memo'; +``` + +--- + +## 5. 데이터베이스 스키마 (AI 전용) + +```sql +-- 핵심: 워크플로우 패턴 (RAG 지식 베이스) +CREATE TABLE workflow_patterns ( + pattern_id SERIAL PRIMARY KEY, + category VARCHAR(50) NOT NULL, -- 'inventory', 'sales' + pattern_name VARCHAR(200) NOT NULL, -- '입고→재고 증가' + intent_keywords TEXT[] NOT NULL, -- ['입고', '자재입고', 'inbound'] + description TEXT, + source_table_hint VARCHAR(100), -- 'inbound_mng' + target_table_hint VARCHAR(100), -- 'inventory_stock' + logic_template JSONB NOT NULL, -- 제어관리 생성 템플릿 + company_code VARCHAR(20) DEFAULT '*', + priority INTEGER DEFAULT 100, + is_active BOOLEAN DEFAULT true +); + +CREATE INDEX idx_workflow_patterns_keywords ON workflow_patterns USING GIN(intent_keywords); + +-- 초기 데이터 +INSERT INTO workflow_patterns (category, pattern_name, intent_keywords, description, source_table_hint, target_table_hint, logic_template) VALUES +('inventory', '입고→재고 증가', + ARRAY['입고', '구매입고', '자재입고', 'inbound'], + '입고 저장 시 재고 수량 증가', + 'inbound_mng', 'inventory_stock', + '{"actionType": "upsert", "operation": "increment", "fieldMappings": [{"source": "item_code", "target": "item_code", "type": "key"}, {"source": "inbound_qty", "target": "qty", "type": "increment"}]}'::jsonb +), +('inventory', '출고→재고 감소', + ARRAY['출고', '판매출고', 'outbound'], + '출고 저장 시 재고 수량 감소', + 'outbound_mng', 'inventory_stock', + '{"actionType": "update", "operation": "decrement", "fieldMappings": [{"source": "item_code", "target": "item_code", "type": "key"}, {"source": "outbound_qty", "target": "qty", "type": "decrement"}]}'::jsonb +); + +-- 동의어 매핑 (P1) +CREATE TABLE keyword_mapping ( + id SERIAL PRIMARY KEY, + keyword VARCHAR(100) NOT NULL, + table_name VARCHAR(100) NOT NULL, + company_code VARCHAR(20) DEFAULT '*' +); + +-- AI 대화 이력 (P2) +CREATE TABLE ai_conversations ( + id SERIAL PRIMARY KEY, + session_id VARCHAR(100) NOT NULL, + company_code VARCHAR(20) NOT NULL, + user_id VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE ai_messages ( + id SERIAL PRIMARY KEY, + conversation_id INTEGER REFERENCES ai_conversations(id), + role VARCHAR(20) NOT NULL, + content TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +--- + +## 6. 개발 로드맵 + +### Phase 1: MVP + +**AI:** + +- 채팅 UI + LLM 연동 +- 화면 패턴 분석 +- workflow_patterns 테이블 + RAG 검색 + +**vexplor:** + +- API 스펙 문서화 (screen, flow) +- 주요 테이블 한글 라벨 20개 + +### Phase 2: 워크플로우 + +**AI:** + +- 자연어 → 워크플로우 변환 +- dataflow_diagrams 자동 생성 + +**vexplor:** + +- 워크플로우 API 문서화 + +### Phase 3: 고도화 + +- 대화형 수정 ("왼쪽 패널 넓혀줘") +- 멀티턴 컨텍스트 +- 사용자 피드백 학습 + +--- + +## 7. vexplor 체크리스트 + +### 즉시 (P0) + +- [ ] POST /api/screen/create 스펙 +- [ ] POST /api/flow/definitions 스펙 +- [ ] 화면 레이아웃 JSON 예시 + +### 1주 내 (P1) + +- [ ] 주요 테이블 20개 한글 라벨 + - inbound_mng, outbound_mng, inventory_stock + - item_info, customer_mng, supplier_mng + - sales_order_mng, purchase_order_mng +- [ ] column_labels web_type 보완 + +--- + +## 8. 성공 지표 + +| 지표 | 목표 | +| -------------------- | --------- | +| 화면 생성 성공률 | 90%+ | +| 평균 생성 시간 | 10초 이내 | +| 수정 없이 사용 | 70%+ | +| 워크플로우 자동 연결 | 80%+ | diff --git a/frontend/app/api/fleet-sso/token/route.ts b/frontend/app/api/fleet-sso/token/route.ts new file mode 100644 index 00000000..0dbfd656 --- /dev/null +++ b/frontend/app/api/fleet-sso/token/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; +import { cookies } from "next/headers"; + +const FLEET_API_URL = process.env.FLEET_API_URL || "https://fleet-api.vexplor.com"; +const SSO_SHARED_SECRET = process.env.SSO_SHARED_SECRET || "change_this_sso_secret"; + +export async function POST(request: NextRequest) { + try { + // V1 로그인 세션에서 사용자 정보 추출 + const cookieStore = await cookies(); + const sessionToken = cookieStore.get("session_token")?.value + || cookieStore.get("token")?.value; + + if (!sessionToken) { + return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); + } + + // 세션에서 사용자 정보 파싱 (JWT 디코딩) + let userId = "unknown"; + let userName = "unknown"; + let companyId = ""; + try { + const payload = JSON.parse(atob(sessionToken.split(".")[1])); + userId = payload.userId || payload.user_id || payload.sub || payload.id || "unknown"; + userName = payload.userName || payload.user_name || payload.name || "unknown"; + companyId = payload.companyId || payload.company_id || payload.companyCode || ""; + } catch { + return NextResponse.json({ error: "세션이 유효하지 않습니다." }, { status: 401 }); + } + + // Fleet API로 SSO 토큰 요청 (HMAC 서명) + const timestamp = Math.floor(Date.now() / 1000); + const signPayload = `${userId}|${userName}|${companyId}|${timestamp}`; + const signature = crypto + .createHmac("sha256", SSO_SHARED_SECRET) + .update(signPayload) + .digest("hex"); + + const response = await fetch(`${FLEET_API_URL}/api/auth/sso`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + user_id: userId, + user_name: userName, + company_id: companyId, + timestamp, + signature, + }), + }); + + const data = await response.json(); + + if (!response.ok || !data.success) { + return NextResponse.json( + { error: data.message || "SSO 토큰 발급 실패" }, + { status: response.status }, + ); + } + + return NextResponse.json({ token: data.data.token }); + } catch (error) { + console.error("[fleet-sso] 토큰 발급 에러:", error); + return NextResponse.json({ error: "서버 오류" }, { status: 500 }); + } +} diff --git a/frontend/app/api/system/raw-token/route.ts b/frontend/app/api/system/raw-token/route.ts new file mode 100644 index 00000000..225d50d4 --- /dev/null +++ b/frontend/app/api/system/raw-token/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; + +export async function GET() { + try { + const cookieStore = await cookies(); + const token = + cookieStore.get("authToken")?.value || cookieStore.get("session_token")?.value || cookieStore.get("token")?.value; + + if (!token) { + return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); + } + + return NextResponse.json({ token }); + } catch { + return NextResponse.json({ error: "서버 오류" }, { status: 500 }); + } +} diff --git a/frontend/components/v2/config-panels/V2WebViewConfigPanel.tsx b/frontend/components/v2/config-panels/V2WebViewConfigPanel.tsx new file mode 100644 index 00000000..a193b139 --- /dev/null +++ b/frontend/components/v2/config-panels/V2WebViewConfigPanel.tsx @@ -0,0 +1,236 @@ +"use client"; + +import React, { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Globe, Shield, Settings, ChevronDown, Info, Copy, Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { V2WebViewConfig } from "@/lib/registry/components/v2-web-view/types"; + +interface V2WebViewConfigPanelProps { + config: V2WebViewConfig; + onChange: (config: Partial) => void; +} + +const SSO_GUIDE_SNIPPET = `// URL에서 sso_token 파라미터를 읽어 JWT를 디코딩하세요 +const token = url.searchParams.get("sso_token"); +const payload = JSON.parse(atob(token.split(".")[1])); +// payload.userId, payload.userName, payload.companyCode`; + +export const V2WebViewConfigPanel: React.FC = ({ config, onChange }) => { + const [advancedOpen, setAdvancedOpen] = useState(false); + const [guideOpen, setGuideOpen] = useState(false); + const [copied, setCopied] = useState(false); + + const handleCopySnippet = () => { + navigator.clipboard.writeText(SSO_GUIDE_SNIPPET).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + const updateConfig = (field: keyof V2WebViewConfig, value: any) => { + const newConfig = { ...config, [field]: value }; + onChange({ [field]: value }); + + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("componentConfigChanged", { + detail: { config: newConfig }, + }), + ); + } + }; + + return ( +
+ {/* ─── 1단계: URL 입력 ─── */} +
+
+ +

웹페이지 URL

+
+ updateConfig("url", e.target.value)} + placeholder="https://example.com" + className="h-8 text-sm" + /> +

임베드할 외부 웹페이지 주소를 입력하세요

+
+ + {/* ─── 2단계: SSO 연동 ─── */} +
+
+
+ +
+

SSO 연동

+

현재 로그인 토큰을 URL에 자동 전달해요

+
+
+ updateConfig("useSSO", checked)} /> +
+ + {config.useSSO && ( + + + + + +
+
+

전달 방식

+

URL 쿼리 파라미터로 JWT가 전달됩니다.

+ ?sso_token=eyJhbGciOi... +
+ +
+

JWT Payload 구조

+
+
+ userId + 사용자 ID +
+
+ userName + 사용자 이름 +
+
+ companyCode + 회사 코드 +
+
+ role + 권한 +
+
+
+ +
+
+

수신측 예시 코드

+ +
+
+                    {`const token = url.searchParams
+  .get("sso_token");
+const payload = JSON.parse(
+  atob(token.split(".")[1])
+);
+// payload.userId
+// payload.companyCode`}
+                  
+
+
+
+
+ )} +
+ + {/* ─── 3단계: 표시 옵션 ─── */} +
+
+
+

테두리 표시

+

웹 뷰 주변에 테두리를 표시해요

+
+ updateConfig("showBorder", checked)} + /> +
+ +
+
+

전체 화면 허용

+

임베드된 페이지에서 전체 화면 전환이 가능해요

+
+ updateConfig("allowFullscreen", checked)} + /> +
+
+ + {/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */} + + + + + +
+
+
+

샌드박스 모드

+

보안을 위해 iframe 실행 환경을 제한해요

+
+ updateConfig("sandbox", checked)} + /> +
+ +
+ 모서리 둥글기 + updateConfig("borderRadius", e.target.value)} + placeholder="8px" + className="h-7 w-[100px] text-xs" + /> +
+ +
+ 로딩 텍스트 + updateConfig("loadingText", e.target.value)} + placeholder="로딩 중..." + className="h-7 w-[140px] text-xs" + /> +
+
+
+
+
+ ); +}; + +V2WebViewConfigPanel.displayName = "V2WebViewConfigPanel"; + +export default V2WebViewConfigPanel; diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 46e92af1..091a83a2 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -119,6 +119,7 @@ import "./v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화 import "./v2-status-count/StatusCountRenderer"; // 상태별 카운트 카드 import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준 import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅 +import "./v2-web-view/V2WebViewRenderer"; // 외부 웹페이지 임베딩 (SSO 지원) /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-web-view/V2WebViewComponent.tsx b/frontend/lib/registry/components/v2-web-view/V2WebViewComponent.tsx new file mode 100644 index 00000000..c7118beb --- /dev/null +++ b/frontend/lib/registry/components/v2-web-view/V2WebViewComponent.tsx @@ -0,0 +1,195 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { ComponentRendererProps } from "../../types"; +import { V2WebViewConfig } from "./types"; +import { filterDOMProps } from "@/lib/utils/domPropsFilter"; + +export interface V2WebViewComponentProps extends ComponentRendererProps {} + +export const V2WebViewComponent: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + onClick, + ...props +}) => { + const config = (component.componentConfig || {}) as V2WebViewConfig; + const [iframeSrc, setIframeSrc] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const iframeRef = useRef(null); + + const baseUrl = config.url ?? ""; + + useEffect(() => { + if (!baseUrl) { + setIframeSrc(null); + return; + } + + if (!config.useSSO) { + setIframeSrc(baseUrl); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + const paramName = "sso_token"; + + fetch("/api/system/raw-token") + .then((r) => r.json()) + .then((data) => { + if (cancelled) return; + if (data.token) { + const separator = baseUrl.includes("?") ? "&" : "?"; + setIframeSrc(`${baseUrl}${separator}${encodeURIComponent(paramName)}=${encodeURIComponent(data.token)}`); + } else { + setError(data.error ?? "토큰을 가져올 수 없습니다"); + } + }) + .catch(() => { + if (!cancelled) setError("토큰 조회 실패"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [baseUrl, config.useSSO]); + + const containerStyle: React.CSSProperties = { + position: "absolute", + left: `${component.style?.positionX || 0}px`, + top: `${component.style?.positionY || 0}px`, + width: `${component.style?.width || 400}px`, + height: `${component.style?.height || 300}px`, + zIndex: component.style?.positionZ || 1, + cursor: isDesignMode ? "pointer" : "default", + border: isSelected ? "2px solid #3b82f6" : config.showBorder ? "1px solid #e0e0e0" : "none", + borderRadius: config.borderRadius || "8px", + overflow: "hidden", + background: "#fafafa", + }; + + const handleClick = (e: React.MouseEvent) => { + if (isDesignMode) { + e.stopPropagation(); + onClick?.(e); + } + }; + + const domProps = filterDOMProps(props); + + // 디자인 모드: URL 미리보기 표시 + if (isDesignMode) { + return ( +
+
+ + + + + 웹 뷰 + {baseUrl ? ( + + {baseUrl} + + ) : ( + URL을 설정하세요 + )} + {config.useSSO && SSO: ?sso_token=JWT} +
+
+ ); + } + + // 런타임 모드 + return ( +
+ {loading && ( +
+ {config.loadingText || "로딩 중..."} +
+ )} + {error && ( +
+ {error} +
+ )} + {!loading && !error && iframeSrc && ( +