feat: V2 WebView 컴포넌트 + SSO 연동 구현

- V2WebView 컴포넌트: iframe 기반 외부 웹 임베딩
- SSO 연동: 현재 로그인 JWT를 sso_token 파라미터로 자동 전달
- /api/system/raw-token: 범용 JWT 토큰 조회 API
- V2WebViewConfigPanel: URL, SSO, sandbox 등 설정 UI + 개발자 가이드

Made-with: Cursor
This commit is contained in:
Johngreen
2026-03-20 13:56:24 +09:00
parent 41a5fe5ea4
commit a238ba3623
15 changed files with 1381 additions and 37 deletions

328
.omc/project-memory.json Normal file
View File

@@ -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": []
}

View File

@@ -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": []
}

View File

@@ -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"
}

View File

@@ -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}

View File

@@ -0,0 +1,3 @@
{
"lastSentAt": "2026-03-04T07:30:30.883Z"
}

View File

@@ -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%+ |

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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<V2WebViewConfig>) => 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<V2WebViewConfigPanelProps> = ({ 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 (
<div className="space-y-4">
{/* ─── 1단계: URL 입력 ─── */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Globe className="text-primary h-4 w-4" />
<p className="text-sm font-medium"> URL</p>
</div>
<Input
value={config.url || ""}
onChange={(e) => updateConfig("url", e.target.value)}
placeholder="https://example.com"
className="h-8 text-sm"
/>
<p className="text-muted-foreground text-[11px]"> </p>
</div>
{/* ─── 2단계: SSO 연동 ─── */}
<div className="space-y-2">
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<Shield className="text-muted-foreground h-4 w-4" />
<div>
<p className="text-sm">SSO </p>
<p className="text-muted-foreground text-[11px]"> URL에 </p>
</div>
</div>
<Switch checked={config.useSSO || false} onCheckedChange={(checked) => updateConfig("useSSO", checked)} />
</div>
{config.useSSO && (
<Collapsible open={guideOpen} onOpenChange={setGuideOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-left transition-colors hover:bg-blue-100 dark:border-blue-900 dark:bg-blue-950 dark:hover:bg-blue-900"
>
<Info className="h-3.5 w-3.5 shrink-0 text-blue-500" />
<span className="text-[11px] font-medium text-blue-700 dark:text-blue-300"> </span>
<ChevronDown
className={cn(
"ml-auto h-3.5 w-3.5 text-blue-400 transition-transform duration-200",
guideOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-2.5 rounded-b-lg border border-t-0 border-blue-200 bg-blue-50/50 px-3 py-3 dark:border-blue-900 dark:bg-blue-950/50">
<div className="space-y-1">
<p className="text-[11px] font-medium text-blue-800 dark:text-blue-200"> </p>
<p className="text-muted-foreground text-[10px]">URL JWT가 .</p>
<code className="bg-muted/80 block rounded px-2 py-1 text-[10px]">?sso_token=eyJhbGciOi...</code>
</div>
<div className="space-y-1">
<p className="text-[11px] font-medium text-blue-800 dark:text-blue-200">JWT Payload </p>
<div className="bg-muted/80 rounded px-2 py-1.5 text-[10px] leading-relaxed">
<div className="flex justify-between">
<span className="text-muted-foreground">userId</span>
<span> ID</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">userName</span>
<span> </span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">companyCode</span>
<span> </span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">role</span>
<span></span>
</div>
</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<p className="text-[11px] font-medium text-blue-800 dark:text-blue-200"> </p>
<button
type="button"
onClick={handleCopySnippet}
className="flex items-center gap-1 text-[10px] text-blue-500 hover:text-blue-700"
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? "복사됨" : "복사"}
</button>
</div>
<pre className="bg-muted/80 overflow-x-auto rounded px-2 py-1.5 text-[10px] leading-relaxed whitespace-pre-wrap">
{`const token = url.searchParams
.get("sso_token");
const payload = JSON.parse(
atob(token.split(".")[1])
);
// payload.userId
// payload.companyCode`}
</pre>
</div>
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
{/* ─── 3단계: 표시 옵션 ─── */}
<div className="space-y-2">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-muted-foreground text-[11px]"> </p>
</div>
<Switch
checked={config.showBorder !== false}
onCheckedChange={(checked) => updateConfig("showBorder", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-muted-foreground text-[11px]"> </p>
</div>
<Switch
checked={config.allowFullscreen || false}
onCheckedChange={(checked) => updateConfig("allowFullscreen", checked)}
/>
</div>
</div>
{/* ─── 4단계: 고급 설정 (기본 접혀있음) ─── */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="bg-muted/30 hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-left transition-colors"
>
<div className="flex items-center gap-2">
<Settings className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"text-muted-foreground h-4 w-4 transition-transform duration-200",
advancedOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-3 rounded-b-lg border border-t-0 p-4">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-muted-foreground text-[11px]"> iframe </p>
</div>
<Switch
checked={config.sandbox !== false}
onCheckedChange={(checked) => updateConfig("sandbox", checked)}
/>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"> </span>
<Input
value={config.borderRadius || "8px"}
onChange={(e) => updateConfig("borderRadius", e.target.value)}
placeholder="8px"
className="h-7 w-[100px] text-xs"
/>
</div>
<div className="flex items-center justify-between py-1.5">
<span className="text-muted-foreground text-xs"> </span>
<Input
value={config.loadingText || ""}
onChange={(e) => updateConfig("loadingText", e.target.value)}
placeholder="로딩 중..."
className="h-7 w-[140px] text-xs"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2WebViewConfigPanel.displayName = "V2WebViewConfigPanel";
export default V2WebViewConfigPanel;

View File

@@ -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 지원)
/**
* 컴포넌트 초기화 함수

View File

@@ -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<V2WebViewComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
onClick,
...props
}) => {
const config = (component.componentConfig || {}) as V2WebViewConfig;
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(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 (
<div style={containerStyle} className="v2-web-view-component" onClick={handleClick} {...domProps}>
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "8px",
color: "#666",
fontSize: "13px",
}}
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
<span style={{ fontWeight: 500 }}> </span>
{baseUrl ? (
<span
style={{
fontSize: "11px",
color: "#999",
maxWidth: "90%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{baseUrl}
</span>
) : (
<span style={{ fontSize: "11px", color: "#bbb" }}>URL을 </span>
)}
{config.useSSO && <span style={{ fontSize: "10px", color: "#4caf50" }}>SSO: ?sso_token=JWT</span>}
</div>
</div>
);
}
// 런타임 모드
return (
<div style={containerStyle} className="v2-web-view-component" {...domProps}>
{loading && (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#999",
}}
>
{config.loadingText || "로딩 중..."}
</div>
)}
{error && (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#f44336",
fontSize: "13px",
}}
>
{error}
</div>
)}
{!loading && !error && iframeSrc && (
<iframe
ref={iframeRef}
src={iframeSrc}
style={{ width: "100%", height: "100%", border: "none" }}
sandbox={config.sandbox ? "allow-scripts allow-same-origin allow-forms allow-popups" : undefined}
allowFullScreen={config.allowFullscreen}
title="Web View"
/>
)}
{!loading && !error && !iframeSrc && (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#bbb",
fontSize: "13px",
}}
>
URL이
</div>
)}
</div>
);
};
export const V2WebViewWrapper: React.FC<V2WebViewComponentProps> = (props) => {
return <V2WebViewComponent {...props} />;
};

View File

@@ -0,0 +1,16 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2WebViewDefinition } from "./index";
import { V2WebViewComponent } from "./V2WebViewComponent";
export class V2WebViewRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2WebViewDefinition;
render(): React.ReactElement {
return <V2WebViewComponent {...this.props} renderer={this} />;
}
}
V2WebViewRenderer.registerSelf();

View File

@@ -0,0 +1,34 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { V2WebViewWrapper } from "./V2WebViewComponent";
import { V2WebViewConfigPanel } from "@/components/v2/config-panels/V2WebViewConfigPanel";
import type { V2WebViewConfig } from "./types";
export const V2WebViewDefinition = createComponentDefinition({
id: "v2-web-view",
name: "V2 웹 뷰",
nameEng: "V2 WebView Component",
description: "외부 웹페이지를 iframe으로 임베드하여 표시하는 컴포넌트 (SSO 지원)",
category: ComponentCategory.DISPLAY,
webType: "custom",
component: V2WebViewWrapper,
defaultConfig: {
url: "",
useSSO: false,
sandbox: true,
allowFullscreen: false,
showBorder: true,
loadingText: "로딩 중...",
} as V2WebViewConfig,
defaultSize: { width: 600, height: 400 },
configPanel: V2WebViewConfigPanel,
icon: "Globe",
tags: ["v2", "웹", "뷰", "iframe", "임베드", "외부", "SSO", "fleet"],
version: "1.0.0",
author: "개발팀",
});
export type { V2WebViewConfig } from "./types";

View File

@@ -0,0 +1,13 @@
"use client";
import { ComponentConfig } from "@/types/component";
export interface V2WebViewConfig extends ComponentConfig {
url?: string;
useSSO?: boolean;
sandbox?: boolean;
allowFullscreen?: boolean;
borderRadius?: string;
showBorder?: boolean;
loadingText?: string;
}

View File

@@ -266,7 +266,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -308,7 +307,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -342,7 +340,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -3058,7 +3055,6 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0",
@@ -3712,7 +3708,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.6"
},
@@ -3807,7 +3802,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -4121,7 +4115,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@@ -6622,7 +6615,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -6633,7 +6625,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -6676,7 +6667,6 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@@ -6759,7 +6749,6 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -7392,7 +7381,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8543,8 +8531,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
@@ -8866,7 +8853,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -9626,7 +9612,6 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9715,7 +9700,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -9817,7 +9801,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -10989,7 +10972,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -11770,8 +11752,7 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
@@ -13110,7 +13091,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -13404,7 +13384,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -13434,7 +13413,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -13483,7 +13461,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -13687,7 +13664,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13757,7 +13733,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -13808,7 +13783,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -13841,8 +13815,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "5.0.0",
@@ -14150,7 +14123,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -14173,8 +14145,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/recharts/node_modules/redux-thunk": {
"version": "3.1.0",
@@ -15204,8 +15175,7 @@
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@@ -15293,7 +15263,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15642,7 +15611,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"