중간세이브 - 메뉴수정 - INVYONE 스튜디오 작업
This commit is contained in:
@@ -1,148 +0,0 @@
|
||||
# INVYONE 개발 세션 기록 (2026-04-08)
|
||||
|
||||
## 이 세션에서 한 것
|
||||
|
||||
### 1. 대시보드 mockup 제어 모드 완성
|
||||
- **위치**: `notes/gbpark/2026-04-08-invyone-mockup/`
|
||||
- 16종 제어 노드 (조건분기/상태변경/타이머/승인/계산/외부호출 등)
|
||||
- 팔레트 드래그앤드롭 → 캔버스 배치
|
||||
- 포트 연결 (output→input 드래그)
|
||||
- 노드 설정 팝오버
|
||||
- 데모 시나리오 (수주 자동 실패 → 재고 연쇄)
|
||||
- 파일: `js/07-rule-builder.js`, `css/08-rule-builder.css`
|
||||
|
||||
### 2. 개발자 모드 (템플릿 빌더) 프로토타입
|
||||
- index.html 어드민 모드 → "새 템플릿" → 3패널 빌더
|
||||
- 테이블 선택 (22종) → 프리셋 → ⚡ 자동 생성
|
||||
- 4종 프리셋 (기본형/분할형/탭형/M-D형)
|
||||
- 블록 클릭 → 속성 패널 (데이터 바인딩, 필드 on/off, 옵션)
|
||||
- 데이터 연결 (블록 간 매핑, 필드 매핑 편집 모달)
|
||||
- 등록 팝업 오버레이 (같은 캔버스에서 팝업 편집)
|
||||
- 팔레트 드래그앤드롭
|
||||
- 파일: `js/08-admin-builder.js`, developer.html (standalone 참고용)
|
||||
|
||||
### 3. PM 원본 + test-vex DB 분석
|
||||
- PM 원본 (`~/다운로드/INVYONE개발/`) 분석: 사용자/개발자 모드, 70종 컴포넌트, 21종 템플릿
|
||||
- vexplor 본서버 DB 구조 분석:
|
||||
- `screen_definitions`: 화면 정의 (screen_id, table_name, company_code)
|
||||
- `screen_layouts_v3`: 레이아웃 JSON (components 배열, 12컬럼 그리드)
|
||||
- `table_type_columns`: **핵심 메타데이터** (회사별 컬럼 타입/라벨/표시 설정)
|
||||
- `table_column_category_values`: 드롭다운 선택지
|
||||
- `table_labels`: 테이블 한글명
|
||||
- `table_relationships`: 테이블 간 관계
|
||||
- `node_flows`: **제어 플로우** (ReactFlow 노드 64개, 10종 노드 타입)
|
||||
- `button_action_standards`: 12종 액션 (save/edit/delete/modal/control 등)
|
||||
- 25종 버튼 액션 타입 발견 (layout_data 내 action 필드)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 설계 결정사항
|
||||
|
||||
### DB 구조
|
||||
- **공유 테이블 유지** (모든 회사가 같은 테이블, company_code로 분리)
|
||||
- DB 컬럼은 전부 **VARCHAR** — 실제 타입은 `table_type_columns.input_type`에서 정의
|
||||
- input_type 15종: text(30136), date(4722), number(1350), category(987), entity(816), numbering(244), code(192), select(160), textarea(111), image(75), checkbox(60), file(21), radio(13), datetime(2), boolean(1)
|
||||
|
||||
### 제어 모드 = 회사별 자동화
|
||||
- 제어 ≠ 기본 CRUD. CRUD(등록/수정/삭제/결재)는 **템플릿에 내장**
|
||||
- 제어 = **기본 액션 이후 발생하는 회사별 자동화 체인**
|
||||
- 예: "수주 결재 완료 → 발주 자동 생성" (모든 회사가 이렇게 하는 건 아님)
|
||||
- test-vex의 `node_flows`와 같은 개념, UI를 노드 에디터로 시각화한 것
|
||||
|
||||
### 템플릿 = 컴포넌트 덩어리 (일체형)
|
||||
- test-vex: 모달 = 별도 화면 → 버튼으로 연결 (직렬적, 조립식)
|
||||
- invyone: 템플릿 = 메인화면 + 등록팝업 + 수정팝업 **한 덩어리**
|
||||
- 등록/수정은 **팝업** (모달은 중요한 것만 — 결재 등)
|
||||
- 개발자 모드에서 같은 캔버스에 팝업이 오버레이로 뜨고 편집 가능
|
||||
|
||||
---
|
||||
|
||||
## 시행착오 / 깨달은 것
|
||||
|
||||
### ❌ 프리셋 4종을 같은 레벨로 취급
|
||||
- 기본형/분할형/탭형은 **메인 화면** 프리셋
|
||||
- M-D형은 **등록 팝업** 프리셋
|
||||
- 이걸 섞어서 만들어서 구조가 꼬였음
|
||||
|
||||
### ❌ 버튼바를 별도 블록으로 만듦
|
||||
- 등록/삭제/엑셀 버튼은 **메인 화면 툴바에 내장**되는 거지 별도 컴포넌트가 아님
|
||||
|
||||
### ❌ 캔버스에 데이터 연결선 표시
|
||||
- 화면 빌더에서 SVG 연결선은 안 어울림 (플로우 에디터가 아님)
|
||||
- 데이터 연결은 **속성 패널에서만** 관리하는 게 깔끔
|
||||
|
||||
### ❌ 코스믹 디자인을 개발자 도구에 적용
|
||||
- v5 Cosmic Glassmorphism은 사용자 대시보드용
|
||||
- 개발자 모드는 **IDE 스타일** (중성 다크 그레이, 글로우 없음)
|
||||
- 다크/라이트 둘 다 가독성 확보 필수
|
||||
|
||||
### ❌ 자동생성만으로 SI 커버 가능하다고 생각
|
||||
- 자동생성은 **빠른 시작** (경로 A)
|
||||
- SI 프로젝트는 **수동 구성** 필요 (경로 B)
|
||||
- 둘 다 같은 빌더에서 지원해야 함
|
||||
|
||||
### ✅ table_type_columns 기반 자동생성은 가능하고 가치 있음
|
||||
- 테이블 선택만으로 필드 타입/라벨/순서/표시 다 알 수 있음
|
||||
- test-vex에서 7단계 수동이던 것 → 2단계(테이블 선택 → 커스텀)로 줄일 수 있음
|
||||
- 단, SI에서는 시작점일 뿐 최종 결과물은 아님
|
||||
|
||||
### ✅ 팝업 오버레이 편집은 좋은 방향
|
||||
- test-vex: 별도 화면 만들고 연결
|
||||
- invyone: 같은 캔버스에서 팝업이 뜨고 바로 편집
|
||||
- Figma처럼 실제 사용자가 보는 모습 그대로 편집
|
||||
|
||||
---
|
||||
|
||||
## 다음 세션 TODO
|
||||
|
||||
### 우선순위 1: 템플릿 빌더 구조 재정리
|
||||
```
|
||||
[올바른 구조]
|
||||
템플릿 = 메인 화면 + 등록 팝업 + 수정 팝업
|
||||
|
||||
메인 화면:
|
||||
├─ 툴바 (제목 + 액션 버튼 내장)
|
||||
├─ 검색/필터
|
||||
├─ 데이터 목록
|
||||
└─ 프리셋: 기본형 / 분할형 / 탭형
|
||||
|
||||
등록/수정 팝업:
|
||||
├─ 폼 레이아웃
|
||||
└─ 프리셋: 기본 폼 / M-D형
|
||||
|
||||
두 경로:
|
||||
├─ 경로 A: 자동 생성 (테이블 선택 → 한 방)
|
||||
└─ 경로 B: 수동 구성 (빈 캔버스 → 직접 배치)
|
||||
```
|
||||
|
||||
### 우선순위 2: 기존 코드 정리
|
||||
- 08-admin-builder.js 구조 재정리 (프리셋 레벨 분리)
|
||||
- 버튼바 블록 제거 → 툴바 내장
|
||||
- M-D형을 팝업 프리셋으로 이동
|
||||
- 캔버스 연결선 완전 제거 (속성 패널에서만)
|
||||
|
||||
### 우선순위 3: 디자인 개선
|
||||
- 다크모드 가독성 (현재 OK 수준, 더 개선 가능)
|
||||
- 라이트모드 패널 구분 (개선됨, 추가 조정 필요)
|
||||
- 전체적으로 IDE 느낌 강화
|
||||
|
||||
### 장기: SPEC v0.2 갱신
|
||||
- mockup 기반으로 M1 확정
|
||||
- 제어 모드 스펙
|
||||
- 템플릿 빌더 스펙
|
||||
- 메타데이터 기반 자동생성 스펙
|
||||
|
||||
---
|
||||
|
||||
## 참고 파일 위치
|
||||
|
||||
| 파일 | 설명 |
|
||||
|---|---|
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/index.html` | 대시보드 + 제어 모드 + 어드민 빌더 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/developer.html` | 개발자 모드 standalone (참고용) |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/06-control-mode.js` | 제어 모드 (카드 흐름 보기) |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/07-rule-builder.js` | 제어 노드 빌더 (16종) |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/08-admin-builder.js` | 어드민 빌더 (자동생성+속성패널) |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/css/08-rule-builder.css` | 제어 노드 스타일 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/css/09-developer.css` | 개발자 모드 스타일 (standalone용) |
|
||||
| `~/다운로드/INVYONE개발/` | PM 원본 mockup |
|
||||
@@ -1,179 +0,0 @@
|
||||
# INVYONE 빌더 세션 로그 (2026-04-08~09)
|
||||
|
||||
> 이전 세션 로그: `2026-04-08-invyone-dev-session-log.md` (대시보드 mockup + 제어 모드 + 첫 빌더 프로토타입)
|
||||
> 이번 세션: 로우코드 정의 토론 → 규격 재설계 → 빌더 구조 전면 개편
|
||||
|
||||
---
|
||||
|
||||
## 주요 결정사항 (시간순)
|
||||
|
||||
### 1. 로우코드 정의 토론
|
||||
|
||||
"INVYONE이 대체 뭐냐"에 대한 답을 정리:
|
||||
- 로우코드 레벨 정의: L0(노코드) ~ L3(프레임워크)
|
||||
- INVYONE 위치: L0~L1 (중소) + L1~L2 (중견+)
|
||||
- 타겟 사용자: 중소기업 업무담당자 (코드 모름) + 중견 이상 IT팀 (빌더 사용)
|
||||
- 결론: **개발자 빌더 먼저, 사용자 화면 나중에**
|
||||
|
||||
### 2. PM 원본 분석
|
||||
|
||||
`~/다운로드/INVYONE개발/` 폴더의 HTML 67개 파일 분석:
|
||||
- 총 110,515줄, 평균 1,647줄/파일
|
||||
- 구성: JS 67% / CSS 19% / HTML 14%
|
||||
- 패턴: 55/66 CRUD 버튼, 54/66 검색 섹션, 51/66 테이블/그리드
|
||||
- 결론: 대부분의 화면이 검색+테이블+CRUD 조합 → 빌더로 자동 생성 가능
|
||||
|
||||
### 3. vex DB 분석
|
||||
|
||||
test-vex (bexplorer-prod) DB 핵심 테이블:
|
||||
- `screen_definitions`: 화면 정의 (screen_id, table_name, company_code)
|
||||
- `screen_layouts_v3`: 레이아웃 JSON (components 배열, 12컬럼 그리드)
|
||||
- `table_type_columns`: 핵심 메타데이터 (회사별 컬럼 타입/라벨/표시 설정, input_type 15종)
|
||||
- `table_column_category_values`: 드롭다운 선택지
|
||||
- `table_relationships`: 테이블 간 관계
|
||||
- `node_flows`: 제어 플로우 (ReactFlow 노드 64개, 10종 노드 타입)
|
||||
- `button_action_standards`: 12종 액션
|
||||
|
||||
발견: DB 컬럼이 전부 VARCHAR, 실제 타입은 table_type_columns.input_type에서 정의
|
||||
|
||||
### 4. vex 방식 문제점 파악 → 개선 방향
|
||||
|
||||
vex 문제:
|
||||
- ColumnConfig 354줄 + FilterConfig + FormField 전부 다른 규격
|
||||
- 컴포넌트 간 데이터 전달 시 변환 필요 → 에러 다발
|
||||
- 목록/등록/수정 = 3개 화면 따로 만들어 연결
|
||||
|
||||
개선 방향:
|
||||
- 추상화 레벨 올리기 (컴포넌트 레벨 → 필드 레벨)
|
||||
- "사용자는 필드를 배치하는 게 아니라, FieldConfig를 소비하는 컴포넌트를 배치한다"
|
||||
|
||||
### 5. 컴포넌트 규격 신규 설계
|
||||
|
||||
FieldConfig + DataPort + Template 신규 규격 확정:
|
||||
- **FieldConfig** (~30줄) — 테이블/폼/검색 전부 하나의 규격
|
||||
- **DataPort** — output→input 표준 통신 (row/rows/value/params)
|
||||
- **Template** = 3뷰 내장 (목록+등록+수정, 같은 fields 공유)
|
||||
- vex 규격 호환 고려하지 않기로 결정 (신규 설계)
|
||||
|
||||
산출물: `notes/gbpark/2026-04-08-invyone-component-spec.md` (584줄)
|
||||
|
||||
### 6. 팔레트 정리: 26개 → 8개
|
||||
|
||||
초기 팔레트 (11종):
|
||||
table, form, search, button, button-bar, tabs, split-panel, title, stats, divider, pagination
|
||||
|
||||
정리 과정:
|
||||
- 레이아웃 (tabs, split-panel) → 프리셋이 처리 (내부 모델 유지, 팔레트에서 숨김)
|
||||
- 필드 → FieldConfig.type (컴포넌트가 아님)
|
||||
- 개별 버튼 종류 → ActionType (버튼 컴포넌트의 속성)
|
||||
- button-bar → 버튼으로 통합
|
||||
- pagination → 테이블 내장
|
||||
- 마스터-디테일 → 테이블+폼 조합 + DataPort 연결
|
||||
|
||||
최종 8종: **데이터 테이블, 검색 필터, 입력 폼, 버튼, 통계 카드, 차트, 텍스트/제목, 구분선**
|
||||
|
||||
### 7. 렌더링 계약 / computed 파서 / required 검증
|
||||
|
||||
- 렌더링 계약: FieldType별 테이블/폼/검색 렌더링 매핑 테이블 명시 (암묵적 규칙 금지)
|
||||
- computed: eval/new Function 절대 금지 → 스택 기반 수식 파서만 허용
|
||||
- required: 0, false는 유효한 값 (empty = null/undefined/'' 만)
|
||||
|
||||
---
|
||||
|
||||
## 구현한 파일들
|
||||
|
||||
### mockup 폴더 (`notes/gbpark/2026-04-08-invyone-mockup/`)
|
||||
|
||||
총 23파일, 12,142줄:
|
||||
|
||||
| 파일 | 줄수 | 설명 |
|
||||
|---|---|---|
|
||||
| **HTML** | | |
|
||||
| `index.html` | 1,370 | 대시보드 + 제어 모드 + 어드민 빌더 진입점 |
|
||||
| `builder-v2.html` | 1,032 | 템플릿 빌더 v2 standalone (3뷰, 프리셋, 필드 on/off) |
|
||||
| `developer.html` | 701 | 개발자 모드 standalone 참고용 |
|
||||
| `renderer-proto.html` | 1,455 | FieldConfig 렌더러 프로토타입 |
|
||||
| `spec-viewer.html` | 2,146 | 컴포넌트 규격 인터랙티브 뷰어 |
|
||||
| **CSS** (css/) | | |
|
||||
| `01-tokens.css` | 62 | v5 토큰 + cosmic 배경 |
|
||||
| `02-shell.css` | 189 | 헤더/탭/사이드바 |
|
||||
| `03-canvas.css` | 145 | 캔버스/카드/드래그/리사이즈 |
|
||||
| `04-settings.css` | 121 | 카드 설정 패널 |
|
||||
| `05-widgets.css` | 122 | 위젯 스타일 |
|
||||
| `06-modals.css` | 90 | 라이브러리 모달 |
|
||||
| `07-control-mode.css` | 189 | 제어 모드 |
|
||||
| `08-rule-builder.css` | 184 | 제어 노드 빌더 |
|
||||
| `09-developer.css` | 304 | 개발자 모드 |
|
||||
| **JS** (js/) | | |
|
||||
| `01-shell.js` | 123 | 테마/모드/사이드바 |
|
||||
| `02-canvas.js` | 141 | 편집모드/드래그/리사이즈 |
|
||||
| `03-settings.js` | 94 | 설정 패널 |
|
||||
| `04-templates.js` | 173 | 위젯 렌더러/카드 빌드 |
|
||||
| `05-state.js` | 249 | 대시보드 상태/저장/복원 |
|
||||
| `06-control-mode.js` | 996 | 제어 모드 (카드 흐름, 트리 확산) |
|
||||
| `07-rule-builder.js` | 752 | 제어 노드 빌더 (16종 노드) |
|
||||
| `08-admin-builder.js` | 1,481 | 어드민 빌더 (최대 파일) |
|
||||
| `99-init.js` | 23 | init IIFE |
|
||||
| `README.md` | 131 | mockup 가이드 |
|
||||
|
||||
### 스펙 문서
|
||||
|
||||
| 파일 | 설명 |
|
||||
|---|---|
|
||||
| `2026-04-08-invyone-component-spec.md` | 컴포넌트 규격 v1.0 (FieldConfig, DataPort, Template) |
|
||||
| `2026-04-08-lowcode-platform-spec.md` | 로우코드 플랫폼 SPEC v1.0 (역할, 레벨, 67개 화면 분석) |
|
||||
| `2026-04-09-invyone-architecture.md` | 아키텍처 결정 문서 (본 세션 최종 정리) |
|
||||
|
||||
---
|
||||
|
||||
## 시행착오
|
||||
|
||||
### 블록 배경 투명으로 만들었다가 복원
|
||||
빌더 블록의 배경을 투명하게 만들어 캔버스가 비쳐 보이게 했으나, 가독성이 심각하게 떨어져서 원래 불투명 배경으로 복원.
|
||||
|
||||
### vex 호환 고려했다가 폐기
|
||||
처음에는 vex의 components 배열 형식을 출력하는 방향으로 builder-v2를 만들었음. 그러나 vex의 ColumnConfig(354줄)과 분리된 규격들이 근본적 문제이므로, 호환을 포기하고 FieldConfig 단일 규격으로 신규 설계.
|
||||
|
||||
### 자동생성 집착 → 기능 구현 우선으로 전환
|
||||
테이블 선택만으로 화면이 뿅 나오는 "자동생성"에 집착했으나, 실제로는 SI 프로젝트에서 커스텀이 필수. 자동생성은 시작점일 뿐이고, 빌더의 수동 구성 기능이 더 중요하다는 결론.
|
||||
|
||||
### 팔레트에 필드/레이아웃/개별버튼 넣었다가 제거
|
||||
- 필드를 팔레트에 넣으면 "필드를 드래그해서 배치"하는 UX가 되는데, 이건 FieldConfig 소비 모델과 맞지 않음
|
||||
- 레이아웃(tabs, split-panel)은 프리셋이 처리하는 것이 맞음 (개발자가 직접 배치할 필요 없음)
|
||||
- 개별 버튼 종류(저장/삭제/엑셀 등)를 팔레트에 넣으면 팔레트가 비대해짐 → ActionType으로 통합
|
||||
|
||||
### 코스믹 디자인을 개발자 도구에 적용
|
||||
v5 Cosmic Glassmorphism은 사용자 대시보드용. 개발자 모드는 IDE 스타일 (중성 다크 그레이, 글로우 없음)이 맞다.
|
||||
|
||||
---
|
||||
|
||||
## 다음 할 일 (TODO)
|
||||
|
||||
### 빌더 UX 개선
|
||||
- [ ] 테이블 설정 모달 UX — 더블클릭으로 열 것인지, 아이콘 클릭인지 결정 필요
|
||||
- [ ] 블록 더블클릭 → 설정 모달 열기 (현재는 클릭→속성 패널만)
|
||||
- [ ] 등록/수정 팝업 뷰 편집 개선 — 현재 오버레이 기본만 있음
|
||||
- [ ] 반응형 미리보기 — 모바일/태블릿 뷰
|
||||
|
||||
### React 이식
|
||||
- [ ] React 이식 시작 조건 정의 — mockup 어디까지 완성하면 이식 시작할 것인가
|
||||
- [ ] FieldConfig 타입스크립트 정의 파일 작성
|
||||
- [ ] 렌더러 프로토타입 → React 컴포넌트로 변환
|
||||
- [ ] DataPort 이벤트 버스 구현
|
||||
|
||||
### Phase 2 준비
|
||||
- [ ] ValidationRule 배열 설계
|
||||
- [ ] 필드 간 연동 규칙
|
||||
- [ ] 조건부 표시/숨김
|
||||
|
||||
---
|
||||
|
||||
## 참고 파일 위치
|
||||
|
||||
| 파일 | 설명 |
|
||||
|---|---|
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/` | mockup 전체 폴더 |
|
||||
| `notes/gbpark/2026-04-08-invyone-component-spec.md` | 컴포넌트 규격 |
|
||||
| `notes/gbpark/2026-04-08-lowcode-platform-spec.md` | 로우코드 플랫폼 SPEC |
|
||||
| `notes/gbpark/2026-04-09-invyone-architecture.md` | 아키텍처 결정 문서 |
|
||||
| `notes/gbpark/2026-04-08-invyone-dev-session-log.md` | 이전 세션 로그 |
|
||||
@@ -1,323 +0,0 @@
|
||||
# INVYONE 카드 엔진 Phase 1 — 구현 로그
|
||||
|
||||
**작업일**: 2026-04-10
|
||||
**작업자**: gbpark + Claude
|
||||
**기반 스펙**: `notes/gbpark/2026-04-10-card-engine-final-spec.md`
|
||||
**이전 로그**: `notes/gbpark/2026-04-10-template-redesign-phase1-log.md` (OBSOLETE — 12-grid 모델)
|
||||
|
||||
---
|
||||
|
||||
## 0. 요약
|
||||
|
||||
스펙 §6 의 Phase 1 (토대 정리 + 핵심 컴포넌트 마이그레이션) 을 7단계로 나눠 진행.
|
||||
|
||||
| Step | 제목 | 상태 |
|
||||
|---|---|---|
|
||||
| 1 | 타입 추가 (FreePosition, TemplateComponent, TemplateViewConfig, Card, Dashboard…) | ✅ |
|
||||
| 2 | template-builder 새 빌더 작성 (3뷰 + toTemplate/fromTemplate) | ✅ |
|
||||
| 3 | admin/builder/page.tsx 진입점 전환 | ✅ |
|
||||
| 4 | components/builder/* + ScreenDesigner_new.tsx 완전 삭제 | ✅ |
|
||||
| 5 | 레거시 타입 @deprecated 표기 (완전 제거는 Phase 2) | ✅ |
|
||||
| 6 | v2-* 우선순위 1 컴포넌트 마이그레이션 (완전 2 + 경량 7) | ✅ |
|
||||
| 7 | PoC 시각 검증 페이지 | ✅ (코드 + 브라우저 검증 완료 2026-04-11) |
|
||||
| 8 | 세션 후반 버그 픽스 (검증 중 발견) | ✅ (2026-04-11) |
|
||||
|
||||
**tsc 결과**: 내 변경 영역 기준 에러 0. 전체 에러 카운트는 3423 → 3381 로 감소(삭제된 폐기 파일의 기존 에러가 사라짐).
|
||||
|
||||
---
|
||||
|
||||
## 1. 수정·생성 파일
|
||||
|
||||
### 타입
|
||||
- **수정** `frontend/types/invyone-component.ts`
|
||||
- §7 추가: `FreePosition`, `ViewTrigger`, `TemplateComponent`, `TemplateViewConfig`, `TemplateViews`, `DataPortDef`, `Card`, `CardConnection`, `Dashboard`
|
||||
- 레거시 타입 전부 `@deprecated` 표기: `GridPosition`, `AbsolutePosition`, `ComponentPosition`, `ResponsiveGridOverride`, `isGridPosition`, `isAbsolutePosition`, `TemplateKind`, `DEFAULT_COMPONENT_LAYOUTS`, `CANVAS_KEYWORDS`
|
||||
- `Template.kind` 를 `kind?: TemplateKind` 로 optional + deprecated 변경
|
||||
|
||||
### 빌더 (신규)
|
||||
- **신규** `frontend/components/template-builder/TemplateBuilder.tsx` (~550줄)
|
||||
- 자유배치 캔버스 + 드래그/리사이즈 + 히스토리 + 격자 옵션 + 3뷰 토글 + 팔레트 + 속성/격자/메타 사이드 패널
|
||||
- v2-* 렌더링은 placeholder (Phase 2 에서 실제 컴포넌트 연결)
|
||||
- localStorage 기반 임시 저장/복원
|
||||
- 단축키: Delete, Ctrl+Z/Y, Ctrl+S
|
||||
- **신규** `frontend/components/template-builder/store/templateBuilderStore.ts`
|
||||
- Zustand, `toTemplate` / `fromTemplate` / `undo` / `redo` / `commit` / viewTrigger 자동 감지
|
||||
- `BuilderView = 'list' | 'create' | 'edit'`
|
||||
|
||||
### 빌더 진입점 전환
|
||||
- **수정** `frontend/app/(main)/admin/builder/page.tsx`
|
||||
- `BuilderLayout` (12-grid) → `TemplateBuilder` import 교체
|
||||
|
||||
### 폐기 삭제
|
||||
- **삭제** `frontend/components/builder/` 전체
|
||||
- BuilderLayout/Canvas/Block/Palette/Props/Toolbar + hooks(useBuilderState, useBlockDrag, gridMetrics) + props/*
|
||||
- **삭제** `frontend/components/screen/ScreenDesigner_new.tsx` (dead code, 어디서도 import 되지 않았음)
|
||||
|
||||
### v2-* 마이그레이션 — 완전 (2개)
|
||||
- **신규** `frontend/lib/registry/components/v2-table-list/TableListContainerWrapper.tsx`
|
||||
- ResizeObserver 로 카드 폭 측정 → 600px 미만이면 `config.displayMode` 를 `"card"` 로 런타임 override
|
||||
- 기존 `TableListComponent` 내부는 0줄 수정
|
||||
- **수정** `frontend/lib/registry/components/v2-table-list/index.ts`
|
||||
- `component: TableListWrapper` → `component: TableListContainerWrapper`
|
||||
- **신규** `frontend/lib/registry/components/v2-table-search-widget/TableSearchContainerWrapper.tsx`
|
||||
- 래퍼 div 에 `.v2-tsw-responsive-root` 클래스 부여
|
||||
- **신규** `frontend/lib/registry/components/v2-table-search-widget/table-search-widget-responsive.css`
|
||||
- `container-type: inline-size` + `@container v2-tsw (max-width: 599px)` 에서 flex-col 강제
|
||||
- **수정** `frontend/lib/registry/components/v2-table-search-widget/index.tsx`
|
||||
- `component: TableSearchWidget` → `component: TableSearchContainerWrapper`
|
||||
|
||||
### v2-* 마이그레이션 — 경량 (7개)
|
||||
- **신규** `frontend/lib/registry/hoc/withContainerQuery.tsx`
|
||||
- 간단한 HOC: `<div style={{ containerType: 'inline-size', containerName, width: '100%', height: '100%' }}>` 로 감싸기
|
||||
- **수정** 각 컴포넌트 `index.ts` 에서 `component: Wrapper` → `component: withContainerQuery(Wrapper, "<id>")`:
|
||||
- v2-button-primary
|
||||
- v2-aggregation-widget
|
||||
- v2-card-display
|
||||
- v2-input
|
||||
- v2-select
|
||||
- v2-date
|
||||
- v2-text-display
|
||||
|
||||
### PoC 검증
|
||||
- **신규** `frontend/app/(main)/test-card-responsive/page.tsx`
|
||||
- 카드 폭 슬라이더 (240 ~ 1400px)
|
||||
- 수주관리 화면 구성 (v2-text-display + aggregation-widget + search-widget + button-primary + table-list 의 **레이아웃 시뮬레이션**)
|
||||
- ResizeObserver 기반 `data-v2-table-list-mode` 전환 + CSS @container 기반 search-widget 세로 스택 동시 시각 확인
|
||||
|
||||
### 스펙 MD
|
||||
- **수정** `notes/gbpark/2026-04-10-card-engine-final-spec.md`
|
||||
- Phase 2 시작 섹션에 "Phase 1 에서 미뤄둔 레거시 타입 정리" 체크리스트 삽입
|
||||
|
||||
---
|
||||
|
||||
## 2. 주요 결정 사항 (사용자 협의)
|
||||
|
||||
### 결정 1 — ScreenDesigner_new 처리 방식
|
||||
**옵션**: (a) 완전 재작성 / (b) 변환 레이어만 추가 / (c) 백지에서 새로 작성
|
||||
**결정**: **(c) 와 (a) 혼합 — 새 `components/template-builder/` 폴더에 완전 재작성**
|
||||
**이유**: ScreenDesigner_new 는 `ScreenDefinition/ComponentData/LayoutData` 기반이라 Template 모델로의 전환은 사실상 재작성. 새 경로가 의미상 더 명확(스펙 §4.1 검토 항목).
|
||||
|
||||
### 결정 2 — components/builder 폐기 방식
|
||||
**결정**: **완전 삭제**
|
||||
**이유**: 스펙 MD 가 폐기 명시, git history 로 추적 가능, 흔적 남기면 혼란.
|
||||
|
||||
### 결정 3 — Step 5 레거시 타입 처리
|
||||
**옵션**: (a) @deprecated + 유지 / (b) DashboardCard 도 임시 수정해 완전 제거 / (c) Phase 2 로 이관
|
||||
**결정**: **(a) @deprecated 주석 + 유지 + Phase 2 체크리스트에 제거 항목 명시**
|
||||
**이유**: DashboardCard.tsx 는 Phase 2 재작성 대상(스펙 §6/§7). Phase 1 에서 임시 수정하면 Phase 2 재작성 때 두 번 일. 옵션 3 은 방향성 불명.
|
||||
|
||||
### 결정 4 — Step 6 v2-* 마이그레이션 범위
|
||||
**결정**: **핵심 2개 완전 + 나머지 7개 경량**
|
||||
- **완전**: v2-table-list (ResizeObserver), v2-table-search-widget (CSS @container)
|
||||
- **경량**: button-primary, input, select, date, text-display, card-display, aggregation-widget → `container-type: inline-size` 만 부착
|
||||
**이유**: PoC 검증력을 담보(검색+테이블 CRUD 기본형)하면서 Phase 1 스코프 유지. 나머지 7개의 모드 분기는 Phase 2 개별 재작성 시 추가.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 2 이월 체크리스트
|
||||
|
||||
스펙 MD §6 Phase 2 섹션에 다음 항목이 추가됐다(`notes/gbpark/2026-04-10-card-engine-final-spec.md` 참조).
|
||||
|
||||
### 레거시 타입 최종 제거 (Phase 1 이월)
|
||||
- [ ] `GridPosition`, `AbsolutePosition`, `ComponentPosition`, `ResponsiveGridOverride` 제거
|
||||
- [ ] `isGridPosition()`, `isAbsolutePosition()` 가드 제거
|
||||
- [ ] `TemplateKind`, `DEFAULT_COMPONENT_LAYOUTS`, `CANVAS_KEYWORDS` 제거
|
||||
- [ ] `Template.kind` 필드 완전 제거
|
||||
- [ ] `DashboardCard.tsx` 재작성하면서 위 타입 사용처를 전부 `FreePosition` 으로 교체
|
||||
- [ ] 잔존 확인: `grep -rn "GridPosition\|AbsolutePosition\|TemplateKind" frontend/`
|
||||
|
||||
### 경량 7개 v2-* 컴포넌트 모드 분기 추가 (Phase 1 이월)
|
||||
- [ ] v2-button-primary: narrow 에서 아이콘만 / wide 에서 아이콘+텍스트
|
||||
- [ ] v2-input: 라벨 위치 (narrow=top / wide=left)
|
||||
- [ ] v2-select: 동일
|
||||
- [ ] v2-date: 동일 + narrow 에서 range 모드 자동 compact
|
||||
- [ ] v2-text-display: font-size 반응
|
||||
- [ ] v2-card-display: narrow 에서 cardsPerRow 자동 1
|
||||
- [ ] v2-aggregation-widget: narrow 에서 2x2 그리드
|
||||
|
||||
### 우선순위 2/3 v2-* 마이그레이션 (스펙 §5.3)
|
||||
- [ ] 우선순위 2: v2-bom-tree, v2-bom-item-editor, v2-shipping-plan-editor, v2-pivot-grid, v2-timeline-scheduler, v2-process-work-standard, v2-approval-step
|
||||
- [ ] 우선순위 3: v2-rack-structure, v2-numbering-rule, v2-category-manager, v2-divider-line, v2-file-upload, v2-media
|
||||
|
||||
### 기타
|
||||
- [ ] TemplateBuilder 의 placeholder 렌더링 → 실제 v2-* 컴포넌트 렌더링 (ComponentRegistry 연동)
|
||||
- [ ] TemplateBuilder 저장/복원을 localStorage 에서 백엔드 API 로
|
||||
- [ ] 3뷰 자동 생성 실제 동작 (등록 버튼 감지 → create 뷰 placeholder 자동 생성) — 현재 store 에는 탐지 로직 있으나 UI placeholder 생성 로직 없음
|
||||
|
||||
---
|
||||
|
||||
## 4. 사용자 수동 검증 체크리스트 (Phase 1 종료 조건)
|
||||
|
||||
다음 항목을 브라우저에서 직접 확인.
|
||||
|
||||
### (A) 테스트 페이지 — 반응형 메커니즘 시각 확인
|
||||
1. 도커 or 로컬에서 frontend 실행
|
||||
2. 브라우저에서 `/test-card-responsive` 접속
|
||||
3. 카드 폭 슬라이더를 **800 → 400** 으로 드래그
|
||||
4. 확인:
|
||||
- [ ] 상단 "감지된 모드" 배지가 `wide` → `narrow` 로 전환 (버튼 색도 인디고 → 로즈)
|
||||
- [ ] v2-table-list 영역이 테이블 → 카드 리스트로 재렌더 (`data-v2-table-list-mode` 속성값 wide/narrow 교차)
|
||||
- [ ] v2-table-search-widget 영역의 필터/버튼이 가로 → 세로 스택으로 재배열
|
||||
5. 슬라이더 프리셋 (320 / 520 / 800 / 1200) 로 경계값 확인
|
||||
6. **실패 시**: 스펙 §9 백업 플랜 적용 검토 (카드 min-width 강제, 모드 강제 등)
|
||||
|
||||
### (B) 빌더 접근 확인
|
||||
1. `/admin/builder` 접속
|
||||
2. 확인:
|
||||
- [ ] TemplateBuilder UI 가 표시 (상단 툴바 + 좌측 팔레트 + 중앙 캔버스 + 우측 속성 패널)
|
||||
- [ ] 상단 탭에서 목록/등록/수정 3뷰 토글 가능
|
||||
- [ ] 좌측 팔레트에서 v2-* 컴포넌트 드래그해 캔버스에 드롭 → 배치 성공
|
||||
- [ ] 배치된 블록 드래그로 이동, 우하단 핸들로 리사이즈
|
||||
- [ ] Ctrl+Z 실행취소, Ctrl+S 저장 (브라우저 저장 다이얼로그 아님 → localStorage)
|
||||
- [ ] 새로고침 후에도 localStorage 에서 복원
|
||||
3. 팔레트에 v2-* 컴포넌트가 안 보이면 ComponentRegistry 초기화가 안 된 것. 그 경우 fallback 9개가 표시되어야 함.
|
||||
|
||||
### (C) 기존 기능 영향 없음 확인
|
||||
1. `/admin/screenMng` 접속 → ScreenDesigner(구버전) 가 여전히 동작
|
||||
2. `/dashboard/...` 접속 → DashboardCard 가 여전히 동작 (Phase 2 재작성 전까지 @deprecated 타입 의존)
|
||||
3. 아무 v2-* 사용 화면이 있으면 팔레트 드래그 안 해도 렌더 자체는 그대로
|
||||
|
||||
---
|
||||
|
||||
## 5. 알려진 한계 / TODO
|
||||
|
||||
1. **TemplateBuilder 는 아직 placeholder 빌더**. 실제 v2-* 컴포넌트는 Phase 2 에서 연결. 지금은 블록명/크기만 표시.
|
||||
2. **ResizeObserver 기반 v2-table-list 는 `isDesignMode=true` 에서는 narrow 카드 모드가 동작하지 않음** — TableListComponent 의 원 로직이 design 모드에서 card 분기를 건너뜀. 빌더 안 프리뷰에서는 확인 불가, 실 렌더링(런타임)에서만 작동. 테스트는 `/test-card-responsive` 페이지로 수행.
|
||||
3. **localStorage 저장 키**: `invyone-template:<templateId>` / `invyone-template:__last__`. 나중에 백엔드 API 로 전환 시 마이그레이션 필요.
|
||||
4. **DashboardCard.tsx 는 여전히 레거시 타입 의존**. Phase 2 재작성 전까지 그대로 유지.
|
||||
5. **스펙의 v2-search-widget 은 실제 폴더명이 v2-table-search-widget**. 이름만 다르고 마이그레이션은 정상 적용.
|
||||
|
||||
---
|
||||
|
||||
## 6. 참고 (작업 중 확인한 기존 파일)
|
||||
|
||||
- `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` (283KB, 내부 불변 약속)
|
||||
- `frontend/lib/registry/components/v2-table-list/CardModeRenderer.tsx` (기존 카드 모드 렌더러 — 재사용)
|
||||
- `frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx` (880줄, 내부 불변)
|
||||
- `frontend/components/screen/ScreenDesigner.tsx` (347KB, screenMng 사용 중, 건드리지 않음)
|
||||
- `frontend/components/dash/DashboardCard.tsx` (Phase 2 재작성 대상)
|
||||
|
||||
---
|
||||
|
||||
## 7. 세션 후반 버그 픽스 (검증 중 발견, 2026-04-11)
|
||||
|
||||
Phase 1 코드 작업이 완료된 뒤 §4 사용자 수동 검증을 진행하면서 3개의 버그가 발견되어 즉시 수정됨. Phase 1 의 일부로 포함.
|
||||
|
||||
### 7.1 `/test-card-responsive` 가 AppLayout 탭 시스템에 흡수됨
|
||||
|
||||
**증상**: 브라우저에서 URL 접속 시 `/admin` 과 비슷한 셸(사이드바 + 빈 탭 영역) 만 나오고 "열린 탭이 없습니다" 텍스트가 표시됨. PoC 페이지 자체가 렌더되지 않음.
|
||||
|
||||
**원인**: 페이지가 `frontend/app/(main)/test-card-responsive/page.tsx` 에 있었는데, `frontend/app/(main)/layout.tsx` 가 `<AppLayout>{children}</AppLayout>` 으로 감싸고 있고, `AppLayout` 은 탭 기반 SPA 셸이라 `/test-card-responsive` 라는 URL 이 탭 시스템에 등록되어 있지 않으면 `children` 이 렌더되지 않았음.
|
||||
|
||||
**수정**: 파일을 `(main)` 그룹 밖으로 이동해서 AppLayout 을 우회.
|
||||
- 이전: `frontend/app/(main)/test-card-responsive/page.tsx`
|
||||
- 이후: `frontend/app/test-card-responsive/page.tsx`
|
||||
|
||||
루트 layout 만 적용되고 AppLayout 의 탭 시스템은 통과. 검증 페이지라 사이드바/헤더가 필요 없어 문제 없음.
|
||||
|
||||
### 7.2 `useRegistryPalette` 가 VEX `default_size` 포맷 불일치로 NaN position 생성
|
||||
|
||||
**증상**: TemplateBuilder 팔레트에서 컴포넌트(예: split-panel-layout)를 드래그해 캔버스에 드롭해도 블록이 시각적으로 나타나지 않음. 우측 속성 패널의 LEFT/TOP/WIDTH/HEIGHT 필드가 모두 비어있음. 컴포넌트 자체는 선택된 상태(COMPONENT 이름 + CONFIG JSON 표시) 로 패널에 보이지만 캔버스에서는 사라짐.
|
||||
|
||||
**원인 연쇄**:
|
||||
1. VEX `ComponentDefinition.default_size` 는 `{ width, height }` 포맷 (예: `{ width: 300, height: 40 }`)
|
||||
2. TemplateBuilder 의 `PaletteItem.defaultSize` 는 `{ w, h }` 포맷
|
||||
3. `useRegistryPalette` 가 `c.default_size` 를 그대로 전달 → `defaultSize.w` = `undefined`
|
||||
4. 드롭 핸들러에서 `e.clientX - rect.left - defaultSize.w / 2` = `NaN`
|
||||
5. `position = { left: NaN, top: NaN, width: undefined, height: undefined }` 가 store 에 저장됨
|
||||
6. React 가 style 의 NaN/undefined 를 무시 → 블록이 "보이지 않음" (또는 0,0 에 0 크기)
|
||||
7. `LabeledNumber` 의 `Math.round(NaN) = NaN` → 속성 패널 input 이 빈 칸으로 렌더
|
||||
|
||||
**수정**: `frontend/components/template-builder/TemplateBuilder.tsx` 의 `useRegistryPalette` 에서 두 가지 정규화:
|
||||
|
||||
1. `default_size` 의 `{width, height}` 와 `{w, h}` 두 포맷 모두 지원:
|
||||
```ts
|
||||
const rawSize = c.default_size ?? {};
|
||||
const defaultSize = {
|
||||
w: rawSize.w ?? rawSize.width ?? 280,
|
||||
h: rawSize.h ?? rawSize.height ?? 180,
|
||||
};
|
||||
```
|
||||
|
||||
2. lucide icon 이름(긴 문자열 "Table", "LayoutGrid" 등) 은 `◼` 로 fallback:
|
||||
```ts
|
||||
const icon =
|
||||
typeof rawIcon === "string" && rawIcon.length > 0 && rawIcon.length <= 2
|
||||
? rawIcon
|
||||
: "◼";
|
||||
```
|
||||
짧은 이모지/유니코드만 그대로 표시. lucide 이름의 실제 아이콘 렌더링은 Phase 2 에서 ComponentRegistry 연동할 때 본격 구현.
|
||||
|
||||
### 7.3 TemplateBuilder.tsx 다크 모드 가시성 전혀 없음
|
||||
|
||||
**증상**: INVYONE 기본 다크 테마에서 TemplateBuilder 전체가 흰색/밝은 회색 배경에 옅은 텍스트로 표시되어 거의 읽을 수 없음. 팔레트, 툴바, 캔버스, 속성 패널 모두 동일. 블록을 드롭해도 어두운 배경 위에 어두운 블록이 거의 구분 안 됨.
|
||||
|
||||
**원인**: TemplateBuilder.tsx 가 라이트 모드 전제로 작성됨. `bg-white`, `bg-slate-50`, `text-slate-*`, `border-slate-200` 등 Tailwind 클래스가 `dark:` variant 없이 사용됨.
|
||||
|
||||
**수정**: Python 정규식 스크립트로 일괄 치환. **21개 패턴, 총 71곳** 치환.
|
||||
|
||||
| 원본 | 치환 | 건수 |
|
||||
|---|---|---|
|
||||
| `bg-white` | `bg-white dark:bg-slate-900` | 8 |
|
||||
| `bg-slate-50` | `bg-slate-50 dark:bg-slate-950` | 2 |
|
||||
| `bg-slate-100` | `bg-slate-100 dark:bg-slate-800` | 1 |
|
||||
| `bg-amber-50` | `bg-amber-50 dark:bg-amber-950/40` | 1 |
|
||||
| `text-slate-800` | `text-slate-800 dark:text-slate-100` | 1 |
|
||||
| `text-slate-700` | `text-slate-700 dark:text-slate-200` | 1 |
|
||||
| `text-slate-600` | `text-slate-600 dark:text-slate-300` | 5 |
|
||||
| `text-slate-500` | `text-slate-500 dark:text-slate-400` | 2 |
|
||||
| `text-slate-400` | `text-slate-400 dark:text-slate-500` | 14 |
|
||||
| `text-amber-700` | `text-amber-700 dark:text-amber-300` | 1 |
|
||||
| `text-rose-600` | `text-rose-600 dark:text-rose-400` | 1 |
|
||||
| `text-rose-500` | `text-rose-500 dark:text-rose-400` | 1 |
|
||||
| `border-slate-200` | `border-slate-200 dark:border-slate-700` | 23 |
|
||||
| `border-rose-200` | `border-rose-200 dark:border-rose-800` | 1 |
|
||||
| `ring-indigo-200` | `ring-indigo-200 dark:ring-indigo-800` | 1 |
|
||||
| `hover:bg-slate-100` | `hover:bg-slate-100 dark:hover:bg-slate-700` | 2 |
|
||||
| `hover:bg-slate-50` | `hover:bg-slate-50 dark:hover:bg-slate-800/60` | 2 |
|
||||
| `hover:bg-indigo-50` | `hover:bg-indigo-50 dark:hover:bg-indigo-950/40` | 1 |
|
||||
| `hover:bg-rose-50` | `hover:bg-rose-50 dark:hover:bg-rose-950/40` | 1 |
|
||||
| `hover:border-indigo-300` | `hover:border-indigo-300 dark:hover:border-indigo-600` | 1 |
|
||||
| `hover:border-slate-300` | `hover:border-slate-300 dark:hover:border-slate-600` | 1 |
|
||||
|
||||
치환 순서: `hover:` variants 먼저, 그 다음 일반 variants. `(?<!hover:)` negative lookbehind 사용해서 `hover:bg-*` 와 일반 `bg-*` 를 분리.
|
||||
|
||||
**후속 정리**: 치환 순서 때문에 `text-slate-500 dark:text-slate-400 dark:text-slate-500` 같은 중복 체인이 일부 생김. 원인: `text-slate-500` → `... dark:text-slate-400` 치환 직후, 다음 `text-slate-400` 치환이 방금 추가된 `dark:text-slate-400` 안의 `text-slate-400` 을 또 매칭해서 `dark:text-slate-500` 를 뒤에 붙임. 추가 Python 스크립트로 `(dark:text-slate-\d00)\s+dark:text-slate-\d00` 패턴을 반복 제거. **1회 반복 후 남은 체인 0개** 확인.
|
||||
|
||||
### 7.4 수정 후 검증 결과
|
||||
|
||||
| 검증 | 결과 |
|
||||
|---|---|
|
||||
| `/test-card-responsive` 페이지 렌더 | ✅ 정상 |
|
||||
| 카드 폭 슬라이더로 반응형 전환 (800 → 400) | ✅ v2-table-list 테이블 → 카드 리스트, v2-table-search-widget 가로 → 세로 스택 |
|
||||
| `/admin/builder` TemplateBuilder UI | ✅ 정상 |
|
||||
| 팔레트 → 캔버스 드롭 → 블록 표시 | ✅ position 값 정상 |
|
||||
| 드래그/리사이즈/Delete/Ctrl+Z/Ctrl+S | ✅ 전부 동작 |
|
||||
| 3뷰 토글 (목록/등록/수정) | ✅ 독립 블록 유지 |
|
||||
| 다크 모드 가시성 | ✅ 모든 영역 구분 가능 |
|
||||
| 기존 VEX 화면/디자이너 (DTG 이력관리, screenMng 등) | ⏭️ 마이그레이션 미완 상태라 작동 안 함 (알려진 상태, Phase 1 범위 아님, Phase 2 이후 개별 마이그레이션 예정) |
|
||||
|
||||
### 7.5 Phase 2 에 고려할 사항
|
||||
|
||||
- **`useRegistryPalette`**: Phase 2 의 "TemplateBuilder 실렌더링 연결" 작업에서 다시 건드림. 그때 lucide icon 실제 렌더 + FieldConfig 연결 + DataPort 연결 등 포함해 더 정교하게 개선.
|
||||
- **다크 모드 치환**: `dark:` variant 수동 추가 방식이라 지저분할 수 있음. Phase 2 에서 shadcn CSS 변수 기반(`bg-background`, `text-foreground`, `border-border` 등) 으로 리팩터 고려 가능. 단 v5-layout.css 의 토큰과의 통합이 필요.
|
||||
- 이 버그들은 **컴포넌트 레지스트리 연동 + 라이트/다크 디자인 시스템 통합** 이라는 더 큰 작업의 일부이므로, Phase 2 에서 자연스럽게 전체 정리됨.
|
||||
|
||||
---
|
||||
|
||||
## 끝
|
||||
|
||||
Phase 1 종료 (2026-04-11).
|
||||
|
||||
§4 사용자 수동 검증 **(A) `/test-card-responsive` 반응형 메커니즘 + (B) `/admin/builder` TemplateBuilder UI** 통과 확인. **(C) 기존 기능 sanity** 는 VEX → INVYONE 컴포넌트 마이그레이션이 "기능 업그레이드 먼저" 정책으로 아직 진행되지 않아 기존 VEX 화면들이 원래 작동하지 않는 정상 상태라 생략됨.
|
||||
|
||||
핵심 보장(사용자가 "반응형 된다" 고 해서 믿고 진행한 결정)이 PoC 에서 실제로 작동함을 확인: 카드 폭 변경 시 v2-table-list 가 ResizeObserver 기반으로 wide/narrow 모드를 자동 전환, v2-table-search-widget 은 CSS `@container` 기반으로 가로 → 세로 스택 전환. 스펙 MD §9 의 백업 플랜 적용 불필요.
|
||||
|
||||
Phase 2 에서:
|
||||
1. 사용자가 만드는 대시보드 시스템 구현 (`components/dash/*` 재작성)
|
||||
2. `DashboardCard` 재작성 — Card = Template 인스턴스, FreePosition + @container
|
||||
3. 레거시 타입 완전 제거 (@deprecated 표기한 것들)
|
||||
4. TemplateBuilder 실렌더링 연결 (ComponentRegistry + 실제 v2-* 렌더)
|
||||
5. 경량 7개 v2-* 모드 분기 추가
|
||||
6. 우선순위 2/3 v2-* 마이그레이션
|
||||
7. VEX 화면들을 INVYONE Template 로 마이그레이션 시작
|
||||
@@ -1,298 +0,0 @@
|
||||
# Phase 1 구현 작업기록 — DB 메타 → FieldConfig 변환
|
||||
|
||||
> **작업일**: 2026-04-10
|
||||
> **설계서**: `notes/gbpark/2026-04-10-phase1-db-meta-to-fieldconfig.md`
|
||||
> **상태**: 구현 완료 + DB 실테스트 통과
|
||||
|
||||
---
|
||||
|
||||
## 1. 생성된 파일 (4개)
|
||||
|
||||
### 1.1 `backend-spring/src/main/resources/mapper/meta.xml`
|
||||
|
||||
MyBatis XML 매퍼. namespace=`meta`, 쿼리 5개:
|
||||
|
||||
| 쿼리 ID | 용도 | 반환 |
|
||||
|---|---|---|
|
||||
| `getMetaTableList` | public 스키마 테이블 목록 | table_name, table_label, column_count, has_custom_meta |
|
||||
| `getSchemaColumns` | information_schema.columns 전체 컬럼 | column_name, data_type, is_nullable, column_default, ordinal_position |
|
||||
| `getPrimaryKeys` | PK 컬럼명 목록 | string (column_name) |
|
||||
| `getCustomMeta` | table_type_columns 커스텀 메타 (회사 우선순위) | column_name, column_label, input_type, detail_settings, reference_table 등 |
|
||||
| `getTableLabel` | TABLE_LABELS에서 라벨 단건 | string (table_label) |
|
||||
|
||||
**회사 우선순위 처리**: `getCustomMeta`에서 `DISTINCT ON (COLUMN_NAME)` + `CASE WHEN COMPANY_CODE = #{company_code} THEN 0 ELSE 1 END` 정렬로 회사별 메타가 '*'(글로벌)보다 우선.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 `backend-spring/src/main/java/com/erp/service/MetaService.java`
|
||||
|
||||
핵심 변환 로직. 덕일 스타일 준수 — extends BaseService, @Autowired CommonService, sqlSession 직접 호출.
|
||||
|
||||
#### 주요 메서드
|
||||
|
||||
| 메서드 | 역할 |
|
||||
|---|---|
|
||||
| `getMetaTableList(params)` | 테이블 목록 pass-through |
|
||||
| `getMetaFields(params)` | 4개 쿼리 오케스트레이션 → buildFieldConfigs 호출 → 결과 조립 |
|
||||
| `buildFieldConfigs(schemaCols, pks, customMeta)` | **핵심** — 3소스 병합하여 FieldConfig Map 리스트 생성 |
|
||||
| `mapDataTypeToFieldType(dataType)` | PostgreSQL data_type → FieldType 변환 (12개 매핑) |
|
||||
| `mapInputTypeToFieldType(inputType)` | table_type_columns.input_type → FieldType 변환 (15개 매핑) |
|
||||
| `buildFieldRef(meta, detailSettings)` | entity 타입의 ref 객체 빌드 (top-level 컬럼 우선 → detail_settings 폴백) |
|
||||
| `extractOptions(detailSettings)` | select 타입의 options 추출 (`string[]` 및 `[{value,label}]` 둘 다 처리) |
|
||||
| `parseDetailSettings(meta)` | detail_settings JSONB → Map 파싱 (ObjectMapper) |
|
||||
|
||||
#### buildFieldConfigs 병합 규칙 (필드별)
|
||||
|
||||
```
|
||||
column = information_schema.column_name
|
||||
label = table_type_columns.column_label (있으면) || column_name
|
||||
type = table_type_columns.input_type 매핑 (있으면) || data_type 매핑
|
||||
visible = system이면 무조건 false, 아니면 is_visible
|
||||
order = display_order > 0이면 우선 || ordinal_position
|
||||
required = ★ 앱 레벨 메타(table_type_columns.is_nullable) 우선, 없으면 DB 스키마 폴백
|
||||
editable = !PK && !system && type!='code'
|
||||
pk = information_schema PK 제약조건
|
||||
system = SYSTEM_FIELDS 목록에 포함 여부
|
||||
searchable = !system && (text|select|entity|date|code)
|
||||
sortable = !system
|
||||
format = number→'#,##0', date→'YYYY-MM-DD', datetime→'YYYY-MM-DD HH:mm'
|
||||
width = type별 기본값 (number:100, date:120, entity:180 등)
|
||||
align = number→'right', 나머지→'left'
|
||||
options = detail_settings.options (string[] 또는 [{value,label}] → label 추출)
|
||||
ref = entity일 때 reference_table/column/display_column (top-level 우선 → detail_settings 폴백)
|
||||
computed = detail_settings.computed (있으면 editable=false 강제)
|
||||
```
|
||||
|
||||
#### required 판정 로직 (★ 수동 수정 반영)
|
||||
|
||||
```java
|
||||
// ★ 앱 레벨 메타 우선, DB 스키마 폴백
|
||||
if (!isSystem && meta != null && meta.get("is_nullable") != null) {
|
||||
// table_type_columns.IS_NULLABLE가 있으면 앱 레벨 메타가 진실
|
||||
required = "NO".equalsIgnoreCase(metaNullable);
|
||||
} else {
|
||||
// 없으면 information_schema 폴백
|
||||
required = "NO".equalsIgnoreCase(isNullable) && columnDefault == null && !isSystem;
|
||||
}
|
||||
```
|
||||
|
||||
원래 구현: information_schema.is_nullable + column_default 기반.
|
||||
수정 이유: VEX에서 앱 레벨 메타(table_type_columns.IS_NULLABLE)가 DB 스키마보다 우선하는 패턴. 관리자가 설정한 필수 여부가 DB 제약조건보다 비즈니스적으로 정확함.
|
||||
|
||||
#### system 필드 목록
|
||||
|
||||
```
|
||||
company_code, created_by, created_date, updated_by, updated_date,
|
||||
is_active, deleted_date, deleted_by, writer, write_date
|
||||
```
|
||||
|
||||
→ 자동으로 `visible: false`, `editable: false`, `required: false`, `searchable: false`, `sortable: false`
|
||||
|
||||
#### 타입 매핑 테이블
|
||||
|
||||
**PostgreSQL data_type → FieldType:**
|
||||
|
||||
| data_type | FieldType |
|
||||
|---|---|
|
||||
| character varying, varchar | text |
|
||||
| text | textarea |
|
||||
| integer, bigint, smallint | number |
|
||||
| numeric, decimal, real, double precision | number |
|
||||
| boolean | checkbox |
|
||||
| date | date |
|
||||
| timestamp (with/without tz) | datetime |
|
||||
| jsonb, json | textarea |
|
||||
| bytea | file |
|
||||
| 기타 | text |
|
||||
|
||||
**input_type → FieldType (custom meta 우선):**
|
||||
|
||||
| input_type | FieldType |
|
||||
|---|---|
|
||||
| text, email, password, tel | text |
|
||||
| number, decimal | number |
|
||||
| date | date |
|
||||
| datetime | datetime |
|
||||
| select, category | select |
|
||||
| entity | entity |
|
||||
| checkbox, boolean | checkbox |
|
||||
| textarea, text_area | textarea |
|
||||
| file | file |
|
||||
| code, numbering | code |
|
||||
| 기타 | text |
|
||||
|
||||
#### entity ref 빌드 로직
|
||||
|
||||
```
|
||||
1차: top-level 컬럼 (REFERENCE_TABLE, REFERENCE_COLUMN, DISPLAY_COLUMN)
|
||||
2차: detail_settings JSON 폴백 (referenceTable, referenceColumn, displayColumn)
|
||||
→ table이 없으면 ref=null
|
||||
→ value_column 기본값 "id", display_column 기본값 = value_column
|
||||
→ search_columns는 detail_settings에서만 추출
|
||||
```
|
||||
|
||||
#### select options 추출 로직
|
||||
|
||||
```
|
||||
detail_settings.options 필드에서 추출
|
||||
- string[] 형식: ["대기", "확정", ...] → 그대로 반환
|
||||
- [{value, label}] 형식: [{value:"PENDING", label:"대기"}, ...] → label만 추출
|
||||
- options 없으면 null (category 타입은 common code 연동 필요 — Phase 1 범위 밖)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 `backend-spring/src/main/java/com/erp/controller/MetaController.java`
|
||||
|
||||
REST 컨트롤러. 2개 엔드포인트:
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|---|---|---|
|
||||
| `GET /api/meta/tables` | `getMetaTableList` | 접근 가능한 테이블 목록 |
|
||||
| `GET /api/meta/tables/{tableName}/fields` | `getMetaFields` | 특정 테이블의 FieldConfig[] 반환 |
|
||||
|
||||
- `@RequestAttribute("company_code")` — JWT 필터에서 주입 (snake_case, 기존 패턴 따름)
|
||||
- `@RequiredArgsConstructor` + `private final MetaService` — 기존 컨트롤러 패턴 따름
|
||||
- relations API는 Phase 1에서 안 만듦 (Phase 5 담당)
|
||||
|
||||
---
|
||||
|
||||
### 1.4 `frontend/lib/api/meta.ts`
|
||||
|
||||
프론트엔드 API 클라이언트. 2개 함수:
|
||||
|
||||
| 함수 | 반환 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `getMetaTableList()` | `Record<string, any>[]` | 테이블 목록 (별도 인터페이스 안 만듦) |
|
||||
| `getMetaFields(tableName)` | `{table_name, table_label, primary_key, fields: FieldConfig[]}` | FieldConfig 규격 타입 사용 (유일한 예외) |
|
||||
|
||||
**snake_case → camelCase 변환 처리:**
|
||||
- `toFieldConfig(raw)` — 대부분 단일 단어라 변환 불필요, `default_value` → `defaultValue`만 처리
|
||||
- `toFieldRef(raw)` — `value_column` → `valueColumn`, `display_column` → `displayColumn`, `search_columns` → `searchColumns`
|
||||
- `apiClient` 사용 (baseURL에 `/api` 이미 포함 → `/meta/tables`로 호출)
|
||||
|
||||
---
|
||||
|
||||
## 2. 덕일 스타일 준수 체크리스트
|
||||
|
||||
| 규칙 | 준수 |
|
||||
|---|---|
|
||||
| 3레이어 (Controller → Service → XML) | O |
|
||||
| Mapper Interface 금지 | O — sqlSession 직접 호출 |
|
||||
| Map<String, Object> 사용, DTO 금지 | O — ApiResponse만 예외 |
|
||||
| BaseService 상속 | O |
|
||||
| @Autowired CommonService | O |
|
||||
| XML 파일명: 소문자, Mapper 안 붙임 | O — `meta.xml` |
|
||||
| XML namespace: 파일명과 동일 | O — `namespace="meta"` |
|
||||
| SQL: UPPER_SNAKE | O |
|
||||
| SELECT 쉼표: 앞에 | O |
|
||||
| #{파라미터}: snake_case | O |
|
||||
| OGNL test: 바깥 작은따옴표 | O |
|
||||
| 프론트 타입: Record<string, any> (FieldConfig만 예외) | O |
|
||||
|
||||
---
|
||||
|
||||
## 3. 실제 DB 테스트 결과
|
||||
|
||||
**테스트 환경**: `test_dev` DB (211.115.91.141:11134)
|
||||
**테스트 계정**: shkim (SUPER_ADMIN)
|
||||
**테스트 테이블**: `sales_order_mng` (entity/select/category/number/date 타입 다수 보유)
|
||||
|
||||
### 3.1 GET /api/meta/tables
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{"table_name": "approval_definitions", "table_label": "approval_definitions", "column_count": 15, "has_custom_meta": false},
|
||||
{"table_name": "sales_order_mng", "table_label": "수주관리", "column_count": 44, "has_custom_meta": true},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 GET /api/meta/tables/sales_order_mng/fields
|
||||
|
||||
**검증 결과:**
|
||||
|
||||
| 검증 항목 | 결과 | 상세 |
|
||||
|---|---|---|
|
||||
| table_label | OK | "수주관리" (TABLE_LABELS에서) |
|
||||
| primary_key | OK | "id" |
|
||||
| PK 감지 | OK | id: `pk:true, editable:false` |
|
||||
| system 필드 | OK | company_code, created_by/date, updated_by/date, writer → `visible:false, system:true` |
|
||||
| entity + ref | OK | partner_id: `ref:{table:"customer_mng", value_column:"customer_code", display_column:"customer_name"}` |
|
||||
| entity (top-level 컬럼) | OK | delivery_partner_id: `ref:{table:"delivery_destination", ...}` |
|
||||
| select + options ({value,label}) | OK | shipping_method: `options:["직접배송","택배","화물","퀵서비스"]` |
|
||||
| select + options (문자열) | OK | status: `options:["대기","확정","출하","완료","취소"]` |
|
||||
| custom meta 우선 | OK | id(integer) → table_type_columns에 input_type='text' 설정 → type:"text" |
|
||||
| date 포맷 | OK | `format:"YYYY-MM-DD"` |
|
||||
| number 포맷/정렬 | OK | `format:"#,##0", align:"right"` |
|
||||
| 컴파일 | OK | `./gradlew compileJava` + `./gradlew bootJar` 성공 |
|
||||
| 프론트 타입체크 | OK | `npx tsc --noEmit` — meta.ts 에러 0개 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 1 범위 밖 (나중에 해야 할 것)
|
||||
|
||||
| 항목 | 담당 Phase | 비고 |
|
||||
|---|---|---|
|
||||
| relations API (table_relationships) | Phase 5 | 제어 모드에서 사용 |
|
||||
| category 타입의 common code 옵션 로딩 | Phase 2+ | CODE_CATEGORY → 공통코드 테이블 조회 |
|
||||
| entity ref가 없는 entity 필드 처리 | Phase 2+ | part_code, manager_id 등 — reference_table 미설정 |
|
||||
| FieldConfig.defaultValue, placeholder 세팅 | Phase 2+ | detail_settings에서 추출 가능하나 현재 미구현 |
|
||||
| select options의 value/label 분리 저장 | Phase 2+ | 현재 label만 string[]로 반환, value 매핑 필요 시 타입 확장 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 파일별 코드 요약
|
||||
|
||||
### meta.xml (89줄)
|
||||
|
||||
```
|
||||
5개 쿼리:
|
||||
- getMetaTableList: INFORMATION_SCHEMA.TABLES + TABLE_LABELS + TABLE_TYPE_COLUMNS EXISTS
|
||||
- getSchemaColumns: INFORMATION_SCHEMA.COLUMNS
|
||||
- getPrimaryKeys: TABLE_CONSTRAINTS + KEY_COLUMN_USAGE
|
||||
- getCustomMeta: TABLE_TYPE_COLUMNS (DISTINCT ON + 회사 우선순위)
|
||||
- getTableLabel: TABLE_LABELS 단건
|
||||
```
|
||||
|
||||
### MetaService.java (375줄)
|
||||
|
||||
```
|
||||
퍼블릭 메서드 2개:
|
||||
- getMetaTableList(params) → List<Map>
|
||||
- getMetaFields(params) → Map (table_name, table_label, primary_key, fields[])
|
||||
|
||||
프라이빗 메서드 10개:
|
||||
- buildFieldConfigs: 3소스 병합 (핵심, ~120줄)
|
||||
- mapDataTypeToFieldType: PG타입→FieldType (12매핑)
|
||||
- mapInputTypeToFieldType: input_type→FieldType (15매핑)
|
||||
- buildFieldRef: entity ref 조립 (top-level 우선 → detail_settings 폴백)
|
||||
- extractOptions: select options 추출 (string[] + {value,label} 대응)
|
||||
- parseDetailSettings: JSONB→Map (ObjectMapper)
|
||||
- getDefaultWidth: 타입별 기본 너비
|
||||
- getDefaultFormat: 타입별 기본 포맷
|
||||
- str, strFromMap, num: Map 유틸
|
||||
```
|
||||
|
||||
### MetaController.java (45줄)
|
||||
|
||||
```
|
||||
2개 엔드포인트:
|
||||
- GET /api/meta/tables → getMetaTableList
|
||||
- GET /api/meta/tables/{tableName}/fields → getMetaFields
|
||||
```
|
||||
|
||||
### meta.ts (86줄)
|
||||
|
||||
```
|
||||
2개 API 함수:
|
||||
- getMetaTableList() → Record<string, any>[]
|
||||
- getMetaFields(tableName) → {fields: FieldConfig[], ...}
|
||||
|
||||
2개 변환 헬퍼:
|
||||
- toFieldConfig(raw) → FieldConfig
|
||||
- toFieldRef(raw) → FieldRef (snake→camel)
|
||||
```
|
||||
@@ -1,305 +0,0 @@
|
||||
# Phase 2 구현 작업기록 — FieldConfig 기반 컴포넌트 (FcTable/FcForm/FcSearch)
|
||||
|
||||
> **작업일**: 2026-04-10
|
||||
> **설계서**: `notes/gbpark/2026-04-10-phase2-fieldconfig-components.md`
|
||||
> **상태**: 구현 완료 + TypeScript 타입체크 통과 (tsc --noEmit 에러 0개)
|
||||
|
||||
---
|
||||
|
||||
## 1. 생성된 파일 (17개)
|
||||
|
||||
### 1.1 파일 구조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── components/fc/ ← FieldConfig 기반 컴포넌트 (신규)
|
||||
│ ├── index.ts ← 공개 exports
|
||||
│ ├── FcTable.tsx ← 데이터 테이블 (TanStack Table v8)
|
||||
│ ├── FcForm.tsx ← 입력 폼 (자체 상태 관리)
|
||||
│ ├── FcSearch.tsx ← 검색 필터
|
||||
│ ├── FcButton.tsx ← 단일 버튼 (confirm 팝업 포함)
|
||||
│ ├── FcButtonBar.tsx ← 버튼 그룹
|
||||
│ ├── FcPagination.tsx ← 페이지네이션
|
||||
│ ├── fields/ ← FieldType별 렌더러 (10종)
|
||||
│ │ ├── FieldRenderer.tsx ← FieldType→위젯 디스패처
|
||||
│ │ ├── TextField.tsx
|
||||
│ │ ├── NumberField.tsx
|
||||
│ │ ├── DateField.tsx
|
||||
│ │ ├── DateTimeField.tsx
|
||||
│ │ ├── SelectField.tsx
|
||||
│ │ ├── EntityField.tsx
|
||||
│ │ ├── CheckboxField.tsx
|
||||
│ │ ├── TextareaField.tsx
|
||||
│ │ ├── FileField.tsx
|
||||
│ │ └── CodeField.tsx
|
||||
│ └── table/ ← 테이블 전용
|
||||
│ └── CellRenderer.tsx ← 셀 포맷 디스패처
|
||||
├── lib/api/
|
||||
│ └── fcData.ts ← FieldConfig 기반 CRUD API 래퍼 (신규)
|
||||
└── app/(main)/test-fc/
|
||||
└── page.tsx ← 테스트 페이지 (신규)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 컴포넌트 상세
|
||||
|
||||
### 2.1 FieldRenderer — FieldType→위젯 디스패처
|
||||
|
||||
`frontend/components/fc/fields/FieldRenderer.tsx`
|
||||
|
||||
- `mode='form'` / `mode='search'` 분기로 같은 FieldConfig가 다른 위젯으로 렌더
|
||||
- switch/case로 10종 FieldType을 개별 컴포넌트에 위임
|
||||
- 모든 필드 공통 인터페이스: `{ field, value, onChange, mode, disabled?, error? }`
|
||||
|
||||
### 2.2 렌더링 계약 구현 (spec Section 2.2 전부 준수)
|
||||
|
||||
| FieldType | FcTable 셀 | FcForm 입력 | FcSearch 검색 |
|
||||
|---|---|---|---|
|
||||
| `text` | 텍스트 그대로 | `<Input type="text">` | `<Input>` (부분 일치) |
|
||||
| `number` | `#,##0` 포맷 (toLocaleString) | `<Input type="number">` | min~max 범위 2개 |
|
||||
| `date` | YYYY-MM-DD 포맷 | `<Input type="date">` | from~to 범위 2개 |
|
||||
| `datetime` | YYYY-MM-DD HH:mm 포맷 | `<Input type="datetime-local">` | from~to 범위 2개 |
|
||||
| `select` | 텍스트 그대로 | Radix `<Select>` 단일 | MultiSelect (Checkbox 드롭다운) |
|
||||
| `entity` | ref.displayColumn 그대로 | `<Input>` + 검색 버튼 | `<Input>` + 검색 버튼 |
|
||||
| `checkbox` | ✓(green) / ✗(muted) 아이콘 | Radix `<Checkbox>` | `<Select>` 전체/✓/✗ |
|
||||
| `textarea` | 40자 말줄임 (...) | `<textarea rows={3}>` | `<Input>` (부분 일치) |
|
||||
| `file` | 파일명 + FileText 아이콘 | 파일 업로드 버튼 | — (렌더 안 함) |
|
||||
| `code` | 텍스트 그대로 | readonly + Lock 아이콘 | `<Input>` (완전 일치) |
|
||||
|
||||
### 2.3 FcTable — 데이터 테이블
|
||||
|
||||
`frontend/components/fc/FcTable.tsx` (~190줄)
|
||||
|
||||
**기술 스택**: TanStack Table v8 (`@tanstack/react-table`)
|
||||
|
||||
**기능:**
|
||||
- `fields`에서 `visible: true`인 것만 컬럼으로 표시, `order` 순 정렬
|
||||
- `width`, `align` 적용 (없으면 타입별 기본값)
|
||||
- `sortable: true`인 컬럼은 헤더 클릭 시 정렬 토글 (asc→desc→none)
|
||||
- 행 클릭 시 `onRowSelect(row)` 호출 + 선택 행 하이라이트
|
||||
- `showCheckbox: true` + `selectionMode: 'multiple'`일 때 체크박스 전체선택/개별선택
|
||||
- 로딩 스피너, 빈 데이터 표시
|
||||
- `CellRenderer`가 FieldType에 따라 셀 포맷팅
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface FcTableProps {
|
||||
fields: FieldConfig[];
|
||||
data: Record<string, any>[];
|
||||
config?: Partial<TableConfig>;
|
||||
loading?: boolean;
|
||||
onRowSelect?: (row: Record<string, any>) => void;
|
||||
onRowsSelect?: (rows: Record<string, any>[]) => void;
|
||||
selectedRowIndex?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 FcForm — 입력 폼
|
||||
|
||||
`frontend/components/fc/FcForm.tsx` (~130줄)
|
||||
|
||||
**상태 관리**: `useState` 직접 사용 (React Hook Form 의존성 제거 — 폼이 단순하고 FieldConfig 동적 필드라 자체 관리가 적합)
|
||||
|
||||
**기능:**
|
||||
- `system: true` 필드 숨김, `visible: true`만 표시, `order` 순
|
||||
- `config.columns` (1/2/3)에 따라 CSS Grid 레이아웃
|
||||
- `config.sections`가 있으면 섹션별로 그룹핑 (라벨 + 구분선)
|
||||
- `required: true` 필드에 * 표시
|
||||
- `editable: false` 필드 disabled
|
||||
- `pk: true` + `type: 'code'`이면 readonly (자동채번)
|
||||
- `loadRow` 변경 시 폼 데이터 자동 갱신
|
||||
- 제출 시 `required` 검증 (null/undefined/'' 만 empty, 0/false는 유효)
|
||||
- 초기화 버튼 (loadRow/initialData로 복원)
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface FcFormProps {
|
||||
fields: FieldConfig[];
|
||||
config?: Partial<FormConfig>;
|
||||
initialData?: Record<string, any>;
|
||||
onSubmit?: (data: Record<string, any>) => void;
|
||||
loadRow?: Record<string, any>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 FcSearch — 검색 필터
|
||||
|
||||
`frontend/components/fc/FcSearch.tsx` (~110줄)
|
||||
|
||||
**기능:**
|
||||
- `searchable: true` + `!system` 필드만 추출, `order` 순
|
||||
- `FieldRenderer(mode='search')`로 각 필드 렌더
|
||||
- `config.layout: 'inline'` (한 줄 나열) / `'stacked'` (세로)
|
||||
- `config.autoSearch: true`이면 300ms 디바운스 후 자동 `onSearch`
|
||||
- 검색/초기화 버튼
|
||||
- `buildSearchParams()` 변환 로직:
|
||||
|
||||
```
|
||||
date/datetime 범위 → { column_from: '2026-01-01', column_to: '2026-12-31' }
|
||||
number 범위 → { column_min: 1000, column_max: 9999 }
|
||||
select 다중 → { column: ['확정', '완료'] }
|
||||
text 부분 일치 → { column: '삼성' }
|
||||
```
|
||||
|
||||
### 2.6 CellRenderer — 테이블 셀 포맷터
|
||||
|
||||
`frontend/components/fc/table/CellRenderer.tsx` (~85줄)
|
||||
|
||||
| 타입 | 포맷 |
|
||||
|---|---|
|
||||
| number | `#,##0` → `toLocaleString('ko-KR')`, 소수점 포맷도 지원 |
|
||||
| date | `YYYY-MM-DD` (Date 파싱 후 수동 포맷) |
|
||||
| datetime | `YYYY-MM-DD HH:mm` |
|
||||
| checkbox | ✓ (green Check 아이콘) / ✗ (muted X 아이콘) |
|
||||
| textarea | 40자 초과 시 `...` 말줄임 + title 툴팁 |
|
||||
| file | FileText 아이콘 + 파일명 링크 스타일 |
|
||||
| null/undefined | `-` (muted) |
|
||||
| 기타 | `String(value)` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 보조 컴포넌트
|
||||
|
||||
### 3.1 FcButton
|
||||
|
||||
- `ButtonConfig.variant` 5종 (primary/default/destructive/outline/ghost) → v5 토큰 매핑
|
||||
- `confirm` 속성 있으면 클릭 시 확인/취소 인라인 팝업
|
||||
|
||||
### 3.2 FcButtonBar
|
||||
|
||||
- `ButtonBarConfig.buttons[]` 순회하며 `FcButton` 렌더
|
||||
- `onAction(actionType)` 콜백으로 12종 ActionType 전달
|
||||
|
||||
### 3.3 FcPagination
|
||||
|
||||
- 총 건수, 페이지 크기 선택기 (10/20/50/100)
|
||||
- 5페이지 범위 번호 표시 + 처음/이전/다음/끝 버튼
|
||||
- `onPageChange({ page, size })` 콜백
|
||||
|
||||
---
|
||||
|
||||
## 4. API 래퍼
|
||||
|
||||
### 4.1 fcData.ts
|
||||
|
||||
`frontend/lib/api/fcData.ts` (~35줄)
|
||||
|
||||
기존 `dataApi` (frontend/lib/api/data.ts)를 감싸는 얇은 래퍼.
|
||||
|
||||
| 함수 | 용도 | 내부 호출 |
|
||||
|---|---|---|
|
||||
| `fcList(params)` | 목록 조회 (검색+페이징) | `dataApi.getTableData()` |
|
||||
| `fcGet(tableName, id)` | 단건 조회 | `dataApi.getRecordDetail()` |
|
||||
| `fcInsert(tableName, data)` | 등록 | `dataApi.createRecord()` |
|
||||
| `fcUpdate(tableName, id, data)` | 수정 | `dataApi.updateRecord()` |
|
||||
| `fcDelete(tableName, ids)` | 삭제 (복수) | `dataApi.deleteRecord()` × N |
|
||||
|
||||
★ 별도 인터페이스 정의 안 함 — 전부 `Record<string, any>`
|
||||
|
||||
---
|
||||
|
||||
## 5. 테스트 페이지
|
||||
|
||||
### 5.1 `/test-fc` (frontend/app/(main)/test-fc/page.tsx)
|
||||
|
||||
**레이아웃**: 좌(검색+테이블+페이지네이션) / 우(폼) 2컬럼
|
||||
|
||||
**흐름:**
|
||||
```
|
||||
1. 드롭다운에서 테이블 선택
|
||||
2. getMetaFields(tableName) → FieldConfig[] 수신
|
||||
3. FcSearch + FcTable + FcForm 동시 렌더
|
||||
4. FcSearch 검색 → searchParams → fcList() 재조회 → FcTable 갱신
|
||||
5. FcTable 행 클릭 → selectedRow → FcForm 데이터 로드
|
||||
6. FcForm 저장 → fcInsert/fcUpdate → 성공 메시지 + FcTable 새로고침
|
||||
```
|
||||
|
||||
**기능:**
|
||||
- 테이블 목록에 `has_custom_meta` 표시 (★)
|
||||
- 테이블 선택 시 필드 수, PK 표시
|
||||
- 수정/신규 모드 자동 전환 (selectedRow 유무)
|
||||
- 성공/실패 알림 (3초 자동 소멸)
|
||||
|
||||
---
|
||||
|
||||
## 6. v5 디자인 토큰 사용 목록
|
||||
|
||||
모든 컴포넌트에서 즉흥 hex/rgb 사용 안 함. v5-layout.css 토큰만 사용:
|
||||
|
||||
| 용도 | 토큰 |
|
||||
|---|---|
|
||||
| 배경 | `var(--v5-glass)`, `var(--v5-surface)`, `var(--v5-bg-subtle)` |
|
||||
| 테두리 | `var(--v5-glass-border)`, `var(--v5-border)`, `var(--v5-border-subtle)` |
|
||||
| 텍스트 | `var(--v5-text)`, `var(--v5-text-sec)`, `var(--v5-text-muted)` |
|
||||
| 강조 | `var(--v5-primary)`, `var(--v5-primary-glow)` |
|
||||
| 상태 | `var(--v5-green)` (✓), `var(--v5-red)` (* 필수, 에러) |
|
||||
| 호버 | `var(--v5-surface-hover)` |
|
||||
| 글로우 | `var(--v5-glow-sm)` |
|
||||
| 블러 | `backdrop-blur-[20px]` (v5 글래스 패턴) |
|
||||
|
||||
폰트 크기: 0.65rem(라벨) ~ 0.75rem(본문) — v5 컴팩트 스케일 준수
|
||||
|
||||
---
|
||||
|
||||
## 7. 덕일 스타일 / 프로젝트 컨벤션 준수
|
||||
|
||||
| 규칙 | 준수 |
|
||||
|---|---|
|
||||
| 프론트 타입: `Record<string, any>` (FieldConfig만 예외) | ✅ |
|
||||
| 별도 인터페이스 정의 금지 | ✅ — Props만 인라인 정의 |
|
||||
| FieldConfig/TableConfig 등은 invyone-component.ts 타입 사용 | ✅ |
|
||||
| v5 CSS 토큰 사용, 즉흥 값 금지 | ✅ |
|
||||
| shadcn UI 컴포넌트 재활용 (Input, Select, Checkbox) | ✅ |
|
||||
| 기존 dataApi 활용 (중복 API 안 만듦) | ✅ |
|
||||
| 컴팩트 폰트 사이즈 (0.55~0.85rem) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 8. TypeScript 검증 결과
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit 2>&1 | grep -E "(components/fc/|test-fc/|lib/api/fcData)"
|
||||
# 출력 없음 — 에러 0개
|
||||
```
|
||||
|
||||
기존 코드(admin 페이지)의 타입 에러는 Phase 2와 무관.
|
||||
|
||||
---
|
||||
|
||||
## 9. Phase 2 범위 밖 (다음에 해야 할 것)
|
||||
|
||||
| 항목 | 담당 Phase | 비고 |
|
||||
|---|---|---|
|
||||
| entity 팝업 검색 (ref 테이블 조회) | Phase 3+ | 현재는 텍스트 입력 + 검색 버튼 UI만 |
|
||||
| category 공통코드 옵션 로딩 | Phase 2+ | CODE_CATEGORY → 공통코드 테이블 조회 |
|
||||
| 실제 파일 업로드 구현 | Phase 3+ | 현재는 파일 선택 UI만 |
|
||||
| computed 수식 파서 | Phase 5 | AST 기반 안전한 파서 필요 |
|
||||
| DataPort 이벤트 버스 | Phase 3 | 빌더에서 컴포넌트 간 연결 시 |
|
||||
| inlineEdit (테이블 인라인 편집) | Phase 3+ | TableConfig.inlineEdit는 정의만 |
|
||||
| 엑셀 내보내기 | Phase 3+ | toolbar.showExcel UI 미구현 |
|
||||
| select options의 value/label 분리 | Phase 2+ | 현재 label만 string[]으로 반환 |
|
||||
| defaultValue, placeholder 자동 세팅 | Phase 2+ | Phase 1에서 미구현 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 사용법 (다음 Phase에서 참고)
|
||||
|
||||
```tsx
|
||||
import { FcTable, FcForm, FcSearch, FcPagination } from '@/components/fc';
|
||||
import { getMetaFields } from '@/lib/api/meta';
|
||||
import { fcList } from '@/lib/api/fcData';
|
||||
|
||||
// 1. FieldConfig 가져오기
|
||||
const meta = await getMetaFields('sales_order_mng');
|
||||
const fields = meta.fields;
|
||||
|
||||
// 2. 데이터 조회
|
||||
const result = await fcList({ tableName: 'sales_order_mng', page: 1, size: 20 });
|
||||
|
||||
// 3. 컴포넌트 렌더
|
||||
<FcSearch fields={fields} onSearch={handleSearch} />
|
||||
<FcTable fields={fields} data={result.data} onRowSelect={handleSelect} />
|
||||
<FcForm fields={fields} loadRow={selectedRow} onSubmit={handleSave} />
|
||||
<FcPagination total={result.total} page={1} onPageChange={handlePage} />
|
||||
```
|
||||
@@ -1,480 +0,0 @@
|
||||
# Phase 3 구현 작업기록 — 개발자 빌더 (수동 템플릿 구성)
|
||||
|
||||
> **작업일**: 2026-04-10
|
||||
> **설계서**: `notes/gbpark/2026-04-10-phase3-developer-builder.md`
|
||||
> **상태**: 구현 완료 + TypeScript 타입체크 통과 + DB 테이블 생성 완료
|
||||
|
||||
---
|
||||
|
||||
## 1. 생성된 파일 (18개)
|
||||
|
||||
### 1.1 파일 구조
|
||||
|
||||
```
|
||||
backend-spring/
|
||||
├── src/main/java/com/erp/
|
||||
│ ├── controller/TemplateController.java ← CRUD + publish 엔드포인트 (신규)
|
||||
│ └── service/TemplateService.java ← Template CRUD 비즈니스 로직 (신규)
|
||||
└── src/main/resources/mapper/
|
||||
└── template.xml ← MyBatis SQL (신규)
|
||||
|
||||
frontend/
|
||||
├── lib/api/
|
||||
│ └── template.ts ← Template CRUD API 클라이언트 (신규)
|
||||
├── styles/
|
||||
│ └── developer.css ← IDE 스타일 개발자 테마 (신규)
|
||||
├── components/builder/ ← 빌더 컴포넌트 디렉토리 (전체 신규)
|
||||
│ ├── BuilderLayout.tsx ← 3패널 셸 + 키보드 단축키 + 상태바
|
||||
│ ├── BuilderToolbar.tsx ← 헤더 + 도구모음 (테이블 선택, 뷰 탭, 저장)
|
||||
│ ├── BuilderPalette.tsx ← 좌측 팔레트 (8종 컴포넌트)
|
||||
│ ├── BuilderCanvas.tsx ← 중앙 캔버스 (드롭 영역, 팝업 뷰)
|
||||
│ ├── BuilderBlock.tsx ← 개별 블록 (드래그/리사이즈 + 타입별 프리뷰)
|
||||
│ ├── BuilderProps.tsx ← 우측 속성 패널 (공통 + 타입별 분기)
|
||||
│ ├── hooks/
|
||||
│ │ ├── useBuilderState.ts ← Zustand 빌더 상태관리
|
||||
│ │ └── useBlockDrag.ts ← 블록 드래그/리사이즈 훅
|
||||
│ └── props/
|
||||
│ ├── FieldListEditor.tsx ← 필드 체크리스트 (공통)
|
||||
│ ├── TableProps.tsx ← 테이블 속성 패널
|
||||
│ ├── FormProps.tsx ← 폼 속성 패널
|
||||
│ ├── SearchProps.tsx ← 검색 속성 패널
|
||||
│ ├── ButtonProps.tsx ← 버튼/버튼바 속성 패널
|
||||
│ └── TitleProps.tsx ← 제목 속성 패널
|
||||
└── app/(main)/admin/builder/
|
||||
└── page.tsx ← 빌더 페이지 진입점
|
||||
```
|
||||
|
||||
### 1.2 수정된 파일 (2개)
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|---|---|
|
||||
| `backend-spring/src/main/resources/application.yml` | DB 접속 정보 변경: `39.117.244.52:11132/testvex` → `211.115.91.141:11134/test_dev` |
|
||||
| `CLAUDE.local.md` | DB 접속 정보 업데이트 기록 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 백엔드 상세
|
||||
|
||||
### 2.1 TemplateController.java (~100줄)
|
||||
|
||||
`/api/templates` REST 컨트롤러. 6개 엔드포인트:
|
||||
|
||||
| 메서드 | 엔드포인트 | 설명 |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/templates` | 템플릿 목록 (keyword, status, category 필터 + 페이지네이션) |
|
||||
| `GET` | `/api/templates/{templateId}` | 템플릿 상세 (JSONB 파싱된 Template 전체) |
|
||||
| `POST` | `/api/templates` | 템플릿 생성 (template_id 자동 생성: `tpl_` + UUID 12자) |
|
||||
| `PUT` | `/api/templates/{templateId}` | 템플릿 수정 (version 자동 증가) |
|
||||
| `PUT` | `/api/templates/{templateId}/publish` | 템플릿 게시 (draft → published) |
|
||||
| `DELETE` | `/api/templates/{templateId}` | 템플릿 삭제 (소프트: IS_ACTIVE='D') |
|
||||
|
||||
**패턴**: MetaController와 동일 — `@RequestAttribute("company_code")`, `@RequestAttribute("user_id")`, `ApiResponse.success/error`
|
||||
|
||||
### 2.2 TemplateService.java (~120줄)
|
||||
|
||||
덕일 스타일 준수 — `extends BaseService`, `@Autowired CommonService`, `sqlSession` 직접 호출.
|
||||
|
||||
| 메서드 | 역할 |
|
||||
|---|---|
|
||||
| `getTemplateList(params)` | CommonService.applyPagination → selectList + selectOne(Cnt) → buildListResponse |
|
||||
| `getTemplateInfo(params)` | selectOne + JSONB 파싱 (fields, views, connections) |
|
||||
| `insertTemplate(params)` | UUID 생성 + JSON 직렬화 + insert |
|
||||
| `updateTemplate(params)` | JSON 직렬화 + update (VERSION + 1) |
|
||||
| `publishTemplate(params)` | status='published' 업데이트 |
|
||||
| `deleteTemplate(params)` | IS_ACTIVE='D' 소프트 삭제 |
|
||||
|
||||
**JSONB 처리 유틸 (private):**
|
||||
- `parseJsonField(row, key)` — PostgreSQL JSONB 문자열 → Java Object (ObjectMapper)
|
||||
- `stringifyJsonField(params, key)` — Java Object → JSON 문자열 (INSERT/UPDATE 전)
|
||||
|
||||
### 2.3 template.xml (~120줄)
|
||||
|
||||
MyBatis 매퍼. namespace=`template`, 쿼리 6개:
|
||||
|
||||
| 쿼리 ID | 용도 |
|
||||
|---|---|
|
||||
| `getTemplateList` | 목록 (keyword LIKE, status/category 필터, companyCodeFilter, dynamicOrderBy, pagination) |
|
||||
| `getTemplateListCnt` | 목록 카운트 |
|
||||
| `getTemplateInfo` | 단건 (FIELDS, VIEWS, CONNECTIONS JSONB 포함) |
|
||||
| `insertTemplate` | 등록 (`#{fields}::jsonb` 캐스팅) |
|
||||
| `updateTemplate` | 수정 (VERSION + 1) |
|
||||
| `deleteTemplate` | 소프트 삭제 |
|
||||
| `publishTemplate` | 게시 상태 변경 |
|
||||
|
||||
**common include 사용**: `companyCodeFilter`, `dynamicOrderBy`, `pagination`
|
||||
**OGNL test**: 바깥 작은따옴표 규칙 준수 (`test='keyword != null and keyword != ""'`)
|
||||
|
||||
---
|
||||
|
||||
## 3. 프론트엔드 상세
|
||||
|
||||
### 3.1 template.ts — API 클라이언트 (~40줄)
|
||||
|
||||
| 함수 | 반환 | 설명 |
|
||||
|---|---|---|
|
||||
| `getTemplateList(params?)` | `Record<string, any>` | 목록 (list + total_count) |
|
||||
| `getTemplateInfo(templateId)` | `Record<string, any> \| null` | 상세 (Template JSON 전체) |
|
||||
| `insertTemplate(data)` | `Record<string, any>` | 생성 → `{ template_id }` 반환 |
|
||||
| `updateTemplate(templateId, data)` | `void` | 수정 |
|
||||
| `publishTemplate(templateId)` | `void` | 게시 |
|
||||
| `deleteTemplate(templateId)` | `void` | 삭제 |
|
||||
|
||||
★ 별도 인터페이스 정의 안 함 — 전부 `Record<string, any>` (덕일 스타일)
|
||||
|
||||
### 3.2 developer.css — IDE 스타일 테마 (~350줄)
|
||||
|
||||
mockup `09-developer.css` 기반 React 포팅. v5 코스믹이 아닌 IDE/Figma 스타일.
|
||||
|
||||
**색상 체계 (CSS 변수 `--d-*`):**
|
||||
|
||||
| 변수 | 다크 | 라이트 | 용도 |
|
||||
|---|---|---|---|
|
||||
| `--d-bg` | #121218 | #f5f5f8 | 기본 배경 |
|
||||
| `--d-bg2` | #1a1a22 | #ededf2 | 패널 배경 |
|
||||
| `--d-bg3` | #22222c | #e4e4ec | 입력 배경 |
|
||||
| `--d-surface` | #2a2a36 | #fff | 호버 배경 |
|
||||
| `--d-border` | #3a3a48 | #d8d8e2 | 기본 보더 |
|
||||
| `--d-text` | #e8e8ee | #1a1a24 | 기본 텍스트 |
|
||||
| `--d-accent` | #5b9ef5 | #3b7dd8 | 액센트 (블루) |
|
||||
| `--d-red` | #f87171 | #dc2626 | 위험/필수 |
|
||||
| `--d-green` | #4ade80 | #16a34a | 성공/검색 |
|
||||
|
||||
**주요 클래스:**
|
||||
- `.dev-shell` — 전체 셸 (flex column, 100vh)
|
||||
- `.dev-hdr` — 헤더 (42px)
|
||||
- `.dev-toolbar` — 도구모음 (34px)
|
||||
- `.dev-palette` — 좌측 팔레트 (180px)
|
||||
- `.dev-canvas` — 중앙 캔버스 (도트 그리드 배경)
|
||||
- `.dev-props` — 우측 속성 패널 (260px)
|
||||
- `.dev-block` — 캔버스 위 블록 (점선 보더, 선택 시 solid + 글로우)
|
||||
- `.dev-status` — 하단 상태바 (22px)
|
||||
- `.dev-popup-overlay/frame` — 등록/수정 팝업 편집 프레임
|
||||
|
||||
**폰트 사이즈**: 0.36rem(배지) ~ 0.72rem(로고) — mockup 컴팩트 스케일 그대로
|
||||
|
||||
### 3.3 useBuilderState.ts — Zustand 상태관리 (~280줄)
|
||||
|
||||
`create<BuilderState>()(devtools(...))` 패턴 (기존 tabStore와 동일).
|
||||
|
||||
**상태:**
|
||||
|
||||
| 키 | 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `tableName` | `string \| null` | 선택된 테이블 |
|
||||
| `fields` | `FieldConfig[]` | 현재 테이블의 필드 목록 |
|
||||
| `currentView` | `'list' \| 'create' \| 'edit'` | 현재 뷰 탭 |
|
||||
| `blocks` | `Record<BuilderView, Component[]>` | 뷰별 블록 목록 |
|
||||
| `selectedBlockId` | `string \| null` | 선택된 블록 |
|
||||
| `connections` | `Connection[]` | DataPort 연결 목록 |
|
||||
| `templateId` | `string \| null` | 저장된 템플릿 ID |
|
||||
| `templateName` | `string` | 템플릿 이름 |
|
||||
| `category` | `string` | 분류 |
|
||||
| `description` | `string` | 설명 |
|
||||
| `isDirty` | `boolean` | 변경 여부 |
|
||||
|
||||
**액션 (17개):**
|
||||
|
||||
| 액션 | 설명 |
|
||||
|---|---|
|
||||
| `setTable(name, fields)` | 테이블 선택 → fields 로드 |
|
||||
| `switchView(view)` | 뷰 탭 전환 (선택 해제) |
|
||||
| `addBlock(type, position)` | 블록 추가 (기본 config/size/label 자동 설정) |
|
||||
| `removeBlock(id)` | 블록 삭제 (연결도 함께 제거) |
|
||||
| `updateBlock(id, updates)` | 블록 업데이트 |
|
||||
| `selectBlock(id)` | 블록 선택 |
|
||||
| `moveBlock(id, x, y)` | 블록 이동 (min 0) |
|
||||
| `resizeBlock(id, w, h)` | 블록 리사이즈 (min 40x20) |
|
||||
| `updateBlockConfig(id, config)` | 타입별 config 업데이트 |
|
||||
| `updateField(column, updates)` | FieldConfig 속성 변경 |
|
||||
| `setTemplateMeta(meta)` | 템플릿 이름/분류/설명 변경 |
|
||||
| `addConnection(conn)` | DataPort 연결 추가 |
|
||||
| `removeConnection(connId)` | 연결 제거 |
|
||||
| `toTemplate()` | 현재 상태 → Template JSON (저장용) |
|
||||
| `fromTemplate(tpl)` | Template JSON → 상태 복원 (로드용) |
|
||||
| `resetBuilder()` | 초기화 |
|
||||
| `markClean()` | isDirty=false |
|
||||
|
||||
**셀렉터 훅:**
|
||||
- `useCurrentViewBlocks()` — 현재 뷰의 블록 목록
|
||||
- `useSelectedBlock()` — 선택된 블록 객체
|
||||
|
||||
**컴포넌트 기본 설정 (`defaultConfig`):**
|
||||
|
||||
| ComponentType | 기본 config |
|
||||
|---|---|
|
||||
| `table` | pageSize:20, selectionMode:'single', autoLoad:true, style:'default' |
|
||||
| `form` | columns:2, saveAction:{method:'UPSERT', refreshAfterSave:true} |
|
||||
| `search` | dateRangeEnabled:true, showResetButton:true, autoSearch:false, layout:'inline' |
|
||||
| `button` | text:'버튼', actionType:'save', variant:'default' |
|
||||
| `button-bar` | buttons:[{등록/primary}, {삭제/destructive}] |
|
||||
| `title` | text:'제목', fontSize:'0.75rem', fontWeight:'700', align:'left' |
|
||||
| `stats` | items:[] |
|
||||
| `divider` | style:'solid' |
|
||||
| `pagination` | pageSize:20, showSizeSelector:true, sizeOptions:[10,20,50,100] |
|
||||
|
||||
**컴포넌트 기본 크기 (`defaultSize`):**
|
||||
|
||||
| ComponentType | W × H |
|
||||
|---|---|
|
||||
| `table` | 854 × 380 |
|
||||
| `form` | 440 × 300 |
|
||||
| `search` | 854 × 42 |
|
||||
| `button` | 100 × 36 |
|
||||
| `button-bar` | 370 × 36 |
|
||||
| `title` | 300 × 36 |
|
||||
| `pagination` | 854 × 24 |
|
||||
|
||||
### 3.4 useBlockDrag.ts — 드래그/리사이즈 훅 (~90줄)
|
||||
|
||||
mousedown → document.mousemove → mouseup 패턴.
|
||||
|
||||
- `startDrag(e, id, origX, origY, origW, origH)` — 블록 이동 시작
|
||||
- `startResize(e, id, origX, origY, origW, origH)` — 리사이즈 시작
|
||||
- **Shift 키**: 8px 스냅 (mockup의 `Math.round(n/8)*8` 그대로)
|
||||
- mouseup 시 리스너 자동 정리
|
||||
- `document.body.style.cursor/userSelect` 드래그 중 설정/해제
|
||||
|
||||
### 3.5 BuilderLayout.tsx — 3패널 셸 (~55줄)
|
||||
|
||||
```
|
||||
┌─ BuilderToolbar (헤더 + 도구모음) ─────────────────────┐
|
||||
├──────────┬────────────────────────────┬──────────────────┤
|
||||
│ Palette │ Canvas │ Props │
|
||||
│ (180px) │ (flex:1) │ (260px) │
|
||||
├──────────┴────────────────────────────┴──────────────────┤
|
||||
│ 상태바: 블록 N개 · 테이블명 · 연결 N개 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**키보드 단축키:**
|
||||
- `Delete` / `Backspace` — 선택된 블록 삭제
|
||||
- `Escape` — 블록 선택 해제
|
||||
|
||||
### 3.6 BuilderToolbar.tsx — 헤더 + 도구모음 (~130줄)
|
||||
|
||||
**헤더 (dev-hdr):**
|
||||
- INVYONE 로고 + DEV 배지
|
||||
- 템플릿 이름 입력
|
||||
- 저장 버튼
|
||||
|
||||
**도구모음 (dev-toolbar):**
|
||||
- 테이블 드롭다운 (getMetaTableList → has_custom_meta ★ 표시)
|
||||
- 뷰 탭 (목록 / 등록 / 수정)
|
||||
- 수정됨 표시 (isDirty)
|
||||
|
||||
**저장 흐름:**
|
||||
1. `toTemplate()` → Template JSON 생성
|
||||
2. `templateId` 있으면 `updateTemplate()`, 없으면 `insertTemplate()`
|
||||
3. 성공 시 `markClean()` + templateId 저장
|
||||
|
||||
### 3.7 BuilderPalette.tsx — 좌측 팔레트 (~75줄)
|
||||
|
||||
8종 컴포넌트를 4개 섹션으로 분류:
|
||||
|
||||
| 섹션 | 컴포넌트 |
|
||||
|---|---|
|
||||
| 데이터 | 📊 데이터 테이블, 🔍 검색 필터 |
|
||||
| 입력 | 📝 입력 폼 |
|
||||
| 액션 | 🔘 버튼, ⬜ 버튼 바 |
|
||||
| 표시 | 📌 제목/텍스트, 📈 통계 카드, ── 구분선, 📄 페이지네이션 |
|
||||
|
||||
- **드래그앤드롭**: `onDragStart` → `component-type` 데이터 전달 → 캔버스에서 `onDrop`
|
||||
- **클릭 추가**: 테이블 미선택 시 data 컴포넌트 비활성화 (opacity 0.4)
|
||||
|
||||
### 3.8 BuilderCanvas.tsx — 중앙 캔버스 (~80줄)
|
||||
|
||||
- **목록 뷰**: 전체 캔버스에 블록 자유 배치 (min 1200×800, 도트 그리드 배경)
|
||||
- **등록/수정 뷰**: `dev-popup-overlay` + `dev-popup-frame` (500px 너비) 안에 블록 배치
|
||||
- **드롭 처리**: `onDrop` → 마우스 좌표 계산 → `addBlock(type, {x, y})`
|
||||
- **빈 캔버스**: 안내 메시지 표시 ("팔레트에서 컴포넌트를 드래그하거나 클릭하여 추가하세요")
|
||||
- **선택 해제**: 캔버스 빈 공간 클릭 시 `selectBlock(null)`
|
||||
|
||||
### 3.9 BuilderBlock.tsx — 개별 블록 (~160줄)
|
||||
|
||||
**구조:**
|
||||
```
|
||||
.dev-block (position:absolute, 점선 보더)
|
||||
├── .dev-block-label (블록 이름)
|
||||
├── .dev-block-content (타입별 프리뷰)
|
||||
└── .dev-resize-handle (우하단 리사이즈 핸들)
|
||||
```
|
||||
|
||||
**타입별 프리뷰 (BlockPreview 내부 컴포넌트):**
|
||||
|
||||
| 타입 | 프리뷰 내용 |
|
||||
|---|---|
|
||||
| `table` | `<table>` 헤더 + 3행 더미 (visible 필드 최대 8개) |
|
||||
| `form` | CSS Grid (columns 수) + 필드 라벨/입력 (최대 10개, required * 표시) |
|
||||
| `search` | 가로 나열 필드 라벨/입력 (searchable 필드 최대 5개) + 검색 버튼 |
|
||||
| `title` | fontSize/fontWeight/align 적용된 텍스트 |
|
||||
| `button` | variant 스타일 적용된 단일 버튼 |
|
||||
| `button-bar` | 버튼 목록 가로 나열 |
|
||||
| `pagination` | 총 건수 / 페이지 번호 / 건수 선택기 |
|
||||
| `divider` | 수평선 |
|
||||
| `stats` | "통계 카드 프리뷰" 텍스트 |
|
||||
|
||||
**FieldOption 렌더링**: `string | {value, label}` 유니온 타입 처리 (tsc 에러 수정)
|
||||
|
||||
### 3.10 BuilderProps.tsx — 우측 속성 패널 (~90줄)
|
||||
|
||||
**공통 속성 (모든 블록):**
|
||||
- 컴포넌트 종류 (아이콘 + 한글 라벨)
|
||||
- 이름 (input, 캔버스 라벨과 양방향 동기화)
|
||||
- 위치·크기 (X/Y/W/H 4칸 그리드, 캔버스와 양방향)
|
||||
|
||||
**타입별 분기:**
|
||||
- `table` → TableProps
|
||||
- `form` → FormProps
|
||||
- `search` → SearchProps
|
||||
- `button` → SingleButtonProps
|
||||
- `button-bar` → ButtonBarProps
|
||||
- `title` → TitleProps
|
||||
|
||||
**공통 삭제 버튼**: 하단 빨간 테두리 버튼
|
||||
|
||||
### 3.11 FieldListEditor.tsx — 필드 체크리스트 (~110줄)
|
||||
|
||||
table/form/search 속성 패널에서 공통으로 사용하는 필드 목록.
|
||||
|
||||
**기능:**
|
||||
- 체크박스 토글 (`visible` 또는 `searchable` 속성)
|
||||
- 필드 배지 표시 (PK, 필수, 검색, SYS, 계산)
|
||||
- 타입 배지 (text, number, date 등)
|
||||
- **클릭하면 상세 펼침** (FieldDetail 패널):
|
||||
- 표시 이름, 너비 편집
|
||||
- 토글: 필수, 편집, 검색, 정렬 (dev-toggle 스타일)
|
||||
|
||||
### 3.12 타입별 속성 패널
|
||||
|
||||
**TableProps.tsx** (~70줄):
|
||||
- 페이지 크기 (10/20/50/100)
|
||||
- 선택 방식 (없음/단일/다중)
|
||||
- 자동 로드, 인라인 편집, 체크박스 토글
|
||||
- 스타일 (기본/줄무늬/테두리/컴팩트)
|
||||
- FieldListEditor (visible 토글)
|
||||
|
||||
**FormProps.tsx** (~40줄):
|
||||
- 컬럼 수 (1/2/3칸)
|
||||
- 저장 방식 (등록/수정/등록+수정)
|
||||
- FieldListEditor (visible 토글)
|
||||
|
||||
**SearchProps.tsx** (~50줄):
|
||||
- 날짜 범위 검색, 초기화 버튼, 자동 검색 토글
|
||||
- 레이아웃 (인라인/세로)
|
||||
- FieldListEditor (searchable 토글)
|
||||
|
||||
**ButtonProps.tsx** (~140줄):
|
||||
- `SingleButtonProps`: 텍스트, 액션 종류(12종), 스타일(5종), 확인 메시지
|
||||
- `ButtonBarProps`: 버튼 목록 CRUD (추가/삭제/편집), 각 버튼 액션+스타일 설정
|
||||
|
||||
**TitleProps.tsx** (~40줄):
|
||||
- 텍스트, 크기(4단계), 굵기(4단계), 정렬(3종)
|
||||
|
||||
---
|
||||
|
||||
## 4. DB 변경
|
||||
|
||||
### 4.1 templates 테이블 (신규)
|
||||
|
||||
```sql
|
||||
CREATE TABLE templates (
|
||||
template_id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
category VARCHAR(50),
|
||||
description TEXT,
|
||||
primary_table VARCHAR(100) NOT NULL,
|
||||
fields JSONB NOT NULL,
|
||||
views JSONB NOT NULL,
|
||||
connections JSONB DEFAULT '[]',
|
||||
company_code VARCHAR(20) NOT NULL DEFAULT '*',
|
||||
version INTEGER DEFAULT 1,
|
||||
status VARCHAR(20) DEFAULT 'draft',
|
||||
created_by VARCHAR(50),
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(50),
|
||||
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active VARCHAR(1) DEFAULT 'Y'
|
||||
);
|
||||
```
|
||||
|
||||
**인덱스 3개:**
|
||||
- `idx_templates_company` (company_code)
|
||||
- `idx_templates_table` (primary_table)
|
||||
- `idx_templates_status` (status)
|
||||
|
||||
**실행 대상**: `211.115.91.141:11134/test_dev` (2026-04-10 실행 완료)
|
||||
|
||||
### 4.2 application.yml DB 접속 변경
|
||||
|
||||
```
|
||||
변경 전: jdbc:postgresql://39.117.244.52:11132/testvex (pw: ph0909!!)
|
||||
변경 후: jdbc:postgresql://211.115.91.141:11134/test_dev (pw: vexplor0909!!)
|
||||
```
|
||||
|
||||
39.117 서버 폐기에 따른 변경.
|
||||
|
||||
---
|
||||
|
||||
## 5. 덕일 스타일 / 프로젝트 컨벤션 준수
|
||||
|
||||
| 규칙 | 준수 |
|
||||
|---|---|
|
||||
| 3레이어 (Controller → Service → XML) | ✅ |
|
||||
| Mapper Interface 금지 | ✅ — sqlSession 직접 호출 |
|
||||
| Map<String, Object> 사용, DTO 금지 | ✅ — ApiResponse만 예외 |
|
||||
| BaseService 상속 | ✅ |
|
||||
| @Autowired CommonService | ✅ |
|
||||
| XML 파일명: 소문자, Mapper 안 붙임 | ✅ — `template.xml` |
|
||||
| XML namespace: 파일명과 동일 | ✅ — `namespace="template"` |
|
||||
| SQL: UPPER_SNAKE | ✅ |
|
||||
| SELECT 쉼표: 앞에 | ✅ |
|
||||
| #{파라미터}: snake_case | ✅ |
|
||||
| OGNL test: 바깥 작은따옴표 | ✅ |
|
||||
| 프론트 타입: Record<string, any> | ✅ — FieldConfig/Component만 예외 |
|
||||
| 개발자 모드 CSS: IDE 스타일 (v5 코스믹 아님) | ✅ — `--d-*` 변수 체계 |
|
||||
| Zustand devtools 미들웨어 | ✅ |
|
||||
| 기존 apiClient 사용 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증 결과
|
||||
|
||||
| 검증 항목 | 결과 |
|
||||
|---|---|
|
||||
| `./gradlew compileJava` | BUILD SUCCESSFUL |
|
||||
| `./gradlew bootJar` | BUILD SUCCESSFUL |
|
||||
| `npx tsc --noEmit` (빌더 관련) | 에러 0개 |
|
||||
| DB 테이블 생성 (test_dev) | CREATE TABLE + INDEX 3개 성공 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 3 범위 밖 (다음에 해야 할 것)
|
||||
|
||||
| 항목 | 담당 Phase | 비고 |
|
||||
|---|---|---|
|
||||
| 메타 드리프트 감지 (빌더에서 Template 열 때 최신 메타 비교) | Phase 3+ | 설계서 Section 9에 정의됨 |
|
||||
| DataPort 연결 UI (속성 패널에서 연결 추가/삭제) | Phase 3+ | mockup의 `propsConnections()` 참고 |
|
||||
| 기존 Template 목록/로드 UI | Phase 3+ | 현재는 새 템플릿 생성만 |
|
||||
| 자동생성 + 프리셋 (⚡ 버튼) | Phase 6 | 설계서 명시 |
|
||||
| stats/chart 타입 속성 패널 | Phase 3+ | 현재 빈 구조만 |
|
||||
| 필드 순서 드래그 재정렬 | Phase 3+ | 현재 order 기준 정렬만 |
|
||||
| Template 게시 워크플로우 UI | Phase 4 | 대시보드에서 게시된 Template 사용 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 빌더 사용 흐름
|
||||
|
||||
```
|
||||
1. /admin/builder 접속
|
||||
2. 도구모음에서 테이블 선택 (예: sales_order_mng)
|
||||
→ Phase 1 API로 FieldConfig[] 자동 로드
|
||||
3. 팔레트에서 컴포넌트 선택 → 캔버스에 드래그 또는 클릭
|
||||
(예: 제목 → 검색 필터 → 데이터 테이블 → 페이지네이션)
|
||||
4. 캔버스에서 블록 드래그(이동) / 우하단 핸들로 리사이즈
|
||||
5. 블록 클릭 → 우측 속성 패널에서 설정 조정
|
||||
(필드 ON/OFF, 페이지 크기, 선택 모드 등)
|
||||
6. 뷰 탭으로 목록/등록/수정 전환
|
||||
(등록/수정은 팝업 프레임 안에서 편집)
|
||||
7. 💾 저장 → Template JSON이 DB에 저장
|
||||
8. 기존 Template 로드 → fromTemplate()으로 상태 복원
|
||||
```
|
||||
@@ -1,297 +0,0 @@
|
||||
# Phase 4 구현 작업기록 — 대시보드(=메뉴) 사용자 화면 시스템
|
||||
|
||||
> **작업일**: 2026-04-10
|
||||
> **설계서**: `notes/gbpark/2026-04-10-phase4-dashboard-menu.md`
|
||||
> **상태**: 구현 완료 + 빌드 검증 통과 + DB 테이블 생성 완료
|
||||
|
||||
---
|
||||
|
||||
## 1. 생성/수정된 파일 (19개)
|
||||
|
||||
### 1.1 백엔드 (6개)
|
||||
|
||||
| 파일 | 상태 | 역할 |
|
||||
|---|---|---|
|
||||
| `backend-spring/src/main/resources/mapper/dashboard.xml` | 수정 (VEX 레거시 교체) | 대시보드/카드/사이드바 SQL 12쿼리 |
|
||||
| `backend-spring/src/main/resources/mapper/userOverride.xml` | 신규 | 사용자 오버라이드 UPSERT/조회/삭제 3쿼리 |
|
||||
| `backend-spring/src/main/java/com/erp/service/DashboardService.java` | 수정 (VEX 레거시 교체) | BaseService + sqlSession 덕일 스타일 |
|
||||
| `backend-spring/src/main/java/com/erp/service/UserOverrideService.java` | 신규 | JSONB 파싱/직렬화 + UPSERT |
|
||||
| `backend-spring/src/main/java/com/erp/controller/DashboardController.java` | 수정 (VEX 레거시 교체) | `/api/dashboards` 엔드포인트 |
|
||||
| `backend-spring/src/main/java/com/erp/controller/UserOverrideController.java` | 신규 | `/api/overrides` 엔드포인트 |
|
||||
|
||||
### 1.2 프론트엔드 (13개)
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `frontend/lib/api/dashMenu.ts` | 대시보드/카드/사이드바 API 래퍼 |
|
||||
| `frontend/lib/api/override.ts` | 사용자 오버라이드 API 래퍼 |
|
||||
| `frontend/stores/dashboardStore.ts` | Zustand 대시보드 상태관리 |
|
||||
| `frontend/styles/dashboard.css` | v5 Cosmic Glassmorphism 대시보드 CSS |
|
||||
| `frontend/components/dash/DashboardLayout.tsx` | 전체 레이아웃 (사이드바+캔버스+모달) |
|
||||
| `frontend/components/dash/DashboardSidebar.tsx` | 대시보드 목록 사이드바 |
|
||||
| `frontend/components/dash/DashboardToolbar.tsx` | 상단 툴바 (편집/저장/+템플릿) |
|
||||
| `frontend/components/dash/DashboardCanvas.tsx` | 자유 배치 캔버스 + 드래그/리사이즈 |
|
||||
| `frontend/components/dash/DashboardCard.tsx` | 카드 (Template→FcTable/FcSearch 렌더) |
|
||||
| `frontend/components/dash/DashboardEmpty.tsx` | 빈 대시보드 안내 |
|
||||
| `frontend/components/dash/TemplateLibraryModal.tsx` | 템플릿 라이브러리 모달 |
|
||||
| `frontend/components/dash/CardSettingsPanel.tsx` | 카드 설정 패널 (컬럼 ON/OFF) |
|
||||
| `frontend/components/dash/CardMiniView.tsx` | 접힌 카드 미니 뷰 |
|
||||
| `frontend/app/(main)/dash/page.tsx` | `/dash` 라우트 페이지 |
|
||||
|
||||
---
|
||||
|
||||
## 2. VEX 레거시 교체 사항
|
||||
|
||||
### 2.1 기존 DashboardService/Controller (VEX)
|
||||
|
||||
**교체 전**: JdbcTemplate 직접 사용, `dashboards`+`dashboard_elements` VEX 테이블, `/api/dashboard` (단수)
|
||||
**교체 후**: BaseService + sqlSession 덕일 스타일, `DASHBOARDS`+`DASHBOARD_CARDS` 테이블, `/api/dashboards` (복수)
|
||||
|
||||
기존 VEX 대시보드의 유틸리티 엔드포인트 (`execute-query`, `execute-dml`, `table-schema`, `fetch-external-api`)는 제거됨. 필요 시 별도 유틸리티 컨트롤러로 분리 가능.
|
||||
|
||||
### 2.2 프론트엔드 경로 분리
|
||||
|
||||
| 구분 | VEX 레거시 | Phase 4 (INVYONE) |
|
||||
|---|---|---|
|
||||
| API 파일 | `lib/api/dashboard.ts` (유지) | `lib/api/dashMenu.ts` (신규) |
|
||||
| 컴포넌트 | `components/dashboard/` (유지) | `components/dash/` (신규) |
|
||||
| 페이지 | `app/(main)/dashboard/` (유지) | `app/(main)/dash/` (신규) |
|
||||
|
||||
VEX 레거시 파일은 건드리지 않음. 나중에 VEX 완전 폐기 시 삭제.
|
||||
|
||||
---
|
||||
|
||||
## 3. 백엔드 상세
|
||||
|
||||
### 3.1 dashboard.xml (namespace="dashboard", 12쿼리)
|
||||
|
||||
| 쿼리 ID | 용도 |
|
||||
|---|---|
|
||||
| `getDashboardList` | 대시보드 목록 (유저+회사공통, 페이지네이션) |
|
||||
| `getDashboardListCnt` | 목록 카운트 |
|
||||
| `getDashboardInfo` | 대시보드 단건 |
|
||||
| `insertDashboard` | 대시보드 생성 |
|
||||
| `updateDashboard` | 대시보드 수정 (이름/아이콘/순서) |
|
||||
| `deleteDashboard` | 대시보드 소프트 삭제 (IS_ACTIVE='D') |
|
||||
| `getDashboardCardList` | 카드 목록 (TEMPLATES JOIN으로 기본 정보 포함) |
|
||||
| `insertDashboardCard` | 카드 추가 |
|
||||
| `updateDashboardCard` | 카드 업데이트 (위치/크기/접기) |
|
||||
| `updateCardPosition` | 카드 일괄 위치 업데이트 (단건, for loop용) |
|
||||
| `deleteDashboardCard` | 카드 소프트 삭제 |
|
||||
| `getSidebarMenu` | 사이드바 메뉴 (간략 대시보드 목록) |
|
||||
|
||||
### 3.2 userOverride.xml (namespace="userOverride", 3쿼리)
|
||||
|
||||
| 쿼리 ID | 용도 |
|
||||
|---|---|
|
||||
| `getUserOverride` | 유저+카드 기준 오버라이드 조회 |
|
||||
| `upsertUserOverride` | ON CONFLICT UPSERT (UNIQUE(user_id, card_id)) |
|
||||
| `deleteUserOverride` | 오버라이드 삭제 |
|
||||
|
||||
### 3.3 DashboardService.java (~100줄)
|
||||
|
||||
덕일 스타일 준수: `extends BaseService`, `@Autowired CommonService`, `sqlSession` 직접 호출.
|
||||
- ID 생성: `dash_` + UUID 12자 / `card_` + UUID 12자
|
||||
- 기본값: icon=📋, position_x=50, width=600, height=400
|
||||
|
||||
### 3.4 DashboardController.java (~120줄)
|
||||
|
||||
| 메서드 | 엔드포인트 | 설명 |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/dashboards` | 목록 (keyword, page, limit) |
|
||||
| `GET` | `/api/dashboards/{id}` | 단건 |
|
||||
| `POST` | `/api/dashboards` | 생성 |
|
||||
| `PUT` | `/api/dashboards/{id}` | 수정 |
|
||||
| `DELETE` | `/api/dashboards/{id}` | 삭제 |
|
||||
| `GET` | `/api/dashboards/{id}/cards` | 카드 목록 (Template JOIN) |
|
||||
| `POST` | `/api/dashboards/{id}/cards` | 카드 추가 |
|
||||
| `PUT` | `/api/dashboards/{id}/cards/{cardId}` | 카드 수정 |
|
||||
| `DELETE` | `/api/dashboards/{id}/cards/{cardId}` | 카드 삭제 |
|
||||
| `PUT` | `/api/dashboards/{id}/cards/batch` | 카드 일괄 위치 업데이트 |
|
||||
| `GET` | `/api/dashboards/sidebar/menu` | 사이드바 메뉴 |
|
||||
|
||||
### 3.5 UserOverrideController.java (~40줄)
|
||||
|
||||
| 메서드 | 엔드포인트 | 설명 |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/overrides?card_id=xxx` | 조회 |
|
||||
| `PUT` | `/api/overrides` | UPSERT |
|
||||
| `DELETE` | `/api/overrides?card_id=xxx` | 삭제 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 프론트엔드 상세
|
||||
|
||||
### 4.1 dashMenu.ts — API 래퍼 (~55줄)
|
||||
|
||||
```
|
||||
getDashboardList, getDashboardInfo, insertDashboard, updateDashboard, deleteDashboard
|
||||
getDashboardCards, insertDashboardCard, updateDashboardCard, deleteDashboardCard
|
||||
updateCardPositionsBatch, getSidebarMenu
|
||||
```
|
||||
|
||||
★ 전부 `Record<string, any>` — 별도 인터페이스 정의 안 함
|
||||
|
||||
### 4.2 dashboardStore.ts — Zustand (~80줄)
|
||||
|
||||
상태: `dashboards[]`, `activeDashboardId`, `cards[]`, `editMode`, `loading`
|
||||
액션: `setDashboards`, `setActiveDashboard`, `setCards`, `addCard`, `updateCard`, `removeCard`, `toggleEditMode`, `addDashboard`, `updateDashboardInList`, `removeDashboard`
|
||||
|
||||
### 4.3 DashboardLayout.tsx — 전체 오케스트레이터 (~180줄)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ DashboardLayout │
|
||||
├────────────┬─────────────────────────────────────┤
|
||||
│ Sidebar │ Toolbar │
|
||||
│ (220px) ├─────────────────────────────────────┤
|
||||
│ - 목록 │ Canvas │
|
||||
│ - +추가 │ - DashboardCard × N │
|
||||
│ │ - 드래그/리사이즈 │
|
||||
│ │ + CardSettingsPanel (조건부) │
|
||||
├────────────┴─────────────────────────────────────┤
|
||||
│ TemplateLibraryModal (조건부) │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.4 DashboardCanvas.tsx — 드래그/리사이즈 (~130줄)
|
||||
|
||||
mockup의 `02-canvas.js` 로직 포팅:
|
||||
- mousedown → 드래그 또는 리사이즈 모드 판별
|
||||
- mousemove → 캔버스 경계 clamp 적용
|
||||
- mouseup → store에 최종 위치 반영
|
||||
- snap 없음 (px 단위), 캔버스 밖 방지
|
||||
|
||||
### 4.5 DashboardCard.tsx — Template 렌더 (~160줄)
|
||||
|
||||
카드 안에서 Phase 2 컴포넌트가 실제 데이터로 동작:
|
||||
1. `primaryTable`로 `getMetaFields()` → FieldConfig[] 로드
|
||||
2. `fcList()` → 실제 데이터 조회
|
||||
3. `FcSearch` + `FcTable` + `FcPagination` 렌더
|
||||
4. 검색 → 재조회, 페이지네이션 → 재조회
|
||||
|
||||
### 4.6 TemplateLibraryModal.tsx (~130줄)
|
||||
|
||||
mockup의 `06-modals.css` 스타일 포팅:
|
||||
- 좌측 카테고리 (7종: 전체/영업/생산/인사/재고/재무/관리자)
|
||||
- 우측 템플릿 카드 그리드 (auto-fill, minmax 180px)
|
||||
- 검색 필터
|
||||
- 클릭 → `onSelectTemplate` 콜백
|
||||
|
||||
### 4.7 CardSettingsPanel.tsx (~100줄)
|
||||
|
||||
- 필드 visible 토글 (ON/OFF)
|
||||
- `getUserOverride` + `upsertUserOverride` 실시간 저장
|
||||
- JSONB overrides 구조: `{ fields: { column: { visible: bool } } }`
|
||||
|
||||
### 4.8 dashboard.css — v5 토큰 전부 사용 (~350줄)
|
||||
|
||||
| 용도 | 토큰 |
|
||||
|---|---|
|
||||
| 배경 | `var(--v5-glass)`, `var(--v5-surface)` |
|
||||
| 테두리 | `var(--v5-glass-border)`, `var(--v5-border-subtle)` |
|
||||
| 텍스트 | `var(--v5-text)`, `var(--v5-text-sec)`, `var(--v5-text-muted)` |
|
||||
| 강조 | `var(--v5-primary)`, `var(--v5-primary-light)` |
|
||||
| 글로우 | `var(--v5-glow-sm)`, `var(--v5-glow-md)` |
|
||||
| 유리 | `backdrop-filter: blur(20px) saturate(1.4)` |
|
||||
|
||||
다크/라이트 모드 변형: `.dark` 선택자 사용 (mockup 패턴 동일)
|
||||
|
||||
---
|
||||
|
||||
## 5. DB 테이블 (✅ 생성 완료)
|
||||
|
||||
**실행 대상**: `211.115.91.141:11134/test_dev` (2026-04-10 실행 완료)
|
||||
|
||||
### 5.1 VEX 레거시 처리
|
||||
|
||||
기존 `dashboards` 테이블(VEX 스키마: id, title, description, tags, settings 등)이 있어서 **`dashboards_vex_backup`으로 rename** 후 Phase 4 테이블 신규 생성.
|
||||
|
||||
### 5.2 생성된 테이블 3개 + 인덱스 3개
|
||||
|
||||
| 테이블 | PK | 용도 |
|
||||
|---|---|---|
|
||||
| `DASHBOARDS` | DASHBOARD_ID (VARCHAR 50) | 대시보드 (=메뉴 항목) |
|
||||
| `DASHBOARD_CARDS` | CARD_ID (VARCHAR 50) | 대시보드 위 카드 배치 (위치/크기) |
|
||||
| `USER_OVERRIDES` | OVERRIDE_ID (VARCHAR 50) | 사용자별 카드 오버라이드 (JSONB) |
|
||||
|
||||
**DASHBOARDS 컬럼**: DASHBOARD_ID, NAME, ICON, DISPLAY_ORDER, COMPANY_CODE, USER_ID, IS_ACTIVE, CREATED_BY, CREATED_DATE, UPDATED_BY, UPDATED_DATE
|
||||
|
||||
**DASHBOARD_CARDS 컬럼**: CARD_ID, DASHBOARD_ID, TEMPLATE_ID, POSITION_X, POSITION_Y, WIDTH, HEIGHT, IS_COLLAPSED, DISPLAY_ORDER, IS_ACTIVE, CREATED_DATE, UPDATED_DATE
|
||||
|
||||
**USER_OVERRIDES 컬럼**: OVERRIDE_ID, USER_ID, CARD_ID, OVERRIDES(JSONB), CREATED_DATE, UPDATED_DATE + `UNIQUE(USER_ID, CARD_ID)`
|
||||
|
||||
**인덱스**: `idx_dashboards_company`, `idx_dashboards_user`, `idx_dcards_dashboard`
|
||||
|
||||
★ DB FK 제약조건 안 걸음 (앱 레벨 관리, Phase 4 설계서 Section 3 참조)
|
||||
|
||||
---
|
||||
|
||||
## 6. 덕일 스타일 / 프로젝트 컨벤션 준수
|
||||
|
||||
| 규칙 | 준수 |
|
||||
|---|---|
|
||||
| 3레이어 (Controller → Service → XML) | ✅ |
|
||||
| Mapper Interface 금지 | ✅ — sqlSession 직접 호출 |
|
||||
| Map<String, Object> 사용, DTO 금지 | ✅ — ApiResponse만 예외 |
|
||||
| BaseService 상속 | ✅ |
|
||||
| @Autowired CommonService | ✅ |
|
||||
| XML 파일명: 소문자, Mapper 안 붙임 | ✅ — `dashboard.xml`, `userOverride.xml` |
|
||||
| XML namespace: 파일명과 동일 | ✅ |
|
||||
| SQL: UPPER_SNAKE | ✅ |
|
||||
| SELECT 쉼표: 앞에 | ✅ |
|
||||
| #{파라미터}: snake_case | ✅ |
|
||||
| OGNL test: 바깥 작은따옴표 | ✅ |
|
||||
| 프론트 타입: Record<string, any> | ✅ — FieldConfig만 예외 |
|
||||
| v5 CSS 토큰 사용, 즉흥 값 금지 | ✅ |
|
||||
| 컴팩트 폰트 사이즈 (0.55~0.85rem) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 7. 검증 결과
|
||||
|
||||
| 검증 항목 | 결과 |
|
||||
|---|---|
|
||||
| `./gradlew compileJava` | BUILD SUCCESSFUL |
|
||||
| `npx tsc --noEmit` (Phase 4 파일) | 에러 0개 |
|
||||
| 기존 VEX 코드 에러 | Phase 4와 무관 (기존 camelCase/snake_case 불일치 에러) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 접속 경로
|
||||
|
||||
- 대시보드 메뉴 시스템: `/dash`
|
||||
- 개발자 빌더: `/admin/builder` (Phase 3)
|
||||
- 테스트 페이지: `/test-fc` (Phase 2)
|
||||
|
||||
---
|
||||
|
||||
## 9. Phase 4 범위 밖 (다음에 해야 할 것)
|
||||
|
||||
| 항목 | 담당 Phase | 비고 |
|
||||
|---|---|---|
|
||||
| AppLayout 사이드바 통합 (대시보드 목록을 기존 사이드바에 표시) | Phase 4+ | 현재는 `/dash` 페이지에 자체 사이드바 |
|
||||
| 대시보드 순서 드래그 재정렬 | Phase 4+ | 현재 display_order는 수동 |
|
||||
| Template 게시 워크플로우 (빌더에서 publish → 라이브러리에 표시) | Phase 3+ | 현재 빌더 publish API는 구현됨 |
|
||||
| 카드 간 DataPort 연결 (제어 모드) | Phase 5 | 대시보드 캔버스 위에 오버레이 |
|
||||
| 사용자 오버라이드 실시간 반영 (FcTable에 override 적용) | Phase 4+ | 현재 settings에서 toggle만 저장 |
|
||||
| 카드 미니 KPI 뷰 (접었을 때 실제 데이터 집계) | Phase 4+ | 현재 템플릿 이름/분류만 표시 |
|
||||
| 대시보드 아이콘 선택 UI | Phase 4+ | 현재 기본 📋 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 사용 흐름
|
||||
|
||||
```
|
||||
1. /dash 접속
|
||||
2. 좌측 사이드바: 대시보드 목록 표시
|
||||
3. "+ 새 대시보드" → 이름 입력 → 사이드바에 자동 등록
|
||||
4. 대시보드 클릭 → 캔버스 표시
|
||||
5. "편집" 버튼 → 편집 모드 (드래그/리사이즈)
|
||||
6. "+ 템플릿 추가" → 라이브러리 모달 → 게시된 Template 선택
|
||||
7. → 캔버스에 카드 추가 (FcTable/FcSearch가 실제 DB 데이터 표시)
|
||||
8. 카드 ⚙ → 컬럼 ON/OFF (사용자 오버라이드)
|
||||
9. 카드 ▼ → 접기 (미니 뷰)
|
||||
10. "저장" → DB에 카드 위치/크기 일괄 저장
|
||||
11. 다른 대시보드 클릭 → 전환 (편집 모드 자동 해제)
|
||||
```
|
||||
@@ -1,357 +0,0 @@
|
||||
# Phase 5: 제어 모드 — 비즈니스 룰 / 데이터 흐름
|
||||
|
||||
> **목적**: 대시보드의 카드(Template) 간 비즈니스 룰과 데이터 흐름을 시각적으로 정의하는 제어 모드 구현
|
||||
> **전제 조건**: Phase 1~4 완료 (DB 메타 + 컴포넌트 + 빌더 + 대시보드에 카드 배치까지 동작)
|
||||
> **산출물**: 제어 모드 UI (SVG 연결선 + 테이블 노드 + 규칙 빌더) + 비즈니스 룰 엔진
|
||||
> **다음 단계**: Phase 6 (자동생성/프리셋 — 편의 기능, 맨 마지막)
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 개념
|
||||
|
||||
**제어 모드 = 같은 캔버스에서 "데이터가 어떻게 흐르는지" 보고 편집하는 모드**
|
||||
|
||||
일반 모드: 카드(화면)를 사용
|
||||
제어 모드: 카드 간 관계/비즈니스 룰을 설정
|
||||
|
||||
```
|
||||
[수주관리 카드] ──수주 확정──→ [발주관리 카드] ──금액>1000만──→ [프로젝트 카드]
|
||||
(자동 등록) (조건분기)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 두 가지 기능
|
||||
|
||||
### 2.1 읽기 모드 — 흐름 시각화 (카드 클릭)
|
||||
|
||||
카드 클릭 → 해당 카드의 데이터 소스(테이블) + 관련 테이블 + 비즈니스 룰이 트리 형태로 표시
|
||||
|
||||
mockup 참조: `js/06-control-mode.js`의 `showCardFlow()`, `enterControlMode()`
|
||||
|
||||
### 2.2 편집 모드 — 규칙 빌더 (드래그앤드롭)
|
||||
|
||||
팔레트에서 테이블 노드 + 제어 노드를 캔버스에 드래그, I/O 포트로 연결
|
||||
|
||||
mockup 참조: `js/07-rule-builder.js`의 `dropTable()`, `dropControl()`, `initPortEvents()`
|
||||
|
||||
---
|
||||
|
||||
## 3. 읽기 모드 상세
|
||||
|
||||
### 3.1 진입
|
||||
|
||||
캔버스 상단 툴바의 **`⚡ 제어`** 버튼 클릭 → 제어 모드 진입
|
||||
|
||||
### 3.2 시각적 변화
|
||||
|
||||
1. 캔버스 격자가 시안 톤으로 변경 (`rgba(0,206,201,.22)`)
|
||||
2. 모든 카드 반투명 (opacity 0.5)
|
||||
3. 편집 모드 자동 비활성화
|
||||
|
||||
### 3.3 카드 클릭 → 흐름 표시
|
||||
|
||||
1. 클릭된 카드만 좌측 고정 + opacity 1, 나머지 fade out (0.08)
|
||||
2. 카드의 `data-source-table`에서 소스 테이블 추출
|
||||
3. Phase 1의 `GET /api/meta/tables/{tableName}/relations`로 관계 조회
|
||||
4. BFS로 도달 가능한 전체 체인 계산
|
||||
5. **트리 확산 애니메이션**: 선이 그려짐 → 노드 reveal → 또 선 → 또 노드 (depth별 지연)
|
||||
|
||||
### 3.4 시각 요소
|
||||
|
||||
**테이블 노드** (mockup: `.tbl-node`):
|
||||
```
|
||||
┌─ 🏢 DEPARTMENT ─── 4컬럼 ──┐
|
||||
│ ● dept_code VARCHAR PK │
|
||||
│ ○ dept_name VARCHAR │
|
||||
│ ○ company_code VARCHAR FK │
|
||||
│ ○ parent_dept VARCHAR FK │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
**연결선 4종** (SVG bezier):
|
||||
|
||||
| 타입 | 색상 | CSS 클래스 | 용도 |
|
||||
|---|---|---|---|
|
||||
| 소스 | 핑크 (#fd79a8) | `ctrl-line-tpl` | 카드 → 소스 테이블 |
|
||||
| 자동실행 | 보라 (#6c5ce7) | `ctrl-line-auto` | 테이블 A → 테이블 B 자동 등록 |
|
||||
| 조건분기 | 앰버 (#fdcb6e) | `ctrl-line-cond` | 조건 충족 시 실행 |
|
||||
| FK | 시안 (#00cec9) | `ctrl-line` | 외래키 관계 (기본 비표시, 비즈니스 룰만) |
|
||||
|
||||
**연결선 위 뱃지** (mockup: `.ctrl-badge`):
|
||||
- 클릭 가능, 해당 룰의 상세 정보 표시
|
||||
- 조건분기 뱃지: Yes/No 분기 경로 표시
|
||||
|
||||
### 3.5 라이트 모드 보정
|
||||
|
||||
라이트 모드에서 연결선은 더 진하게 (배경 대비):
|
||||
- 시안 → `#00a89e`, 보라 → `#5b4acf`, 앰버 → `#d4a017`, 핑크 → `#e0559e`
|
||||
|
||||
---
|
||||
|
||||
## 4. 규칙 빌더 상세
|
||||
|
||||
### 4.1 제어 노드 16종
|
||||
|
||||
mockup의 `CTRL_NODE_TYPES` (js/07-rule-builder.js):
|
||||
|
||||
| 카테고리 | 노드 | 아이콘 | RGB | 특수 출력 포트 |
|
||||
|---|---|---|---|---|
|
||||
| 트리거 | 타이머 | ⏱ | 0,206,201 | — |
|
||||
| 조건 | 조건분기 | ◇ | 253,203,110 | Yes/No |
|
||||
| 조건 | 데이터 검증 | ✔ | 255,107,129 | Pass/Fail |
|
||||
| 액션 | 상태 변경 | 🔄 | 108,92,231 | — |
|
||||
| 액션 | 자동 등록 | 📝 | 85,239,196 | — |
|
||||
| 액션 | 계산/수식 | 🧮 | 45,152,218 | — |
|
||||
| 액션 | 삭제/보관 | 🗑 | 255,71,87 | — |
|
||||
| 액션 | 문서 생성 | 📄 | 162,155,254 | — |
|
||||
| 흐름 | 승인/결재 | ✋ | 255,165,2 | Approved/Rejected |
|
||||
| 흐름 | 대기/지연 | ⏳ | 72,219,251 | — |
|
||||
| 흐름 | 반복 | 🔁 | 223,142,254 | Each/Done |
|
||||
| 흐름 | 병렬 실행 | 🔀 | 0,206,201 | — |
|
||||
| 흐름 | 병합/합류 | ⤵ | 149,175,192 | — |
|
||||
| 연동 | 외부 호출 | 🌐 | 116,185,255 | — |
|
||||
| 연동 | 알림 발송 | 📨 | 253,121,168 | — |
|
||||
| 기록 | 로그 기록 | 📜 | 150,150,160 | — |
|
||||
|
||||
### 4.2 노드 구조
|
||||
|
||||
```
|
||||
┌─ [In] ──────────────────── [Out] ──┐
|
||||
│ 📝 자동 등록 │
|
||||
│ ───────────────────────── │
|
||||
│ 클릭하여 설정 │
|
||||
│ (대상 테이블, 필드 매핑) │ [Yes]
|
||||
│ │ [No]
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
각 노드에 I/O 포트:
|
||||
- **Input 포트** (좌측): 데이터/이벤트를 받음
|
||||
- **Output 포트** (우측): 결과를 내보냄
|
||||
- 조건분기/승인 등은 다중 output (Yes/No)
|
||||
|
||||
### 4.3 포트 연결 인터랙션
|
||||
|
||||
1. output 포트 mousedown → 연결선 드래그 시작 (SVG 임시선)
|
||||
2. input 포트 위에서 mouseup → 연결 완료
|
||||
3. 연결 중간에 삭제 뱃지 (hover 시 표시)
|
||||
4. 같은 노드끼리 연결 금지, 중복 연결 금지
|
||||
|
||||
### 4.4 노드 설정 팝오버
|
||||
|
||||
노드 body 클릭 → 설정 팝오버:
|
||||
|
||||
| 노드 타입 | 설정 항목 |
|
||||
|---|---|
|
||||
| 자동 등록 | 대상 테이블, 필드 매핑 (소스→대상), 조건 |
|
||||
| 상태 변경 | 대상 테이블, 대상 필드, 변경값 |
|
||||
| 조건분기 | 조건식 (필드, 연산자, 값) |
|
||||
| 승인/결재 | 결재선, 승인자 |
|
||||
| 타이머 | 실행 주기, 시작 조건 |
|
||||
| 외부 호출 | URL, 메서드, 파라미터 매핑 |
|
||||
| 알림 발송 | 대상 (사용자/이메일/슬랙), 메시지 템플릿 |
|
||||
| 계산/수식 | 수식 (computed 문법), 대상 필드 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 모델
|
||||
|
||||
### 5.1 비즈니스 룰 DB
|
||||
|
||||
**`business_rules` 테이블 (신규 생성):**
|
||||
|
||||
```sql
|
||||
CREATE TABLE business_rules (
|
||||
rule_id VARCHAR(50) PRIMARY KEY,
|
||||
dashboard_id VARCHAR(50), -- ★ DB FK 제약조건 안 걸음 (앱 레벨 관리)
|
||||
name VARCHAR(200),
|
||||
description TEXT,
|
||||
nodes JSONB NOT NULL, -- 노드 배열 (위치, 타입, 설정)
|
||||
connections JSONB NOT NULL, -- 연결 배열 (from → to)
|
||||
is_enabled BOOLEAN DEFAULT TRUE,
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
created_by VARCHAR(50),
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(50),
|
||||
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active VARCHAR(1) DEFAULT 'Y'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_brules_dashboard ON business_rules(dashboard_id);
|
||||
```
|
||||
|
||||
### 5.2 노드 JSON 구조
|
||||
|
||||
```
|
||||
★ 별도 인터페이스 정의 안 함 — Record<string, any> 사용
|
||||
★ JSONB로 저장되므로 구조만 문서화
|
||||
|
||||
노드 (Record<string, any>):
|
||||
id, type(CTRL_NODE_TYPES 키 또는 'table'), label, x, y,
|
||||
config(타입별 설정), table_name(table 노드일 때)
|
||||
|
||||
연결 (Record<string, any>):
|
||||
id, from_node_id, from_port('out'|'yes'|'no'|'each'|'done'),
|
||||
to_node_id, to_port('in')
|
||||
|
||||
비즈니스 룰 (Record<string, any>):
|
||||
rule_id, dashboard_id, name, nodes(배열), connections(배열), is_enabled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 백엔드 API
|
||||
|
||||
### 6.1 관계 조회 (★ table_relationships 기반 — Phase 1과 소스 분리)
|
||||
|
||||
```
|
||||
GET /api/meta/tables/{tableName}/relations → 테이블 간 업무 관계
|
||||
```
|
||||
|
||||
**★ 2소스 책임 분리:**
|
||||
- Phase 1 `getTableFields()` → `table_type_columns` (필드 레벨 entity 참조 → FieldConfig.ref)
|
||||
- Phase 5 `getMetaRelations()` → `table_relationships` (테이블 간 업무 관계 → 제어 모드 흐름)
|
||||
- 제어 모드에서 두 소스 필요하면 **서비스 레이어에서 읽기 시점에만 합친다** (저장 구조 통합 안 함)
|
||||
|
||||
### 6.2 비즈니스 룰 CRUD
|
||||
|
||||
```
|
||||
GET /api/dashboards/{dashboard_id}/rules → 해당 대시보드의 룰 목록
|
||||
GET /api/rules/{rule_id} → 룰 상세 (노드+연결)
|
||||
POST /api/dashboards/{dashboard_id}/rules → 룰 생성
|
||||
PUT /api/rules/{rule_id} → 룰 수정
|
||||
DELETE /api/rules/{rule_id} → 룰 삭제
|
||||
PUT /api/rules/{rule_id}/toggle → 활성/비활성 토글
|
||||
```
|
||||
|
||||
**★ 덕일 스타일 3레이어. 파일명 1:1 매칭.**
|
||||
|
||||
| Java 파일 | XML | namespace |
|
||||
|---|---|---|
|
||||
| `BusinessRuleController.java` | `businessRule.xml` | `businessRule` |
|
||||
| `BusinessRuleService.java` | | |
|
||||
|
||||
```java
|
||||
@Service
|
||||
@Slf4j
|
||||
public class BusinessRuleService extends BaseService {
|
||||
|
||||
@Autowired
|
||||
private CommonService commonService;
|
||||
|
||||
public Map<String, Object> getBusinessRuleList(Map<String, Object> params) {
|
||||
commonService.applyPagination(params);
|
||||
int totalCount = sqlSession.selectOne("businessRule.getBusinessRuleListCnt", params);
|
||||
List<Map<String, Object>> list = sqlSession.selectList("businessRule.getBusinessRuleList", params);
|
||||
return commonService.buildListResponse(list, totalCount, params);
|
||||
}
|
||||
|
||||
public Map<String, Object> getBusinessRuleInfo(Map<String, Object> params) {
|
||||
return sqlSession.selectOne("businessRule.getBusinessRuleInfo", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertBusinessRule(Map<String, Object> params) {
|
||||
// nodes, connections → ObjectMapper로 JSON 문자열 변환 후 #{nodes}::jsonb
|
||||
sqlSession.insert("businessRule.insertBusinessRule", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateBusinessRule(Map<String, Object> params) {
|
||||
sqlSession.update("businessRule.updateBusinessRule", params);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int deleteBusinessRule(Map<String, Object> params) {
|
||||
return sqlSession.update("businessRule.deleteBusinessRule", params);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 프론트엔드 구현
|
||||
|
||||
### 7.1 파일 구조
|
||||
|
||||
```
|
||||
frontend/components/control/
|
||||
├── ControlMode.tsx ← 제어 모드 오버레이 (캔버스 위에)
|
||||
├── ControlToolbar.tsx ← 제어 모드 툴바 (읽기/편집 전환)
|
||||
├── FlowViewer.tsx ← 읽기 모드: 카드 흐름 시각화
|
||||
├── RuleBuilder.tsx ← 편집 모드: 규칙 빌더
|
||||
├── TableNode.tsx ← 테이블 노드 UI
|
||||
├── ControlNode.tsx ← 제어 노드 UI (16종)
|
||||
├── NodeConfigPopover.tsx ← 노드 설정 팝오버
|
||||
├── PortHandle.tsx ← I/O 포트 (드래그 연결)
|
||||
├── ConnectionLine.tsx ← SVG bezier 연결선
|
||||
├── ControlPalette.tsx ← 제어 모드 팔레트 (사이드바 교체)
|
||||
└── hooks/
|
||||
├── useControlMode.ts ← 제어 모드 상태 관리
|
||||
├── usePortDrag.ts ← 포트 연결 드래그 로직
|
||||
└── useFlowAnimation.ts ← 트리 확산 애니메이션
|
||||
```
|
||||
|
||||
### 7.2 SVG 연결선 렌더링
|
||||
|
||||
mockup의 bezier 곡선 방식 그대로:
|
||||
|
||||
```typescript
|
||||
// from 좌표 (x1,y1) → to 좌표 (x2,y2)
|
||||
const dx = x2 - x1;
|
||||
const d = `M${x1},${y1} C${x1+dx*0.5},${y1} ${x1+dx*0.5},${y2} ${x2},${y2}`;
|
||||
// SVG <path d={d} class="ctrl-line-auto" marker-end="url(#arr-auto)" />
|
||||
```
|
||||
|
||||
선이 그려지는 애니메이션: `stroke-dashoffset` transition
|
||||
|
||||
---
|
||||
|
||||
## 8. 스타일
|
||||
|
||||
제어 모드 전용 스타일은 mockup의 `css/07-control-mode.css` + `css/08-rule-builder.css` 참조.
|
||||
|
||||
핵심:
|
||||
- 캔버스 격자: 시안 톤 (`rgba(0,206,201,.22)`)
|
||||
- 테이블 노드: 시안 보더 + 글래스 배경
|
||||
- 연결선: 점선 + 펄스 애니메이션 (`stroke-dasharray: 6 3`)
|
||||
- 뱃지: 글래스 + 시안/보라/앰버 보더
|
||||
|
||||
---
|
||||
|
||||
## 9. 참고 파일
|
||||
|
||||
| 파일 | 용도 |
|
||||
|---|---|
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/06-control-mode.js` | **흐름 시각화 (진실의 원천)** — 996줄, enterControlMode, showCardFlow, buildCtrlTree, calcFlowPositions |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/js/07-rule-builder.js` | **규칙 빌더 (진실의 원천)** — 752줄, 16종 노드, 포트 드래그, 연결 관리, 설정 팝오버 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/css/07-control-mode.css` | 제어 모드 스타일 |
|
||||
| `notes/gbpark/2026-04-08-invyone-mockup/css/08-rule-builder.css` | 규칙 빌더 스타일 |
|
||||
| `notes/gbpark/2026-04-09-invyone-architecture.md` (Section 9 Phase 3) | 제어 플로우 로드맵 |
|
||||
| `notes/gbpark/2026-04-08-lowcode-platform-spec.md` | 비즈니스 룰 정의 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 완료 기준
|
||||
|
||||
1. **제어 모드 진입/탈출**: ⚡ 버튼으로 토글, 캔버스 시각이 변함
|
||||
2. **카드 클릭 → 흐름 표시**: 소스 테이블 + 관련 테이블 + 비즈니스 룰이 트리로 표시
|
||||
3. **트리 확산 애니메이션**: 선 → 노드 순서로 연쇄 등장
|
||||
4. **4종 연결선**: 소스(핑크), 자동(보라), 조건(앰버), FK(시안) 구분
|
||||
5. **팔레트에서 노드 드래그앤드롭**: 테이블 노드 + 제어 노드 캔버스에 배치
|
||||
6. **포트 연결**: output → input 드래그로 연결, SVG bezier 곡선
|
||||
7. **노드 설정 팝오버**: 타입별 설정 폼이 동작
|
||||
8. **규칙 저장/로드**: DB에 저장하고 다시 열면 복원
|
||||
9. **빈 영역 클릭 → 흐름 닫기**
|
||||
10. **다크/라이트 모드 지원**
|
||||
|
||||
---
|
||||
|
||||
## 11. 다음 단계 연결
|
||||
|
||||
Phase 6 (자동생성/프리셋):
|
||||
- 테이블 선택 → FieldConfig 기반으로 Template 자동 생성
|
||||
- 프리셋 3종 (basic/split/tabs) 자동 배치
|
||||
- 이 시점에서 모든 기능이 갖춰져 있으므로, 자동생성이 올바른 Template JSON을 생성할 수 있음
|
||||
@@ -1,368 +0,0 @@
|
||||
# Phase 5 구현 작업기록 — 제어 모드 (비즈니스 룰 / 데이터 흐름)
|
||||
|
||||
> **작업일**: 2026-04-10
|
||||
> **설계서**: `notes/gbpark/2026-04-10-phase5-control-mode.md`
|
||||
> **상태**: 구현 완료 + 빌드 검증 통과 + DB 테이블 생성 완료
|
||||
|
||||
---
|
||||
|
||||
## 1. 생성/수정된 파일 (22개)
|
||||
|
||||
### 1.1 백엔드 (6개)
|
||||
|
||||
| 파일 | 상태 | 역할 |
|
||||
|---|---|---|
|
||||
| `backend-spring/src/main/resources/mapper/meta.xml` | 수정 | `getMetaRelations` 쿼리 추가 (table_relationships) |
|
||||
| `backend-spring/src/main/java/com/erp/service/MetaService.java` | 수정 | `getMetaRelations()` 메서드 추가 |
|
||||
| `backend-spring/src/main/java/com/erp/controller/MetaController.java` | 수정 | `GET /api/meta/tables/{tableName}/relations` 엔드포인트 |
|
||||
| `backend-spring/src/main/resources/mapper/businessRule.xml` | 신규 | 비즈니스 룰 CRUD 7쿼리 (namespace=`businessRule`) |
|
||||
| `backend-spring/src/main/java/com/erp/service/BusinessRuleService.java` | 신규 | 비즈니스 룰 서비스 (JSONB 파싱/직렬화) |
|
||||
| `backend-spring/src/main/java/com/erp/controller/BusinessRuleController.java` | 신규 | 룰 CRUD + 토글 엔드포인트 6개 |
|
||||
|
||||
### 1.2 프론트엔드 (16개)
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `frontend/lib/api/meta.ts` | `getMetaRelations()` 추가 |
|
||||
| `frontend/lib/api/businessRule.ts` | 비즈니스 룰 CRUD API 클라이언트 (신규) |
|
||||
| `frontend/styles/control-mode.css` | 제어 모드 + 규칙 빌더 CSS (~350줄, mockup 포팅) |
|
||||
| `frontend/components/control/hooks/useControlMode.ts` | Zustand 상태관리 + `CTRL_NODE_TYPES` 16종 |
|
||||
| `frontend/components/control/hooks/useFlowAnimation.ts` | BFS 체인 + 위치 계산 + 애니메이션 타이밍 |
|
||||
| `frontend/components/control/hooks/usePortDrag.ts` | 포트 연결 드래그 로직 |
|
||||
| `frontend/components/control/TableNode.tsx` | 테이블 노드 UI (컬럼 목록 + 드래그) |
|
||||
| `frontend/components/control/ConnectionLine.tsx` | SVG bezier 연결선 4종 + 마커 + 뱃지 |
|
||||
| `frontend/components/control/FlowViewer.tsx` | 읽기 모드: 카드 클릭 → 흐름 시각화 |
|
||||
| `frontend/components/control/ControlNode.tsx` | 제어 노드 16종 (I/O 포트 포함) |
|
||||
| `frontend/components/control/PortHandle.tsx` | I/O 포트 핸들 (드래그 연결 시작/끝) |
|
||||
| `frontend/components/control/NodeConfigPopover.tsx` | 노드 설정 팝오버 (타입별 폼) |
|
||||
| `frontend/components/control/ControlPalette.tsx` | 제어 팔레트 (사이드바 교체) |
|
||||
| `frontend/components/control/RuleBuilder.tsx` | 편집 모드: 규칙 빌더 (드래그앤드롭) |
|
||||
| `frontend/components/control/ControlToolbar.tsx` | 읽기/편집 모드 전환 + 저장 버튼 |
|
||||
| `frontend/components/control/ControlMode.tsx` | 제어 모드 오버레이 메인 컴포넌트 |
|
||||
|
||||
### 1.3 기존 파일 수정 (3개)
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|---|---|
|
||||
| `frontend/components/dash/DashboardToolbar.tsx` | ⚡ 제어 모드 토글 버튼 추가 (제어 진입 시 편집 모드 자동 해제) |
|
||||
| `frontend/components/dash/DashboardLayout.tsx` | `ControlMode` 오버레이 통합 + 제어 편집 시 사이드바→팔레트 교체 |
|
||||
| `frontend/components/dash/DashboardCanvas.tsx` | `forwardRef` + `control-mode` CSS 클래스 + 제어 모드 시 드래그 비활성화 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 백엔드 상세
|
||||
|
||||
### 2.1 Meta Relations API (2소스 책임 분리)
|
||||
|
||||
```
|
||||
GET /api/meta/tables/{tableName}/relations → table_relationships 기반
|
||||
```
|
||||
|
||||
**★ Phase 1과 소스 분리:**
|
||||
- Phase 1 `getMetaFields()` → `table_type_columns` (필드 레벨 entity 참조 → FieldConfig.ref)
|
||||
- Phase 5 `getMetaRelations()` → `table_relationships` (테이블 간 업무 관계 → 제어 모드 흐름)
|
||||
- 제어 모드에서 두 소스 필요하면 **서비스 레이어에서 읽기 시점에만 합침**
|
||||
|
||||
### 2.2 BusinessRule CRUD (덕일 스타일 3레이어)
|
||||
|
||||
| 엔드포인트 | 메서드 | 설명 |
|
||||
|---|---|---|
|
||||
| `/api/dashboards/{id}/rules` | GET | 대시보드별 룰 목록 (페이지네이션) |
|
||||
| `/api/dashboards/{id}/rules` | POST | 룰 생성 (`rule_` + UUID 12자) |
|
||||
| `/api/rules/{id}` | GET | 룰 상세 (JSONB → Object 파싱) |
|
||||
| `/api/rules/{id}` | PUT | 룰 수정 (Object → JSON 직렬화) |
|
||||
| `/api/rules/{id}` | DELETE | 룰 소프트 삭제 (IS_ACTIVE='D') |
|
||||
| `/api/rules/{id}/toggle` | PUT | 활성/비활성 토글 |
|
||||
|
||||
**businessRule.xml 쿼리 7개:**
|
||||
`getBusinessRuleList`, `getBusinessRuleListCnt`, `getBusinessRuleInfo`, `insertBusinessRule`, `updateBusinessRule`, `deleteBusinessRule`, `toggleBusinessRule`
|
||||
|
||||
**JSONB 처리**: `nodes`/`connections` → `ObjectMapper`로 파싱/직렬화 + `#{nodes}::jsonb` 캐스팅
|
||||
|
||||
---
|
||||
|
||||
## 3. 프론트엔드 상세
|
||||
|
||||
### 3.1 useControlMode — Zustand 상태관리 (~160줄)
|
||||
|
||||
| 상태 | 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `active` | boolean | 제어 모드 ON/OFF |
|
||||
| `mode` | 'view' \| 'edit' | 읽기/편집 모드 |
|
||||
| `activeFlowCardId` | string \| null | 흐름 표시 중인 카드 |
|
||||
| `flowEdges` | Record[] | BFS 결과 엣지 배열 |
|
||||
| `tablePositions` | Record | 테이블 노드 위치 |
|
||||
| `ruleNodes` | Record[] | 규칙 빌더 노드 |
|
||||
| `ruleConnections` | Record[] | 규칙 빌더 연결 |
|
||||
| `configNodeId` | string \| null | 설정 팝오버 대상 노드 |
|
||||
|
||||
**CTRL_NODE_TYPES 16종 (mockup 그대로):**
|
||||
|
||||
| 카테고리 | 노드 | 아이콘 | 특수 포트 |
|
||||
|---|---|---|---|
|
||||
| 트리거 | 타이머 | ⏱ | — |
|
||||
| 조건 | 조건분기 | ◇ | Yes/No |
|
||||
| 조건 | 데이터 검증 | ✔ | Pass/Fail |
|
||||
| 액션 | 상태 변경 | 🔄 | — |
|
||||
| 액션 | 자동 등록 | 📝 | — |
|
||||
| 액션 | 계산/수식 | 🧮 | — |
|
||||
| 액션 | 삭제/보관 | 🗑 | — |
|
||||
| 액션 | 문서 생성 | 📄 | — |
|
||||
| 흐름 | 승인/결재 | ✋ | Approved/Rejected |
|
||||
| 흐름 | 대기/지연 | ⏳ | — |
|
||||
| 흐름 | 반복 | 🔁 | Each/Done |
|
||||
| 흐름 | 병렬 실행 | 🔀 | — |
|
||||
| 흐름 | 병합/합류 | ⤵ | — |
|
||||
| 연동 | 외부 호출 | 🌐 | — |
|
||||
| 연동 | 알림 발송 | 📨 | — |
|
||||
| 기록 | 로그 기록 | 📜 | — |
|
||||
|
||||
### 3.2 FlowViewer — 읽기 모드 (카드 클릭 → 흐름 시각화)
|
||||
|
||||
**흐름:**
|
||||
1. 캔버스에서 카드 클릭
|
||||
2. `getMetaRelations(sourceTable)` → 업무 관계 조회
|
||||
3. BFS로 도달 가능한 전체 체인 계산 (depth 무제한)
|
||||
4. `calcFlowPositions()` — 카드 우측에 depth별 트리 배치 (colGap=270~350, rowGap=240)
|
||||
5. `calcAnimationTimings()` — depth별 지연 (STEP=500ms, NODE_D=350ms)
|
||||
6. **트리 확산 애니메이션**: 선 → 노드 순서로 연쇄 등장
|
||||
|
||||
**시각 효과:**
|
||||
- 선택된 카드: opacity 1 + 좌측 고정 + 시안 보더
|
||||
- 나머지 카드: opacity 0.08
|
||||
- 테이블 노드: scale(0.3) → scale(1) 트랜지션
|
||||
- 연결선: `stroke-dashoffset` draw 애니메이션 → pulse 복원
|
||||
- 빈 영역 클릭: 흐름 닫기 (모든 카드 0.5로 복원)
|
||||
- 노드 드래그: 실시간 위치 업데이트 + 선 재그리기
|
||||
|
||||
### 3.3 RuleBuilder — 편집 모드 (규칙 빌더)
|
||||
|
||||
**흐름:**
|
||||
1. 사이드바 → 제어 팔레트 교체 (DB 테이블 + 제어 노드 16종)
|
||||
2. 팔레트에서 캔버스로 드래그앤드롭 → 노드 생성
|
||||
3. 노드 헤더 드래그 → 이동
|
||||
4. output 포트 mousedown → bezier 임시선 → input 포트 mouseup → 연결 생성
|
||||
5. 연결 중간 hover → 삭제 뱃지 (✕)
|
||||
6. 노드 body 클릭 → 설정 팝오버 (타입별 폼)
|
||||
7. "규칙 저장" → `insertBusinessRule()` / `updateBusinessRule()`
|
||||
|
||||
**포트 연결 규칙:**
|
||||
- 같은 노드끼리 연결 금지
|
||||
- 중복 연결 금지
|
||||
- 드래그 중 모든 input 포트 pulse 애니메이션
|
||||
|
||||
### 3.4 SVG 연결선 4종
|
||||
|
||||
| 타입 | 색상 | CSS 클래스 | 마커 | 용도 |
|
||||
|---|---|---|---|---|
|
||||
| 소스 | 핑크 #fd79a8 | `ctrl-line-tpl` | arr-src | 카드 → 소스 테이블 |
|
||||
| 자동실행 | 보라 #6c5ce7 | `ctrl-line-auto` | arr-auto | 테이블 → 테이블 자동 등록 |
|
||||
| 조건분기 | 앰버 #fdcb6e | `ctrl-line-cond` | arr-cond | 조건 충족 시 실행 |
|
||||
| FK | 시안 #00cec9 | `ctrl-line` | arr-fk | 외래키 관계 |
|
||||
|
||||
**라이트 모드 보정**: 시안→#00a89e, 보라→#5b4acf, 앰버→#d4a017, 핑크→#e0559e
|
||||
|
||||
### 3.5 연결선 위 뱃지
|
||||
|
||||
- 일반: 글래스 배경 + 시안 보더 + 라벨 텍스트
|
||||
- 조건분기: 확장형 (`cb-head` + `cb-cond` + `cb-paths` Yes/No)
|
||||
- 소스: 핑크 보더
|
||||
- 자동실행: 보라 보더
|
||||
|
||||
### 3.6 NodeConfigPopover — 타입별 설정 폼
|
||||
|
||||
| 노드 타입 | 설정 항목 |
|
||||
|---|---|
|
||||
| 조건분기 | 필드, 연산자(=, ≠, >, <, 기한경과, 포함), 값 |
|
||||
| 상태 변경 | 대상 테이블, 변경 필드, 변경값 |
|
||||
| 자동 등록 | 대상 테이블 |
|
||||
| 타이머 | 기준 필드, 경과량, 단위(일/시간/주) |
|
||||
| 알림 발송 | 채널(이메일/SMS/푸시/Slack), 수신자, 메시지 |
|
||||
| 승인/결재 | 승인자, 승인 조건 |
|
||||
| 계산/수식 | 대상 테이블, 결과 필드, 수식 |
|
||||
| 외부 호출 | URL, 메서드(POST/GET/PUT/DELETE) |
|
||||
| 데이터 검증 | 대상 필드, 검증 규칙 |
|
||||
| 로그 기록 | 내용 |
|
||||
|
||||
### 3.7 Dashboard 통합
|
||||
|
||||
**DashboardToolbar:**
|
||||
- ⚡ 버튼 추가 (제어 모드 진입 시 편집 모드 자동 해제)
|
||||
- 제어 모드 활성 중: 편집/템플릿추가/저장 버튼 숨김
|
||||
|
||||
**DashboardLayout:**
|
||||
- `ControlMode` 오버레이 (ControlToolbar + FlowViewer/RuleBuilder)
|
||||
- 제어 편집 모드: 사이드바 → `ControlPalette` 교체
|
||||
|
||||
**DashboardCanvas:**
|
||||
- `forwardRef`로 부모에서 캔버스 DOM 참조
|
||||
- `control-mode` CSS 클래스 (시안 격자 배경)
|
||||
- 제어 모드 시 카드 드래그/리사이즈 비활성화
|
||||
|
||||
---
|
||||
|
||||
## 4. DB 변경
|
||||
|
||||
### 4.1 business_rules 테이블 (✅ 생성 완료)
|
||||
|
||||
```sql
|
||||
CREATE TABLE business_rules (
|
||||
rule_id VARCHAR(50) PRIMARY KEY,
|
||||
dashboard_id VARCHAR(50),
|
||||
name VARCHAR(200),
|
||||
description TEXT,
|
||||
nodes JSONB NOT NULL,
|
||||
connections JSONB NOT NULL,
|
||||
is_enabled BOOLEAN DEFAULT TRUE,
|
||||
company_code VARCHAR(20) NOT NULL,
|
||||
created_by VARCHAR(50),
|
||||
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(50),
|
||||
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active VARCHAR(1) DEFAULT 'Y'
|
||||
);
|
||||
CREATE INDEX idx_brules_dashboard ON business_rules(dashboard_id);
|
||||
```
|
||||
|
||||
**실행 대상**: `211.115.91.141:11134/test_dev` (2026-04-10 실행 완료)
|
||||
★ DB FK 제약조건 안 걸음 (설계서 명시 — 앱 레벨 관리)
|
||||
|
||||
---
|
||||
|
||||
## 5. 덕일 스타일 / 프로젝트 컨벤션 준수
|
||||
|
||||
| 규칙 | 준수 |
|
||||
|---|---|
|
||||
| 3레이어 (Controller → Service → XML) | ✅ |
|
||||
| Mapper Interface 금지 | ✅ — sqlSession 직접 호출 |
|
||||
| Map<String, Object> 사용, DTO 금지 | ✅ — ApiResponse만 예외 |
|
||||
| BaseService 상속 | ✅ |
|
||||
| @Autowired CommonService | ✅ |
|
||||
| XML 파일명: 소문자, Mapper 안 붙임 | ✅ — `businessRule.xml` |
|
||||
| XML namespace: 파일명과 동일 | ✅ — `namespace="businessRule"` |
|
||||
| SQL: UPPER_SNAKE | ✅ |
|
||||
| SELECT 쉼표: 앞에 | ✅ |
|
||||
| #{파라미터}: snake_case | ✅ |
|
||||
| OGNL test: 바깥 작은따옴표 | ✅ |
|
||||
| 프론트 타입: Record<string, any> | ✅ — FieldConfig만 예외 |
|
||||
| v5/ctrl CSS 변수 사용, 즉흥 값 금지 | ✅ |
|
||||
| 컴팩트 폰트 사이즈 (0.42~0.68rem) | ✅ |
|
||||
| mockup 진실의 원천 | ✅ — 06-control-mode.js, 07-rule-builder.js |
|
||||
|
||||
---
|
||||
|
||||
## 6. CSS 토큰 사용 목록
|
||||
|
||||
| 용도 | 토큰 |
|
||||
|---|---|
|
||||
| 시안 | `--ctrl-cyan` (#00cec9), `--ctrl-cyan-glow` |
|
||||
| 보라 | `--ctrl-primary` (#6c5ce7) |
|
||||
| 앰버 | `--ctrl-amber` (#fdcb6e) |
|
||||
| 핑크 | `--ctrl-pink` (#fd79a8) |
|
||||
| 그린 | `--ctrl-green` (#55efc4) |
|
||||
| 레드 | `--ctrl-red` (#ff4757) |
|
||||
| 유리 배경 | `--ctrl-glass`, `--ctrl-glass-strong` |
|
||||
| 유리 보더 | `--ctrl-glass-border` |
|
||||
| 텍스트 | `var(--v5-text)`, `var(--v5-text-sec)`, `var(--v5-text-muted)` |
|
||||
| 보더 | `var(--v5-border)` |
|
||||
| 서피스 | `var(--v5-surface)`, `var(--v5-surface-hover)` |
|
||||
| 블러 | `backdrop-filter: blur(20px) saturate(1.4)` (v5 글래스 패턴) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 검증 결과
|
||||
|
||||
| 검증 항목 | 결과 |
|
||||
|---|---|
|
||||
| `./gradlew compileJava` | BUILD SUCCESSFUL |
|
||||
| `./gradlew bootJar` | BUILD SUCCESSFUL |
|
||||
| `npx tsc --noEmit` (Phase 5 파일) | 에러 0개 |
|
||||
| DB 테이블 생성 (test_dev) | CREATE TABLE + INDEX 성공 |
|
||||
| 기존 레거시 에러 | 2827개 (Phase 5 무관, 변동 없음) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 완료 기준 대비 상태
|
||||
|
||||
| # | 기준 | 상태 |
|
||||
|---|---|---|
|
||||
| 1 | ⚡ 버튼으로 제어 모드 토글 | ✅ |
|
||||
| 2 | 캔버스 시각 변화 (시안 격자, 카드 반투명) | ✅ |
|
||||
| 3 | 카드 클릭 → 흐름 표시 (소스 테이블 + 관련 테이블 + 비즈니스 룰 트리) | ✅ |
|
||||
| 4 | 트리 확산 애니메이션 (선→노드 순차 등장) | ✅ |
|
||||
| 5 | 4종 연결선 (소스/자동/조건/FK) | ✅ |
|
||||
| 6 | 팔레트에서 노드 드래그앤드롭 | ✅ |
|
||||
| 7 | 포트 연결 (output→input 드래그, bezier 곡선) | ✅ |
|
||||
| 8 | 노드 설정 팝오버 (타입별 폼) | ✅ |
|
||||
| 9 | 규칙 저장/로드 (DB JSONB) | ✅ |
|
||||
| 10 | 빈 영역 클릭 → 흐름 닫기 | ✅ |
|
||||
| 11 | 다크/라이트 모드 | ✅ (라이트 보정 CSS 포함) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 접속 경로
|
||||
|
||||
- **대시보드 (제어 모드 포함)**: `/dash` → ⚡ 버튼 클릭
|
||||
- 개발자 빌더: `/admin/builder` (Phase 3)
|
||||
- 테스트 페이지: `/test-fc` (Phase 2)
|
||||
|
||||
---
|
||||
|
||||
## 10. Phase 5 범위 밖 (다음에 해야 할 것)
|
||||
|
||||
| 항목 | 비고 |
|
||||
|---|---|
|
||||
| `table_relationships` 테이블에 실제 데이터 INSERT | 현재 빈 테이블이면 흐름이 소스→1개만 표시 |
|
||||
| 비즈니스 룰 실행 엔진 (트리거/조건 평가/액션 실행) | 현재는 시각 편집만 |
|
||||
| 규칙 로드 UI (기존 규칙 목록에서 선택 → 복원) | 현재는 새 규칙 생성만 |
|
||||
| computed 수식 파서 (AST 기반 안전한 파서) | Phase 5+ |
|
||||
| DataPort 이벤트 버스 (카드 간 실시간 데이터 전달) | Phase 5+ |
|
||||
| 자동생성/프리셋 (Phase 6) | 모든 기능 갖춰진 후 마지막 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 파일별 코드 요약
|
||||
|
||||
### businessRule.xml (107줄)
|
||||
```
|
||||
7개 쿼리: getBusinessRuleList, getBusinessRuleListCnt, getBusinessRuleInfo,
|
||||
insertBusinessRule, updateBusinessRule, deleteBusinessRule, toggleBusinessRule
|
||||
common include: companyCodeFilter, dynamicOrderBy, pagination
|
||||
```
|
||||
|
||||
### BusinessRuleService.java (~95줄)
|
||||
```
|
||||
퍼블릭: getBusinessRuleList, getBusinessRuleInfo, insertBusinessRule,
|
||||
updateBusinessRule, deleteBusinessRule, toggleBusinessRule
|
||||
프라이빗: parseJsonField, stringifyJsonField (JSONB 유틸)
|
||||
```
|
||||
|
||||
### BusinessRuleController.java (~100줄)
|
||||
```
|
||||
6개 엔드포인트: GET/POST rules + GET/PUT/DELETE/toggle rule
|
||||
```
|
||||
|
||||
### useControlMode.ts (~160줄)
|
||||
```
|
||||
Zustand store: active, mode, flowEdges, ruleNodes, ruleConnections
|
||||
+ CTRL_NODE_TYPES 16종 정의 + genNodeId/genConnId 헬퍼
|
||||
```
|
||||
|
||||
### FlowViewer.tsx (~180줄)
|
||||
```
|
||||
카드 클릭 이벤트 → getMetaRelations → BFS → 위치 계산 → 순차 reveal
|
||||
TableNode + ConnectionSvg + FlowLine + FlowBadge 렌더
|
||||
```
|
||||
|
||||
### RuleBuilder.tsx (~180줄)
|
||||
```
|
||||
캔버스 드래그앤드롭 → 노드 생성 (테이블/제어)
|
||||
포트 연결 SVG + 삭제 뱃지 + NodeConfigPopover
|
||||
```
|
||||
|
||||
### control-mode.css (~350줄)
|
||||
```
|
||||
--ctrl-* 변수 체계, 연결선 4종 + pulse 애니메이션,
|
||||
테이블 노드, 제어 노드, I/O 포트, 설정 팝오버, 팔레트
|
||||
다크/라이트 모드 보정
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,527 +0,0 @@
|
||||
# Template 모델 재설계 — Phase 1 구현 로그 (1차)
|
||||
|
||||
**작업일**: 2026-04-10
|
||||
**작업자**: gbpark + Claude
|
||||
**기반 스펙**: `notes/gbpark/2026-04-10-template-model-redesign.md` (섹션 8~16, 19~20)
|
||||
**범위**: 스펙 섹션 19 "다음 액션" 중 **1~4번까지** (타입 재정의, CSS 변수, DashboardCard grid 전환, 빌더 grid-snap 전환)
|
||||
**남은 작업**: 5~8번 (팝업 grid 적용, 레거시 마이그레이션 다이얼로그, Template 전수 검증, Phase 3/4 문서 업데이트)
|
||||
|
||||
---
|
||||
|
||||
## 0. 결과 요약
|
||||
|
||||
| 항목 | 상태 |
|
||||
|---|---|
|
||||
| 작업 스코프 1~4번 | ✅ 완료 |
|
||||
| `npx tsc --noEmit` 작업 범위 에러 | 0건 |
|
||||
| Finding 1 (row 반응형) 코드 구조 확보 | ✅ (브라우저 시각 검증 남음) |
|
||||
| Finding 2 (공용 기하학 수식) 단위 검증 | ✅ `pixelDeltaToColDelta(200, 800, 8, 16) === 3` |
|
||||
| Finding 3 (레거시 변환 다이얼로그) | ⏸ 다음 턴 |
|
||||
| Finding 4 (drop/drag/resize 일관성) 코드 구조 확보 | ✅ (브라우저 시각 검증 남음) |
|
||||
| **원칙 3 임시 위반 잔존** | ⚠ `useBuilderState.ts:303-308` `fromTemplate` business fallback — 섹션 3.5 / 4.6 참조 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 수정/생성된 파일 목록
|
||||
|
||||
### 타입
|
||||
- **수정** `frontend/types/invyone-component.ts`
|
||||
|
||||
### 스타일
|
||||
- **수정** `frontend/styles/v5-layout.css`
|
||||
- **수정** `frontend/styles/dashboard.css`
|
||||
- **수정** `frontend/styles/developer.css`
|
||||
|
||||
### DashboardCard (대시보드 카드 → grid 렌더)
|
||||
- **수정** `frontend/components/dash/DashboardCard.tsx`
|
||||
|
||||
### Builder (빌더 grid-snap 전환)
|
||||
- **신규** `frontend/components/builder/hooks/gridMetrics.ts`
|
||||
- **수정** `frontend/components/builder/hooks/useBuilderState.ts`
|
||||
- **수정** `frontend/components/builder/hooks/useBlockDrag.ts`
|
||||
- **수정** `frontend/components/builder/BuilderCanvas.tsx`
|
||||
- **수정** `frontend/components/builder/BuilderBlock.tsx`
|
||||
- **수정** `frontend/components/builder/BuilderProps.tsx`
|
||||
- **수정** `frontend/components/builder/BuilderToolbar.tsx`
|
||||
- **수정** `frontend/components/builder/BuilderPalette.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 2. 스펙 작업 항목별 상세
|
||||
|
||||
### 2.1 타입 재정의 — 섹션 8
|
||||
|
||||
**파일**: `frontend/types/invyone-component.ts`
|
||||
|
||||
#### 추가된 타입
|
||||
- `GridPosition { col, colSpan, row?, rowSpan?, responsive? }`
|
||||
- `AbsolutePosition { x, y, w, h }`
|
||||
- `ComponentPosition = GridPosition | AbsolutePosition`
|
||||
- `ResponsiveGridOverride { narrow?, normal?, wide? }` — 각 breakpoint는 `Partial<Omit<GridPosition, 'responsive'>>`
|
||||
- `TemplateKind = 'business' | 'canvas'`
|
||||
|
||||
#### 삭제된 타입
|
||||
- 기존 `Position { x, y, w, h }` (주석은 "그리드 단위"였지만 실제는 픽셀)
|
||||
- 기존 `ResponsiveOverride { lg, md, sm }` (뷰포트 네이밍, 구현도 없었음)
|
||||
- `Component.responsive` 필드 (이제 `GridPosition.responsive` 안으로 이동)
|
||||
|
||||
#### 추가된 타입 가드
|
||||
```ts
|
||||
export function isGridPosition(pos: ComponentPosition): pos is GridPosition {
|
||||
return pos != null && typeof pos === 'object' && 'col' in pos && 'colSpan' in pos;
|
||||
}
|
||||
export function isAbsolutePosition(pos: ComponentPosition): pos is AbsolutePosition {
|
||||
return pos != null && typeof pos === 'object' && 'x' in pos && 'y' in pos && 'w' in pos && 'h' in pos;
|
||||
}
|
||||
```
|
||||
|
||||
#### `Component.position` → `ComponentPosition`
|
||||
- Template.kind에 따라 어느 쪽으로 해석되는지 주석으로 명시
|
||||
|
||||
#### `Template.kind: TemplateKind` 필수 필드 추가
|
||||
- 레이아웃 모델 분기. `business`(기본) / `canvas`(예외)
|
||||
|
||||
#### 신규 상수
|
||||
- `DEFAULT_COMPONENT_LAYOUTS: Record<ComponentType, GridPosition>` — 8종 팔레트 기본값 + responsive 디폴트. 수주관리 스펙(섹션 10.3)을 참고해 table: 8span wide / 12span narrow-normal, form: 4span wide / 12span narrow-normal 등
|
||||
- `CANVAS_KEYWORDS` — 레거시 canvas 휴리스틱 키워드 (섹션 13.3.1용, 마이그레이션 다이얼로그 구현 시 사용)
|
||||
|
||||
---
|
||||
|
||||
### 2.2 CSS 변수 + grid 스타일 — 섹션 11.5, 12
|
||||
|
||||
#### `frontend/styles/v5-layout.css`
|
||||
`:root`에 grid 네임스페이스 변수 세트 추가:
|
||||
```css
|
||||
--grid-cols: 12;
|
||||
--grid-gap: .5rem;
|
||||
--grid-gap-narrow: .35rem;
|
||||
--grid-gap-normal: .45rem;
|
||||
--grid-gap-wide: .55rem;
|
||||
--card-narrow-max: 520px;
|
||||
--card-normal-max: 900px;
|
||||
--grid-line: rgba(108,92,231,.08);
|
||||
--grid-line-hover: rgba(108,92,231,.2);
|
||||
--grid-drop-preview: rgba(108,92,231,.15);
|
||||
--grid-drop-preview-border: rgba(108,92,231,.5);
|
||||
```
|
||||
`.dark`에도 대응하는 다크 테마 grid 토큰(보라 대신 라벤더 계열) 추가.
|
||||
v5 Cosmic 토큰(`--v5-*`)은 그대로 유지. grid 토큰은 독립 네임스페이스로만 추가.
|
||||
|
||||
#### `frontend/styles/dashboard.css`
|
||||
`.dash-card-body`에 container query 활성화:
|
||||
```css
|
||||
.dash-card-body {
|
||||
flex: 1; overflow: auto; padding: .5rem;
|
||||
container-type: inline-size;
|
||||
container-name: card;
|
||||
}
|
||||
```
|
||||
|
||||
추가된 클래스:
|
||||
- `.dash-card-error`, `.dash-card-loading` — 에러/로딩 상태
|
||||
- `.dash-card-grid` — 12-col grid (business 렌더)
|
||||
- `.dash-card-grid > .tpl-component` — **`min-width: 0` 필수** (섹션 11.6, 12열 비율 유지의 핵심)
|
||||
- `.dash-card-canvas-wrapper` / `.dash-card-canvas` / `.dash-card-canvas > .tpl-component` — absolute 렌더 (canvas kind)
|
||||
|
||||
`@container card` 쿼리 3단계:
|
||||
- `max-width: 520px` → narrow
|
||||
- `520.01px ~ 900px` → normal
|
||||
- `>900.01px` → wide
|
||||
|
||||
★ 각 쿼리에서 `grid-column` + `grid-row` **둘 다** 오버라이드. Finding 1 대응 — row 오버라이드가 실제로 반응해야 narrow에서 form이 테이블 아래로 내려감.
|
||||
|
||||
#### `frontend/styles/developer.css` — 빌더 그리드
|
||||
기존 `.dev-canvas-inner` (자유배치용, 1200×800 고정)는 유지하되, 새로 `.dev-canvas-grid` 추가:
|
||||
```css
|
||||
.dev-canvas-grid {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
min-height: 600px;
|
||||
container-type: inline-size;
|
||||
container-name: card;
|
||||
background-image: linear-gradient(to right, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: calc((100% - 32px) / 12 + 8px) 100%;
|
||||
background-position: 16px 0;
|
||||
background-repeat: repeat-x;
|
||||
}
|
||||
.dev-canvas-grid.dragging { /* grid-line-hover */ }
|
||||
.dev-popup-grid { min-height: 320px; padding: 12px; }
|
||||
.dev-grid-warn { /* 비-grid 블록 잔존 시 경고 배너 */ }
|
||||
```
|
||||
|
||||
`.dev-block` 블록은 `position: absolute` → `position: relative` (grid item)으로 전환. `min-width: 0`, `min-height: 48px`, `cursor: move` 추가.
|
||||
|
||||
반응형 오버라이드 속성 패널 UI용 `.dev-resp-row`, `.dev-resp-label` 추가.
|
||||
|
||||
---
|
||||
|
||||
### 2.3 DashboardCard grid 렌더 전환 — 섹션 11.2~11.7
|
||||
|
||||
**파일**: `frontend/components/dash/DashboardCard.tsx`
|
||||
|
||||
#### 삭제된 코드 (~40줄)
|
||||
- `canvasBounds` useMemo (캔버스 전체 박스 추정)
|
||||
- `bodyRef` / `scale` state
|
||||
- `ResizeObserver` + `transform: scale()` 계산 useEffect
|
||||
|
||||
#### 추가된 상태
|
||||
- `templateKind: TemplateKind | null` — Template 로드 시 `tpl.kind` 복원
|
||||
|
||||
#### 렌더 분기 (섹션 11.3, 11.7)
|
||||
```tsx
|
||||
{effectiveKind === 'canvas' ? (
|
||||
<div className="dash-card-canvas-wrapper">
|
||||
<div className="dash-card-canvas">
|
||||
{sortedComponents.map(c => <AbsoluteComponent ... />)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dash-card-grid">
|
||||
{sortedComponents.map(c => <GridComponent ... />)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
`effectiveKind`는 `templateKind`을 우선하되, kind 필드가 없는 레거시는 position 형태로 보수적 추정:
|
||||
- `GridPosition` → `business`
|
||||
- 그 외 (`{x,y,w,h}` 레거시) → `canvas`
|
||||
|
||||
★ 이 추정은 **렌더 경로 선택만** 담당하고 **DB 쓰기 없음**. 섹션 13.2의 "자동 변환 금지" 원칙과 충돌하지 않음 (변환이 아니라 렌더 분기).
|
||||
|
||||
#### `GridComponent` — business kind 전용
|
||||
섹션 11.4 Finding 1 반영. **12개 CSS 변수 전부 주입**:
|
||||
```tsx
|
||||
const r = pos.responsive ?? {};
|
||||
const style: React.CSSProperties = {
|
||||
'--col': pos.col ?? 1,
|
||||
'--col-span': pos.colSpan ?? 12,
|
||||
'--row': toRowVal(pos.row), // auto 문자열 허용
|
||||
'--row-span': toSpanVal(pos.rowSpan),
|
||||
'--col-narrow': r.narrow?.col ?? pos.col ?? 1,
|
||||
'--col-span-narrow': r.narrow?.colSpan ?? pos.colSpan ?? 12,
|
||||
'--row-narrow': toRowVal(r.narrow?.row ?? pos.row),
|
||||
'--row-span-narrow': toSpanVal(r.narrow?.rowSpan ?? pos.rowSpan),
|
||||
// ... normal/wide 동일
|
||||
};
|
||||
```
|
||||
`toRowVal(undefined) === 'auto'` — CSS grid auto placement 동작.
|
||||
|
||||
#### `AbsoluteComponent` — canvas kind 전용
|
||||
`position.x/y/w/h` 그대로 `left/top/width/height`에 매핑.
|
||||
|
||||
#### `renderByType()` — 타입별 렌더 공통화
|
||||
기존 `ComponentRenderer` switch 를 `renderByType(props)`로 분리. `wrapStyle` (absolute 박스)을 없애고 각 case에서 `width: 100%; height: 100%` 기준으로 렌더.
|
||||
|
||||
---
|
||||
|
||||
### 2.4 빌더 grid-snap 전환 — 섹션 9, 14.5
|
||||
|
||||
#### 2.4.1 `hooks/gridMetrics.ts` 신규
|
||||
|
||||
**★ drop / drag / resize 모두 반드시 이 파일만 사용. `canvasW/12` 직접 계산 금지.**
|
||||
|
||||
공용 함수:
|
||||
- `getGridMetrics(canvasWidth, gap, padding)` → `{ contentWidth, colWidth, step, padding, gap }`
|
||||
- `pixelToGridCol(pixelX, canvasWidth, gap, padding)` — 드롭 위치 계산용
|
||||
- `pixelToColSpan(pixelW, canvasWidth, gap, padding)`
|
||||
- `pixelDeltaToColDelta(pixelDelta, canvasWidth, gap, padding)` — drag/resize 증감용
|
||||
- `readCanvasGeometry(el)` → `{ canvasWidth, gap, padding }` — DOM에서 1회 읽기
|
||||
- `computeNextAvailableRow(newCol, newColSpan, blocks)` — 섹션 14.4 자동 밀기
|
||||
- `BUILDER_ROW_HEIGHT = 48` — row 이동 환산용
|
||||
|
||||
#### 2.4.2 `useBuilderState.ts`
|
||||
|
||||
새 상태 필드:
|
||||
- `templateKind: TemplateKind` (기본 `'business'`)
|
||||
- `previewWidth: 'narrow' | 'normal' | 'wide'` (기본 `'normal'`)
|
||||
|
||||
새 액션:
|
||||
- `addBlock(type: ComponentType, position: GridPosition)` — 시그니처 교체
|
||||
- `setTemplateKind(kind)`
|
||||
- `setPreviewWidth(w)`
|
||||
|
||||
삭제된 액션:
|
||||
- `moveBlock(id, x, y)`, `resizeBlock(id, w, h)` — grid에서는 `updateBlock` 에 position 객체 통째로 넘김
|
||||
|
||||
삭제된 함수:
|
||||
- `defaultSize(type)` — 픽셀 크기 테이블. `DEFAULT_COMPONENT_LAYOUTS`가 대체
|
||||
|
||||
`toTemplate()` — 반환 객체에 `kind: s.templateKind` 포함.
|
||||
|
||||
`fromTemplate(tpl)` — **⚠ 원칙 3 임시 위반 상태** (line 303~308). `tpl.kind`이 `business`/`canvas`면 그대로 수용, 없으면 `business` 강제 디폴트. 스펙 섹션 13.4 "절대 금지 패턴"(`if (!tpl.kind) tpl.kind = 'business'`)과 형식만 다를 뿐 동일 동작.
|
||||
|
||||
**주석-구현 불일치**: 주석(line 305)에는 "`isDirty=true`로 표시해 사용자가 확인 없이 저장하지 못하게 한다"고 적혔으나 실제 구현 line 325는 `isDirty: false`. 저장 차단 경로 자체가 없음.
|
||||
|
||||
DB 저장은 여전히 사용자 "저장" 클릭 필요하지만, 레거시 Template에 새 블록 추가 후 저장 시 데이터 혼합 리스크 있음. 상세는 섹션 3.5, 해소 계획은 섹션 4.6 참조.
|
||||
|
||||
#### 2.4.3 `BuilderCanvas.tsx` 재작성
|
||||
|
||||
`handleDrop` — 공용 함수 경로:
|
||||
```tsx
|
||||
const canvasEl = e.currentTarget as HTMLElement;
|
||||
const { canvasWidth, gap, padding } = readCanvasGeometry(canvasEl);
|
||||
const rect = canvasEl.getBoundingClientRect();
|
||||
const relX = e.clientX - rect.left;
|
||||
const dropCol = pixelToGridCol(relX, canvasWidth, gap, padding);
|
||||
|
||||
const defaultLayout = DEFAULT_COMPONENT_LAYOUTS[type];
|
||||
const col = Math.min(dropCol, 13 - defaultLayout.colSpan);
|
||||
const colSpan = Math.min(defaultLayout.colSpan, 13 - col);
|
||||
const row = computeNextAvailableRow(col, colSpan, blocks);
|
||||
|
||||
addBlock(type, { col, colSpan, row, ...responsive });
|
||||
```
|
||||
|
||||
분기:
|
||||
- `templateKind === 'canvas'` → placeholder (자유배치 빌더는 Phase 2 이후)
|
||||
- `templateKind === 'business'` + `currentView === 'list'` → `.dev-canvas-grid` 렌더
|
||||
- `templateKind === 'business'` + 팝업 뷰 → `.dev-canvas-grid.dev-popup-grid`를 `.dev-popup-frame` 안에 렌더
|
||||
|
||||
`previewWidth`에 따라 `maxWidth` 적용 (`400 / 720 / 1100`px). `@container`가 실제로 반응.
|
||||
|
||||
비-grid 블록이 잔존하면 `.dev-grid-warn` 배너로 경고.
|
||||
|
||||
#### 2.4.4 `BuilderBlock.tsx`
|
||||
|
||||
grid item 전환:
|
||||
```tsx
|
||||
const pos = block.position as GridPosition;
|
||||
<div
|
||||
className={`dev-block${isSelected ? ' selected' : ''}`}
|
||||
style={{
|
||||
gridColumn: `${pos.col} / span ${pos.colSpan}`,
|
||||
gridRow: pos.row != null ? `${pos.row} / span ${pos.rowSpan ?? 1}` : 'auto',
|
||||
}}
|
||||
...
|
||||
>
|
||||
```
|
||||
|
||||
`isGridPosition` 아니면 `null` 반환 (canvas/legacy는 이번 단계에서 렌더 스킵).
|
||||
|
||||
드래그 label에 `col N span M · row R` 표기 추가 — 사용자 피드백용.
|
||||
|
||||
#### 2.4.5 `useBlockDrag.ts` 재작성
|
||||
|
||||
`startDrag` / `startResize` 둘 다 세션 시작 시 1회 `readCanvasGeometry` 호출해서 `canvasWidth/gap/padding`을 캐시. `onMove`에서는 `pixelDeltaToColDelta(dx, ...)`만 호출.
|
||||
|
||||
드래그:
|
||||
```ts
|
||||
const deltaCol = pixelDeltaToColDelta(dx, canvasWidth, gap, padding);
|
||||
const deltaRow = Math.round(dy / BUILDER_ROW_HEIGHT);
|
||||
const newCol = Math.max(1, Math.min(13 - cur.colSpan, origCol + deltaCol));
|
||||
const newRow = Math.max(1, origRow + deltaRow);
|
||||
updateBlock(id, { position: { ...cur, col: newCol, row: newRow } });
|
||||
```
|
||||
|
||||
리사이즈:
|
||||
```ts
|
||||
const deltaCols = pixelDeltaToColDelta(dx, canvasWidth, gap, padding);
|
||||
const newSpan = Math.max(1, Math.min(13 - cur.col, origSpan + deltaCols));
|
||||
updateBlock(id, { position: { ...cur, colSpan: newSpan } });
|
||||
```
|
||||
|
||||
★ 둘 다 `zustand.getState()`로 최신 block을 꺼내기 때문에 onMove 중 불필요한 재등록 없음.
|
||||
|
||||
#### 2.4.6 `BuilderProps.tsx`
|
||||
|
||||
위치 편집 UI를 `X/Y/W/H` → `col/colSpan/row/rowSpan`로 교체.
|
||||
|
||||
반응형 오버라이드 섹션 신규 — narrow/normal/wide 각 breakpoint마다 `col/span/row/rowSpan` 4칸. 빈 값은 `undefined`(= 기본값 fallback).
|
||||
|
||||
`isGridPosition` 아니면 "grid 위치가 아닙니다" 안내.
|
||||
|
||||
#### 2.4.7 `BuilderToolbar.tsx`
|
||||
|
||||
새 툴바 그룹 2개:
|
||||
1. **모델** — `business` / `canvas` 토글 → `setTemplateKind`
|
||||
2. **미리보기** — `narrow 400` / `보통 720` / `넓음 1100` 토글 → `setPreviewWidth`. `templateKind === 'business'`일 때만 표시
|
||||
|
||||
`handleSave` 페이로드에 `kind: tpl.kind` 포함.
|
||||
|
||||
#### 2.4.8 `BuilderPalette.tsx`
|
||||
|
||||
클릭으로 추가 시에도 `DEFAULT_COMPONENT_LAYOUTS` + `computeNextAvailableRow` 사용. 기존 `{x:16, y:16, w:0, h:0}` 제거.
|
||||
|
||||
---
|
||||
|
||||
## 3. 검증 결과
|
||||
|
||||
### 3.1 타입체크
|
||||
```
|
||||
$ npx tsc --noEmit | grep -E "invyone-component|components/builder|components/dash/DashboardCard|components/dash/CardSettings"
|
||||
(0 matches)
|
||||
```
|
||||
내 작업 범위에서 에러 **0건**.
|
||||
|
||||
전체 프로젝트 타입체크는 VEX 레거시(screen-management, v2-*, dataflow, admin dashboard widgets 등)에서 기존부터 누적된 ~2800건 에러가 있으나 이번 작업과 무관.
|
||||
|
||||
### 3.2 Finding 2 단위 검증
|
||||
```js
|
||||
// getGridMetrics(800, 8, 16)
|
||||
// content = 800 - 32 = 768
|
||||
// totalGap = 88
|
||||
// colWidth = (768 - 88) / 12 = 56.666...
|
||||
// step = 64.666...
|
||||
// 200 / step = 3.093
|
||||
// round → 3
|
||||
|
||||
pixelDeltaToColDelta(200, 800, 8, 16) === 3 ✅
|
||||
```
|
||||
|
||||
### 3.3 Finding 1 구조 확보
|
||||
- `GridComponent`에서 `--row*`, `--row-span*` 8개 변수 주입 확인
|
||||
- `@container card` 3개 쿼리 모두 `grid-row: var(--row-*) / span var(--row-span-*)` 라인 포함
|
||||
- 브라우저 시각 검증은 사용자 몫 (수주관리 예시를 400px/1000px 카드에서 렌더했을 때 form 위치)
|
||||
|
||||
### 3.4 Finding 4 구조 확보
|
||||
- `handleDrop`, `startDrag`, `startResize` 전부 `readCanvasGeometry` → `pixelToGridCol` / `pixelDeltaToColDelta` 경로
|
||||
- `canvasW / 12` 직접 계산 grep 결과: 0건
|
||||
|
||||
### 3.5 Finding 3 — ⚠ 원칙 위반 잔존 (완료 아님)
|
||||
|
||||
스펙 섹션 13.4 "절대 금지 패턴"이 코드에 남아 있는 상태. 다음 턴 6번으로 해소 이월.
|
||||
|
||||
#### 위반 위치
|
||||
|
||||
**파일**: `frontend/components/builder/hooks/useBuilderState.ts`
|
||||
**함수**: `fromTemplate(tpl)` (line 303~327)
|
||||
**핵심 라인**: 307~308
|
||||
|
||||
```ts
|
||||
const kind: TemplateKind =
|
||||
tpl.kind === "business" || tpl.kind === "canvas" ? tpl.kind : "business";
|
||||
```
|
||||
|
||||
스펙 섹션 13.4 "절대 금지 패턴"의 `if (!tpl.kind) tpl.kind = 'business';` 와 형식만 삼항으로 바꾼 동일 동작.
|
||||
|
||||
#### 주석-구현 불일치
|
||||
|
||||
line 303~306 주석:
|
||||
```
|
||||
// kind 는 호출자(빌더 진입점)가 다이얼로그로 확정한 뒤 넘겨야 함.
|
||||
// 여기서는 tpl.kind 를 그대로 받고, 없으면 기본 business 로 두되 isDirty=true
|
||||
// 로 표시해 사용자가 확인 없이 저장하지 못하게 한다.
|
||||
```
|
||||
|
||||
그러나 실제 line 325는 `isDirty: false`. **저장 차단 의도를 구현에 반영하지 못함.** 주석과 구현 중 어느 쪽이 정답인지 불분명 — 다음 턴에서 다이얼로그 도입으로 이 문제 자체를 없애는 것이 맞음.
|
||||
|
||||
#### 현 시점 완화 요소 (사고 확률을 낮추는 요인)
|
||||
|
||||
1. **DB 자동 저장 경로 없음** — 사용자가 "저장" 버튼을 명시적으로 클릭해야만 반영
|
||||
2. **빌더에서 레거시 블록 렌더 안 됨** — `BuilderBlock`이 `isGridPosition` 가드로 레거시 `{x,y,w,h}` 블록을 `null` 반환. 사용자 화면에 안 보이는 상태에서는 "저장" 누를 동기가 적음
|
||||
3. **대시보드는 `effectiveKind` 추정** — position 형태로 business/canvas 분기. 레거시는 canvas fallback → absolute 렌더, 데이터 손상은 없음
|
||||
|
||||
#### 실제 리스크 시나리오
|
||||
|
||||
1. 사용자가 기존 `{x,y,w,h}` 기반 레거시 Template을 빌더에서 열기 → `fromTemplate`가 `kind: 'business'` 강제 부여, `isDirty: false`
|
||||
2. 사용자가 "화면이 비었네" 하고 새 컴포넌트를 palette에서 drop → `blocks.list`에 grid 블록이 추가되면서 기존 레거시 `{x,y,w,h}` 블록들과 혼재
|
||||
3. 사용자가 "저장" 클릭 → `toTemplate`가 `kind: 'business'` + 혼합 blocks 배열을 DB에 기록
|
||||
4. **결과**: 원본 레거시 데이터가 business kind로 덮여쓰임, grid + absolute 포맷 혼합 상태. `migration.backup` 미적용이라 롤백 어려움
|
||||
|
||||
#### 다음 턴 해소 방법 (섹션 4.6 ★0순위)
|
||||
|
||||
1. `fromTemplate` 시그니처에 `kind: TemplateKind`를 **필수 파라미터**로 추가 — 호출자가 다이얼로그로 결정한 값만 넘김
|
||||
2. 빌더 진입점(`BuilderLayout` 또는 `TemplateLibraryModal`)에 `openTemplate(templateId)` 도입 — 내부에서 `isLikelyCanvasTemplate` 휴리스틱 + 블로킹 다이얼로그
|
||||
3. 사용자가 `business` 선택 시에만 `migrateTemplateAbsoluteToGrid(tpl)` + `migration.backup` 적용 후 `fromTemplate(migrated, 'business')` 호출
|
||||
4. 사용자가 `canvas` 선택 시 `fromTemplate({...tpl, kind: 'canvas'}, 'canvas')` 호출 — 위치 유지
|
||||
5. `fromTemplate` 내부의 `'business'` 강제 디폴트 삭제
|
||||
|
||||
#### 임시 부분 수정 하지 않는 이유
|
||||
|
||||
"`isDirty: true`만 고치면 되지 않냐" — **권장하지 않음**:
|
||||
- `isDirty` 플래그만으로는 저장 차단 확실하지 않음 (다른 경로로 isDirty 초기화될 수 있음)
|
||||
- `fromTemplate` 호출 경로는 빌더 진입 외에도 import/template library 등 여러 곳 → 부분 수정은 회귀 위험
|
||||
- 다음 턴에서 정공법으로 한 번에 처리하는 것이 안전. 이번 턴 완료 보고는 **이 섹션으로 상태 명시**만 수행
|
||||
|
||||
---
|
||||
|
||||
## 4. 다음 턴에 해야 할 것 (스펙 5~8번)
|
||||
|
||||
### 5. 등록/수정 팝업 grid 적용 — 섹션 15
|
||||
- `ViewConfig.size` 활용
|
||||
- 팝업도 `.dev-canvas-grid` 사용, `container-name: popup` 분기는 필요 시
|
||||
- 현재 `BuilderCanvas`에서 `dev-popup-grid`로 1차 적용 해둠 — 스펙 대로 정비 필요
|
||||
- `FormConfig.columns` 는 폼 내부 필드 배치용으로 유지 (2단계 구조)
|
||||
|
||||
### 6. Template 마이그레이션 + 승인 다이얼로그 — 섹션 13 ★★ 최우선 (0순위)
|
||||
|
||||
**★★★ 가장 먼저 해야 할 것 — `fromTemplate` 임시 위반 제거** (상세: 섹션 3.5):
|
||||
`useBuilderState.ts:307-308`의 `tpl.kind ?? 'business'` 폴백 삭제. `fromTemplate` 시그니처에 `kind: TemplateKind`를 **필수 파라미터**로 추가. 주석의 `isDirty=true` 의도도 함께 정리 (다이얼로그가 생기면 이 주석 자체가 불필요해짐).
|
||||
|
||||
이어지는 구현 순서:
|
||||
|
||||
1. `isLikelyCanvasTemplate(oldTpl)` 휴리스틱 (키워드 / y 분포 / controlFlow 필드 — 스펙 섹션 13.3.1)
|
||||
2. `migrateTemplateAbsoluteToGrid(oldTpl)` — `migration.backup` 원본 보존 (스펙 섹션 13.3.2)
|
||||
3. `openTemplate(templateId)` 블로킹 다이얼로그 (스펙 섹션 13.5)
|
||||
4. 빌더 진입점(`BuilderLayout` / `TemplateLibraryModal`)에서 `openTemplate` → 다이얼로그 → 확정된 `kind`로 `fromTemplate(tpl, kind)` 호출 체인 구성
|
||||
5. **절대 금지 패턴 재확인**: `if (!tpl.kind) tpl.kind = 'business'` 코드 어디에도 두지 말 것
|
||||
|
||||
**순서 제안**: 5번(팝업) 전에 **6번부터 먼저 처리** — 원칙 위반 잔존 시간을 최소화. 5번 작업 중 사용자가 레거시 Template을 건드려 데이터가 망가지면 뒤늦게 후회.
|
||||
|
||||
### 7. 기존 Template 전수 검증
|
||||
- DB에 있는 Template 목록을 빌더에서 하나씩 열어서 다이얼로그 동작 확인
|
||||
- canvas 디폴트 선택이 맞는지, 취소 시 DB 무변경, business 선택 시만 변환되고 `migration.backup` 저장되는지
|
||||
|
||||
### 8. Phase 3/4 문서 업데이트 — 섹션 16
|
||||
- `notes/gbpark/2026-04-10-phase3-developer-builder.md` — 섹션 16.1 표대로 grid 기준으로 갱신
|
||||
- `notes/gbpark/2026-04-10-phase4-dashboard-menu.md` — 섹션 16.2 표대로 `@container` 기준으로 갱신 + 2레이어 명확화 블록 추가
|
||||
|
||||
### 추가로 남은 사소한 것 (우선순위 낮음)
|
||||
- 빌더 `.dev-canvas-grid.dragging` 클래스 on/off 시각 피드백
|
||||
- `DropPreview` 컴포넌트 (섹션 14.2) — 드래그 중 가장 가까운 셀 프리뷰
|
||||
- Canvas kind 빌더 실제 UX (현재는 placeholder)
|
||||
|
||||
---
|
||||
|
||||
## 5. 브라우저 실측 체크리스트 (사용자 몫)
|
||||
|
||||
### Finding 1 — 반응형 row 오버라이드
|
||||
1. 빌더에서 `kind: business` 로 새 Template 만들기
|
||||
2. 수주관리 패턴 배치:
|
||||
- search: col 1 span 12 row 1
|
||||
- table: col 1 span 8 row 2 / narrow,normal span 12
|
||||
- form: col 9 span 4 row 2 / narrow,normal col 1 span 12 row 3
|
||||
3. 저장 후 대시보드 카드에 꽂고 카드 크기 리사이즈
|
||||
4. 400px 폭에서 form이 테이블 **아래** row 3 위치에 오는지 확인
|
||||
5. 1000px 폭에서 form이 오른쪽 col 9~12에 오는지 확인
|
||||
6. 겹치면 Finding 1 버그 재발 → 즉시 보고
|
||||
|
||||
### Finding 4 — drop/drag/resize 스냅 일치
|
||||
1. 빌더에서 캔버스 배경 그리드 라인이 12줄로 보이는지
|
||||
2. 팔레트 table 아이템을 정확히 같은 위치에 세 번 drop → 매번 같은 col/colSpan 인지 label 확인
|
||||
3. 한 블록을 drag로 몇 칸 이동 → col 값이 gap을 고려한 정확한 값인지
|
||||
4. 한 블록을 resize로 확장 → colSpan이 drop/drag와 같은 기준으로 증감하는지
|
||||
|
||||
### 기타
|
||||
- **기존 DB Template 열기**: 현재 `{x,y,w,h}` 기반이므로 빌더에서 열면 `fromTemplate`의 default `business`로 들어감 → grid가 아니어서 BuilderBlock이 null 반환 → 화면이 비어 보일 수 있음. **레거시 변환 다이얼로그는 다음 턴**에서 구현. 당장 테스트는 **새 Template**으로 진행 권장.
|
||||
- 대시보드 카드에서 kind 없는 레거시 Template 을 꽂으면 `effectiveKind === 'canvas'`로 fallback → absolute 그대로 렌더 (기존 동작과 다름 없음, scale은 빠졌으니 카드 밖으로 넘칠 수 있음 → narrow 카드에서는 스크롤)
|
||||
|
||||
---
|
||||
|
||||
## 6. 참조
|
||||
|
||||
### 원본 스펙
|
||||
- `notes/gbpark/2026-04-10-template-model-redesign.md` — 섹션 0~20 전체
|
||||
|
||||
### 관련 메모리
|
||||
- `memory/project_template_builder.md` — 2026-04-10 재설계 반영
|
||||
- `memory/feedback_responsive_container_based.md` — @container 원칙
|
||||
- `memory/project_component_spec.md` — FieldConfig 규격
|
||||
|
||||
### 주요 코드 위치
|
||||
- 타입 정의: `frontend/types/invyone-component.ts`
|
||||
- Grid 공용 수식: `frontend/components/builder/hooks/gridMetrics.ts`
|
||||
- 카드 렌더: `frontend/components/dash/DashboardCard.tsx`
|
||||
- 빌더 캔버스: `frontend/components/builder/BuilderCanvas.tsx`
|
||||
- 빌더 상태: `frontend/components/builder/hooks/useBuilderState.ts`
|
||||
- 빌더 드래그: `frontend/components/builder/hooks/useBlockDrag.ts`
|
||||
- 빌더 속성 패널: `frontend/components/builder/BuilderProps.tsx`
|
||||
- 빌더 툴바: `frontend/components/builder/BuilderToolbar.tsx`
|
||||
|
||||
### v5 디자인 토큰
|
||||
- `frontend/styles/v5-layout.css` — v5 Cosmic 토큰 + grid 네임스페이스 변수
|
||||
- `frontend/styles/dashboard.css` — `.dash-card-grid`, `@container` 쿼리
|
||||
- `frontend/styles/developer.css` — `.dev-canvas-grid`, 빌더 block/props 스타일
|
||||
@@ -1,273 +0,0 @@
|
||||
# INVYONE 카드 엔진 Phase 2.1 — 구현 로그
|
||||
|
||||
**작업일**: 2026-04-11
|
||||
**작업자**: gbpark + Claude
|
||||
**이전 로그**: `notes/gbpark/2026-04-10-card-engine-phase1-log.md`
|
||||
**기반 스펙**: `notes/gbpark/2026-04-10-card-engine-final-spec.md`
|
||||
|
||||
---
|
||||
|
||||
## 0. 요약
|
||||
|
||||
Phase 2.1 = "수주관리 엔드투엔드 MVP". 네 가지 레이어(컴포넌트 → 화면 디자이너 → 템플릿 → 대시보드)가 전부 엔드투엔드로 기본 작동하는 상태를 목표.
|
||||
|
||||
| Step | 제목 | 상태 |
|
||||
|---|---|---|
|
||||
| A | 레거시 타입 제거 (GridPosition 등) | ✅ |
|
||||
| B | 경량 7개 v2-* @container 모드 분기 | ✅ |
|
||||
| C | TemplateBuilder 실렌더링 + 백엔드 API + FieldConfig 편집 UI | ✅ |
|
||||
| D | DashboardCard 재작성 + 기존 dash 인프라 유지 | ✅ |
|
||||
| E | 수주관리 PoC (사용자 수동 검증) | ⏳ 사용자 검증 대기 |
|
||||
|
||||
**tsc 결과**: 작업 범위 기준 에러 0. 전체 에러 카운트 3381 (Phase 1 완료 시점과 동일, 증가 없음).
|
||||
|
||||
---
|
||||
|
||||
## 1. 방향 변경 (Step D) — 초기 지시와 다른 결정
|
||||
|
||||
초기 Phase 2.1 지시문은 "frontend/store/dashboardStore.ts 신규 + localStorage 재작성" 이었으나,
|
||||
D-0 파악 결과 **이미 작동하는 백엔드 API 기반 대시보드 시스템**이 존재해 방향 변경:
|
||||
|
||||
- ❌ **폐기**: localStorage 재작성, `store/dashboardStore.ts` 신규
|
||||
- ✅ **유지**: `stores/dashboardStore`, 백엔드 API (`lib/api/dashMenu.ts`), `DashboardSidebar`, `DashboardCanvas`, `TemplateLibraryModal`, `CardSettingsPanel`, `ControlMode` 전부 보존
|
||||
- ✅ **재작성**: `DashboardCard.tsx` 하나만 — 레거시 타입 제거 + FreePosition + Template 기반 CRUD
|
||||
|
||||
이유: 기존 시스템은 이미 서버 DB 영속화, 제어 모드(Phase 3 예정 기능의 일부), 카드 설정 패널을 구현해 놓은 상태. 이를 폐기하면 수 주의 손실.
|
||||
|
||||
### Phase 3 기능 처리 3단 전략 — 실제 사용 결과
|
||||
사용자가 준 전략은 (1) 필드명 수준 교체 → (2) 함수 단위 간소화 → (3) any 캐스팅. **실제로는 1단계조차 불필요했음**: `ControlMode/CardSettingsPanel/FlowViewer/RuleBuilder` 전부 `FieldConfig` 만 import 하고 카드의 `position.col/row` 같은 grid 필드에 접근하지 않음. DashboardCard 재작성만으로 Phase 3 기능은 아무 영향 없이 동작.
|
||||
|
||||
---
|
||||
|
||||
## 2. 수정·생성 파일
|
||||
|
||||
### 타입
|
||||
- **수정** `frontend/types/invyone-component.ts`
|
||||
- 삭제: `GridPosition`, `AbsolutePosition`, `ComponentPosition`, `ResponsiveGridOverride`, `isGridPosition()`, `isAbsolutePosition()`, `TemplateKind`, `DEFAULT_COMPONENT_LAYOUTS`, `CANVAS_KEYWORDS`
|
||||
- `Template.kind` 필드 제거
|
||||
- `Template.views` 타입을 `TemplateViews` 로 교체 (자유배치 단일 모델)
|
||||
- `Template.icon`, `Template.badge`, `Template.defaultSize`, `Template.inputs`, `Template.outputs` 추가 (Phase 1 log 에서 any 캐스팅으로 우회하던 것들)
|
||||
- `Component.position` 필드를 `FreePosition?` 으로 정리 (Component 인터페이스 자체는 참조 0 이라 정리 수준)
|
||||
|
||||
### 대시보드 카드
|
||||
- **재작성** `frontend/components/dash/DashboardCard.tsx` (646줄 → 386줄)
|
||||
- 레거시 타입 의존 완전 제거
|
||||
- `FcSearch + FcTable + FcPagination` 기본 CRUD UI
|
||||
- 모달 기반 `FormOverlay` (등록/수정) + `FcForm`
|
||||
- `fcInsert / fcUpdate / fcDelete` 와 백엔드 연결
|
||||
- `containerType: inline-size + containerName: card` 부착 (내부 v2-* 가 폭 감지)
|
||||
- Template 에 `views.list.components` 가 있으면 "Phase 2.2 에서 커스텀 배치 활성화" 안내 (현 Phase 범위 아님)
|
||||
|
||||
- **수정** `frontend/styles/dashboard.css`
|
||||
- `.dash-card-crud-actions`, `.dash-crud-btn(.primary/.danger)`, `.dash-crud-note` 추가
|
||||
- `.dash-form-overlay / .dash-form-modal / .dash-form-head / .dash-form-body` 추가 (등록/수정 모달)
|
||||
- 카드 @container 400px 이하에서 버튼 텍스트 숨김 (아이콘만)
|
||||
|
||||
### 경량 컴포넌트 반응형 (Step B)
|
||||
- **신규** `frontend/lib/registry/hoc/withContainerQuery.css`
|
||||
- `@container v2-button-primary (max-width: 120px)` → 버튼 텍스트 숨김
|
||||
- `@container v2-input (max-width: 400px)` → 라벨 위로 (flex column)
|
||||
- `@container v2-select (max-width: 400px)` → 동일
|
||||
- `@container v2-date (max-width: 400px)` → range 세로 스택
|
||||
- `@container v2-text-display (max-width: 300px)` → font-size 축소
|
||||
- `@container v2-card-display (max-width: 480px)` → gridTemplateColumns 1fr 강제
|
||||
- `@container v2-aggregation-widget (max-width: 480px)` → 2열 그리드
|
||||
- **수정** `frontend/lib/registry/hoc/withContainerQuery.tsx`
|
||||
- `import "./withContainerQuery.css"` 추가 (HOC 사용 시 자동 적용)
|
||||
|
||||
### TemplateBuilder (Step C)
|
||||
- **신규** `frontend/components/template-builder/LucideIcon.tsx`
|
||||
- lucide-react 의 모든 아이콘을 이름 기반 동적 렌더링. 캐시로 반복 조회 최적화.
|
||||
- **신규** `frontend/components/template-builder/DesignPreview.tsx`
|
||||
- 각 v2-* 컴포넌트의 디자인 모드 미리보기 (버튼, 입력, 테이블, 검색 등)
|
||||
- Phase 2.2 에서 실제 런타임 컴포넌트로 교체 예정
|
||||
- **신규** `frontend/components/template-builder/FieldConfigPanel.tsx`
|
||||
- 우측 "테이블" 탭: primaryTable 드롭다운 + FieldConfig 편집 UI
|
||||
- `/api/meta/tables`, `/api/meta/tables/:name/fields` 호출
|
||||
- 필드별 label / type / visible / required / searchable 토글
|
||||
- **신규** `frontend/components/template-builder/template-builder.css`
|
||||
- DesignPreview 전용 스타일 (tpl-preview-*)
|
||||
- **수정** `frontend/components/template-builder/TemplateBuilder.tsx`
|
||||
- `useRegistryPalette`: lucide 이름 보존 (short 문자열만 이모지, 긴 문자열은 lucide 렌더 대상)
|
||||
- `PaletteIcon` 헬퍼로 팔레트 아이템의 아이콘 실렌더
|
||||
- `CanvasBlock`: placeholder 텍스트 → `DesignPreview` 로 교체
|
||||
- `SidePanel`: "테이블" 탭 추가 (`FieldConfigPanel` 호스트)
|
||||
- `handleSave`: localStorage → 백엔드 API (`insertTemplate` / `updateTemplate`) 전환
|
||||
- `handlePublish` + "게시" 버튼 (`publishTemplate`)
|
||||
- `templateStatus` 배지 ("초안" / "게시됨")
|
||||
- `serverTemplateId` state 로 insert/update 분기를 깔끔하게 (localStorage 복원 혼란 제거)
|
||||
- `import "./template-builder.css"`
|
||||
|
||||
- **수정** `frontend/components/template-builder/store/templateBuilderStore.ts`
|
||||
- `toTemplate`: `kind: "business"` 제거, `views as unknown as` 캐스팅 제거, `icon/badge/defaultSize` 전달
|
||||
- `fromTemplate`: `tpl.icon / tpl.badge / tpl.defaultSize` 를 `(tpl as any)` 캐스팅 없이 접근
|
||||
|
||||
- **수정** `frontend/app/(main)/admin/builder/page.tsx`
|
||||
- `useSearchParams` 로 `?id=<templateId>` 쿼리 파싱 → TemplateBuilder 에 templateId prop 전달
|
||||
- `Suspense` 감싸기 (Next 15 요구)
|
||||
|
||||
---
|
||||
|
||||
## 3. 주요 결정 사항 (사용자 협의)
|
||||
|
||||
### 결정 1 — Step D 방향 (기존 인프라 유지)
|
||||
**옵션**: (a) localStorage 재작성 (지시문 그대로) / (b) 기존 백엔드 API 유지 + DashboardCard만 재작성 / (c) 하이브리드
|
||||
**결정**: **(b) 기존 유지 + 최소 수정**
|
||||
**이유**: 이미 작동하는 서버 기반 대시보드 시스템 보존 우선. localStorage 로 다운그레이드는 퇴보.
|
||||
|
||||
### 결정 2 — Step E PoC 렌더 방식
|
||||
**옵션**: (a) TemplateRenderer + ComponentRegistry 로 v2-* 실 렌더 / (b) DefaultCardContent 유지 (FcTable/FcSearch/FcForm)
|
||||
**결정**: **(b) FcTable/FcSearch/FcForm 기본 CRUD**
|
||||
**이유**: v2-* 컴포넌트는 VEX ScreenDesigner 전제의 props 시그니처(`component: ComponentData`, `isDesignMode`, `form_data` 등)를 가져 INVYONE 대시보드 카드에 그대로 끼우려면 어댑터 필요. Phase 2.1 "기본이 돈다" MVP 범위 밖.
|
||||
**결과**: Template.views.list.components 에 배치가 있으면 "Phase 2.2 에서 활성화" 안내만 표시하고, 실질 렌더는 FcTable 기반.
|
||||
|
||||
### 결정 3 — lucide 아이콘 렌더링
|
||||
**옵션**: (a) `import * as LucideIcons` 정적 import / (b) `dynamic-icon-imports` 동적 로드 / (c) 문자열/이모지 폴백만
|
||||
**결정**: **(a) 정적 import + 캐시**
|
||||
**이유**: tree-shaking 손실은 있지만 Phase 2.1 MVP 에서는 수용 가능. API 단순.
|
||||
|
||||
### 결정 4 — 경량 7개 모드 분기 방식
|
||||
**옵션**: (a) 각 컴포넌트 파일마다 별도 CSS / (b) 공통 withContainerQuery.css 한 파일 / (c) HOC 확장 (JS ResizeObserver + data-mode)
|
||||
**결정**: **(b) 공통 CSS 한 파일**
|
||||
**이유**: 유지 간단. 컨테이너 이름으로 구분해 충돌 없음. 내부 DOM 에 영향 최소 (wrapper level 만). 완벽 구현은 Phase 2.2 에서 각 컴포넌트 개별 재작성 시.
|
||||
|
||||
---
|
||||
|
||||
## 4. 사용자 수동 검증 체크리스트 (Phase 2.1 종료 조건)
|
||||
|
||||
**Step E "수주관리 PoC" 는 사용자 수동 검증 영역**. 아래 순서로 진행.
|
||||
|
||||
### (A) 빌드 + 기본 sanity
|
||||
1. `cd frontend && docker compose -f ../docker/dev/docker-compose.invyone.yml restart frontend backend-spring` (또는 로컬 `npm run dev`)
|
||||
2. 브라우저에서 접속
|
||||
3. 확인:
|
||||
- [ ] `/test-card-responsive` 가 여전히 작동 (Phase 1 보장)
|
||||
- [ ] 콘솔에 critical 에러 없음
|
||||
- [ ] 다크 모드 UI 정상
|
||||
|
||||
### (B) `/admin/builder` TemplateBuilder 검증
|
||||
1. `/admin/builder` 접속 → TemplateBuilder UI 표시
|
||||
2. 좌측 팔레트 확인:
|
||||
- [ ] 컴포넌트 아이콘이 lucide 로 실제 렌더됨 (◼ 폴백 아님)
|
||||
3. 우측 "테이블" 탭 클릭:
|
||||
- [ ] 기본 테이블 드롭다운에 테이블 목록 표시
|
||||
- [ ] 테이블 선택 → 필드 목록 자동 로드
|
||||
- [ ] 각 필드 label 편집, type 변경, 표시/필수/검색 토글 가능
|
||||
4. 캔버스 작업:
|
||||
- [ ] 팔레트에서 컴포넌트 드래그 → 캔버스 드롭 → **placeholder 아닌 DesignPreview** 렌더
|
||||
- [ ] v2-button-primary 는 파란 버튼 실 형태, v2-text-display 는 텍스트, v2-input 은 input 박스, v2-aggregation-widget 은 KPI, v2-table-list 는 3열 테이블 미리보기 등
|
||||
- [ ] 블록 이동/리사이즈 동작
|
||||
5. 저장/게시:
|
||||
- [ ] 상단 "템플릿 이름" 입력 → "저장" 클릭 → "템플릿이 등록되었습니다" 토스트
|
||||
- [ ] 저장 후 상단 배지가 "초안" 상태
|
||||
- [ ] "게시" 버튼 클릭 → "템플릿이 게시되었습니다" 토스트, 배지 "게시됨" 전환
|
||||
|
||||
### (C) 수주관리 Template 작성 (PoC 핵심)
|
||||
1. `/admin/builder` 신규 세션 (URL 에 ?id 없음)
|
||||
2. 상단 툴바에서 템플릿 이름 "수주관리" 입력, 카테고리 "sales" 입력
|
||||
3. 우측 "테이블" 탭 → DB 에서 수주 관련 테이블 선택 (예: ORDER_MASTER 또는 존재하는 수주 테이블)
|
||||
4. FieldConfig 가 자동 로드되면, 필요한 필드만 visible=true, 검색 대상 searchable=true 로 토글
|
||||
5. 우측 "메타" 탭 → 아이콘 📋, 배지 "ERP · 영업"
|
||||
6. 캔버스에는 컴포넌트 배치 없음 (Phase 2.1 MVP 는 기본 CRUD, 자유배치는 Phase 2.2)
|
||||
7. "저장" → "게시"
|
||||
8. URL 이 `/admin/builder?id=tpl_xxx` 로 바뀌지 않아도 store.templateId 와 serverTemplateId 에 내부 저장됨 (URL 동기화는 Phase 2.2)
|
||||
|
||||
### (D) 수주관리 대시보드 배치 + CRUD
|
||||
1. `/dashboard` (또는 사이드바에서 기존 대시보드 진입)
|
||||
2. "+ 새 대시보드" → "영업 대시보드" 생성
|
||||
3. 빈 캔버스에서 "+ 템플릿 추가" → 라이브러리 모달
|
||||
4. 확인:
|
||||
- [ ] 모달에 "수주관리" 템플릿 표시 (published 만 노출)
|
||||
- [ ] 카테고리 필터 "영업/CRM" 에도 표시
|
||||
5. 클릭 → 대시보드에 카드 배치됨
|
||||
6. 카드 헤더:
|
||||
- [ ] 아이콘 + "수주관리" 이름 + "sales" 배지
|
||||
- [ ] 새로고침 / 설정 / 접기 / 삭제 버튼 (editMode 일 때)
|
||||
7. 카드 본문:
|
||||
- [ ] FcSearch 검색 필터
|
||||
- [ ] [등록] [수정] [삭제] CRUD 액션 버튼 바
|
||||
- [ ] FcTable 수주 목록 (fcList 호출)
|
||||
- [ ] FcPagination
|
||||
8. CRUD 작동:
|
||||
- [ ] "등록" 클릭 → FormOverlay 모달 → FcForm 입력 → 저장 → "등록되었습니다"
|
||||
- [ ] 테이블 행 선택 → "수정" 클릭 → FormOverlay (기존 값 로드) → 수정 → 저장
|
||||
- [ ] 행 선택 → "삭제" → confirm → 삭제 → 목록 새로고침
|
||||
9. 반응형 전환:
|
||||
- [ ] 카드 우하단 핸들로 드래그 → 카드 폭 400px 이하 → CRUD 버튼 바의 텍스트 숨김 (아이콘만)
|
||||
- [ ] FcSearch 의 입력 배치는 FcSearch 자체의 @container 쿼리에 의존 (Phase 2 이후 개별 확장)
|
||||
10. 새로고침 후:
|
||||
- [ ] 대시보드 카드 위치/크기 유지 (백엔드 저장)
|
||||
|
||||
### (E) ControlMode / CardSettingsPanel sanity
|
||||
1. 대시보드에서 "편집 모드" 진입 후 제어 모드 버튼 클릭
|
||||
- [ ] 제어 모드 진입 (기존 Phase 3 기능)
|
||||
- [ ] 카드의 레거시 타입 제거 때문에 깨진 건 없음
|
||||
2. 카드의 "설정" 버튼 클릭
|
||||
- [ ] CardSettingsPanel 열림 (컬럼 표시/숨김 토글)
|
||||
|
||||
### 실패 시 디버깅
|
||||
- 백엔드 응답 실패 → network 탭 확인, `docker compose logs backend-spring` 로 스택
|
||||
- fcDelete 가 실패 → pk 컬럼이 정확히 FieldConfig.pk = true 인지, 백엔드 /api/data/:table/delete 가 { [pk]: value } 포맷을 받는지 확인 필요
|
||||
- Template 게시 후 라이브러리 모달에 안 보임 → `getTemplateList({ status: 'published' })` 결과 확인
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 2.2 이월 항목
|
||||
|
||||
### 필수
|
||||
- [ ] **TemplateRenderer 실 구현**: Template.views.list.components 를 FreePosition + ComponentRegistry 기반으로 자유배치 렌더. v2-* 컴포넌트의 VEX 포맷 props 어댑터 필요
|
||||
- [ ] **v2-* 런타임 컴포넌트 props 표준화**: INVYONE DashboardCard 에서도 design 모드와 runtime 모드 양쪽 렌더 가능하도록
|
||||
- [ ] **경량 7개 컴포넌트 내부 DOM 기반 반응형 개선**: 현재 CSS 한 파일의 wrapper level 분기는 정확도 한계. 각 컴포넌트 개별 재작성 시 wide/narrow 모드 내부 로직 추가
|
||||
- [ ] **TemplateBuilder URL 동기화**: insertTemplate 성공 후 `/admin/builder?id=tpl_xxx` 로 URL 갱신 (`router.replace`)
|
||||
|
||||
### 우선순위 2
|
||||
- [ ] 우선순위 2 v2-* 마이그레이션: BOM, 출하, 피벗, 타임라인, 프로세스/작업 표준, 결재 단계
|
||||
- [ ] 우선순위 3 v2-* 마이그레이션: 랙 구조, 번호 규칙, 카테고리 관리자, 구분선, 파일, 미디어
|
||||
|
||||
### 선택
|
||||
- [ ] FieldConfigPanel 개선: options/ref/format/computed/sortable 등 나머지 FieldConfig 속성 편집
|
||||
- [ ] TemplateBuilder 팔레트 카테고리 필터 & 검색 개선
|
||||
- [ ] 자동 뷰 생성: 등록 버튼 추가 시 create 뷰 placeholder 자동 생성 (store 는 이미 감지 로직 있음)
|
||||
- [ ] Template 버전 관리 UI
|
||||
|
||||
---
|
||||
|
||||
## 6. 알려진 한계
|
||||
|
||||
1. **DashboardCard 는 아직 Template.views.list.components 를 실제로 렌더하지 않음** — 배치된 v2-* 컴포넌트는 무시되고 기본 CRUD(FcTable 등) 로 대체. Phase 2.2 에서 TemplateRenderer 구현 시 해소.
|
||||
2. **TemplateBuilder 캔버스의 DesignPreview 는 정적 미리보기** — 실제 데이터가 흐르지 않고 wire-frame 수준. 마찬가지로 Phase 2.2.
|
||||
3. **경량 7개 @container 쿼리는 wrapper level 만** — 내부 DOM 에 깊이 관여하지 않아 일부 컴포넌트는 narrow 모드가 시각적으로 거의 변화 없음. Phase 2.2 개별 재작성 시 보강.
|
||||
4. **Template.icon / badge / defaultSize** 는 백엔드 TEMPLATES 테이블의 컬럼이 아니라 views 또는 meta 안에 잠재적으로 섞일 수 있음. 현재는 Template 인터페이스에만 있고 저장 시 누락될 수 있음 — Phase 2.2 에서 테이블 스키마 확장 또는 views 안 embed 결정.
|
||||
5. **BlockProperties 의 config JSON 편집기는 텍스트 기반** — v2-* 컴포넌트별 config 패널 통합은 Phase 2.2.
|
||||
6. **templateId URL 동기화 부재** — 새 템플릿 저장 후에도 URL 은 `/admin/builder` 그대로. 브라우저 새로고침 시 신규 세션으로 초기화됨. Phase 2.2 에서 `router.replace` 로 동기화.
|
||||
|
||||
---
|
||||
|
||||
## 7. 작업 범위 / 비작업 범위
|
||||
|
||||
### 작업함
|
||||
- Step A: 레거시 타입 완전 제거 + DashboardCard 재작성
|
||||
- Step B: 경량 7개 반응형 CSS 추가
|
||||
- Step C: TemplateBuilder 실렌더링 (DesignPreview) + 백엔드 API + FieldConfig 편집 UI + 게시
|
||||
- Step D: 기존 인프라 유지 + DashboardCard 재작성 (=Step A 일부)
|
||||
|
||||
### 작업 안 함 (Phase 2.2 이후)
|
||||
- TemplateRenderer 실 렌더링 (ComponentRegistry 어댑터)
|
||||
- v2-* 컴포넌트 props 표준화
|
||||
- 경량 7개 컴포넌트 내부 재작성 (모드 분기 정밀)
|
||||
- 우선순위 2/3 v2-* 마이그레이션
|
||||
- 대시보드 시스템 재작성 (localStorage → 이미 백엔드 기반이라 불필요)
|
||||
- AppLayout 전역 사이드바에 대시보드 목록 중복 (결정 2, 중복 방지)
|
||||
|
||||
---
|
||||
|
||||
## 끝
|
||||
|
||||
Phase 2.1 코드 작업 종료 (2026-04-11).
|
||||
`§4 사용자 수동 검증` 통과 후 Phase 2.1 전체 완료.
|
||||
|
||||
Phase 2.2 는:
|
||||
1. TemplateRenderer 실 구현 (ComponentRegistry 어댑터)
|
||||
2. v2-* 우선순위 2/3 마이그레이션
|
||||
3. 경량 7개 모드 분기 정교화
|
||||
4. TemplateBuilder URL 동기화 + 기타 폴리싱
|
||||
@@ -1,256 +0,0 @@
|
||||
# INVYONE 카드 엔진 Phase 2.1 재작업 — 구현 로그
|
||||
|
||||
**작업일**: 2026-04-11
|
||||
**작업자**: gbpark + Claude
|
||||
**이전 로그**: `notes/gbpark/2026-04-11-card-engine-phase2.1-log.md`
|
||||
**기반 스펙**: `notes/gbpark/2026-04-10-card-engine-final-spec.md`
|
||||
**레퍼런스 구현**: `frontend/app/test-card-responsive/page.tsx` (Phase 1 반응형 증명)
|
||||
|
||||
---
|
||||
|
||||
## 0. 재작업 배경
|
||||
|
||||
Phase 2.1 의 TemplateBuilder / TemplateRenderer 가 **카드 내부 자유배치 (FreePosition / position: absolute)** 로 구현되었는데, 이는 Phase 1 에서 `test-card-responsive/page.tsx` 로 "반응형 보장" 이라 증명했던 모델(자동 레이아웃 = Tailwind flex/grid + 세로 스택) 과 모순된다.
|
||||
|
||||
결과적으로 카드 폭이 줄면 내부 블록이 px 좌표로 고정돼 **겹침 / 잘림 / 가로 스크롤** 이 발생. Phase 1 의 "반응형 된다" 는 약속이 runtime 에서 지켜지지 않음.
|
||||
|
||||
→ Phase 2.1 전체를 폐기하지 않고, **빌더/렌더러/타입** 세 층을 자동 레이아웃 모델로 재작성하기로 결정 (Step D DashboardCard + Step B 경량 반응형 + Step C FieldConfigPanel 등 나머지는 그대로 유지).
|
||||
|
||||
### 핵심 방향 전환
|
||||
|
||||
| 항목 | Before (Phase 2.1 초안) | After (재작업) |
|
||||
|---|---|---|
|
||||
| 카드 내부 위치 모델 | `FreePosition { left, top, width, height }` | `order: number` + `row?: number` |
|
||||
| 렌더러 레이아웃 | `position: absolute` + CSS var | `flex flex-col gap-2` + row 그룹핑 `flex flex-row flex-wrap` |
|
||||
| 좁은 카드 폭 대응 | `@container card (max-width: 599px)` 에서 세로 스택 강제 (임시방편) | 처음부터 세로 스택. 카드 폭 변해도 자동 줄바꿈/분배 |
|
||||
| 빌더 캔버스 | absolute 자유배치 + 드래그/리사이즈 | 세로 리스트 + 드롭 존 + 드래그 재배열 |
|
||||
| 가로 나열 수단 | 좌표로 옆에 배치 | `row` 키 — 같은 숫자 키를 가진 **연속** 블록이 한 줄 |
|
||||
|
||||
> **카드 자체의 대시보드 내 배치**는 여전히 `Card.position: FreePosition` (자유배치) 유지. 즉 **대시보드 → 카드** 는 자유배치, **카드 → 내부** 는 자동 레이아웃. 두 층의 모델이 다르다는 점이 이번 재작업의 요지.
|
||||
|
||||
---
|
||||
|
||||
## 1. 수정·생성·삭제 파일
|
||||
|
||||
### 타입
|
||||
- **수정** `frontend/types/invyone-component.ts`
|
||||
- `TemplateComponent.position: FreePosition` 필드 제거
|
||||
- `TemplateComponent.order: number` (필수) 추가 — 0 부터 시작하는 세로 스택 순서
|
||||
- `TemplateComponent.row?: number` (선택) 추가 — 같은 키를 가진 연속 블록을 flex-row 로 묶는 그룹핑 키
|
||||
- `FreePosition` 인터페이스 자체는 `Card.position` 에서 여전히 쓰이므로 유지
|
||||
|
||||
### 런타임 렌더러
|
||||
- **재작성** `frontend/components/dash/TemplateRenderer.tsx`
|
||||
- `absolute` + CSS variable 방식 완전 제거
|
||||
- `flex flex-col gap-2 overflow-auto p-2` 로 세로 스택
|
||||
- `groupByRow` 헬퍼: `order` 정렬 후 `row` 가 동일한 연속 블록들을 하나의 row 배열로 묶음. `undefined` row 는 항상 단독 행
|
||||
- 각 row 는 `flex flex-row flex-wrap gap-2`, 각 블록 wrapper 는 `flex-1 min-w-0` 로 가로 자동 분배 + 폭 부족 시 줄바꿈
|
||||
- `normalizeBlocks` 헬퍼: 구 포맷(`position` 기반, `order` 누락) 블록의 **런타임 호환 레이어**. `order` 가 없으면 배열 인덱스를 부여해 순서만 보장
|
||||
- `ComponentSwitch` (v2-table-list / search-widget / button-primary / text-display / aggregation-widget + 기본 fallback) 는 기존 로직 유지. v2-button-primary 의 `h-full flex items-center justify-center` 래핑 제거 (고정 높이 전제 제거)
|
||||
- **삭제** `frontend/components/dash/TemplateRenderer.css`
|
||||
- 모든 규칙이 `position: absolute` + `@container card (max-width: 599px)` narrow 강제 기반이었고, 재작성된 렌더러가 순수 Tailwind 로 자동 레이아웃을 처리하므로 불필요
|
||||
- **유지** `frontend/components/dash/DashboardCard.tsx`
|
||||
- TemplateRenderer 호출부는 변경 없음
|
||||
- `dash-card-body` 의 `containerType: inline-size + containerName: card` 도 유지 (`styles/dashboard.css` 의 `@container card` 규칙이 여전히 카드 헤더/미니뷰에서 활용됨)
|
||||
|
||||
### 빌더
|
||||
- **재작성** `frontend/components/template-builder/store/templateBuilderStore.ts`
|
||||
- `updateBlockPosition`, `gridSettings`, `setGridSettings`, `resetGrid`, `selectedIds`, `setSelectedIds` 전부 제거
|
||||
- `addBlock(componentId, config?, opts?: { insertAt?: number })` — 시그니처에서 position 파라미터 제거, insertAt 옵션으로 삽입 위치 지정. 미지정 시 끝에 추가
|
||||
- `removeBlock(id)` — 삭제 후 `reindex` 로 order 자동 재계산
|
||||
- `moveBlockUp(id)` / `moveBlockDown(id)` — 한 칸 이동
|
||||
- `reorderBlock(id, targetIndex)` — 임의 위치로 이동. 드래그 드롭이 사용
|
||||
- `setBlockRow(id, row?)` — 우측 속성 패널에서 가로 나열 키 편집
|
||||
- `cloneSnapshot` / `undo` / `redo` 에서 `position` 복제 코드 제거
|
||||
- `fromTemplate`: `migrateBlocks` 헬퍼로 **구 포맷 자동 마이그레이션** — `position` 필드 drop, `order` 누락 시 인덱스 부여, 상대 순서 유지 후 `reindex`
|
||||
- `toTemplate`: 그대로 (Template.views 구조는 변하지 않음, 내부 블록만 새 포맷으로 직렬화)
|
||||
- `commitBlocks` 내부 헬퍼로 블록 변경 + 스냅샷 기록을 한 곳으로 집약
|
||||
- **재작성** `frontend/components/template-builder/TemplateBuilder.tsx`
|
||||
- `CanvasDragState { move / resize }` 타입 + `handleBlockMouseDown` + `snapValue` + `MIN_BLOCK_SIZE` + `gridLines` + `GridSettings` 탭 전부 삭제
|
||||
- `CanvasList` 추가 — 중앙 캔버스를 세로 리스트로 렌더. 최상단/각 블록 사이/최하단에 `DropZone` 삽입
|
||||
- `DropZone` 추가 — `onDragOver`/`onDragEnter` 시 active 하이라이트, `onDrop` 시 `handleDropAt(index, e)` 호출
|
||||
- `handleDropAt` 추가 — `dataTransfer` 에 팔레트 아이템이면 `addBlock(..., { insertAt: index })`, 블록 ID 면 `reorderBlock(blockId, index)`
|
||||
- `BlockRow` 추가 — 각 블록을 `draggable` div 로 렌더. 드래그 핸들 아이콘 + 라벨 + `#순서` + `row` 배지 + `DesignPreview` 재사용 + 우측에 `↑` / `↓` / `✕` `IconButton` 세로 스택
|
||||
- `IconButton` 추가 — 소형 아이콘 버튼 헬퍼
|
||||
- `EmptyCanvas` 추가 — 빈 뷰 안내
|
||||
- `BlockProperties` 재작성 — `LabeledNumber` 4개(`left`/`top`/`width`/`height`) 삭제, 대신 `↑ 위로`/`↓ 아래로` 버튼 + `#순서` 배지 + `row` 키 숫자 input + 기존 config JSON 편집기 + 삭제 버튼
|
||||
- `SidePanel` 탭에서 "격자" 탭 제거 (props / table / meta 3개로 축소)
|
||||
- `MetaForm` 에서도 `defaultSize` 편집 삭제 (자동 레이아웃에서 의미 없음)
|
||||
- `Toolbar` 에서 `격자` / `스냅` 체크박스 삭제
|
||||
- 드래그 MIME type 2종 정의: `application/x-template-component` (팔레트 → 캔버스), `application/x-template-block` (블록 재배열)
|
||||
- `Delete` / `Ctrl+Z` / `Ctrl+Y` / `Ctrl+S` 단축키는 그대로 유지
|
||||
- **유지** `frontend/components/template-builder/DesignPreview.tsx`
|
||||
- 원래부터 `block.position` 을 참조하지 않음. 변경 없음
|
||||
- **유지** `frontend/components/template-builder/FieldConfigPanel.tsx`, `LucideIcon.tsx`, `template-builder.css`
|
||||
- **유지** `frontend/app/(main)/admin/builder/page.tsx` — `templateId` prop 전달 방식 그대로
|
||||
|
||||
---
|
||||
|
||||
## 2. 자동 레이아웃의 핵심 계약
|
||||
|
||||
### 2.1 TemplateComponent 의 새 위치 필드
|
||||
|
||||
```typescript
|
||||
export interface TemplateComponent {
|
||||
id: string;
|
||||
componentId: string;
|
||||
/** 세로 스택 내 순서. 작을수록 위. 0부터 시작. */
|
||||
order: number;
|
||||
/** 같은 값 공유 → flex-row 그룹. undefined 면 단독 행. */
|
||||
row?: number;
|
||||
config: Record<string, any>;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 TemplateRenderer 의 레이아웃 규칙
|
||||
|
||||
1. `template.views.list.components` 를 `normalizeBlocks` 로 정규화 (구 포맷 호환)
|
||||
2. `groupByRow` 로 `row` 가 동일한 연속 블록을 하나의 row 배열로 묶음
|
||||
3. 전체를 `flex flex-col gap-2 w-full h-full overflow-auto p-2` 로 감쌈
|
||||
4. 각 row 를 `flex flex-row flex-wrap gap-2 w-full` 로
|
||||
5. 각 블록 wrapper 는 `flex-1 min-w-0` — 가로 폭 자동 분배 + 내용 넘치면 줄바꿈
|
||||
|
||||
> ★ `min-w-0` 이 없으면 flex 기본값 `min-width: auto` 때문에 자식(테이블 등) 이 자신의 content width 를 주장하며 overflow 를 일으킨다. 이 한 줄이 "카드 폭 줄여도 깨지지 않는다" 의 핵심.
|
||||
|
||||
### 2.3 row 그룹핑 규칙 (사용자 정신 모델)
|
||||
|
||||
- `row` 를 비워두면 단독 행 (기본, 가장 자주 쓰는 형태)
|
||||
- 같은 숫자를 연속 블록 N 개에 주면 한 줄 안에 가로 나열됨
|
||||
- 숫자값 자체는 의미 없음 — **키** 로만 작동
|
||||
- `order` 는 행 경계를 넘어 전역 순서를 결정. 같은 row 안에서도 `order` 가 작은 게 왼쪽
|
||||
|
||||
### 2.4 빌더의 드롭 모델
|
||||
|
||||
- 팔레트 아이템 드래그: `application/x-template-component` MIME, payload = `{ componentId, defaultConfig }`
|
||||
- 블록 재배열 드래그: `application/x-template-block` MIME, payload = `blockId`
|
||||
- 드롭 존 index 는 "이 자리에 삽입되면 **이동 후** 배열의 몇 번째가 되는가"
|
||||
- `handleDropAt(index, e)` 가 두 MIME 을 구분해 `addBlock` / `reorderBlock` 호출
|
||||
|
||||
---
|
||||
|
||||
## 3. 구 포맷 호환 (이중 방어)
|
||||
|
||||
DB 에 이미 저장된 Template 중 Phase 2.1 초안 빌더가 만든 것은 `position: { left, top, width, height }` 만 있고 `order` 가 없다. 두 층에서 자동 처리:
|
||||
|
||||
| 층 | 헬퍼 | 동작 |
|
||||
|---|---|---|
|
||||
| **Store** (빌더 로드) | `migrateBlocks` in `templateBuilderStore.fromTemplate` | `position` 필드 drop, `order` 누락 시 인덱스 부여, 정렬 후 `reindex` — 빌더에서 열어 **저장** 버튼만 누르면 DB 가 새 포맷으로 이관됨 |
|
||||
| **Renderer** (런타임) | `normalizeBlocks` in `TemplateRenderer` | `order` 없으면 배열 인덱스 부여, `row` 없으면 undefined — 당장 마이그레이션 안 해도 대시보드 카드에서 순서대로 읽기는 정상 |
|
||||
|
||||
즉 **구 포맷 Template 을 당장 삭제하지 않아도 시스템은 깨지지 않는다.** 다만 구 포맷은 layout 이 본래 의도와 다르게 재해석되므로(px 좌표가 무시되고 단순 세로 순서가 됨) 시각적으로 어색할 수 있다. → Step D (soft-delete) 는 정리 목적이며 기술 필수가 아니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 검증 결과
|
||||
|
||||
### 4.1 TypeScript
|
||||
|
||||
```
|
||||
# 전체 에러
|
||||
npx tsc --noEmit 2>&1 | grep -c "error TS"
|
||||
# → 2791 (Phase 2.1 완료 시점 3381 에서 −590)
|
||||
|
||||
# 내 작업 영역
|
||||
npx tsc --noEmit 2>&1 | grep -E "template-builder|TemplateRenderer|DashboardCard|invyone-component|DesignPreview|FieldConfigPanel"
|
||||
# → (빈 출력)
|
||||
```
|
||||
|
||||
- 내 작업 영역 에러 **0**
|
||||
- 전체 카운트 감소의 원인은 `updateBlockPosition` / `gridSettings` / `selectedIds` 등 Phase 2.1 초안에서 노출하던 심볼 제거로 의존 측의 에러가 사라진 것. 증가 요소 없음
|
||||
|
||||
### 4.2 잔존 참조 감사
|
||||
|
||||
- `block.position` / `.position.left|top|width|height` 사용처: **0 건** (전역)
|
||||
- `template-builder` 디렉터리 내 `gridSettings` / `updateBlockPosition` / `selectedIds` / `FreePosition`: **0 건**
|
||||
- `dash` 디렉터리 내 동일 심볼: **0 건** (TemplateRenderer 의 주석 1 건만 — `FreePosition` 단어로 구 포맷을 설명하는 문구)
|
||||
- `@container card` 규칙은 `styles/dashboard.css` 에만 남아있으며, 카드 헤더/미니뷰 전용으로 여전히 유효
|
||||
|
||||
### 4.3 사용자 브라우저 검증 (남은 체크리스트)
|
||||
|
||||
tsc / grep 은 코드 정합성만 보장한다. 아래는 사용자 수동 확인 필요:
|
||||
|
||||
- [ ] `/test-card-responsive` 는 여전히 작동 (Phase 1 기반)
|
||||
- [ ] `/admin/builder` — 빌더 진입, 세로 리스트 캔버스 표시, 좌측 팔레트 드래그 → 드롭 존 하이라이트 → 드롭 → 새 블록 삽입
|
||||
- [ ] 블록 드래그 → 드롭 존 → 순서 재배열 동작
|
||||
- [ ] 블록 속성 패널에서 `↑`/`↓` 이동, `row` 키 입력, `config` JSON 편집, 삭제
|
||||
- [ ] 수주관리 Template 생성 후 저장 → 게시
|
||||
- [ ] 대시보드에 카드 배치 → 카드 폭을 실제로 **800 → 400 → 260** 으로 줄여도 overflow 없이 flex-wrap 으로 재배치
|
||||
- [ ] `test-card-responsive` 와 시각적으로 동일한 반응형 동작 확인
|
||||
- [ ] ControlMode / CardSettingsPanel sanity (Phase 2.1 log §4-E 와 동일)
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 결정 사항
|
||||
|
||||
### 결정 1 — `row` 필드 의미 (숫자 키 vs bool flag)
|
||||
|
||||
**옵션**: (a) `row?: boolean` — 직전 블록과 같은 row 에 붙임 / (b) `row?: number` — 같은 숫자 키끼리 그룹
|
||||
**결정**: **(b) 숫자 키**
|
||||
**이유**: 사용자가 3개 블록을 한 줄에 두고 가운데 블록만 제거하면, bool 방식은 옆 블록 관계가 깨진다. 숫자 키는 나머지 2 블록이 같은 키를 공유하므로 그대로 유지. 또 같은 키 블록을 순서만 바꿔도 그룹이 유지돼 재배열 UX 가 단순.
|
||||
|
||||
### 결정 2 — 빌더 캔버스에서 row 그룹핑을 시각적으로 반영할지
|
||||
|
||||
**옵션**: (a) 캔버스도 runtime 처럼 row 그룹핑 반영 (가로 배치 미리보기) / (b) 평면 세로 리스트 + row 값 배지만 표시
|
||||
**결정**: **(b) MVP 로 평면 세로 리스트**
|
||||
**이유**: 드롭 존 / 드래그 / 삽입/재배열 UX 는 평면 리스트가 훨씬 단순. runtime 의 실제 row 그룹핑은 대시보드 카드에서 확인. 빌더에서 가로 배치 미리보기는 Phase 2.2 이월 (우측 속성 패널에 실 렌더 미리보기 탭 등).
|
||||
|
||||
### 결정 3 — 빌더 단축키 `Delete` 처리
|
||||
|
||||
속성 패널 textarea 에서도 Delete 가 발동되지 않도록 `if (tag === "INPUT" || "TEXTAREA" || "SELECT") return` 유지. 기존 로직 그대로.
|
||||
|
||||
### 결정 4 — DashboardCard 의 `containerName: 'card'` 유지
|
||||
|
||||
초안에서는 TemplateRenderer.css 의 `@container card (max-width: 599px)` narrow 스택을 위해 필요했지만, 이제는 `styles/dashboard.css` 의 카드 헤더 / 미니뷰 규칙이 여전히 이 컨테이너를 사용. 제거 시 다른 규칙이 깨질 수 있어 유지.
|
||||
|
||||
### 결정 5 — 구 포맷 Template 처리 (Step D)
|
||||
|
||||
**옵션**: (a) soft-delete / (b) 유지 + 자동 마이그레이션 레이어
|
||||
**결정**: **(b) + (a) 사용자 승인 받으면 병행**
|
||||
**이유**: 기술적으로는 (b) 만으로 충분. (a) 는 "의미 없는 레이아웃" 을 청소하는 용도. 사용자가 명시 승인할 때만 DB UPDATE 실행 (`CLAUDE.md` DB 규칙).
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase 2.2 이월 항목
|
||||
|
||||
### 재작업에서 미뤄둔 것
|
||||
- [ ] **빌더 캔버스의 row 그룹핑 시각화** — 평면 리스트가 아니라 실제 runtime 렌더 모양으로 미리보기
|
||||
- [ ] **가로 나열 UX 개선** — row 키를 숫자로 편집하지 않고, 드롭 위치(좌/우) 로 자동 부여
|
||||
- [ ] **undo/redo 범위** — 현재 블록 변경만 history 에 들어감. row 변경 / reorder 는 포함되지만, `updateBlock` (config 편집 이외) 은 commit 시점이 다름 — 추후 통합
|
||||
|
||||
### Phase 2.1 에서 원래 이월한 것 (변경 없음)
|
||||
- [ ] TemplateRenderer 를 ComponentRegistry 어댑터로 v2-* 실제 렌더 (현재는 FcTable/FcSearch/FcButton 기반 MVP)
|
||||
- [ ] v2-* 컴포넌트 props 시그니처 표준화 (VEX `ComponentData` / `isDesignMode` 의존 제거)
|
||||
- [ ] 경량 7 개 컴포넌트 내부 DOM 기반 반응형 개선
|
||||
- [ ] TemplateBuilder URL 동기화 (저장 후 `?id=tpl_xxx` 반영)
|
||||
- [ ] 우선순위 2/3 v2-* 마이그레이션
|
||||
|
||||
---
|
||||
|
||||
## 7. 알려진 한계 / 주의
|
||||
|
||||
1. **빌더 캔버스는 평면 리스트** — 같은 row 키를 공유하는 블록들이 실제로 가로 배치되는 모습은 대시보드 카드에서만 볼 수 있음. 빌더에서는 row 배지만 표시
|
||||
2. **`row` 키는 연속 블록에만 유효** — 예를 들어 `[A(row=1), B(row=1), C(row=2), D(row=1)]` 이면 A/B 만 가로 묶이고, D 는 C 뒤 단독 행. C 와 D 사이에 row=2 블록이 끼면 row=1 그룹이 끊기도록 의도적으로 설계 (drag 이동 시 직관적인 결과를 위해)
|
||||
3. **구 포맷 Template 은 레이아웃 의미 손실** — `position` 필드가 drop 되므로 원래 자유배치했던 의도는 "순서대로 세로 쌓기" 로 바뀜. 시각적으로 어색할 수 있음 → Step D 가 존재하는 이유
|
||||
4. **DashboardCard 의 TemplateRenderer 호출 경로는 그대로** — Template.views.list.components 가 있으면 새 렌더러가, 없으면 기존 DashboardCard 의 기본 CRUD (FcSearch + FcTable + CRUD 버튼 바) 가 그대로 보여줌. 이 분기는 Phase 2.1 초안과 동일
|
||||
5. **Phase 1 의 `v2-table-list` ResizeObserver narrow 전환** 은 **카드 폭 기준** 으로 여전히 동작. 이번 재작업은 TemplateRenderer 의 *블록 배치* 만 바꿨고, 블록 안 컴포넌트(v2-table-list 등) 의 자체 반응형 로직은 건드리지 않음
|
||||
|
||||
---
|
||||
|
||||
## 8. 사용자 Step D 승인 대기
|
||||
|
||||
구 포맷 Template `tpl_80704df4029a` (수주123123) soft-delete 는 사용자 명시 승인 후 실행:
|
||||
|
||||
```sql
|
||||
-- 대상 서버: 사용자 확인 필요 (vexplor / vexplor_dev / testvex 중)
|
||||
UPDATE templates SET is_active='D' WHERE template_id='tpl_80704df4029a';
|
||||
```
|
||||
|
||||
승인 없으면 자동 마이그레이션 레이어만으로도 시스템은 정상 동작. 어느 쪽이든 기능적 차이는 없다.
|
||||
|
||||
---
|
||||
|
||||
## 끝
|
||||
|
||||
Phase 2.1 재작업 코드 종료 (2026-04-11).
|
||||
사용자 브라우저 검증 (§4.3) 통과 후 Phase 2.1 전체 완료로 이월.
|
||||
@@ -0,0 +1,402 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Builder Panel Mockup — Resizable + Field Display</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
:root{
|
||||
--bg:#121218;--bg2:#1a1a22;--bg3:#22222c;
|
||||
--fg:#e8e8ee;--fg2:#9898a8;--fg3:#5a5a6e;
|
||||
--bd:#3a3a48;--bd2:#2a2a36;
|
||||
--blue:#5b9ef5;--blue-bg:rgba(91,158,245,0.12);--blue-bd:rgba(91,158,245,0.3);
|
||||
--amber:#f0c060;--amber-bg:rgba(240,192,96,0.12);
|
||||
--green:#55c4a0;--green-bg:rgba(85,196,160,0.12);
|
||||
--violet:#a88cee;--violet-bg:rgba(168,140,238,0.12);
|
||||
--rose:#f07088;--rose-bg:rgba(240,112,136,0.12);
|
||||
--cyan:#50c8c8;--cyan-bg:rgba(80,200,200,0.12);
|
||||
--panel-w:280px;
|
||||
}
|
||||
html:not(.dark){
|
||||
--bg:#f5f5f8;--bg2:#ededf2;--bg3:#e4e4ec;
|
||||
--fg:#1a1a24;--fg2:#6a6a80;--fg3:#9898a8;
|
||||
--bd:#d8d8e2;--bd2:#e4e4ec;
|
||||
--blue:#3b7dd8;--blue-bg:rgba(59,125,216,0.08);--blue-bd:rgba(59,125,216,0.2);
|
||||
--amber:#c09030;--amber-bg:rgba(192,144,48,0.08);
|
||||
--green:#30a078;--green-bg:rgba(48,160,120,0.08);
|
||||
--violet:#7860c0;--violet-bg:rgba(120,96,192,0.08);
|
||||
--rose:#d05068;--rose-bg:rgba(208,80,104,0.08);
|
||||
--cyan:#309898;--cyan-bg:rgba(48,152,152,0.08);
|
||||
}
|
||||
body{font-family:'Pretendard','Apple SD Gothic Neo',sans-serif;background:var(--bg);color:var(--fg);height:100vh;display:flex;overflow:hidden;}
|
||||
|
||||
/* ===== 테마 토글 ===== */
|
||||
.theme-toggle{position:fixed;top:12px;right:16px;z-index:100;display:flex;gap:6px;}
|
||||
.theme-toggle button{padding:6px 14px;border-radius:6px;border:1px solid var(--bd);background:var(--bg2);color:var(--fg2);font-size:12px;cursor:pointer;transition:all .15s;}
|
||||
.theme-toggle button:hover{background:var(--blue-bg);color:var(--blue);border-color:var(--blue-bd);}
|
||||
.theme-toggle button.active{background:var(--blue);color:#fff;border-color:var(--blue);}
|
||||
|
||||
/* ===== 좌측 패널 ===== */
|
||||
.panel{
|
||||
width:var(--panel-w);min-width:200px;max-width:450px;
|
||||
background:var(--bg2);border-right:1px solid var(--bd2);
|
||||
display:flex;flex-direction:column;height:100%;position:relative;
|
||||
transition:background .2s;
|
||||
}
|
||||
|
||||
/* 리사이즈 핸들 */
|
||||
.resize-handle{
|
||||
position:absolute;right:-3px;top:0;bottom:0;width:6px;
|
||||
cursor:col-resize;z-index:10;
|
||||
transition:background .15s;
|
||||
}
|
||||
.resize-handle:hover,.resize-handle.active{
|
||||
background:var(--blue);opacity:0.5;
|
||||
}
|
||||
|
||||
/* 패널 헤더 (테이블명) */
|
||||
.panel-header{
|
||||
padding:12px 14px 8px;display:flex;align-items:center;gap:8px;
|
||||
border-bottom:1px solid var(--bd2);flex-shrink:0;
|
||||
}
|
||||
.panel-header .table-icon{
|
||||
width:20px;height:20px;border-radius:4px;
|
||||
background:var(--blue-bg);color:var(--blue);
|
||||
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;
|
||||
}
|
||||
.panel-header .table-name{font-size:13px;font-weight:700;flex:1;}
|
||||
.panel-header .field-count{
|
||||
font-size:11px;color:var(--fg3);background:var(--bg3);
|
||||
padding:2px 8px;border-radius:10px;
|
||||
}
|
||||
|
||||
/* 검색 */
|
||||
.search-wrap{padding:8px 12px;flex-shrink:0;}
|
||||
.search-wrap input{
|
||||
width:100%;height:30px;padding:0 10px 0 32px;
|
||||
border:1px solid var(--bd2);border-radius:6px;
|
||||
background:var(--bg);color:var(--fg);font-size:12px;
|
||||
outline:none;transition:border .15s;
|
||||
}
|
||||
.search-wrap input:focus{border-color:var(--blue-bd);}
|
||||
.search-wrap{position:relative;}
|
||||
.search-wrap svg{position:absolute;left:22px;top:50%;transform:translateY(-50%);color:var(--fg3);}
|
||||
|
||||
/* 스크롤 영역 */
|
||||
.panel-scroll{flex:1;overflow-y:auto;padding:4px 0;}
|
||||
.panel-scroll::-webkit-scrollbar{width:6px;}
|
||||
.panel-scroll::-webkit-scrollbar-thumb{background:var(--bd);border-radius:3px;}
|
||||
|
||||
/* ===== 섹션 ===== */
|
||||
.section{margin-bottom:4px;}
|
||||
.section-header{
|
||||
display:flex;align-items:center;gap:6px;
|
||||
padding:6px 14px;font-size:11px;font-weight:700;
|
||||
color:var(--fg3);text-transform:uppercase;letter-spacing:0.04em;
|
||||
cursor:pointer;user-select:none;
|
||||
}
|
||||
.section-header:hover{color:var(--fg2);}
|
||||
.section-divider{height:1px;background:var(--bd2);margin:6px 12px;}
|
||||
|
||||
/* ===== 필드 행 ===== */
|
||||
.field-list{padding:0 8px 4px;}
|
||||
.field-row{
|
||||
display:flex;align-items:center;gap:10px;
|
||||
padding:7px 10px;border-radius:5px;
|
||||
cursor:grab;transition:all .12s;
|
||||
border-left:3px solid transparent;
|
||||
position:relative;
|
||||
}
|
||||
.field-row:hover{
|
||||
background:var(--blue-bg);
|
||||
border-left-color:var(--blue);
|
||||
}
|
||||
.field-row:active{cursor:grabbing;opacity:0.7;}
|
||||
.field-row.placed{opacity:0.3;cursor:default;}
|
||||
|
||||
/* 타입 뱃지 */
|
||||
.field-type{
|
||||
font-size:10px;font-weight:700;
|
||||
padding:2px 7px;border-radius:3px;
|
||||
min-width:34px;text-align:center;
|
||||
flex-shrink:0;letter-spacing:0.03em;
|
||||
}
|
||||
.field-type.txt{background:var(--blue-bg);color:var(--blue);}
|
||||
.field-type.num{background:var(--amber-bg);color:var(--amber);}
|
||||
.field-type.date{background:var(--green-bg);color:var(--green);}
|
||||
.field-type.bool{background:var(--violet-bg);color:var(--violet);}
|
||||
|
||||
/* 타입별 hover 보더 */
|
||||
.field-row.num:hover{border-left-color:var(--amber);background:var(--amber-bg);}
|
||||
.field-row.date:hover{border-left-color:var(--green);background:var(--green-bg);}
|
||||
.field-row.bool:hover{border-left-color:var(--violet);background:var(--violet-bg);}
|
||||
|
||||
/* 필드명 */
|
||||
.field-info{flex:1;min-width:0;overflow:hidden;}
|
||||
.field-label{
|
||||
font-size:12.5px;font-weight:500;color:var(--fg);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
}
|
||||
.field-col{
|
||||
font-size:10px;color:var(--fg3);margin-top:1px;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||||
display:none; /* 기본 숨김, 넓을 때 표시 */
|
||||
}
|
||||
|
||||
/* 드래그 힌트 */
|
||||
.field-grip{
|
||||
color:var(--fg3);opacity:0;transition:opacity .12s;flex-shrink:0;
|
||||
}
|
||||
.field-row:hover .field-grip{opacity:0.5;}
|
||||
|
||||
/* ★ 패널 넓을 때 (280px+): 2줄 표시 */
|
||||
@container panel (min-width: 280px){
|
||||
.field-col{display:block;}
|
||||
}
|
||||
/* container query 미지원 폴백 — JS로 class 토글 */
|
||||
.panel.wide .field-col{display:block;}
|
||||
|
||||
/* ===== 컴포넌트 카드 ===== */
|
||||
.comp-list{padding:0 8px 4px;display:flex;flex-direction:column;gap:3px;}
|
||||
.comp-card{
|
||||
display:flex;align-items:center;gap:8px;
|
||||
padding:7px 10px;border-radius:5px;
|
||||
border:1px solid var(--bd2);
|
||||
border-left:3px solid var(--comp-color,var(--bd));
|
||||
background:var(--comp-bg,transparent);
|
||||
cursor:grab;transition:all .12s;user-select:none;
|
||||
}
|
||||
.comp-card:hover{
|
||||
border-color:var(--comp-color,var(--blue));
|
||||
border-left-color:var(--comp-color,var(--blue));
|
||||
background:var(--comp-hover,var(--blue-bg));
|
||||
box-shadow:0 1px 6px rgba(0,0,0,0.08);
|
||||
}
|
||||
.comp-icon{
|
||||
width:24px;height:24px;border-radius:5px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
background:var(--comp-bg,var(--blue-bg));
|
||||
color:var(--comp-color,var(--blue));
|
||||
font-size:13px;flex-shrink:0;
|
||||
}
|
||||
.comp-name{font-size:12.5px;font-weight:600;flex:1;}
|
||||
.comp-grip{color:var(--fg3);opacity:0;transition:opacity .12s;flex-shrink:0;}
|
||||
.comp-card:hover .comp-grip{opacity:0.4;}
|
||||
|
||||
/* ===== 더보기 ===== */
|
||||
.more-toggle{
|
||||
display:flex;align-items:center;justify-content:center;gap:6px;
|
||||
padding:7px;margin:4px 8px;border-radius:5px;
|
||||
border:1px dashed var(--bd);color:var(--fg3);
|
||||
font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;
|
||||
}
|
||||
.more-toggle:hover{border-color:var(--blue-bd);color:var(--blue);background:var(--blue-bg);}
|
||||
.more-count{font-size:10px;background:var(--bg3);padding:1px 7px;border-radius:8px;}
|
||||
|
||||
/* ===== 캔버스 (더미) ===== */
|
||||
.canvas{flex:1;background:var(--bg);position:relative;display:flex;align-items:center;justify-content:center;}
|
||||
.canvas-label{color:var(--fg3);font-size:14px;}
|
||||
|
||||
/* ===== 패널 너비 표시 ===== */
|
||||
.width-indicator{
|
||||
position:absolute;bottom:8px;left:50%;transform:translateX(-50%);
|
||||
font-size:10px;color:var(--fg3);background:var(--bg3);
|
||||
padding:2px 10px;border-radius:10px;pointer-events:none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 테마 토글 -->
|
||||
<div class="theme-toggle">
|
||||
<button onclick="setTheme('dark')" class="active" id="btn-dark">Dark</button>
|
||||
<button onclick="setTheme('light')" id="btn-light">Light</button>
|
||||
</div>
|
||||
|
||||
<!-- 좌측 패널 -->
|
||||
<div class="panel" id="panel">
|
||||
<div class="resize-handle" id="resizeHandle"></div>
|
||||
|
||||
<!-- 테이블 헤더 -->
|
||||
<div class="panel-header">
|
||||
<div class="table-icon">T</div>
|
||||
<div class="table-name">USER_INFO</div>
|
||||
<div class="field-count">30</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 -->
|
||||
<div class="search-wrap">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" placeholder="필드 검색...">
|
||||
</div>
|
||||
|
||||
<!-- 스크롤 영역 -->
|
||||
<div class="panel-scroll">
|
||||
|
||||
<!-- 필드 리스트 -->
|
||||
<div class="field-list" id="fieldList">
|
||||
<!-- JS로 생성 -->
|
||||
</div>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<!-- 컴포넌트 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
<span>컴포넌트</span>
|
||||
</div>
|
||||
<div class="comp-list">
|
||||
<div class="comp-card" style="--comp-color:var(--blue);--comp-bg:var(--blue-bg);--comp-hover:var(--blue-bg);">
|
||||
<div class="comp-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg></div>
|
||||
<span class="comp-name">데이터 조회/선택</span>
|
||||
<svg class="comp-grip" width="8" height="14" viewBox="0 0 8 14"><circle cx="2" cy="2" r="1" fill="currentColor"/><circle cx="6" cy="2" r="1" fill="currentColor"/><circle cx="2" cy="7" r="1" fill="currentColor"/><circle cx="6" cy="7" r="1" fill="currentColor"/><circle cx="2" cy="12" r="1" fill="currentColor"/><circle cx="6" cy="12" r="1" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="comp-card" style="--comp-color:var(--rose);--comp-bg:var(--rose-bg);--comp-hover:var(--rose-bg);">
|
||||
<div class="comp-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div>
|
||||
<span class="comp-name">버튼</span>
|
||||
<svg class="comp-grip" width="8" height="14" viewBox="0 0 8 14"><circle cx="2" cy="2" r="1" fill="currentColor"/><circle cx="6" cy="2" r="1" fill="currentColor"/><circle cx="2" cy="7" r="1" fill="currentColor"/><circle cx="6" cy="7" r="1" fill="currentColor"/><circle cx="2" cy="12" r="1" fill="currentColor"/><circle cx="6" cy="12" r="1" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="comp-card" style="--comp-color:var(--violet);--comp-bg:var(--violet-bg);--comp-hover:var(--violet-bg);">
|
||||
<div class="comp-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg></div>
|
||||
<span class="comp-name">제목/텍스트</span>
|
||||
<svg class="comp-grip" width="8" height="14" viewBox="0 0 8 14"><circle cx="2" cy="2" r="1" fill="currentColor"/><circle cx="6" cy="2" r="1" fill="currentColor"/><circle cx="2" cy="7" r="1" fill="currentColor"/><circle cx="6" cy="7" r="1" fill="currentColor"/><circle cx="2" cy="12" r="1" fill="currentColor"/><circle cx="6" cy="12" r="1" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="comp-card" style="--comp-color:var(--green);--comp-bg:var(--green-bg);--comp-hover:var(--green-bg);">
|
||||
<div class="comp-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg></div>
|
||||
<span class="comp-name">테이블</span>
|
||||
<svg class="comp-grip" width="8" height="14" viewBox="0 0 8 14"><circle cx="2" cy="2" r="1" fill="currentColor"/><circle cx="6" cy="2" r="1" fill="currentColor"/><circle cx="2" cy="7" r="1" fill="currentColor"/><circle cx="6" cy="7" r="1" fill="currentColor"/><circle cx="2" cy="12" r="1" fill="currentColor"/><circle cx="6" cy="12" r="1" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="comp-card" style="--comp-color:var(--amber);--comp-bg:var(--amber-bg);--comp-hover:var(--amber-bg);">
|
||||
<div class="comp-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></div>
|
||||
<span class="comp-name">통계 카드</span>
|
||||
<svg class="comp-grip" width="8" height="14" viewBox="0 0 8 14"><circle cx="2" cy="2" r="1" fill="currentColor"/><circle cx="6" cy="2" r="1" fill="currentColor"/><circle cx="2" cy="7" r="1" fill="currentColor"/><circle cx="6" cy="7" r="1" fill="currentColor"/><circle cx="2" cy="12" r="1" fill="currentColor"/><circle cx="6" cy="12" r="1" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="comp-card" style="--comp-color:var(--fg3);--comp-bg:transparent;--comp-hover:rgba(128,128,128,0.06);">
|
||||
<div class="comp-icon" style="background:var(--bg3);color:var(--fg3);"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/></svg></div>
|
||||
<span class="comp-name">구분선</span>
|
||||
<svg class="comp-grip" width="8" height="14" viewBox="0 0 8 14"><circle cx="2" cy="2" r="1" fill="currentColor"/><circle cx="6" cy="2" r="1" fill="currentColor"/><circle cx="2" cy="7" r="1" fill="currentColor"/><circle cx="6" cy="7" r="1" fill="currentColor"/><circle cx="2" cy="12" r="1" fill="currentColor"/><circle cx="6" cy="12" r="1" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="comp-card" style="--comp-color:var(--cyan);--comp-bg:var(--cyan-bg);--comp-hover:var(--cyan-bg);">
|
||||
<div class="comp-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg></div>
|
||||
<span class="comp-name">영역</span>
|
||||
<svg class="comp-grip" width="8" height="14" viewBox="0 0 8 14"><circle cx="2" cy="2" r="1" fill="currentColor"/><circle cx="6" cy="2" r="1" fill="currentColor"/><circle cx="2" cy="7" r="1" fill="currentColor"/><circle cx="6" cy="7" r="1" fill="currentColor"/><circle cx="2" cy="12" r="1" fill="currentColor"/><circle cx="6" cy="12" r="1" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="comp-card" style="--comp-color:var(--violet);--comp-bg:var(--violet-bg);--comp-hover:var(--violet-bg);">
|
||||
<div class="comp-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 10H3"/><path d="M21 6H3"/><path d="M21 14H3"/><path d="M17 18H3"/></svg></div>
|
||||
<span class="comp-name">입력 필드</span>
|
||||
<svg class="comp-grip" width="8" height="14" viewBox="0 0 8 14"><circle cx="2" cy="2" r="1" fill="currentColor"/><circle cx="6" cy="2" r="1" fill="currentColor"/><circle cx="2" cy="7" r="1" fill="currentColor"/><circle cx="6" cy="7" r="1" fill="currentColor"/><circle cx="2" cy="12" r="1" fill="currentColor"/><circle cx="6" cy="12" r="1" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div class="comp-card" style="--comp-color:var(--blue);--comp-bg:var(--blue-bg);--comp-hover:var(--blue-bg);">
|
||||
<div class="comp-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg></div>
|
||||
<span class="comp-name">검색</span>
|
||||
<svg class="comp-grip" width="8" height="14" viewBox="0 0 8 14"><circle cx="2" cy="2" r="1" fill="currentColor"/><circle cx="6" cy="2" r="1" fill="currentColor"/><circle cx="2" cy="7" r="1" fill="currentColor"/><circle cx="6" cy="7" r="1" fill="currentColor"/><circle cx="2" cy="12" r="1" fill="currentColor"/><circle cx="6" cy="12" r="1" fill="currentColor"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="more-toggle">
|
||||
<span>더보기</span>
|
||||
<span class="more-count">9</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="width-indicator" id="widthLabel">280px</div>
|
||||
</div>
|
||||
|
||||
<!-- 캔버스 -->
|
||||
<div class="canvas">
|
||||
<span class="canvas-label">캔버스 — 패널 경계를 드래그해서 리사이즈</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 필드 데이터
|
||||
const fields = [
|
||||
{label:'사번', col:'emp_no', type:'TXT'},
|
||||
{label:'사용자ID', col:'user_id', type:'TXT'},
|
||||
{label:'사용자패스워드', col:'user_password', type:'TXT'},
|
||||
{label:'사용자이름', col:'user_name', type:'TXT'},
|
||||
{label:'사용자 영어 이름', col:'user_name_en', type:'TXT'},
|
||||
{label:'사용자 중국어 이름', col:'user_name_cn', type:'TXT'},
|
||||
{label:'부서코드', col:'department_code', type:'TXT'},
|
||||
{label:'부서이름', col:'department_name', type:'TXT'},
|
||||
{label:'직급코드', col:'grade_code', type:'TXT'},
|
||||
{label:'직급이름', col:'grade_name', type:'TXT'},
|
||||
{label:'이메일', col:'email', type:'TXT'},
|
||||
{label:'전화번호', col:'phone_number', type:'TXT'},
|
||||
{label:'휴대폰 번호', col:'mobile_number', type:'TXT'},
|
||||
{label:'사용자 유형 코드', col:'user_type_code', type:'TXT'},
|
||||
{label:'사용자 유형 명', col:'user_type_name', type:'TXT'},
|
||||
{label:'생성일', col:'created_date', type:'DATE'},
|
||||
{label:'상태', col:'status', type:'TXT'},
|
||||
{label:'퇴사일', col:'resign_date', type:'DATE'},
|
||||
{label:'팩스번호', col:'fax_number', type:'TXT'},
|
||||
{label:'협력사ID', col:'partner_id', type:'NUM'},
|
||||
{label:'사진', col:'photo', type:'TXT'},
|
||||
{label:'언어/지역', col:'locale', type:'TXT'},
|
||||
{label:'회사코드', col:'company_code', type:'TXT'},
|
||||
{label:'데이터 타입', col:'data_type', type:'TXT'},
|
||||
{label:'자격증 번호', col:'cert_number', type:'TXT'},
|
||||
{label:'차량번호', col:'car_number', type:'TXT'},
|
||||
{label:'가입유형', col:'signup_type', type:'TXT'},
|
||||
{label:'지점/지사 명', col:'branch_name', type:'TXT'},
|
||||
{label:'부서이력', col:'department_history', type:'TXT'},
|
||||
{label:'토큰 버전', col:'token_version', type:'NUM'},
|
||||
];
|
||||
|
||||
const list = document.getElementById('fieldList');
|
||||
fields.forEach(f => {
|
||||
const row = document.createElement('div');
|
||||
row.className = `field-row ${f.type.toLowerCase()}`;
|
||||
row.innerHTML = `
|
||||
<span class="field-type ${f.type.toLowerCase()}">${f.type}</span>
|
||||
<div class="field-info">
|
||||
<div class="field-label">${f.label}</div>
|
||||
<div class="field-col">${f.col}</div>
|
||||
</div>
|
||||
<svg class="field-grip" width="8" height="14" viewBox="0 0 8 14"><circle cx="2" cy="2" r="1" fill="currentColor"/><circle cx="6" cy="2" r="1" fill="currentColor"/><circle cx="2" cy="7" r="1" fill="currentColor"/><circle cx="6" cy="7" r="1" fill="currentColor"/><circle cx="2" cy="12" r="1" fill="currentColor"/><circle cx="6" cy="12" r="1" fill="currentColor"/></svg>
|
||||
`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
// 리사이즈
|
||||
const panel = document.getElementById('panel');
|
||||
const handle = document.getElementById('resizeHandle');
|
||||
const label = document.getElementById('widthLabel');
|
||||
let resizing = false;
|
||||
|
||||
handle.addEventListener('mousedown', e => {
|
||||
resizing = true;
|
||||
handle.classList.add('active');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
e.preventDefault();
|
||||
});
|
||||
document.addEventListener('mousemove', e => {
|
||||
if (!resizing) return;
|
||||
const w = Math.min(450, Math.max(200, e.clientX));
|
||||
panel.style.width = w + 'px';
|
||||
label.textContent = w + 'px';
|
||||
panel.classList.toggle('wide', w >= 280);
|
||||
});
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (!resizing) return;
|
||||
resizing = false;
|
||||
handle.classList.remove('active');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
});
|
||||
|
||||
// 초기 wide 체크
|
||||
panel.classList.toggle('wide', parseInt(getComputedStyle(panel).width) >= 280);
|
||||
|
||||
// 테마 토글
|
||||
function setTheme(t) {
|
||||
document.documentElement.classList.toggle('dark', t === 'dark');
|
||||
document.getElementById('btn-dark').classList.toggle('active', t === 'dark');
|
||||
document.getElementById('btn-light').classList.toggle('active', t === 'light');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,380 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Builder Header v3 — 인비온 직각 + 구역 분리 + 구조 변경</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
:root{
|
||||
--bg:#0a0a12;--bg2:#121218;--bg3:#1a1a22;--bg4:#22222c;
|
||||
--fg:#e8e8ee;--fg2:#9898a8;--fg3:#5a5a6e;
|
||||
--bd:#2a2a36;--bd-l:#3a3a48;
|
||||
--purple:#6c5ce7;--purple-l:#a29bfe;--purple-bg:rgba(108,92,231,0.12);--purple-glow:rgba(162,155,254,0.25);
|
||||
--cyan:#00cec9;--cyan-bg:rgba(0,206,201,0.1);
|
||||
--pink:#fd79a8;
|
||||
--glass:rgba(17,16,42,0.5);--glass-s:rgba(17,16,42,0.65);--glass-border:rgba(162,155,254,0.1);
|
||||
--glow-sm:0 0 20px rgba(162,155,254,0.1);
|
||||
}
|
||||
html:not(.dark){
|
||||
--bg:#f5f5f8;--bg2:#ededf2;--bg3:#e4e4ec;--bg4:#d8d8e2;
|
||||
--fg:#1a1a24;--fg2:#6a6a80;--fg3:#9898a8;
|
||||
--bd:#d8d8e2;--bd-l:#c8c8d2;
|
||||
--purple:#6c5ce7;--purple-l:#6c5ce7;--purple-bg:rgba(108,92,231,0.07);--purple-glow:rgba(108,92,231,0.12);
|
||||
--cyan:#00b3ad;--cyan-bg:rgba(0,179,173,0.06);
|
||||
--pink:#e0608a;
|
||||
--glass:rgba(255,255,255,0.6);--glass-s:rgba(255,255,255,0.8);--glass-border:rgba(108,92,231,0.08);
|
||||
--glow-sm:0 0 15px rgba(108,92,231,0.06);
|
||||
}
|
||||
body{font-family:'Pretendard','Apple SD Gothic Neo',-apple-system,sans-serif;background:var(--bg);color:var(--fg);height:100vh;display:flex;flex-direction:column;overflow:hidden;}
|
||||
|
||||
/* 테마 */
|
||||
.tm{position:fixed;bottom:12px;right:12px;z-index:999;display:flex;
|
||||
background:var(--bg3);border:1px solid var(--bd);border-radius:6px;overflow:hidden;}
|
||||
.tm button{padding:4px 12px;border:none;font-size:10px;font-weight:600;
|
||||
background:transparent;color:var(--fg3);cursor:pointer;transition:all .12s;}
|
||||
.tm button.on{background:var(--purple);color:#fff;}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
헤더 — 인비온 직각 스타일 + 구역 분리
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.hdr{
|
||||
height:48px;display:flex;align-items:center;
|
||||
flex-shrink:0;z-index:20;
|
||||
background:var(--bg2);
|
||||
border-bottom:1px solid var(--bd);
|
||||
}
|
||||
|
||||
/* ── 구역: 보이는듯 안보이는듯한 구분 ── */
|
||||
.hdr-zone{
|
||||
display:flex;align-items:center;height:100%;
|
||||
padding:0 12px;
|
||||
position:relative;
|
||||
}
|
||||
.hdr-zone::after{
|
||||
content:'';position:absolute;right:0;top:25%;bottom:25%;
|
||||
width:1px;background:var(--glass-border);
|
||||
}
|
||||
.hdr-zone:last-child::after{display:none;}
|
||||
.hdr-zone.grow{flex:1;min-width:0;}
|
||||
.hdr-zone.end{margin-left:auto;}
|
||||
.hdr-zone.end::after{display:none;}
|
||||
.hdr-zone.end::before{
|
||||
content:'';position:absolute;left:0;top:25%;bottom:25%;
|
||||
width:1px;background:var(--glass-border);
|
||||
}
|
||||
|
||||
/* 브랜드 구역 */
|
||||
.brand{display:flex;align-items:center;gap:6px;}
|
||||
.brand-logo{font-size:13px;font-weight:900;letter-spacing:-0.03em;
|
||||
background:linear-gradient(135deg,var(--purple),var(--cyan));
|
||||
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
|
||||
.brand-tag{font-size:7.5px;font-weight:800;padding:2px 5px;
|
||||
background:linear-gradient(135deg,var(--purple),var(--pink));
|
||||
color:#fff;border-radius:3px;letter-spacing:0.05em;
|
||||
box-shadow:0 0 8px rgba(108,92,231,0.2);}
|
||||
|
||||
/* 직각 아이콘 버튼 — 탭과 동일한 플랫 스타일 */
|
||||
.sq-btn{
|
||||
width:32px;height:32px;border-radius:6px;
|
||||
border:none;
|
||||
background:transparent;color:var(--fg3);
|
||||
cursor:pointer;display:flex;align-items:center;justify-content:center;
|
||||
transition:all .15s;flex-shrink:0;
|
||||
}
|
||||
.sq-btn:hover{color:var(--fg2);background:var(--bg3);}
|
||||
.sq-btn.active{color:var(--purple-l);background:var(--purple-bg);}
|
||||
|
||||
/* 탭 구역 — 직각 탭 */
|
||||
.tab-group{
|
||||
display:flex;gap:0;height:100%;align-items:stretch;
|
||||
}
|
||||
.tab-item{
|
||||
display:flex;align-items:center;gap:5px;
|
||||
padding:0 16px;border:none;
|
||||
font-size:11.5px;font-weight:500;color:var(--fg3);
|
||||
background:transparent;cursor:pointer;
|
||||
transition:all .12s;white-space:nowrap;
|
||||
position:relative;
|
||||
}
|
||||
.tab-item+.tab-item::before{
|
||||
content:'';position:absolute;left:0;top:30%;bottom:30%;
|
||||
width:1px;background:var(--glass-border);
|
||||
}
|
||||
.tab-item:hover{color:var(--fg2);background:var(--bg3);}
|
||||
.tab-item.active{
|
||||
color:var(--purple-l);font-weight:700;
|
||||
background:var(--purple-bg);
|
||||
}
|
||||
.tab-item.active::after{
|
||||
content:'';position:absolute;bottom:0;left:0;right:0;height:2px;
|
||||
background:linear-gradient(90deg,var(--purple),var(--cyan));
|
||||
box-shadow:0 0 8px var(--purple-glow);
|
||||
}
|
||||
.tab-cnt{
|
||||
font-size:9px;font-weight:700;
|
||||
padding:1px 6px;border-radius:4px;
|
||||
background:var(--bg4);color:var(--fg3);
|
||||
}
|
||||
.tab-item.active .tab-cnt{background:rgba(162,155,254,0.15);color:var(--purple-l);}
|
||||
|
||||
/* 화면 정보 구역 */
|
||||
.info-title{font-size:12.5px;font-weight:700;color:var(--fg);
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.info-table{
|
||||
display:inline-flex;align-items:center;gap:4px;
|
||||
font-size:10px;font-weight:600;font-family:ui-monospace,monospace;
|
||||
color:var(--cyan);padding:2px 8px;border-radius:4px;
|
||||
background:var(--cyan-bg);
|
||||
border:1px solid rgba(0,206,201,0.12);
|
||||
margin-left:8px;white-space:nowrap;flex-shrink:0;
|
||||
}
|
||||
|
||||
/* 도구 버튼 — 탭과 동일한 플랫 스타일 */
|
||||
.tool-btn{
|
||||
display:flex;align-items:center;gap:5px;
|
||||
padding:5px 12px;border-radius:6px;
|
||||
border:none;background:transparent;
|
||||
font-size:11px;font-weight:500;color:var(--fg3);
|
||||
cursor:pointer;transition:all .15s;white-space:nowrap;
|
||||
}
|
||||
.tool-btn:hover{color:var(--fg2);background:var(--bg3);}
|
||||
|
||||
/* 저장 — 플랫 아이콘 + 보라 텍스트 (다른 버튼과 같은 톤, 색으로만 구분) */
|
||||
.save-btn{
|
||||
display:flex;align-items:center;gap:5px;
|
||||
padding:5px 12px;border-radius:6px;border:none;
|
||||
font-size:11.5px;font-weight:700;
|
||||
color:var(--purple-l);
|
||||
background:transparent;
|
||||
cursor:pointer;
|
||||
transition:all .15s;flex-shrink:0;
|
||||
}
|
||||
.save-btn:hover{
|
||||
background:var(--purple-bg);
|
||||
}
|
||||
.save-btn svg{opacity:0.7;}
|
||||
|
||||
/* 탭 추가 애니메이션 */
|
||||
@keyframes tabIn{
|
||||
from{opacity:0;max-width:0;padding:0;overflow:hidden;}
|
||||
to{opacity:1;max-width:200px;padding:0 16px;}
|
||||
}
|
||||
|
||||
/* 구역 내부 간격 */
|
||||
.hdr-zone .gap-2{display:flex;align-items:center;gap:6px;}
|
||||
.hdr-zone .gap-1{display:flex;align-items:center;gap:4px;}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
캔버스 + 플로팅 도구
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.canvas-area{flex:1;position:relative;background:var(--bg);display:flex;align-items:center;justify-content:center;}
|
||||
|
||||
.float-tools{
|
||||
position:absolute;top:10px;left:50%;transform:translateX(-50%);
|
||||
display:flex;align-items:center;gap:0;
|
||||
background:var(--bg2);
|
||||
border:1px solid var(--glass-border);border-radius:8px;
|
||||
box-shadow:var(--glow-sm),0 4px 20px rgba(0,0,0,0.15);
|
||||
z-index:10;overflow:hidden;
|
||||
}
|
||||
.ft-cell{
|
||||
display:flex;align-items:center;gap:5px;
|
||||
padding:5px 12px;
|
||||
position:relative;
|
||||
font-size:10.5px;color:var(--fg2);cursor:pointer;
|
||||
transition:all .12s;white-space:nowrap;
|
||||
}
|
||||
.ft-cell+.ft-toggles::before,.ft-toggles+.ft-cell::before{
|
||||
content:'';position:absolute;left:0;top:20%;bottom:20%;
|
||||
width:1px;background:var(--glass-border);
|
||||
}
|
||||
.ft-cell:hover{background:var(--bg3);color:var(--fg);}
|
||||
.ft-cell .sub{font-size:9.5px;color:var(--fg3);}
|
||||
.ft-cell svg{flex-shrink:0;}
|
||||
|
||||
.ft-toggle{
|
||||
width:28px;height:28px;border:none;
|
||||
background:transparent;color:var(--fg3);
|
||||
cursor:pointer;display:flex;align-items:center;justify-content:center;
|
||||
transition:all .12s;
|
||||
}
|
||||
.ft-toggle:hover{color:var(--fg);background:var(--bg3);}
|
||||
.ft-toggle.on{color:var(--purple-l);background:var(--purple-bg);}
|
||||
.ft-toggles{
|
||||
display:flex;position:relative;
|
||||
}
|
||||
|
||||
.canvas-inner{
|
||||
width:80%;max-width:900px;height:70%;
|
||||
background:var(--bg2);border:1px solid var(--bd);border-radius:6px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
color:var(--fg3);font-size:13px;
|
||||
}
|
||||
|
||||
/* 비교 */
|
||||
.note{
|
||||
position:fixed;bottom:12px;left:50%;transform:translateX(-50%);
|
||||
background:var(--bg3);border:1px solid var(--bd);border-radius:8px;
|
||||
padding:10px 18px;font-size:10.5px;color:var(--fg2);z-index:100;
|
||||
max-width:700px;text-align:center;line-height:1.6;
|
||||
}
|
||||
.note b{color:var(--purple-l);}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="tm">
|
||||
<button onclick="setTheme('dark')" class="on" id="bd">Dark</button>
|
||||
<button onclick="setTheme('light')" id="bl">Light</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 헤더 ═══ -->
|
||||
<div class="hdr">
|
||||
|
||||
<!-- 구역 1: 브랜드 -->
|
||||
<div class="hdr-zone" style="padding:0 14px;">
|
||||
<div class="brand">
|
||||
<span class="brand-logo">INVYONE</span>
|
||||
<span class="brand-tag">STUDIO</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 구역 2: 뒤로 + 패널 -->
|
||||
<div class="hdr-zone">
|
||||
<div class="gap-1">
|
||||
<div class="sq-btn" title="목록으로">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||
</div>
|
||||
<div class="sq-btn active" title="패널">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 구역 3: 탭 (헤더 안에 통합, 직각 분리) -->
|
||||
<div class="hdr-zone" style="padding:0;">
|
||||
<div class="tab-group" id="tabGroup">
|
||||
<button class="tab-item active">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||
목록 화면
|
||||
<span class="tab-cnt">8</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 구역 4: 화면 정보 (확장) -->
|
||||
<div class="hdr-zone grow">
|
||||
<span class="info-title">하이큐마그 기준정보 부서관리 사원 추가 모달</span>
|
||||
<span class="info-table">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>
|
||||
user_info
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 구역 5: 도구 아이콘 -->
|
||||
<div class="hdr-zone">
|
||||
<div class="gap-1">
|
||||
<div class="sq-btn" title="라벨">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z"/><path d="M7 7h.01"/></svg>
|
||||
</div>
|
||||
<div class="sq-btn" title="단축키">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h.01"/><path d="M10 8h.01"/><path d="M14 8h.01"/><path d="M18 8h.01"/><path d="M7 16h10"/></svg>
|
||||
</div>
|
||||
<div class="sq-btn" title="미리보기">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 구역 6: 다국어 + 저장 -->
|
||||
<div class="hdr-zone end">
|
||||
<div class="gap-2">
|
||||
<button class="tool-btn">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>
|
||||
다국어 생성
|
||||
</button>
|
||||
<button class="tool-btn">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
다국어 설정
|
||||
</button>
|
||||
<button class="save-btn">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 캔버스 ═══ -->
|
||||
<div class="canvas-area">
|
||||
<!-- 플로팅 도구 (직각 구역 분리 스타일) -->
|
||||
<div class="float-tools">
|
||||
<div class="ft-cell">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
Full HD
|
||||
<span class="sub">1920×1080</span>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="opacity:.4"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
<div class="ft-toggles">
|
||||
<button class="ft-toggle on" title="격자 표시">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||||
</button>
|
||||
<button class="ft-toggle on" title="격자 스냅">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-inner">캔버스 영역</div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<b>탭은 동적:</b> 처음엔 "목록 화면"만. 등록/수정 버튼 배치 시 탭 자동 추가<br>
|
||||
<button onclick="addTab('insert')" style="margin-top:4px;padding:3px 10px;border-radius:4px;border:1px solid var(--bd);background:var(--bg3);color:var(--fg2);font-size:10px;cursor:pointer;">+ 등록 버튼 배치</button>
|
||||
<button onclick="addTab('update')" style="padding:3px 10px;border-radius:4px;border:1px solid var(--bd);background:var(--bg3);color:var(--fg2);font-size:10px;cursor:pointer;">+ 수정 버튼 배치</button>
|
||||
<button onclick="resetTabs()" style="padding:3px 10px;border-radius:4px;border:1px solid var(--bd);background:var(--bg3);color:var(--fg2);font-size:10px;cursor:pointer;">초기화</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function setTheme(t){
|
||||
document.documentElement.classList.toggle('dark',t==='dark');
|
||||
document.getElementById('bd').classList.toggle('on',t==='dark');
|
||||
document.getElementById('bl').classList.toggle('on',t==='light');
|
||||
}
|
||||
function bindTabs(){
|
||||
document.querySelectorAll('.tab-item').forEach(t=>{
|
||||
t.onclick=()=>{
|
||||
document.querySelectorAll('.tab-item').forEach(x=>x.classList.remove('active'));
|
||||
t.classList.add('active');
|
||||
};
|
||||
});
|
||||
}
|
||||
bindTabs();
|
||||
|
||||
const tabDefs={
|
||||
insert:{icon:'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>',label:'등록 팝업'},
|
||||
update:{icon:'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>',label:'수정 팝업'},
|
||||
};
|
||||
const addedTabs=new Set();
|
||||
function addTab(type){
|
||||
if(addedTabs.has(type))return;
|
||||
addedTabs.add(type);
|
||||
const d=tabDefs[type];
|
||||
const btn=document.createElement('button');
|
||||
btn.className='tab-item';
|
||||
btn.dataset.type=type;
|
||||
btn.innerHTML=d.icon+' '+d.label;
|
||||
btn.style.animation='tabIn .25s ease';
|
||||
document.getElementById('tabGroup').appendChild(btn);
|
||||
bindTabs();
|
||||
}
|
||||
function resetTabs(){
|
||||
addedTabs.clear();
|
||||
const g=document.getElementById('tabGroup');
|
||||
g.querySelectorAll('[data-type]').forEach(el=>el.remove());
|
||||
}
|
||||
document.querySelectorAll('.ft-toggle').forEach(b=>b.addEventListener('click',()=>b.classList.toggle('on')));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,677 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>INVYONE — 메뉴 관리 (B · Soft Studio)</title>
|
||||
<style>
|
||||
/* ============ TOKENS ============ */
|
||||
:root{
|
||||
--bg:#f4f2ee; /* warm offwhite, 살짝 크림 */
|
||||
--bg-tint:#ebe7e0;
|
||||
--panel:#fbfaf7;
|
||||
--panel-raise:#ffffff;
|
||||
--sunk:#eeeae3;
|
||||
--text:#1d1b22;
|
||||
--text-2:#52505e;
|
||||
--text-3:#8e8c9a;
|
||||
--text-4:#bfbcc8;
|
||||
--hair:rgba(29,27,34,.07);
|
||||
--hair-2:rgba(29,27,34,.04);
|
||||
--accent:#6c5ce7;
|
||||
--accent-2:#a991ff;
|
||||
--accent-soft:rgba(108,92,231,.08);
|
||||
--accent-line:rgba(108,92,231,.18);
|
||||
--peach:#e77c54;
|
||||
--green:#16a34a;
|
||||
--red:#dc2626;
|
||||
--amber:#c87d18;
|
||||
--shadow-xs:0 1px 1.5px rgba(29,27,34,.04);
|
||||
--shadow-sm:0 1px 3px rgba(29,27,34,.04),0 8px 24px -12px rgba(29,27,34,.14);
|
||||
--shadow-md:0 2px 6px rgba(29,27,34,.06),0 18px 38px -18px rgba(29,27,34,.22);
|
||||
--inner-hi:inset 0 1px 0 rgba(255,255,255,.7);
|
||||
--ring:0 0 0 3px rgba(108,92,231,.14);
|
||||
}
|
||||
html.dark{
|
||||
--bg:#12121a;
|
||||
--bg-tint:#0b0b12;
|
||||
--panel:#1a1a24;
|
||||
--panel-raise:#20202c;
|
||||
--sunk:#15151e;
|
||||
--text:#f1eef8;
|
||||
--text-2:#a8a4b8;
|
||||
--text-3:#7a7689;
|
||||
--text-4:#4d4a5e;
|
||||
--hair:rgba(255,255,255,.06);
|
||||
--hair-2:rgba(255,255,255,.03);
|
||||
--accent:#a991ff;
|
||||
--accent-2:#c6b4ff;
|
||||
--accent-soft:rgba(169,145,255,.1);
|
||||
--accent-line:rgba(169,145,255,.2);
|
||||
--peach:#ff9a73;
|
||||
--green:#66e599;
|
||||
--red:#ff6a7a;
|
||||
--amber:#f2b45e;
|
||||
--shadow-xs:0 1px 1.5px rgba(0,0,0,.5);
|
||||
--shadow-sm:0 1px 3px rgba(0,0,0,.5),0 8px 28px -10px rgba(0,0,0,.7);
|
||||
--shadow-md:0 2px 8px rgba(0,0,0,.55),0 22px 44px -18px rgba(0,0,0,.75);
|
||||
--inner-hi:inset 0 1px 0 rgba(255,255,255,.05);
|
||||
--ring:0 0 0 3px rgba(169,145,255,.22);
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;height:100%;}
|
||||
body{
|
||||
font-family:'Inter','Noto Sans KR',-apple-system,system-ui,sans-serif;
|
||||
background:var(--bg);color:var(--text);
|
||||
font-size:.8125rem;line-height:1.5;overflow:hidden;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
font-feature-settings:"cv11","ss01";
|
||||
transition:background .25s,color .25s;
|
||||
}
|
||||
|
||||
/* ===== Subtle warm gradient on body ===== */
|
||||
body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;
|
||||
background:
|
||||
radial-gradient(ellipse 800px 400px at 10% -10%,rgba(231,124,84,.08),transparent 60%),
|
||||
radial-gradient(ellipse 700px 500px at 90% 120%,rgba(108,92,231,.06),transparent 60%);}
|
||||
html.dark body::before{background:
|
||||
radial-gradient(ellipse 800px 400px at 10% -10%,rgba(255,154,115,.05),transparent 60%),
|
||||
radial-gradient(ellipse 700px 500px at 90% 120%,rgba(169,145,255,.08),transparent 60%);}
|
||||
|
||||
/* ===== SHELL ===== */
|
||||
.shell{position:relative;z-index:1;height:100vh;display:flex;flex-direction:column;padding:10px;gap:10px;}
|
||||
|
||||
/* ===== HEADER — 떠있는 카드 ===== */
|
||||
.hdr{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:0 .95rem;height:46px;flex-shrink:0;
|
||||
background:var(--panel);border-radius:12px;box-shadow:var(--shadow-sm),var(--inner-hi);
|
||||
border:1px solid var(--hair);}
|
||||
.hdr-l{display:flex;align-items:center;gap:1rem;}
|
||||
.logo-w{display:flex;align-items:center;gap:.45rem;}
|
||||
.logo-m{width:20px;height:20px;border-radius:6px;position:relative;overflow:hidden;
|
||||
background:linear-gradient(135deg,var(--accent),var(--peach));
|
||||
box-shadow:0 2px 8px -2px rgba(108,92,231,.5),inset 0 1px 0 rgba(255,255,255,.35);}
|
||||
html.dark .logo-m{box-shadow:0 2px 10px -2px rgba(169,145,255,.5),inset 0 1px 0 rgba(255,255,255,.2);}
|
||||
.logo-m::after{content:'';position:absolute;inset:4px;border-radius:2px;border:1.5px solid rgba(255,255,255,.95);border-right-color:transparent;border-bottom-color:transparent;}
|
||||
.logo{font-size:.88rem;font-weight:700;letter-spacing:-.025em;color:var(--text);}
|
||||
.bc{font-size:.74rem;color:var(--text-3);display:flex;align-items:center;gap:.45rem;}
|
||||
.bc .sep{color:var(--text-4);}
|
||||
.bc .cur{color:var(--text-2);font-weight:600;}
|
||||
|
||||
.hdr-r{display:flex;align-items:center;gap:.5rem;}
|
||||
.kbd-hint{display:flex;align-items:center;gap:.3rem;font-size:.66rem;color:var(--text-3);padding:.25rem .5rem;
|
||||
border-radius:6px;background:var(--sunk);border:1px solid var(--hair);}
|
||||
.kbd-hint kbd{font-family:'JetBrains Mono',monospace;font-size:.6rem;color:var(--text-2);font-weight:600;}
|
||||
.tt{display:flex;padding:2px;border-radius:8px;background:var(--sunk);border:1px solid var(--hair);}
|
||||
.tt button{padding:.25rem .6rem;border-radius:6px;border:none;background:transparent;color:var(--text-3);
|
||||
font-family:inherit;font-size:.65rem;font-weight:600;cursor:pointer;letter-spacing:.01em;transition:all .15s;}
|
||||
.tt button.on{background:var(--panel-raise);color:var(--text);box-shadow:var(--shadow-xs);}
|
||||
.av{width:28px;height:28px;border-radius:50%;
|
||||
background:linear-gradient(135deg,var(--accent) 0%,var(--peach) 100%);
|
||||
color:white;display:flex;align-items:center;justify-content:center;font-size:.64rem;font-weight:700;
|
||||
box-shadow:0 2px 8px -2px rgba(108,92,231,.4),var(--inner-hi);}
|
||||
html.dark .av{box-shadow:0 2px 10px -2px rgba(169,145,255,.4),var(--inner-hi);}
|
||||
|
||||
/* ===== MAIN — 두 패널이 각각 떠있음 ===== */
|
||||
.main{flex:1;min-height:0;display:grid;grid-template-columns:312px 1fr;gap:10px;}
|
||||
|
||||
/* ============ TREE PANE ============ */
|
||||
.tree-pane{display:flex;flex-direction:column;min-width:0;overflow:hidden;
|
||||
background:var(--panel);border-radius:14px;border:1px solid var(--hair);
|
||||
box-shadow:var(--shadow-sm),var(--inner-hi);}
|
||||
|
||||
/* Title */
|
||||
.tp-top{padding:1rem 1rem .6rem;flex-shrink:0;}
|
||||
.tp-ttl{display:flex;align-items:baseline;gap:.45rem;margin-bottom:.2rem;}
|
||||
.tp-ttl h3{margin:0;font-size:.92rem;font-weight:700;letter-spacing:-.02em;color:var(--text);}
|
||||
.tp-ttl .cnt{font-size:.66rem;color:var(--text-3);font-variant-numeric:tabular-nums;font-weight:500;}
|
||||
.tp-sub{font-size:.66rem;color:var(--text-3);}
|
||||
|
||||
/* Type pills */
|
||||
.type-tabs{display:flex;gap:2px;padding:3px;border-radius:9px;background:var(--sunk);
|
||||
border:1px solid var(--hair);margin:.1rem 1rem .7rem;flex-shrink:0;}
|
||||
.type-tab{flex:1;padding:.4rem .5rem;border-radius:6px;border:none;background:transparent;
|
||||
font-family:inherit;font-size:.72rem;font-weight:600;color:var(--text-3);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;gap:.4rem;transition:all .18s;letter-spacing:-.005em;}
|
||||
.type-tab:hover{color:var(--text-2);}
|
||||
.type-tab.on{color:var(--text);background:var(--panel-raise);box-shadow:var(--shadow-xs);}
|
||||
.type-tab .cnt{color:var(--text-3);font-variant-numeric:tabular-nums;font-size:.66rem;font-weight:500;}
|
||||
.type-tab.on .cnt{color:var(--accent);}
|
||||
|
||||
/* Search */
|
||||
.tree-srch{position:relative;margin:0 1rem .7rem;flex-shrink:0;}
|
||||
.tree-srch svg{position:absolute;left:.6rem;top:50%;transform:translateY(-50%);width:13px;height:13px;color:var(--text-3);pointer-events:none;}
|
||||
.tree-srch input{width:100%;height:32px;padding:0 2.3rem 0 1.9rem;
|
||||
border:1px solid var(--hair);border-radius:8px;background:var(--sunk);color:var(--text);
|
||||
font-size:.73rem;font-family:inherit;outline:none;transition:all .15s;}
|
||||
.tree-srch input::placeholder{color:var(--text-3);}
|
||||
.tree-srch input:focus{border-color:var(--accent-line);background:var(--panel-raise);box-shadow:var(--ring);}
|
||||
.tree-srch .kb{position:absolute;right:.45rem;top:50%;transform:translateY(-50%);
|
||||
font-family:'JetBrains Mono',monospace;font-size:.56rem;padding:.1rem .32rem;
|
||||
border-radius:4px;background:var(--panel-raise);color:var(--text-3);border:1px solid var(--hair);font-weight:600;}
|
||||
|
||||
/* Tree body */
|
||||
.tree-body{flex:1;overflow-y:auto;padding:.15rem .55rem;}
|
||||
.tree-body::-webkit-scrollbar{width:6px;}
|
||||
.tree-body::-webkit-scrollbar-thumb{background:var(--text-4);border-radius:3px;opacity:.4;}
|
||||
|
||||
.node{display:flex;align-items:center;gap:.4rem;padding:.36rem .55rem;cursor:pointer;
|
||||
font-size:.74rem;color:var(--text-2);border-radius:7px;margin-bottom:1px;
|
||||
transition:all .15s;position:relative;user-select:none;}
|
||||
.node:hover{background:var(--sunk);color:var(--text);}
|
||||
.node.sel{background:var(--accent-soft);color:var(--text);box-shadow:inset 0 0 0 1px var(--accent-line);}
|
||||
.node.sel .name{color:var(--accent);font-weight:600;}
|
||||
.node .caret{width:12px;height:12px;display:inline-flex;align-items:center;justify-content:center;color:var(--text-3);flex-shrink:0;}
|
||||
.node .caret svg{width:8px;height:8px;transition:transform .18s cubic-bezier(.4,0,.2,1);}
|
||||
.node.open > .caret svg{transform:rotate(90deg);}
|
||||
.node.leaf .caret{opacity:0;}
|
||||
.node .dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;background:var(--text-4);}
|
||||
.node .dot.on{background:var(--green);box-shadow:0 0 0 2px rgba(22,163,74,.1);}
|
||||
html.dark .node .dot.on{background:var(--green);box-shadow:0 0 6px rgba(102,229,153,.3);}
|
||||
.node .name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;}
|
||||
.node .meta{font-size:.6rem;color:var(--text-3);font-family:'JetBrains Mono',monospace;
|
||||
opacity:0;transition:opacity .15s;flex-shrink:0;max-width:130px;overflow:hidden;text-overflow:ellipsis;}
|
||||
.node:hover .meta,.node.sel .meta{opacity:.75;}
|
||||
|
||||
.lvl-1{padding-left:.55rem;}
|
||||
.lvl-2{padding-left:1.55rem;}
|
||||
.lvl-3{padding-left:2.55rem;}
|
||||
|
||||
.subtree{display:none;}
|
||||
.subtree.open{display:block;}
|
||||
|
||||
/* Tree footer */
|
||||
.tree-ft{padding:.6rem .85rem;flex-shrink:0;border-top:1px solid var(--hair);}
|
||||
.tree-ft button{width:100%;height:32px;border-radius:8px;border:1px dashed var(--hair);background:transparent;
|
||||
color:var(--text-2);font-family:inherit;font-size:.72rem;font-weight:600;cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;gap:.4rem;transition:all .2s;letter-spacing:-.005em;}
|
||||
.tree-ft button:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-soft);border-style:solid;}
|
||||
.tree-ft button svg{width:12px;height:12px;}
|
||||
|
||||
/* ============ INSPECTOR PANE ============ */
|
||||
.insp{display:flex;flex-direction:column;min-width:0;overflow:hidden;
|
||||
background:var(--panel);border-radius:14px;border:1px solid var(--hair);
|
||||
box-shadow:var(--shadow-sm),var(--inner-hi);}
|
||||
|
||||
/* Inspector header */
|
||||
.insp-hd{padding:1.05rem 1.4rem 1rem;flex-shrink:0;position:relative;
|
||||
background:linear-gradient(180deg,var(--panel-raise),var(--panel));
|
||||
border-bottom:1px solid var(--hair);border-radius:14px 14px 0 0;}
|
||||
.insp-path{display:flex;align-items:center;gap:.35rem;font-size:.68rem;color:var(--text-3);margin-bottom:.4rem;}
|
||||
.insp-path svg{width:9px;height:9px;color:var(--text-4);}
|
||||
.insp-path .cur{color:var(--accent);font-weight:600;}
|
||||
.insp-title-row{display:flex;align-items:center;justify-content:space-between;gap:.5rem;}
|
||||
.insp-title{display:flex;align-items:center;gap:.6rem;}
|
||||
.insp-title h2{margin:0;font-size:1.15rem;font-weight:700;letter-spacing:-.022em;color:var(--text);}
|
||||
.tag{display:inline-flex;align-items:center;gap:.3rem;padding:.12rem .42rem;border-radius:5px;
|
||||
font-size:.58rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
|
||||
background:var(--sunk);color:var(--text-3);border:1px solid var(--hair);}
|
||||
.tag .td{width:5px;height:5px;border-radius:50%;background:var(--text-3);}
|
||||
.tag.on{color:var(--green);background:rgba(22,163,74,.07);border-color:rgba(22,163,74,.18);}
|
||||
.tag.on .td{background:var(--green);}
|
||||
html.dark .tag.on{background:rgba(102,229,153,.08);border-color:rgba(102,229,153,.18);}
|
||||
.tag.scope{color:var(--peach);background:rgba(231,124,84,.08);border-color:rgba(231,124,84,.2);}
|
||||
.tag.scope .td{background:var(--peach);}
|
||||
html.dark .tag.scope{background:rgba(255,154,115,.08);border-color:rgba(255,154,115,.2);}
|
||||
|
||||
.qck{display:flex;gap:.3rem;align-items:center;}
|
||||
.qck button{width:26px;height:26px;border-radius:6px;border:1px solid var(--hair);background:var(--panel);
|
||||
color:var(--text-3);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;}
|
||||
.qck button:hover{border-color:var(--text-3);color:var(--text);background:var(--panel-raise);}
|
||||
.qck button svg{width:12px;height:12px;}
|
||||
|
||||
/* Inspector body */
|
||||
.insp-body{flex:1;overflow-y:auto;padding:.25rem 1.4rem 1.5rem;}
|
||||
.insp-body::-webkit-scrollbar{width:8px;}
|
||||
.insp-body::-webkit-scrollbar-thumb{background:var(--text-4);border-radius:4px;opacity:.4;}
|
||||
|
||||
.sect{padding:1.25rem 0;border-bottom:1px solid var(--hair-2);}
|
||||
.sect:last-child{border-bottom:none;}
|
||||
.sect-head{display:flex;align-items:center;gap:.5rem;margin-bottom:.1rem;}
|
||||
.sect-ico{width:22px;height:22px;border-radius:6px;display:flex;align-items:center;justify-content:center;
|
||||
background:var(--accent-soft);color:var(--accent);flex-shrink:0;}
|
||||
.sect-ico svg{width:12px;height:12px;}
|
||||
.sect-hd{font-size:.8rem;font-weight:700;color:var(--text);letter-spacing:-.015em;}
|
||||
.sect-sub{font-size:.68rem;color:var(--text-3);margin:.1rem 0 .9rem 2.2rem;}
|
||||
|
||||
.row{display:grid;grid-template-columns:130px 1fr;gap:1rem;align-items:center;padding:.42rem 0;padding-left:2.2rem;}
|
||||
.row.top{align-items:flex-start;}
|
||||
.row > label{font-size:.7rem;color:var(--text-2);font-weight:500;padding-top:.45rem;}
|
||||
.row.top > label{padding-top:.5rem;}
|
||||
.row > label .req{color:var(--red);font-weight:700;}
|
||||
.row > .val{min-width:0;}
|
||||
|
||||
input.inp,select.inp,textarea.inp{width:100%;height:34px;padding:0 .75rem;
|
||||
border:1px solid var(--hair);border-radius:8px;
|
||||
background:var(--panel-raise);color:var(--text);font-size:.74rem;font-family:inherit;
|
||||
outline:none;transition:all .15s;}
|
||||
html.dark input.inp,html.dark select.inp,html.dark textarea.inp{background:var(--sunk);}
|
||||
input.inp::placeholder,textarea.inp::placeholder{color:var(--text-3);}
|
||||
input.inp:hover,select.inp:hover,textarea.inp:hover{border-color:var(--text-4);}
|
||||
input.inp:focus,select.inp:focus,textarea.inp:focus{border-color:var(--accent);background:var(--panel-raise);box-shadow:var(--ring);}
|
||||
input.inp[readonly]{background:var(--sunk);color:var(--text-3);cursor:default;}
|
||||
input.inp[readonly]:focus{box-shadow:none;border-color:var(--hair);}
|
||||
textarea.inp{height:auto;min-height:68px;padding:.5rem .75rem;resize:vertical;line-height:1.55;}
|
||||
.help{font-size:.66rem;color:var(--text-3);margin-top:.4rem;line-height:1.5;}
|
||||
|
||||
select.inp{padding-right:2rem;appearance:none;-webkit-appearance:none;
|
||||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238e8c9a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");
|
||||
background-repeat:no-repeat;background-position:right .6rem center;background-size:11px;}
|
||||
html.dark select.inp{background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%237a7689' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");}
|
||||
|
||||
/* Segmented */
|
||||
.seg{display:inline-flex;padding:3px;border-radius:9px;background:var(--sunk);width:fit-content;
|
||||
border:1px solid var(--hair);}
|
||||
.seg button{padding:.4rem .75rem;border:none;border-radius:6px;background:transparent;color:var(--text-3);
|
||||
font-family:inherit;font-size:.7rem;font-weight:600;cursor:pointer;transition:all .15s;
|
||||
display:inline-flex;align-items:center;gap:.35rem;letter-spacing:-.005em;}
|
||||
.seg button:hover{color:var(--text-2);}
|
||||
.seg button.on{background:var(--panel-raise);color:var(--text);box-shadow:var(--shadow-xs);}
|
||||
.seg button.on svg{color:var(--accent);}
|
||||
.seg button svg{width:13px;height:13px;}
|
||||
|
||||
/* Toggle */
|
||||
.tg{display:inline-flex;align-items:center;gap:.6rem;cursor:pointer;user-select:none;}
|
||||
.tg .sw{width:36px;height:20px;border-radius:999px;background:var(--text-4);position:relative;transition:background .25s;
|
||||
box-shadow:inset 0 1px 2px rgba(29,27,34,.1);}
|
||||
html.dark .tg .sw{background:var(--text-4);box-shadow:inset 0 1px 2px rgba(0,0,0,.4);}
|
||||
.tg .sw::after{content:'';position:absolute;width:16px;height:16px;border-radius:50%;background:#fff;
|
||||
top:2px;left:2px;transition:left .25s cubic-bezier(.4,0,.2,1);box-shadow:0 1px 2px rgba(0,0,0,.2),0 2px 4px rgba(0,0,0,.08);}
|
||||
.tg.on .sw{background:var(--accent);}
|
||||
.tg.on .sw::after{left:18px;}
|
||||
.tg .tg-l{font-size:.72rem;color:var(--text-2);font-weight:500;}
|
||||
.tg.on .tg-l{color:var(--text);font-weight:600;}
|
||||
|
||||
.mono{font-family:'JetBrains Mono',monospace;font-size:.68rem;color:var(--text-3);letter-spacing:0;}
|
||||
|
||||
/* Inspector footer */
|
||||
.insp-ft{display:flex;align-items:center;justify-content:space-between;gap:.5rem;
|
||||
padding:.85rem 1.4rem;flex-shrink:0;border-top:1px solid var(--hair);
|
||||
background:linear-gradient(180deg,var(--panel),var(--sunk));border-radius:0 0 14px 14px;}
|
||||
.insp-ft-l{display:flex;gap:.4rem;}
|
||||
.insp-ft-r{display:flex;gap:.4rem;align-items:center;}
|
||||
.unsaved{display:inline-flex;align-items:center;gap:.35rem;font-size:.66rem;color:var(--amber);font-weight:600;margin-right:.35rem;}
|
||||
.unsaved .d{width:6px;height:6px;border-radius:50%;background:var(--amber);
|
||||
animation:pulse 1.6s ease-in-out infinite;}
|
||||
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.5;transform:scale(.85)}}
|
||||
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:.35rem;
|
||||
height:32px;padding:0 .95rem;border-radius:8px;border:1px solid var(--hair);
|
||||
background:var(--panel-raise);color:var(--text-2);
|
||||
font-family:inherit;font-size:.72rem;font-weight:600;cursor:pointer;transition:all .18s;outline:none;
|
||||
box-shadow:var(--shadow-xs),var(--inner-hi);}
|
||||
.btn:hover{border-color:var(--text-3);color:var(--text);transform:translateY(-1px);box-shadow:var(--shadow-sm);}
|
||||
.btn:active{transform:translateY(0);}
|
||||
.btn svg{width:12px;height:12px;}
|
||||
.btn.primary{background:linear-gradient(180deg,var(--accent),#5a4cd1);color:white;border-color:transparent;
|
||||
box-shadow:0 2px 6px -1px rgba(108,92,231,.4),inset 0 1px 0 rgba(255,255,255,.25);}
|
||||
html.dark .btn.primary{background:linear-gradient(180deg,var(--accent),#8d78e8);
|
||||
box-shadow:0 2px 10px -1px rgba(169,145,255,.4),inset 0 1px 0 rgba(255,255,255,.15);}
|
||||
.btn.primary:hover{color:white;transform:translateY(-1px);
|
||||
box-shadow:0 4px 14px -2px rgba(108,92,231,.5),inset 0 1px 0 rgba(255,255,255,.25);}
|
||||
html.dark .btn.primary:hover{box-shadow:0 4px 16px -2px rgba(169,145,255,.5),inset 0 1px 0 rgba(255,255,255,.15);}
|
||||
.btn.danger{color:var(--red);}
|
||||
.btn.danger:hover{border-color:var(--red);background:rgba(220,38,38,.06);color:var(--red);}
|
||||
.btn.ghost{border-color:transparent;background:transparent;box-shadow:none;}
|
||||
.btn.ghost:hover{background:var(--sunk);border-color:var(--hair);color:var(--text);transform:none;box-shadow:none;}
|
||||
.btn.sm{height:28px;padding:0 .65rem;font-size:.66rem;}
|
||||
|
||||
@media(max-width:820px){
|
||||
.shell{padding:6px;gap:6px;}
|
||||
.main{grid-template-columns:1fr;}
|
||||
.tree-pane{max-height:42vh;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="shell">
|
||||
|
||||
<header class="hdr">
|
||||
<div class="hdr-l">
|
||||
<div class="logo-w">
|
||||
<div class="logo-m"></div>
|
||||
<div class="logo">INVYONE</div>
|
||||
</div>
|
||||
<div class="bc">
|
||||
<span>관리자</span><span class="sep">/</span><span>시스템</span><span class="sep">/</span><span class="cur">메뉴 관리</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hdr-r">
|
||||
<div class="kbd-hint"><span>검색</span><kbd>⌘K</kbd></div>
|
||||
<div class="tt">
|
||||
<button id="th-l" class="on" onclick="setTheme('light')">LIGHT</button>
|
||||
<button id="th-d" onclick="setTheme('dark')">DARK</button>
|
||||
</div>
|
||||
<div class="av">GP</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
|
||||
<aside class="tree-pane">
|
||||
<div class="tp-top">
|
||||
<div class="tp-ttl">
|
||||
<h3>메뉴</h3>
|
||||
<span class="cnt">· 55개</span>
|
||||
</div>
|
||||
<div class="tp-sub">스코프를 선택하고 트리에서 항목을 편집하세요</div>
|
||||
</div>
|
||||
|
||||
<div class="type-tabs">
|
||||
<button class="type-tab on" onclick="selType(this)">관리자 <span class="cnt">37</span></button>
|
||||
<button class="type-tab" onclick="selType(this)">사용자 <span class="cnt">18</span></button>
|
||||
</div>
|
||||
|
||||
<div class="tree-srch">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input placeholder="검색…"/>
|
||||
<span class="kb">⌘K</span>
|
||||
</div>
|
||||
|
||||
<div class="tree-body">
|
||||
|
||||
<div class="node lvl-1 open" onclick="toggleNode(this)">
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="dot on"></span>
|
||||
<span class="name">시스템</span>
|
||||
<span class="meta">10</span>
|
||||
</div>
|
||||
<div class="subtree open">
|
||||
<div class="node lvl-2 leaf sel" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">메뉴 관리</span><span class="meta">/admin/menu</span>
|
||||
</div>
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">사용자 관리</span><span class="meta">/admin/userMng</span>
|
||||
</div>
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot"></span>
|
||||
<span class="name">화면 관리</span><span class="meta">/admin/screenMng</span>
|
||||
</div>
|
||||
<div class="node lvl-2 open" onclick="toggleNode(this)">
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="dot on"></span>
|
||||
<span class="name">공통 코드</span><span class="meta">3</span>
|
||||
</div>
|
||||
<div class="subtree open">
|
||||
<div class="node lvl-3 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">코드 그룹</span><span class="meta">/admin/code/groups</span>
|
||||
</div>
|
||||
<div class="node lvl-3 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">코드 값</span><span class="meta">/admin/code/values</span>
|
||||
</div>
|
||||
<div class="node lvl-3 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot"></span>
|
||||
<span class="name">다국어</span><span class="meta">/admin/i18n</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="node lvl-1" onclick="toggleNode(this)">
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="dot on"></span>
|
||||
<span class="name">업무</span><span class="meta">12</span>
|
||||
</div>
|
||||
<div class="subtree">
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">수주</span><span class="meta">/biz/orders</span>
|
||||
</div>
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">생산</span><span class="meta">/biz/production</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="node lvl-1" onclick="toggleNode(this)">
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="dot on"></span>
|
||||
<span class="name">AI Assistant</span><span class="meta">5</span>
|
||||
</div>
|
||||
<div class="subtree">
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">대시보드</span><span class="meta">/ai/dashboard</span>
|
||||
</div>
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">채팅</span><span class="meta">/ai/chat</span>
|
||||
</div>
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot"></span>
|
||||
<span class="name">API 키</span><span class="meta">/ai/keys</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="node lvl-1" onclick="toggleNode(this)">
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="dot on"></span>
|
||||
<span class="name">리포트</span><span class="meta">7</span>
|
||||
</div>
|
||||
<div class="subtree">
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">생산 리포트</span><span class="meta">/report/production</span>
|
||||
</div>
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">품질 리포트</span><span class="meta">/report/quality</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tree-ft">
|
||||
<button>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
|
||||
새 메뉴 추가
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="insp">
|
||||
|
||||
<div class="insp-hd">
|
||||
<div class="insp-path">
|
||||
<span>관리자</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg>
|
||||
<span>시스템</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg>
|
||||
<span class="cur">메뉴 관리</span>
|
||||
</div>
|
||||
<div class="insp-title-row">
|
||||
<div class="insp-title">
|
||||
<h2>메뉴 관리</h2>
|
||||
<span class="tag on"><span class="td"></span>Active</span>
|
||||
<span class="tag scope"><span class="td"></span>공용 *</span>
|
||||
<span class="tag">L2</span>
|
||||
</div>
|
||||
<div class="qck">
|
||||
<button title="미리보기"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button title="공유"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg></button>
|
||||
<button title="히스토리"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="insp-body">
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-head">
|
||||
<span class="sect-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 7h16M4 12h16M4 17h10"/></svg></span>
|
||||
<span class="sect-hd">기본 정보</span>
|
||||
</div>
|
||||
<div class="sect-sub">메뉴 이름과 다국어 키, 설명을 입력합니다.</div>
|
||||
<div class="row">
|
||||
<label>메뉴명<span class="req">*</span></label>
|
||||
<div class="val"><input class="inp" value="메뉴 관리"/></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>다국어 키</label>
|
||||
<div class="val">
|
||||
<select class="inp">
|
||||
<option>menu.management</option>
|
||||
<option>user.management</option>
|
||||
<option>screen.management</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row top">
|
||||
<label>설명</label>
|
||||
<div class="val"><textarea class="inp">시스템 메뉴 트리를 관리하고 화면을 할당합니다</textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-head">
|
||||
<span class="sect-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg></span>
|
||||
<span class="sect-hd">연결</span>
|
||||
</div>
|
||||
<div class="sect-sub">메뉴 클릭 시 어떤 콘텐츠로 이동할지 결정합니다.</div>
|
||||
<div class="row">
|
||||
<label>유형</label>
|
||||
<div class="val">
|
||||
<div class="seg">
|
||||
<button class="on" onclick="selSeg(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3h18v18H3z"/><path d="M3 9h18M9 21V9"/></svg>
|
||||
화면
|
||||
</button>
|
||||
<button onclick="selSeg(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
|
||||
대시보드
|
||||
</button>
|
||||
<button onclick="selSeg(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2"/></svg>
|
||||
POP
|
||||
</button>
|
||||
<button onclick="selSeg(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
|
||||
URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>화면 / URL</label>
|
||||
<div class="val"><input class="inp" value="/admin/menu"/></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>아이콘</label>
|
||||
<div class="val"><input class="inp" value="Menu" placeholder="Lucide 아이콘 이름"/></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-head">
|
||||
<span class="sect-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="m2 17 10 5 10-5M2 12l10 5 10-5"/></svg></span>
|
||||
<span class="sect-hd">스코프 & 표시</span>
|
||||
</div>
|
||||
<div class="sect-sub">어느 회사에 보이고, 어느 위치에 나타날지 설정합니다.</div>
|
||||
<div class="row">
|
||||
<label>회사</label>
|
||||
<div class="val">
|
||||
<select class="inp">
|
||||
<option>공용 (*)</option>
|
||||
<option>덕일하이텍</option>
|
||||
<option>비엑스플로러</option>
|
||||
</select>
|
||||
<div class="help">하위 메뉴는 상위 스코프를 상속합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>부모</label>
|
||||
<div class="val"><input class="inp" value="시스템" readonly/></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>순서</label>
|
||||
<div class="val"><input class="inp" type="number" value="1" style="width:120px;"/></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>상태</label>
|
||||
<div class="val">
|
||||
<label class="tg on" onclick="this.classList.toggle('on')">
|
||||
<span class="sw"></span>
|
||||
<span class="tg-l">활성</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-head">
|
||||
<span class="sect-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></span>
|
||||
<span class="sect-hd">메타</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>OBJID</label>
|
||||
<div class="val"><span class="mono">mnu_00231</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>수정</label>
|
||||
<div class="val"><span class="mono">2026-04-12 14:23 · gbpark</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="insp-ft">
|
||||
<div class="insp-ft-l">
|
||||
<button class="btn danger sm">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
|
||||
삭제
|
||||
</button>
|
||||
<button class="btn ghost sm">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<div class="insp-ft-r">
|
||||
<span class="unsaved"><span class="d"></span>저장되지 않음</span>
|
||||
<button class="btn ghost">되돌리기</button>
|
||||
<button class="btn primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12l5 5L20 7"/></svg>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function setTheme(t){
|
||||
document.documentElement.classList.toggle('dark',t==='dark');
|
||||
document.getElementById('th-l').classList.toggle('on',t==='light');
|
||||
document.getElementById('th-d').classList.toggle('on',t==='dark');
|
||||
}
|
||||
function selType(el){el.parentElement.querySelectorAll('.type-tab').forEach(c=>c.classList.remove('on'));el.classList.add('on');}
|
||||
function selSeg(el){el.parentElement.querySelectorAll('button').forEach(c=>c.classList.remove('on'));el.classList.add('on');}
|
||||
function toggleNode(el){
|
||||
el.classList.toggle('open');
|
||||
const st=el.nextElementSibling;
|
||||
if(st && st.classList.contains('subtree'))st.classList.toggle('open');
|
||||
}
|
||||
function selNode(el,e){
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll('.node').forEach(n=>n.classList.remove('sel'));
|
||||
el.classList.add('sel');
|
||||
const name=el.querySelector('.name').textContent;
|
||||
const url=el.querySelector('.meta').textContent;
|
||||
document.querySelector('.insp-title h2').textContent=name;
|
||||
document.querySelector('.insp-path .cur').textContent=name;
|
||||
const inputs=document.querySelectorAll('.insp-body input.inp');
|
||||
if(inputs[0])inputs[0].value=name;
|
||||
if(inputs[2])inputs[2].value=url;
|
||||
}
|
||||
document.addEventListener('keydown',e=>{
|
||||
if((e.metaKey||e.ctrlKey)&&e.key==='k'){e.preventDefault();document.querySelector('.tree-srch input').focus();}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,990 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>INVYONE — 메뉴 관리 (C · Cascade, 심플 v3)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#ffffff;
|
||||
--text:#18181b;
|
||||
--text-2:#52525b;
|
||||
--text-3:#8e8c9a;
|
||||
--text-4:#c7c5d3;
|
||||
--border:#e6e4ec;
|
||||
--border-2:#efedf3;
|
||||
--sunk:#f6f5f9;
|
||||
--accent:#6c5ce7;
|
||||
--accent-soft:rgba(108,92,231,.08);
|
||||
--accent-line:rgba(108,92,231,.2);
|
||||
--green:#16a34a;
|
||||
--red:#dc2626;
|
||||
--amber:#d97706;
|
||||
--ring:rgba(108,92,231,.16);
|
||||
}
|
||||
html.dark{
|
||||
--bg:#0a0a12;
|
||||
--text:#f3f2fa;
|
||||
--text-2:#a5a3b8;
|
||||
--text-3:#787689;
|
||||
--text-4:#4a4859;
|
||||
--border:#1e1d28;
|
||||
--border-2:#161620;
|
||||
--sunk:#13131c;
|
||||
--accent:#a991ff;
|
||||
--accent-soft:rgba(169,145,255,.09);
|
||||
--accent-line:rgba(169,145,255,.22);
|
||||
--green:#66e599;
|
||||
--red:#ff6a7a;
|
||||
--amber:#f2b45e;
|
||||
--ring:rgba(169,145,255,.2);
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;height:100%;}
|
||||
body{
|
||||
font-family:'Inter','Noto Sans KR',-apple-system,system-ui,sans-serif;
|
||||
background:var(--bg);color:var(--text);font-size:.875rem;line-height:1.5;overflow:hidden;
|
||||
-webkit-font-smoothing:antialiased;font-feature-settings:"cv11","ss01";transition:background .2s;
|
||||
}
|
||||
.shell{height:100vh;display:flex;flex-direction:column;}
|
||||
|
||||
/* ===== HEADER ===== */
|
||||
.hdr{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:0 1rem;height:46px;flex-shrink:0;
|
||||
background:var(--bg);border-bottom:1px solid var(--border);}
|
||||
.hdr-l{display:flex;align-items:center;gap:1rem;min-width:0;}
|
||||
.logo{font-size:.88rem;font-weight:700;letter-spacing:-.025em;color:var(--accent);flex-shrink:0;}
|
||||
.path{display:flex;align-items:center;gap:.15rem;font-size:.73rem;min-width:0;overflow-x:auto;}
|
||||
.path::-webkit-scrollbar{display:none;}
|
||||
.path-seg{display:inline-flex;align-items:center;gap:.3rem;padding:.22rem .55rem;border-radius:5px;
|
||||
color:var(--text-3);white-space:nowrap;cursor:pointer;transition:all .12s;font-weight:500;}
|
||||
.path-seg:hover{background:var(--sunk);color:var(--text);}
|
||||
.path-seg.cur{color:var(--text);background:var(--accent-soft);font-weight:600;}
|
||||
.path-seg .step-n{font-family:'JetBrains Mono',monospace;font-size:.56rem;color:var(--text-3);font-weight:700;margin-right:.15rem;}
|
||||
.path-seg.cur .step-n{color:var(--accent);}
|
||||
.path-sep{color:var(--text-4);font-weight:300;flex-shrink:0;}
|
||||
|
||||
.hdr-r{display:flex;align-items:center;gap:.45rem;flex-shrink:0;}
|
||||
.ghost-btn{display:inline-flex;align-items:center;gap:.3rem;height:28px;padding:0 .65rem;border-radius:6px;
|
||||
border:1px solid var(--border);background:transparent;color:var(--text-2);font-family:inherit;
|
||||
font-size:.7rem;font-weight:500;cursor:pointer;transition:all .15s;}
|
||||
.ghost-btn:hover{border-color:var(--text-3);color:var(--text);}
|
||||
.ghost-btn svg{width:11px;height:11px;}
|
||||
.ghost-btn kbd{font-family:'JetBrains Mono',monospace;font-size:.56rem;padding:.05rem .28rem;border-radius:3px;
|
||||
background:var(--sunk);color:var(--text-3);border:1px solid var(--border);font-weight:600;margin-left:.25rem;}
|
||||
.tt{display:flex;padding:2px;border-radius:6px;background:var(--sunk);}
|
||||
.tt button{padding:.18rem .55rem;border-radius:4px;border:none;background:transparent;color:var(--text-3);
|
||||
font-family:inherit;font-size:.62rem;font-weight:600;cursor:pointer;letter-spacing:.02em;}
|
||||
.tt button.on{background:var(--bg);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.06);}
|
||||
html.dark .tt button.on{background:var(--sunk);box-shadow:0 1px 2px rgba(0,0,0,.3);}
|
||||
.av{width:26px;height:26px;border-radius:50%;background:var(--accent);color:white;
|
||||
display:flex;align-items:center;justify-content:center;font-size:.6rem;font-weight:700;}
|
||||
|
||||
/* ===== CASCADE ===== */
|
||||
.cascade{flex:1;min-height:0;display:flex;overflow-x:auto;overflow-y:hidden;scroll-behavior:smooth;}
|
||||
.cascade::-webkit-scrollbar{height:8px;}
|
||||
.cascade::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px;}
|
||||
|
||||
.col{display:flex;flex-direction:column;overflow:hidden;border-right:1px solid var(--border);background:var(--bg);
|
||||
animation:colIn .35s cubic-bezier(.16,1,.3,1);}
|
||||
@keyframes colIn{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}}
|
||||
|
||||
.col-hd{padding:1rem 1rem .6rem;flex-shrink:0;}
|
||||
.step-lbl{display:inline-flex;align-items:center;gap:.35rem;font-family:'JetBrains Mono',monospace;font-size:.56rem;
|
||||
color:var(--text-3);font-weight:700;letter-spacing:.1em;text-transform:uppercase;}
|
||||
.step-lbl .num{padding:.06rem .32rem;border-radius:3px;background:var(--sunk);border:1px solid var(--border);color:var(--accent);}
|
||||
.col-hd h3{margin:.3rem 0 .1rem;font-size:.95rem;font-weight:700;letter-spacing:-.018em;}
|
||||
.col-hd p{margin:0;font-size:.64rem;color:var(--text-3);}
|
||||
|
||||
/* ========= COL 1 : SCOPE — 큰 카드 세로 2개 (심플) ========= */
|
||||
.col-scope{width:280px;min-width:280px;background:var(--sunk);}
|
||||
.scope-list{padding:.3rem .6rem 1rem;display:flex;flex-direction:column;gap:.5rem;flex:1;overflow-y:auto;}
|
||||
.scope-card{display:flex;flex-direction:column;gap:.5rem;padding:.85rem;border-radius:10px;
|
||||
background:var(--bg);border:1px solid var(--border);cursor:pointer;transition:all .15s;}
|
||||
.scope-card:hover{border-color:var(--accent-line);}
|
||||
.scope-card.sel{background:var(--accent-soft);border-color:var(--accent);}
|
||||
.scope-top{display:flex;align-items:center;justify-content:space-between;}
|
||||
.scope-ico{width:30px;height:30px;border-radius:8px;display:flex;align-items:center;justify-content:center;
|
||||
background:var(--sunk);color:var(--text-2);border:1px solid var(--border-2);}
|
||||
.scope-card.sel .scope-ico{background:var(--accent);color:white;border-color:transparent;}
|
||||
.scope-ico svg{width:15px;height:15px;}
|
||||
.scope-cnt{font-size:1.1rem;font-weight:800;letter-spacing:-.03em;color:var(--text);font-variant-numeric:tabular-nums;}
|
||||
.scope-card.sel .scope-cnt{color:var(--accent);}
|
||||
.scope-name{font-size:.82rem;font-weight:700;letter-spacing:-.01em;color:var(--text);}
|
||||
.scope-desc{font-size:.62rem;color:var(--text-3);line-height:1.4;}
|
||||
|
||||
/* ========= COL 2 : TREE (L1 > L2 > L3 아코디언) ========= */
|
||||
.col-l1{width:400px;min-width:400px;background:var(--bg);}
|
||||
.col-l1 .col-hd{display:flex;align-items:flex-end;justify-content:space-between;gap:.5rem;}
|
||||
.col-l1 .col-hd > div:first-child{min-width:0;}
|
||||
.col-l1 .add-btn{width:28px;height:28px;border-radius:7px;border:1px solid var(--border);background:var(--bg);
|
||||
color:var(--text-2);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;flex-shrink:0;}
|
||||
.col-l1 .add-btn:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-soft);}
|
||||
.col-l1 .add-btn svg{width:12px;height:12px;}
|
||||
|
||||
.tree-srch-wrap{padding:.2rem .75rem .55rem;flex-shrink:0;position:relative;}
|
||||
.tree-srch-wrap svg.s{position:absolute;left:1.3rem;top:50%;transform:translateY(-50%);width:11px;height:11px;color:var(--text-3);pointer-events:none;}
|
||||
.tree-srch-wrap input{width:100%;height:28px;padding:0 .55rem 0 1.85rem;border:1px solid var(--border);border-radius:6px;
|
||||
background:var(--sunk);color:var(--text);font-size:.68rem;font-family:inherit;outline:none;transition:all .12s;}
|
||||
.tree-srch-wrap input:focus{border-color:var(--accent);background:var(--bg);box-shadow:0 0 0 2px var(--ring);}
|
||||
|
||||
.tree-list{padding:.15rem .4rem 1rem;flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||
|
||||
/* Tree node row */
|
||||
.t-row{display:flex;align-items:center;gap:.45rem;padding:.42rem .55rem;border-radius:6px;margin:1px .15rem;
|
||||
cursor:pointer;transition:all .12s;position:relative;user-select:none;}
|
||||
.t-row:hover{background:var(--sunk);}
|
||||
.t-row.sel{background:var(--accent-soft);}
|
||||
.t-row.sel .t-name{color:var(--accent);font-weight:600;}
|
||||
.t-caret{width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;color:var(--text-3);flex-shrink:0;border-radius:3px;transition:all .1s;}
|
||||
.t-caret:hover{background:var(--bg);color:var(--text);}
|
||||
.t-caret svg{width:9px;height:9px;transition:transform .18s cubic-bezier(.4,0,.2,1);}
|
||||
.t-row.open > .t-caret svg{transform:rotate(90deg);}
|
||||
.t-row.leaf .t-caret{opacity:0;pointer-events:none;}
|
||||
|
||||
.t-ico{width:22px;height:22px;border-radius:6px;display:flex;align-items:center;justify-content:center;
|
||||
background:var(--sunk);color:var(--text-2);flex-shrink:0;border:1px solid var(--border-2);}
|
||||
.t-row.sel .t-ico{background:var(--accent);color:white;border-color:transparent;}
|
||||
.t-ico svg{width:12px;height:12px;}
|
||||
|
||||
.t-body{flex:1;min-width:0;display:flex;flex-direction:column;}
|
||||
.t-name{font-size:.72rem;font-weight:500;color:var(--text);letter-spacing:-.005em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.t-meta{font-size:.56rem;color:var(--text-3);font-family:'JetBrains Mono',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:.05rem;}
|
||||
|
||||
.t-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0;background:var(--text-4);}
|
||||
.t-dot.on{background:var(--green);}
|
||||
|
||||
.t-cnt{font-size:.58rem;font-weight:700;color:var(--text-3);font-variant-numeric:tabular-nums;flex-shrink:0;
|
||||
padding:.08rem .4rem;border-radius:4px;background:var(--sunk);border:1px solid var(--border-2);}
|
||||
.t-row.sel .t-cnt{color:var(--accent);background:var(--bg);border-color:var(--accent-line);}
|
||||
|
||||
/* L1: 크게 강조 */
|
||||
.t-row.l1{padding:.8rem .85rem;border-radius:10px;background:var(--bg);border:1px solid var(--border);margin:.35rem .15rem .12rem;gap:.65rem;}
|
||||
.t-row.l1:hover{border-color:var(--accent-line);background:var(--accent-soft);}
|
||||
.t-row.l1.sel{border-color:var(--accent);background:var(--accent-soft);}
|
||||
.t-row.l1 .t-caret{width:16px;height:16px;}
|
||||
.t-row.l1 .t-caret svg{width:10px;height:10px;}
|
||||
.t-row.l1 .t-ico{width:34px;height:34px;border-radius:9px;}
|
||||
.t-row.l1 .t-ico svg{width:16px;height:16px;}
|
||||
.t-row.l1 .t-name{font-size:.88rem;font-weight:700;letter-spacing:-.015em;}
|
||||
.t-row.l1.sel .t-name{font-weight:700;color:var(--accent);}
|
||||
.t-row.l1 .t-cnt{font-size:.64rem;padding:.15rem .5rem;font-weight:700;}
|
||||
|
||||
/* L2,L3: 컴팩트 + connector line */
|
||||
.t-row.l2{padding-left:1.9rem;}
|
||||
.t-row.l3{padding-left:3rem;}
|
||||
.t-row.l2::before{content:'';position:absolute;left:1.3rem;top:0;bottom:0;width:1px;background:var(--border-2);}
|
||||
.t-row.l2::after{content:'';position:absolute;left:1.3rem;top:50%;width:.4rem;height:1px;background:var(--border);}
|
||||
.t-row.l3::before{content:'';position:absolute;left:2.4rem;top:0;bottom:0;width:1px;background:var(--border-2);}
|
||||
.t-row.l3::after{content:'';position:absolute;left:2.4rem;top:50%;width:.4rem;height:1px;background:var(--border);}
|
||||
/* 마지막 L2 밑 세로 라인 짧게 */
|
||||
.t-sub > .t-row:last-child.l2::before{bottom:50%;}
|
||||
.t-sub-inner > .t-row:last-child.l3::before{bottom:50%;}
|
||||
|
||||
.t-sub{display:none;}
|
||||
.t-row.open + .t-sub{display:block;animation:tsIn .22s cubic-bezier(.16,1,.3,1);}
|
||||
.t-sub-inner{display:none;}
|
||||
.t-row.open + .t-sub-inner{display:block;animation:tsIn .22s cubic-bezier(.16,1,.3,1);}
|
||||
@keyframes tsIn{from{opacity:0;transform:translateY(-2px)}to{opacity:1;transform:none}}
|
||||
|
||||
/* ========= COL 3 : TABBED VIEW (Tree / Overview / Activity) ========= */
|
||||
.col-preview{flex:1;min-width:460px;background:var(--sunk);display:flex;flex-direction:column;overflow:hidden;}
|
||||
.prev-wrap{padding:1.2rem 1.4rem 2rem;max-width:820px;flex:1;overflow-y:auto;}
|
||||
|
||||
/* View tabs — Col 3 최상단 */
|
||||
.view-tabs{display:flex;align-items:center;justify-content:space-between;gap:.5rem;
|
||||
padding:.65rem 1.4rem;border-bottom:1px solid var(--border);background:var(--bg);flex-shrink:0;}
|
||||
.vt-list{display:flex;padding:3px;border-radius:8px;background:var(--sunk);border:1px solid var(--border);}
|
||||
.vt-btn{display:inline-flex;align-items:center;gap:.35rem;padding:.35rem .75rem;border:none;border-radius:5px;
|
||||
background:transparent;color:var(--text-3);font-family:inherit;font-size:.7rem;font-weight:600;cursor:pointer;transition:all .12s;}
|
||||
.vt-btn:hover{color:var(--text-2);}
|
||||
.vt-btn.on{background:var(--bg);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.05);}
|
||||
html.dark .vt-btn.on{background:var(--bg);box-shadow:0 1px 3px rgba(0,0,0,.4);}
|
||||
.vt-btn svg{width:12px;height:12px;}
|
||||
.vt-btn .cnt{font-variant-numeric:tabular-nums;color:var(--text-3);font-size:.6rem;font-weight:500;margin-left:.15rem;}
|
||||
.vt-btn.on .cnt{color:var(--accent);}
|
||||
.vt-right{display:flex;align-items:center;gap:.35rem;}
|
||||
|
||||
.view-pane{display:none;}
|
||||
.view-pane.on{display:block;}
|
||||
|
||||
/* ===== SETTINGS VIEW ===== */
|
||||
.sv-hero{padding-bottom:1.2rem;margin-bottom:.6rem;border-bottom:1px solid var(--border);}
|
||||
.sv-hero-top{display:flex;align-items:center;gap:.8rem;}
|
||||
.sv-hero-ico{width:42px;height:42px;border-radius:11px;display:flex;align-items:center;justify-content:center;
|
||||
background:var(--accent);color:white;flex-shrink:0;box-shadow:0 2px 8px -2px rgba(108,92,231,.35);}
|
||||
html.dark .sv-hero-ico{color:#0a0a12;box-shadow:0 2px 10px -2px rgba(169,145,255,.35);}
|
||||
.sv-hero-ico svg{width:20px;height:20px;}
|
||||
.sv-hero-info{flex:1;min-width:0;}
|
||||
.sv-hero-path{font-size:.7rem;color:var(--text-3);margin-bottom:.2rem;}
|
||||
.sv-hero-path b{color:var(--text-2);font-weight:600;}
|
||||
.sv-hero-info h2{margin:0;font-size:1.4rem;font-weight:700;letter-spacing:-.025em;display:flex;align-items:center;gap:.55rem;}
|
||||
.sv-hero-tags{display:flex;gap:.35rem;}
|
||||
.sv-hero-meta{margin-top:.6rem;font-size:.66rem;color:var(--text-3);font-family:'JetBrains Mono',monospace;display:flex;gap:1.2rem;}
|
||||
.sv-hero-meta b{color:var(--text-2);font-weight:600;}
|
||||
|
||||
.sv-grid{display:grid;grid-template-columns:240px 1fr;gap:2rem;padding:1.5rem 0;border-bottom:1px solid var(--border-2);}
|
||||
.sv-grid:last-of-type{border-bottom:none;}
|
||||
.sv-side h4{margin:0 0 .35rem;font-size:.9rem;font-weight:700;letter-spacing:-.015em;color:var(--text);}
|
||||
.sv-side p{margin:0;font-size:.7rem;color:var(--text-3);line-height:1.55;}
|
||||
.sv-fields{display:flex;flex-direction:column;gap:.85rem;max-width:600px;}
|
||||
|
||||
.sv-row{display:flex;flex-direction:column;gap:.35rem;}
|
||||
.sv-row label{font-size:.68rem;color:var(--text-2);font-weight:600;letter-spacing:-.005em;}
|
||||
.sv-row label .req{color:var(--red);}
|
||||
.sv-row-2{display:grid;grid-template-columns:1fr 1fr;gap:.85rem;}
|
||||
.sv-row .help{font-size:.64rem;color:var(--text-3);line-height:1.5;}
|
||||
|
||||
.sv input.inp,.sv select.inp,.sv textarea.inp{width:100%;height:36px;padding:0 .85rem;
|
||||
border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);
|
||||
font-size:.78rem;font-family:inherit;outline:none;transition:all .15s;}
|
||||
html.dark .sv input.inp,html.dark .sv select.inp,html.dark .sv textarea.inp{background:var(--sunk);}
|
||||
.sv input.inp:focus,.sv select.inp:focus,.sv textarea.inp:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--ring);}
|
||||
.sv input.inp[readonly]{background:var(--sunk);color:var(--text-3);}
|
||||
.sv textarea.inp{height:auto;min-height:72px;padding:.55rem .85rem;resize:vertical;line-height:1.55;}
|
||||
.sv select.inp{padding-right:2rem;appearance:none;-webkit-appearance:none;
|
||||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238e8c9a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");
|
||||
background-repeat:no-repeat;background-position:right .65rem center;background-size:12px;}
|
||||
html.dark .sv select.inp{background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23787689' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");}
|
||||
|
||||
.sv-seg{display:inline-flex;padding:3px;border-radius:9px;background:var(--sunk);border:1px solid var(--border);width:fit-content;}
|
||||
.sv-seg button{padding:.45rem .9rem;border:none;border-radius:6px;background:transparent;color:var(--text-3);
|
||||
font-family:inherit;font-size:.72rem;font-weight:600;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:.35rem;}
|
||||
.sv-seg button.on{background:var(--bg);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.05);}
|
||||
html.dark .sv-seg button.on{background:var(--bg);box-shadow:0 1px 3px rgba(0,0,0,.4);}
|
||||
.sv-seg button.on svg{color:var(--accent);}
|
||||
.sv-seg button svg{width:13px;height:13px;}
|
||||
|
||||
.sv-tg{display:inline-flex;align-items:center;gap:.6rem;cursor:pointer;user-select:none;}
|
||||
.sv-tg .sw{width:38px;height:22px;border-radius:999px;background:var(--text-4);position:relative;transition:background .2s;
|
||||
box-shadow:inset 0 1px 2px rgba(0,0,0,.08);}
|
||||
.sv-tg .sw::after{content:'';position:absolute;width:18px;height:18px;border-radius:50%;background:#fff;top:2px;left:2px;
|
||||
transition:left .22s cubic-bezier(.4,0,.2,1);box-shadow:0 1px 2px rgba(0,0,0,.2);}
|
||||
.sv-tg.on .sw{background:var(--accent);}
|
||||
.sv-tg.on .sw::after{left:18px;}
|
||||
.sv-tg .tg-l{font-size:.76rem;color:var(--text-2);font-weight:500;}
|
||||
.sv-tg.on .tg-l{color:var(--text);font-weight:600;}
|
||||
|
||||
.sv-ft{display:flex;align-items:center;justify-content:space-between;gap:.5rem;
|
||||
padding:1.2rem 0 .5rem;margin-top:1rem;border-top:1px solid var(--border);}
|
||||
.sv-ft-l{display:flex;gap:.4rem;}
|
||||
.sv-ft-r{display:flex;gap:.4rem;align-items:center;}
|
||||
.sv-ft-r .unsaved{display:inline-flex;align-items:center;gap:.35rem;font-size:.68rem;color:var(--amber);font-weight:600;margin-right:.35rem;}
|
||||
.sv-ft-r .unsaved .d{width:6px;height:6px;border-radius:50%;background:var(--amber);animation:pulse 1.6s ease-in-out infinite;}
|
||||
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.5;transform:scale(.85)}}
|
||||
|
||||
.sv-btn{display:inline-flex;align-items:center;justify-content:center;gap:.35rem;
|
||||
height:34px;padding:0 1rem;border-radius:8px;border:1px solid var(--border);
|
||||
background:var(--bg);color:var(--text-2);font-family:inherit;font-size:.74rem;font-weight:600;
|
||||
cursor:pointer;transition:all .15s;outline:none;}
|
||||
.sv-btn:hover{border-color:var(--text-3);color:var(--text);}
|
||||
.sv-btn svg{width:13px;height:13px;}
|
||||
.sv-btn.primary{background:var(--accent);color:white;border-color:var(--accent);
|
||||
box-shadow:0 2px 6px -1px rgba(108,92,231,.35),inset 0 1px 0 rgba(255,255,255,.22);}
|
||||
.sv-btn.primary:hover{background:#5c4ed4;border-color:#5c4ed4;color:white;transform:translateY(-1px);
|
||||
box-shadow:0 4px 12px -2px rgba(108,92,231,.4),inset 0 1px 0 rgba(255,255,255,.22);}
|
||||
html.dark .sv-btn.primary{color:#0a0a12;box-shadow:0 2px 10px -1px rgba(169,145,255,.35);}
|
||||
html.dark .sv-btn.primary:hover{background:#b8b2ff;border-color:#b8b2ff;color:#0a0a12;}
|
||||
.sv-btn.danger{color:var(--red);}
|
||||
.sv-btn.danger:hover{border-color:var(--red);background:rgba(220,38,38,.05);color:var(--red);}
|
||||
|
||||
/* ===== TREE VIEW ===== */
|
||||
.tree-hero{margin-bottom:1rem;}
|
||||
.tree-hero h2{margin:0;font-size:1.2rem;font-weight:700;letter-spacing:-.022em;display:flex;align-items:baseline;gap:.5rem;}
|
||||
.tree-hero h2 .size{font-size:.72rem;color:var(--text-3);font-weight:500;}
|
||||
.tree-hero .sub{font-size:.66rem;color:var(--text-3);margin-top:.3rem;}
|
||||
|
||||
.tree-bar{display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.75rem;}
|
||||
.tree-bar-l{display:flex;gap:.35rem;}
|
||||
.chip{display:inline-flex;align-items:center;gap:.3rem;padding:.22rem .55rem;border-radius:5px;
|
||||
font-size:.62rem;font-weight:600;color:var(--text-2);background:var(--bg);border:1px solid var(--border);cursor:pointer;transition:all .12s;}
|
||||
.chip:hover{border-color:var(--text-3);color:var(--text);}
|
||||
.chip.on{background:var(--accent-soft);color:var(--accent);border-color:var(--accent-line);}
|
||||
.chip svg{width:11px;height:11px;}
|
||||
.tree-search{position:relative;}
|
||||
.tree-search svg{position:absolute;left:.55rem;top:50%;transform:translateY(-50%);width:11px;height:11px;color:var(--text-3);pointer-events:none;}
|
||||
.tree-search input{height:28px;width:200px;padding:0 .6rem 0 1.7rem;border:1px solid var(--border);border-radius:6px;
|
||||
background:var(--bg);color:var(--text);font-size:.68rem;font-family:inherit;outline:none;transition:all .12s;}
|
||||
.tree-search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--ring);}
|
||||
|
||||
.tree{background:var(--bg);border:1px solid var(--border);border-radius:11px;overflow:hidden;}
|
||||
.tree-head{display:grid;grid-template-columns:1fr 120px 80px 70px;gap:.5rem;padding:.5rem .9rem;
|
||||
border-bottom:1px solid var(--border);background:var(--sunk);
|
||||
font-size:.5rem;font-weight:800;color:var(--text-3);letter-spacing:.14em;text-transform:uppercase;}
|
||||
.tree-body{padding:.25rem .3rem .4rem;}
|
||||
|
||||
.tn{display:grid;grid-template-columns:1fr 120px 80px 70px;gap:.5rem;align-items:center;
|
||||
padding:.4rem .6rem;margin:1px .2rem;border-radius:6px;cursor:pointer;transition:all .12s;position:relative;font-size:.72rem;}
|
||||
.tn:hover{background:var(--sunk);}
|
||||
.tn.sel{background:var(--accent-soft);}
|
||||
.tn.sel .tn-name{color:var(--accent);font-weight:600;}
|
||||
.tn-name-wrap{display:flex;align-items:center;gap:.4rem;min-width:0;position:relative;}
|
||||
.tn-caret{width:14px;height:14px;display:inline-flex;align-items:center;justify-content:center;color:var(--text-3);flex-shrink:0;border-radius:3px;transition:all .12s;}
|
||||
.tn-caret:hover{background:var(--sunk);color:var(--text);}
|
||||
.tn-caret svg{width:9px;height:9px;transition:transform .18s cubic-bezier(.4,0,.2,1);}
|
||||
.tn.open > .tn-name-wrap > .tn-caret svg{transform:rotate(90deg);}
|
||||
.tn.leaf .tn-caret{opacity:0;}
|
||||
.tn-ico{width:20px;height:20px;border-radius:5px;display:flex;align-items:center;justify-content:center;
|
||||
background:var(--sunk);color:var(--text-2);flex-shrink:0;}
|
||||
.tn.sel .tn-ico{background:var(--accent);color:white;}
|
||||
.tn-ico svg{width:11px;height:11px;}
|
||||
.tn-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);font-weight:500;}
|
||||
.tn-url{font-family:'JetBrains Mono',monospace;font-size:.62rem;color:var(--text-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.tn-url.empty{color:var(--text-4);font-style:italic;}
|
||||
.tn-status{display:inline-flex;align-items:center;gap:.3rem;font-size:.58rem;color:var(--text-3);font-weight:600;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.tn-status .d{width:5px;height:5px;border-radius:50%;background:var(--text-4);}
|
||||
.tn-status.on{color:var(--green);}
|
||||
.tn-status.on .d{background:var(--green);}
|
||||
.tn-act{display:flex;gap:.15rem;justify-content:flex-end;opacity:0;transition:opacity .12s;}
|
||||
.tn:hover .tn-act,.tn.sel .tn-act{opacity:1;}
|
||||
.tn-act button{width:22px;height:22px;border-radius:4px;border:1px solid transparent;background:transparent;color:var(--text-3);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .12s;}
|
||||
.tn-act button:hover{border-color:var(--border);color:var(--text);background:var(--bg);}
|
||||
.tn-act button svg{width:11px;height:11px;}
|
||||
|
||||
/* indentation + connector lines */
|
||||
.tn.lvl-1{padding-left:.6rem;}
|
||||
.tn.lvl-2{padding-left:1.6rem;}
|
||||
.tn.lvl-3{padding-left:2.7rem;}
|
||||
.tn.lvl-2::before,.tn.lvl-3::before{content:'';position:absolute;top:0;bottom:0;width:1px;background:var(--border-2);}
|
||||
.tn.lvl-2::before{left:1rem;}
|
||||
.tn.lvl-3::before{left:2rem;}
|
||||
.tn.lvl-2::after,.tn.lvl-3::after{content:'';position:absolute;top:50%;width:.4rem;height:1px;background:var(--border);}
|
||||
.tn.lvl-2::after{left:1rem;}
|
||||
.tn.lvl-3::after{left:2rem;}
|
||||
|
||||
.tn-sub{display:none;}
|
||||
.tn.open + .tn-sub{display:block;animation:tnIn .2s cubic-bezier(.16,1,.3,1);}
|
||||
@keyframes tnIn{from{opacity:0;transform:translateY(-2px)}to{opacity:1;transform:none}}
|
||||
|
||||
.tree-foot{display:flex;align-items:center;justify-content:space-between;padding:.5rem .95rem;
|
||||
border-top:1px solid var(--border);font-size:.6rem;color:var(--text-3);background:var(--sunk);}
|
||||
.tree-foot b{color:var(--text-2);font-weight:600;}
|
||||
|
||||
/* Hero */
|
||||
.prev-hero{margin-bottom:1.2rem;}
|
||||
.prev-step{display:inline-flex;align-items:center;gap:.35rem;font-family:'JetBrains Mono',monospace;font-size:.56rem;
|
||||
color:var(--text-3);font-weight:700;letter-spacing:.1em;text-transform:uppercase;margin-bottom:.4rem;}
|
||||
.prev-step .num{padding:.06rem .32rem;border-radius:3px;background:var(--bg);border:1px solid var(--border);color:var(--accent);}
|
||||
.prev-title{display:flex;align-items:baseline;gap:.65rem;}
|
||||
.prev-title h2{margin:0;font-size:1.35rem;font-weight:700;letter-spacing:-.025em;}
|
||||
.prev-title .size{font-size:.72rem;color:var(--text-3);font-weight:500;}
|
||||
.prev-meta{display:flex;gap:1rem;margin-top:.55rem;font-size:.64rem;color:var(--text-3);}
|
||||
.prev-meta b{color:var(--text-2);font-weight:600;}
|
||||
|
||||
/* Stats row */
|
||||
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:.6rem;margin-bottom:1.2rem;}
|
||||
.stat-card{padding:.7rem .8rem;border-radius:9px;background:var(--bg);border:1px solid var(--border);}
|
||||
.stat-lbl{font-size:.52rem;font-weight:800;color:var(--text-3);letter-spacing:.12em;text-transform:uppercase;margin-bottom:.25rem;}
|
||||
.stat-val{display:flex;align-items:baseline;gap:.35rem;}
|
||||
.stat-val .n{font-size:1.25rem;font-weight:800;letter-spacing:-.03em;color:var(--text);font-variant-numeric:tabular-nums;}
|
||||
.stat-val .n.ok{color:var(--green);}
|
||||
.stat-val .n.off{color:var(--text-3);}
|
||||
.stat-val .u{font-size:.58rem;color:var(--text-3);font-weight:500;}
|
||||
|
||||
/* Preview card — 실제 사이드바 미리보기 */
|
||||
.preview-card{background:var(--bg);border:1px solid var(--border);border-radius:11px;overflow:hidden;}
|
||||
.preview-head{display:flex;align-items:center;justify-content:space-between;padding:.7rem .9rem;
|
||||
border-bottom:1px solid var(--border-2);background:var(--sunk);}
|
||||
.preview-head .lbl{font-size:.58rem;font-weight:800;color:var(--text-3);letter-spacing:.12em;text-transform:uppercase;}
|
||||
.preview-head .dots{display:flex;gap:4px;}
|
||||
.preview-head .dots span{width:8px;height:8px;border-radius:50%;background:var(--border);}
|
||||
.preview-body{padding:.6rem;display:flex;gap:1px;background:var(--border-2);min-height:320px;}
|
||||
|
||||
/* mock sidebar */
|
||||
.mock-side{width:160px;padding:.7rem .5rem;background:var(--bg);display:flex;flex-direction:column;gap:2px;}
|
||||
.mock-side-lbl{font-size:.48rem;font-weight:800;color:var(--text-3);letter-spacing:.15em;text-transform:uppercase;padding:.3rem .55rem .15rem;}
|
||||
.mock-item{display:flex;align-items:center;gap:.45rem;padding:.4rem .55rem;border-radius:5px;
|
||||
font-size:.66rem;color:var(--text-2);cursor:default;transition:background .1s;}
|
||||
.mock-item:hover{background:var(--sunk);}
|
||||
.mock-item.on{background:var(--accent-soft);color:var(--accent);font-weight:600;}
|
||||
.mock-item .mi{width:10px;height:10px;display:flex;align-items:center;justify-content:center;opacity:.7;}
|
||||
.mock-item .mi svg{width:10px;height:10px;}
|
||||
|
||||
/* mock content area */
|
||||
.mock-cnt{flex:1;background:var(--bg);display:flex;align-items:center;justify-content:center;padding:1rem;
|
||||
color:var(--text-4);font-size:.6rem;letter-spacing:.2em;text-transform:uppercase;font-weight:700;
|
||||
background-image:repeating-linear-gradient(45deg,var(--sunk) 0,var(--sunk) 1px,transparent 1px,transparent 8px);}
|
||||
|
||||
/* Activity list */
|
||||
.activity{margin-top:1.2rem;}
|
||||
.activity-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:.6rem;}
|
||||
.activity-hd h4{margin:0;font-size:.78rem;font-weight:700;letter-spacing:-.01em;}
|
||||
.activity-hd a{font-size:.64rem;color:var(--accent);text-decoration:none;font-weight:600;cursor:pointer;}
|
||||
.activity-hd a:hover{text-decoration:underline;}
|
||||
|
||||
.act-list{background:var(--bg);border:1px solid var(--border);border-radius:10px;overflow:hidden;}
|
||||
.act-row{display:flex;align-items:center;gap:.65rem;padding:.6rem .85rem;border-bottom:1px solid var(--border-2);font-size:.7rem;}
|
||||
.act-row:last-child{border-bottom:none;}
|
||||
.act-ico{width:22px;height:22px;border-radius:6px;display:flex;align-items:center;justify-content:center;
|
||||
background:var(--sunk);color:var(--text-2);border:1px solid var(--border-2);flex-shrink:0;}
|
||||
.act-ico svg{width:11px;height:11px;}
|
||||
.act-ico.edit{color:var(--accent);background:var(--accent-soft);border-color:var(--accent-line);}
|
||||
.act-ico.add{color:var(--green);background:rgba(22,163,74,.06);border-color:rgba(22,163,74,.2);}
|
||||
.act-ico.del{color:var(--red);background:rgba(220,38,38,.06);border-color:rgba(220,38,38,.2);}
|
||||
html.dark .act-ico.add{background:rgba(102,229,153,.06);border-color:rgba(102,229,153,.22);color:var(--green);}
|
||||
html.dark .act-ico.del{background:rgba(255,106,122,.06);border-color:rgba(255,106,122,.22);color:var(--red);}
|
||||
.act-body{flex:1;min-width:0;}
|
||||
.act-title{font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.act-title b{font-weight:600;}
|
||||
.act-meta{font-size:.58rem;color:var(--text-3);margin-top:.08rem;font-family:'JetBrains Mono',monospace;}
|
||||
.act-time{font-size:.58rem;color:var(--text-3);font-variant-numeric:tabular-nums;flex-shrink:0;}
|
||||
|
||||
@media(max-width:820px){
|
||||
.col-scope,.col-l1{width:88vw;min-width:88vw;}
|
||||
.col-preview{min-width:92vw;}
|
||||
.stats{grid-template-columns:1fr 1fr;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="shell">
|
||||
|
||||
<header class="hdr">
|
||||
<div class="hdr-l">
|
||||
<div class="logo">INVYONE</div>
|
||||
<div class="path">
|
||||
<span class="path-seg" data-col="c1"><span class="step-n">01</span>스코프</span>
|
||||
<span class="path-sep">/</span>
|
||||
<span class="path-seg" data-col="c2"><span class="step-n">02</span>관리자</span>
|
||||
<span class="path-sep">/</span>
|
||||
<span class="path-seg cur" data-col="c3"><span class="step-n">03</span>시스템 · 요약</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hdr-r">
|
||||
<button class="ghost-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
검색 <kbd>⌘K</kbd>
|
||||
</button>
|
||||
<div class="tt">
|
||||
<button id="th-l" class="on" onclick="setTheme('light')">LIGHT</button>
|
||||
<button id="th-d" onclick="setTheme('dark')">DARK</button>
|
||||
</div>
|
||||
<div class="av">GP</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="cascade">
|
||||
|
||||
<!-- ========= COL 1 : SCOPE ========= -->
|
||||
<section class="col col-scope" data-col="c1">
|
||||
<div class="col-hd">
|
||||
<div class="step-lbl"><span class="num">01</span>Scope</div>
|
||||
<h3>스코프</h3>
|
||||
<p>메뉴 타입을 선택하세요</p>
|
||||
</div>
|
||||
<div class="scope-list">
|
||||
<div class="scope-card sel" onclick="selCard(this,'.scope-card')">
|
||||
<div class="scope-top">
|
||||
<div class="scope-ico">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="m2 17 10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
</div>
|
||||
<div class="scope-cnt">37</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="scope-name">관리자</div>
|
||||
<div class="scope-desc">시스템·업무·리포트 관리 화면</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scope-card" onclick="selCard(this,'.scope-card')">
|
||||
<div class="scope-top">
|
||||
<div class="scope-ico">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
</div>
|
||||
<div class="scope-cnt">18</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="scope-name">사용자</div>
|
||||
<div class="scope-desc">최종 사용자 대시보드·업무</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========= COL 2 : TREE (L1 > L2 > L3 아코디언) ========= -->
|
||||
<section class="col col-l1" data-col="c2">
|
||||
<div class="col-hd">
|
||||
<div>
|
||||
<div class="step-lbl"><span class="num">02</span>Tree</div>
|
||||
<h3>관리자 메뉴</h3>
|
||||
<p>5개 · 37개 항목 · depth 3</p>
|
||||
</div>
|
||||
<button class="add-btn" title="새 카테고리">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 5v14M5 12h14"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tree-srch-wrap">
|
||||
<svg class="s" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input placeholder="트리 검색…"/>
|
||||
</div>
|
||||
|
||||
<div class="tree-list">
|
||||
|
||||
<!-- L1: 시스템 (열림) -->
|
||||
<div class="t-row l1 open sel" onclick="selTn2(this,event)">
|
||||
<span class="t-caret" onclick="toggleTn2(event,this)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="t-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg></span>
|
||||
<div class="t-body"><div class="t-name">시스템</div></div>
|
||||
<span class="t-cnt">10</span>
|
||||
</div>
|
||||
<div class="t-sub">
|
||||
<div class="t-row l2 leaf" onclick="selTn2(this,event)">
|
||||
<span class="t-caret"></span>
|
||||
<span class="t-dot on"></span>
|
||||
<div class="t-body"><div class="t-name">메뉴 관리</div><div class="t-meta">/admin/menu</div></div>
|
||||
</div>
|
||||
<div class="t-row l2 leaf" onclick="selTn2(this,event)">
|
||||
<span class="t-caret"></span>
|
||||
<span class="t-dot on"></span>
|
||||
<div class="t-body"><div class="t-name">사용자 관리</div><div class="t-meta">/admin/userMng</div></div>
|
||||
</div>
|
||||
<div class="t-row l2 leaf" onclick="selTn2(this,event)">
|
||||
<span class="t-caret"></span>
|
||||
<span class="t-dot"></span>
|
||||
<div class="t-body"><div class="t-name">화면 관리</div><div class="t-meta">/admin/screenMng</div></div>
|
||||
</div>
|
||||
<div class="t-row l2 open" onclick="selTn2(this,event)">
|
||||
<span class="t-caret" onclick="toggleTn2(event,this)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="t-dot on"></span>
|
||||
<div class="t-body"><div class="t-name">공통 코드</div><div class="t-meta">/admin/code</div></div>
|
||||
<span class="t-cnt">3</span>
|
||||
</div>
|
||||
<div class="t-sub-inner">
|
||||
<div class="t-row l3 leaf" onclick="selTn2(this,event)">
|
||||
<span class="t-caret"></span>
|
||||
<span class="t-dot on"></span>
|
||||
<div class="t-body"><div class="t-name">코드 그룹</div><div class="t-meta">/admin/code/groups</div></div>
|
||||
</div>
|
||||
<div class="t-row l3 leaf" onclick="selTn2(this,event)">
|
||||
<span class="t-caret"></span>
|
||||
<span class="t-dot on"></span>
|
||||
<div class="t-body"><div class="t-name">코드 값</div><div class="t-meta">/admin/code/values</div></div>
|
||||
</div>
|
||||
<div class="t-row l3 leaf" onclick="selTn2(this,event)">
|
||||
<span class="t-caret"></span>
|
||||
<span class="t-dot"></span>
|
||||
<div class="t-body"><div class="t-name">다국어</div><div class="t-meta">/admin/i18n</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-row l2 leaf" onclick="selTn2(this,event)">
|
||||
<span class="t-caret"></span>
|
||||
<span class="t-dot on"></span>
|
||||
<div class="t-body"><div class="t-name">권한</div><div class="t-meta">/admin/roles</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- L1: 업무 (닫힘) -->
|
||||
<div class="t-row l1" onclick="selTn2(this,event)">
|
||||
<span class="t-caret" onclick="toggleTn2(event,this)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="t-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="m2 17 10 5 10-5M2 12l10 5 10-5"/></svg></span>
|
||||
<div class="t-body"><div class="t-name">업무</div></div>
|
||||
<span class="t-cnt">12</span>
|
||||
</div>
|
||||
<div class="t-sub">
|
||||
<div class="t-row l2 leaf" onclick="selTn2(this,event)">
|
||||
<span class="t-caret"></span><span class="t-dot on"></span>
|
||||
<div class="t-body"><div class="t-name">수주</div><div class="t-meta">/biz/orders</div></div>
|
||||
</div>
|
||||
<div class="t-row l2 leaf" onclick="selTn2(this,event)">
|
||||
<span class="t-caret"></span><span class="t-dot on"></span>
|
||||
<div class="t-body"><div class="t-name">생산</div><div class="t-meta">/biz/production</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- L1: AI Assistant (닫힘) -->
|
||||
<div class="t-row l1" onclick="selTn2(this,event)">
|
||||
<span class="t-caret" onclick="toggleTn2(event,this)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="t-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="m18 9-5 5-4-4-3 3"/></svg></span>
|
||||
<div class="t-body"><div class="t-name">AI Assistant</div></div>
|
||||
<span class="t-cnt">5</span>
|
||||
</div>
|
||||
<div class="t-sub">
|
||||
<div class="t-row l2 leaf" onclick="selTn2(this,event)">
|
||||
<span class="t-caret"></span><span class="t-dot on"></span>
|
||||
<div class="t-body"><div class="t-name">대시보드</div><div class="t-meta">/ai/dashboard</div></div>
|
||||
</div>
|
||||
<div class="t-row l2 leaf" onclick="selTn2(this,event)">
|
||||
<span class="t-caret"></span><span class="t-dot on"></span>
|
||||
<div class="t-body"><div class="t-name">채팅</div><div class="t-meta">/ai/chat</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- L1: 리포트 (닫힘) -->
|
||||
<div class="t-row l1" onclick="selTn2(this,event)">
|
||||
<span class="t-caret" onclick="toggleTn2(event,this)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="t-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v4H4zM4 10h16v4H4zM4 16h16v4H4z"/></svg></span>
|
||||
<div class="t-body"><div class="t-name">리포트</div></div>
|
||||
<span class="t-cnt">7</span>
|
||||
</div>
|
||||
<div class="t-sub"></div>
|
||||
|
||||
<!-- L1: 자동화 (닫힘) -->
|
||||
<div class="t-row l1" onclick="selTn2(this,event)">
|
||||
<span class="t-caret" onclick="toggleTn2(event,this)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="t-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82 2 2 0 1 1-2.83 2.83 1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33 2 2 0 1 1-2.83-2.83 1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82 2 2 0 1 1 2.83-2.83 1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33 2 2 0 1 1 2.83 2.83 1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||
<div class="t-body"><div class="t-name">자동화</div></div>
|
||||
<span class="t-cnt">3</span>
|
||||
</div>
|
||||
<div class="t-sub"></div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========= COL 3 : TABBED VIEWS ========= -->
|
||||
<section class="col col-preview" data-col="c3">
|
||||
|
||||
<div class="view-tabs">
|
||||
<div class="vt-list">
|
||||
<button class="vt-btn on" data-view="overview" onclick="selView(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||
Overview
|
||||
</button>
|
||||
<button class="vt-btn" data-view="settings" onclick="selView(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Settings
|
||||
</button>
|
||||
<button class="vt-btn" data-view="activity" onclick="selView(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
Activity <span class="cnt">4</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="vt-right">
|
||||
<button class="ghost-btn" style="height:26px;font-size:.64rem;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
|
||||
메뉴 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prev-wrap">
|
||||
|
||||
|
||||
<!-- ========== OVERVIEW VIEW ========== -->
|
||||
<div class="view-pane on" data-view="overview">
|
||||
|
||||
<div class="prev-hero">
|
||||
<div class="prev-step"><span class="num">03</span>Overview</div>
|
||||
<div class="prev-title">
|
||||
<h2>시스템</h2>
|
||||
<span class="size">10개 항목</span>
|
||||
</div>
|
||||
<div class="prev-meta">
|
||||
<span>경로 <b>관리자 / 시스템</b></span>
|
||||
<span>스코프 <b>공용 *</b></span>
|
||||
<span>최근 수정 <b>2일 전</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STATS -->
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-lbl">전체</div>
|
||||
<div class="stat-val"><span class="n">10</span><span class="u">items</span></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-lbl">활성</div>
|
||||
<div class="stat-val"><span class="n ok">8</span><span class="u">/ 10</span></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-lbl">비활성</div>
|
||||
<div class="stat-val"><span class="n off">2</span><span class="u">disabled</span></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-lbl">화면 연결</div>
|
||||
<div class="stat-val"><span class="n">7</span><span class="u">screens</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PREVIEW -->
|
||||
<div class="preview-card">
|
||||
<div class="preview-head">
|
||||
<span class="lbl">Sidebar Preview</span>
|
||||
<div class="dots"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
<div class="preview-body">
|
||||
<div class="mock-side">
|
||||
<div class="mock-side-lbl">시스템</div>
|
||||
<div class="mock-item on">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h10"/></svg></span>
|
||||
메뉴 관리
|
||||
</div>
|
||||
<div class="mock-item">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>
|
||||
사용자 관리
|
||||
</div>
|
||||
<div class="mock-item">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3h18v18H3z"/><path d="M3 9h18M9 21V9"/></svg></span>
|
||||
화면 관리
|
||||
</div>
|
||||
<div class="mock-item">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M8 2v4M16 2v4M3 10h18"/></svg></span>
|
||||
공통 코드
|
||||
</div>
|
||||
<div class="mock-item">
|
||||
<span class="mi"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
권한
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock-cnt">PREVIEW AREA</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /overview view -->
|
||||
|
||||
<!-- ========== SETTINGS VIEW ========== -->
|
||||
<div class="view-pane sv" data-view="settings">
|
||||
<div class="sv-hero">
|
||||
<div class="sv-hero-top">
|
||||
<div class="sv-hero-ico">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
||||
</div>
|
||||
<div class="sv-hero-info">
|
||||
<div class="sv-hero-path">관리자 · 시스템 · <b>메뉴 관리</b></div>
|
||||
<h2>
|
||||
<span id="sv-name">메뉴 관리</span>
|
||||
<span class="sv-hero-tags">
|
||||
<span class="chip on">Active</span>
|
||||
<span class="chip">공용 *</span>
|
||||
<span class="chip">L2</span>
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sv-hero-meta">
|
||||
<span>OBJID <b>mnu_00231</b></span>
|
||||
<span>최근 수정 <b>2026-04-12 14:23 · gbpark</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sv-grid">
|
||||
<div class="sv-side">
|
||||
<h4>기본 정보</h4>
|
||||
<p>메뉴 이름, 다국어 키, 사용자에게 표시되는 설명을 설정합니다.</p>
|
||||
</div>
|
||||
<div class="sv-fields">
|
||||
<div class="sv-row">
|
||||
<label>메뉴명<span class="req">*</span></label>
|
||||
<input class="inp" id="sv-inp-name" value="메뉴 관리"/>
|
||||
</div>
|
||||
<div class="sv-row-2">
|
||||
<div class="sv-row">
|
||||
<label>다국어 키</label>
|
||||
<select class="inp">
|
||||
<option>menu.management</option>
|
||||
<option>user.management</option>
|
||||
<option>screen.management</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sv-row">
|
||||
<label>아이콘</label>
|
||||
<input class="inp" value="Menu" placeholder="Lucide 아이콘명"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sv-row">
|
||||
<label>설명</label>
|
||||
<textarea class="inp">시스템 메뉴 트리를 관리하고 화면을 할당합니다</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sv-grid">
|
||||
<div class="sv-side">
|
||||
<h4>연결</h4>
|
||||
<p>메뉴 클릭 시 어떤 콘텐츠로 이동할지 결정합니다.</p>
|
||||
</div>
|
||||
<div class="sv-fields">
|
||||
<div class="sv-row">
|
||||
<label>유형</label>
|
||||
<div class="sv-seg">
|
||||
<button class="on" onclick="selSvSeg(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3h18v18H3z"/><path d="M3 9h18M9 21V9"/></svg>
|
||||
화면
|
||||
</button>
|
||||
<button onclick="selSvSeg(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
|
||||
대시보드
|
||||
</button>
|
||||
<button onclick="selSvSeg(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2"/></svg>
|
||||
POP
|
||||
</button>
|
||||
<button onclick="selSvSeg(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
|
||||
URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sv-row">
|
||||
<label>화면 / URL</label>
|
||||
<input class="inp" id="sv-inp-url" value="/admin/menu"/>
|
||||
<div class="help">화면 검색 또는 직접 URL 입력. 유형이 URL일 때만 외부 링크로 동작합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sv-grid">
|
||||
<div class="sv-side">
|
||||
<h4>스코프 & 표시</h4>
|
||||
<p>어느 회사에서 보이고 트리 상 어디에 위치할지 결정합니다.</p>
|
||||
</div>
|
||||
<div class="sv-fields">
|
||||
<div class="sv-row-2">
|
||||
<div class="sv-row">
|
||||
<label>회사</label>
|
||||
<select class="inp">
|
||||
<option>공용 (*)</option>
|
||||
<option>덕일하이텍</option>
|
||||
<option>비엑스플로러</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sv-row">
|
||||
<label>부모</label>
|
||||
<input class="inp" value="시스템" readonly/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sv-row-2">
|
||||
<div class="sv-row">
|
||||
<label>순서</label>
|
||||
<input class="inp" type="number" value="1"/>
|
||||
</div>
|
||||
<div class="sv-row">
|
||||
<label>상태</label>
|
||||
<label class="sv-tg on" onclick="this.classList.toggle('on')" style="height:36px;padding:0 .85rem;border:1px solid var(--border);border-radius:8px;background:var(--bg);">
|
||||
<span class="sw"></span>
|
||||
<span class="tg-l">활성</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sv-ft">
|
||||
<div class="sv-ft-l">
|
||||
<button class="sv-btn danger">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
|
||||
삭제
|
||||
</button>
|
||||
<button class="sv-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<div class="sv-ft-r">
|
||||
<span class="unsaved"><span class="d"></span>저장되지 않음</span>
|
||||
<button class="sv-btn">되돌리기</button>
|
||||
<button class="sv-btn primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12l5 5L20 7"/></svg>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== ACTIVITY VIEW ========== -->
|
||||
<div class="view-pane" data-view="activity">
|
||||
<div class="tree-hero">
|
||||
<h2>활동 로그 <span class="size">· 최근 4건</span></h2>
|
||||
<div class="sub">이 카테고리에서 최근 발생한 변경 이력입니다.</div>
|
||||
</div>
|
||||
<div class="act-list">
|
||||
<div class="act-row">
|
||||
<div class="act-ico edit"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="m18.5 2.5 3 3L12 15l-4 1 1-4z"/></svg></div>
|
||||
<div class="act-body">
|
||||
<div class="act-title"><b>gbpark</b> 메뉴명 수정 · <b>메뉴 관리</b></div>
|
||||
<div class="act-meta">mnu_00231 · 메뉴관리 → 메뉴 관리</div>
|
||||
</div>
|
||||
<span class="act-time">2일 전</span>
|
||||
</div>
|
||||
<div class="act-row">
|
||||
<div class="act-ico add"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 5v14M5 12h14"/></svg></div>
|
||||
<div class="act-body">
|
||||
<div class="act-title"><b>gbpark</b> 하위 메뉴 추가 · <b>공통 코드 / 다국어</b></div>
|
||||
<div class="act-meta">mnu_00248 · /admin/i18n</div>
|
||||
</div>
|
||||
<span class="act-time">4일 전</span>
|
||||
</div>
|
||||
<div class="act-row">
|
||||
<div class="act-ico edit"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="m18.5 2.5 3 3L12 15l-4 1 1-4z"/></svg></div>
|
||||
<div class="act-body">
|
||||
<div class="act-title"><b>park</b> URL 변경 · <b>화면 관리</b></div>
|
||||
<div class="act-meta">/admin/screen → /admin/screenMng</div>
|
||||
</div>
|
||||
<span class="act-time">6일 전</span>
|
||||
</div>
|
||||
<div class="act-row">
|
||||
<div class="act-ico del"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></div>
|
||||
<div class="act-body">
|
||||
<div class="act-title"><b>gbpark</b> 메뉴 비활성화 · <b>테스트</b></div>
|
||||
<div class="act-meta">mnu_00197 · status: inactive</div>
|
||||
</div>
|
||||
<span class="act-time">1주 전</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function setTheme(t){
|
||||
document.documentElement.classList.toggle('dark',t==='dark');
|
||||
document.getElementById('th-l').classList.toggle('on',t==='light');
|
||||
document.getElementById('th-d').classList.toggle('on',t==='dark');
|
||||
}
|
||||
function selCard(el,selector){
|
||||
el.parentElement.querySelectorAll(selector).forEach(r=>r.classList.remove('sel'));
|
||||
el.classList.add('sel');
|
||||
}
|
||||
function selView(btn){
|
||||
const v=btn.dataset.view;
|
||||
document.querySelectorAll('.vt-btn').forEach(b=>b.classList.remove('on'));
|
||||
btn.classList.add('on');
|
||||
document.querySelectorAll('.view-pane').forEach(p=>p.classList.toggle('on',p.dataset.view===v));
|
||||
}
|
||||
function selTn(el){
|
||||
document.querySelectorAll('.tn').forEach(t=>t.classList.remove('sel'));
|
||||
el.classList.add('sel');
|
||||
}
|
||||
function toggleTn(e,el){
|
||||
e.stopPropagation();
|
||||
const tn=el.closest('.tn');
|
||||
tn.classList.toggle('open');
|
||||
}
|
||||
function selTn2(el,e){
|
||||
if(e)e.stopPropagation();
|
||||
document.querySelectorAll('.t-row').forEach(t=>t.classList.remove('sel'));
|
||||
el.classList.add('sel');
|
||||
const isL2L3=el.classList.contains('l2')||el.classList.contains('l3');
|
||||
const name=el.querySelector('.t-name')?.textContent||'';
|
||||
const url=el.querySelector('.t-meta')?.textContent||'';
|
||||
if(isL2L3){
|
||||
// 자동으로 Settings 뷰 전환 + 필드 갱신
|
||||
const svBtn=document.querySelector('.vt-btn[data-view="settings"]');
|
||||
if(svBtn)selView(svBtn);
|
||||
const svName=document.getElementById('sv-name');if(svName)svName.textContent=name;
|
||||
const svIn=document.getElementById('sv-inp-name');if(svIn)svIn.value=name;
|
||||
const svUrl=document.getElementById('sv-inp-url');if(svUrl&&url)svUrl.value=url;
|
||||
const path=document.querySelector('.sv-hero-path');
|
||||
if(path)path.innerHTML='관리자 · 시스템 · <b>'+name+'</b>';
|
||||
}else{
|
||||
// L1 선택 시 Overview
|
||||
const ovBtn=document.querySelector('.vt-btn[data-view="overview"]');
|
||||
if(ovBtn)selView(ovBtn);
|
||||
}
|
||||
}
|
||||
function toggleTn2(e,el){
|
||||
e.stopPropagation();
|
||||
el.closest('.t-row').classList.toggle('open');
|
||||
}
|
||||
function selSvSeg(el){el.parentElement.querySelectorAll('button').forEach(b=>b.classList.remove('on'));el.classList.add('on');}
|
||||
document.querySelectorAll('.path-seg[data-col]').forEach(s=>s.addEventListener('click',()=>{
|
||||
const el=document.querySelector('.col[data-col="'+s.dataset.col+'"]');
|
||||
if(el)el.scrollIntoView({behavior:'smooth',inline:'start',block:'nearest'});
|
||||
}));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,588 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>INVYONE — 메뉴 관리</title>
|
||||
<style>
|
||||
/* ========== TOKENS — 단순 무채색 + accent 1 ========== */
|
||||
:root{
|
||||
--bg:#ffffff;
|
||||
--panel:#ffffff;
|
||||
--panel-2:#fafafa;
|
||||
--sunk:#f5f5f7;
|
||||
--text:#18181b;
|
||||
--text-2:#52525b;
|
||||
--text-3:#a1a1aa;
|
||||
--text-4:#d4d4d8;
|
||||
--border:#e4e4e7;
|
||||
--border-2:#ededef;
|
||||
--accent:#6c5ce7;
|
||||
--accent-soft:#f3f1fe;
|
||||
--accent-line:#d9d3fa;
|
||||
--green:#16a34a;
|
||||
--red:#dc2626;
|
||||
--amber:#d97706;
|
||||
--ring:rgba(108,92,231,.18);
|
||||
}
|
||||
html.dark{
|
||||
--bg:#0b0b0f;
|
||||
--panel:#111114;
|
||||
--panel-2:#16161c;
|
||||
--sunk:#1a1a21;
|
||||
--text:#f4f4f5;
|
||||
--text-2:#a1a1aa;
|
||||
--text-3:#71717a;
|
||||
--text-4:#52525b;
|
||||
--border:#24242b;
|
||||
--border-2:#1e1e25;
|
||||
--accent:#a29bfe;
|
||||
--accent-soft:rgba(162,155,254,.08);
|
||||
--accent-line:rgba(162,155,254,.2);
|
||||
--green:#4ade80;
|
||||
--red:#ef4444;
|
||||
--amber:#f59e0b;
|
||||
--ring:rgba(162,155,254,.22);
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;height:100%;}
|
||||
body{
|
||||
font-family:'Inter','Noto Sans KR',-apple-system,system-ui,sans-serif;
|
||||
background:var(--bg);color:var(--text);
|
||||
font-size:.8125rem;line-height:1.5;overflow:hidden;
|
||||
transition:background .2s,color .2s;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
font-feature-settings:"cv11","ss01";
|
||||
}
|
||||
|
||||
/* ===== SHELL ===== */
|
||||
.shell{height:100vh;display:flex;flex-direction:column;}
|
||||
|
||||
/* ===== HEADER ===== */
|
||||
.hdr{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:0 1.25rem;height:48px;flex-shrink:0;
|
||||
border-bottom:1px solid var(--border);background:var(--panel);}
|
||||
.hdr-l{display:flex;align-items:center;gap:1rem;}
|
||||
.logo{font-size:.85rem;font-weight:700;letter-spacing:-.02em;color:var(--accent);}
|
||||
.bc{font-size:.75rem;color:var(--text-3);display:flex;align-items:center;gap:.4rem;}
|
||||
.bc .sep{color:var(--text-4);}
|
||||
.bc .cur{color:var(--text);font-weight:500;}
|
||||
.hdr-r{display:flex;align-items:center;gap:.5rem;}
|
||||
.tt{display:flex;padding:2px;border-radius:6px;background:var(--sunk);}
|
||||
.tt button{padding:.2rem .55rem;border-radius:4px;border:none;background:transparent;color:var(--text-3);
|
||||
font-family:inherit;font-size:.7rem;font-weight:500;cursor:pointer;}
|
||||
.tt button.on{background:var(--panel);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.06);}
|
||||
html.dark .tt button.on{background:var(--panel-2);box-shadow:0 1px 2px rgba(0,0,0,.3);}
|
||||
.av{width:26px;height:26px;border-radius:50%;background:var(--accent);color:white;
|
||||
display:flex;align-items:center;justify-content:center;font-size:.62rem;font-weight:600;}
|
||||
|
||||
/* ===== PAGE BAR ===== */
|
||||
.pb{padding:1.5rem 1.75rem 1.1rem;flex-shrink:0;display:flex;align-items:baseline;justify-content:space-between;gap:1rem;}
|
||||
.pb h1{margin:0;font-size:1.05rem;font-weight:600;letter-spacing:-.015em;color:var(--text);}
|
||||
.pb .sub{font-size:.75rem;color:var(--text-3);margin-top:.2rem;}
|
||||
|
||||
/* ===== MAIN ===== */
|
||||
.main{flex:1;min-height:0;display:grid;grid-template-columns:300px 1fr;}
|
||||
|
||||
/* ======== TREE PANE ======== */
|
||||
.tree-pane{display:flex;flex-direction:column;min-width:0;border-right:1px solid var(--border);background:var(--panel);}
|
||||
|
||||
/* Type tabs — 상단 구분 없는 간결한 세그먼트 */
|
||||
.type{padding:.75rem 1rem .65rem;}
|
||||
.type-tabs{display:flex;gap:2px;padding:2px;border-radius:6px;background:var(--sunk);}
|
||||
.type-tab{flex:1;padding:.4rem .5rem;border-radius:4px;border:none;background:transparent;
|
||||
font-family:inherit;font-size:.72rem;font-weight:500;color:var(--text-3);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;gap:.4rem;transition:color .15s;}
|
||||
.type-tab:hover{color:var(--text-2);}
|
||||
.type-tab.on{color:var(--text);background:var(--panel);box-shadow:0 1px 2px rgba(0,0,0,.05);font-weight:600;}
|
||||
html.dark .type-tab.on{background:var(--panel-2);}
|
||||
.type-tab .cnt{color:var(--text-3);font-variant-numeric:tabular-nums;font-size:.66rem;}
|
||||
.type-tab.on .cnt{color:var(--text-2);}
|
||||
|
||||
/* Search */
|
||||
.tree-tb{padding:0 1rem .75rem;flex-shrink:0;}
|
||||
.tree-srch{position:relative;}
|
||||
.tree-srch svg{position:absolute;left:.6rem;top:50%;transform:translateY(-50%);width:13px;height:13px;color:var(--text-3);pointer-events:none;}
|
||||
.tree-srch input{width:100%;height:30px;padding:0 2.3rem 0 1.85rem;
|
||||
border:1px solid var(--border);border-radius:6px;background:var(--panel);color:var(--text);
|
||||
font-size:.73rem;font-family:inherit;outline:none;transition:border .15s,box-shadow .15s;}
|
||||
.tree-srch input::placeholder{color:var(--text-3);}
|
||||
.tree-srch input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--ring);}
|
||||
.tree-srch .kb{position:absolute;right:.45rem;top:50%;transform:translateY(-50%);
|
||||
font-family:'JetBrains Mono',monospace;font-size:.55rem;padding:.08rem .3rem;
|
||||
border-radius:3px;background:var(--sunk);color:var(--text-3);border:1px solid var(--border-2);}
|
||||
|
||||
/* Tree body */
|
||||
.tree-body{flex:1;overflow-y:auto;padding:.25rem .5rem 1rem;}
|
||||
.tree-body::-webkit-scrollbar{width:8px;}
|
||||
.tree-body::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px;}
|
||||
|
||||
.node{display:flex;align-items:center;gap:.4rem;padding:.32rem .5rem;cursor:pointer;
|
||||
font-size:.74rem;color:var(--text-2);border-radius:5px;margin-bottom:1px;
|
||||
transition:background .1s;position:relative;user-select:none;}
|
||||
.node:hover{background:var(--sunk);color:var(--text);}
|
||||
.node.sel{background:var(--accent-soft);color:var(--text);}
|
||||
.node.sel .name{color:var(--accent);font-weight:600;}
|
||||
html.dark .node.sel{background:var(--accent-soft);}
|
||||
html.dark .node.sel .name{color:var(--accent);}
|
||||
.node .caret{width:12px;height:12px;display:inline-flex;align-items:center;justify-content:center;color:var(--text-3);flex-shrink:0;}
|
||||
.node .caret svg{width:8px;height:8px;transition:transform .15s;}
|
||||
.node.open > .caret svg{transform:rotate(90deg);}
|
||||
.node.leaf .caret{opacity:0;}
|
||||
.node .dot{width:5px;height:5px;border-radius:50%;flex-shrink:0;background:var(--text-4);}
|
||||
.node .dot.on{background:var(--green);}
|
||||
.node .name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;}
|
||||
.node .meta{font-size:.62rem;color:var(--text-3);font-family:'JetBrains Mono',monospace;
|
||||
opacity:0;transition:opacity .15s;flex-shrink:0;}
|
||||
.node:hover .meta,.node.sel .meta{opacity:1;}
|
||||
|
||||
/* indent */
|
||||
.lvl-1{padding-left:.55rem;}
|
||||
.lvl-2{padding-left:1.5rem;}
|
||||
.lvl-3{padding-left:2.5rem;}
|
||||
|
||||
.subtree{display:none;}
|
||||
.subtree.open{display:block;}
|
||||
|
||||
/* Tree footer */
|
||||
.tree-ft{padding:.6rem 1rem .7rem;border-top:1px solid var(--border);flex-shrink:0;}
|
||||
.tree-ft button{width:100%;height:30px;border-radius:6px;border:1px solid var(--border);background:transparent;
|
||||
color:var(--text-2);font-family:inherit;font-size:.72rem;font-weight:500;cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;gap:.4rem;transition:all .15s;}
|
||||
.tree-ft button:hover{border-color:var(--accent);color:var(--accent);}
|
||||
.tree-ft button svg{width:12px;height:12px;}
|
||||
|
||||
/* ========= INSPECTOR ========= */
|
||||
.insp{display:flex;flex-direction:column;min-width:0;overflow:hidden;background:var(--bg);}
|
||||
|
||||
.insp-hd{padding:1.3rem 1.75rem 1.1rem;flex-shrink:0;border-bottom:1px solid var(--border);}
|
||||
.insp-path{display:flex;align-items:center;gap:.35rem;font-size:.7rem;color:var(--text-3);margin-bottom:.35rem;}
|
||||
.insp-path svg{width:9px;height:9px;color:var(--text-4);}
|
||||
.insp-path .cur{color:var(--text-2);font-weight:500;}
|
||||
.insp-title{display:flex;align-items:center;gap:.65rem;}
|
||||
.insp-title h2{margin:0;font-size:1.1rem;font-weight:600;letter-spacing:-.02em;color:var(--text);}
|
||||
.tag{display:inline-flex;align-items:center;gap:.3rem;padding:.12rem .4rem;border-radius:4px;
|
||||
font-size:.62rem;font-weight:500;
|
||||
background:var(--sunk);color:var(--text-3);border:1px solid var(--border-2);
|
||||
font-family:'JetBrains Mono',monospace;letter-spacing:0;}
|
||||
.tag .td{width:5px;height:5px;border-radius:50%;background:var(--text-3);}
|
||||
.tag.on{color:var(--green);background:rgba(22,163,74,.06);border-color:rgba(22,163,74,.18);}
|
||||
.tag.on .td{background:var(--green);}
|
||||
html.dark .tag.on{background:rgba(74,222,128,.06);border-color:rgba(74,222,128,.16);}
|
||||
|
||||
/* Inspector body */
|
||||
.insp-body{flex:1;overflow-y:auto;padding:.5rem 1.75rem 2rem;}
|
||||
.insp-body::-webkit-scrollbar{width:8px;}
|
||||
.insp-body::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px;}
|
||||
|
||||
.sect{padding:1.25rem 0;border-bottom:1px solid var(--border);}
|
||||
.sect:last-child{border-bottom:none;}
|
||||
.sect-hd{font-size:.75rem;font-weight:600;color:var(--text);margin-bottom:.25rem;letter-spacing:-.01em;}
|
||||
.sect-sub{font-size:.7rem;color:var(--text-3);margin-bottom:1rem;}
|
||||
|
||||
.row{display:grid;grid-template-columns:140px 1fr;gap:1rem;align-items:center;padding:.4rem 0;}
|
||||
.row.top{align-items:flex-start;}
|
||||
.row > label{font-size:.72rem;color:var(--text-2);font-weight:500;padding-top:.45rem;}
|
||||
.row.top > label{padding-top:.5rem;}
|
||||
.row > label .req{color:var(--red);}
|
||||
.row > .val{min-width:0;}
|
||||
|
||||
input.inp,select.inp,textarea.inp{width:100%;height:32px;padding:0 .7rem;
|
||||
border:1px solid var(--border);border-radius:6px;
|
||||
background:var(--panel);color:var(--text);font-size:.73rem;font-family:inherit;
|
||||
outline:none;transition:border .15s,box-shadow .15s;}
|
||||
input.inp::placeholder,textarea.inp::placeholder{color:var(--text-3);}
|
||||
input.inp:focus,select.inp:focus,textarea.inp:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--ring);}
|
||||
input.inp[readonly]{background:var(--sunk);color:var(--text-3);}
|
||||
input.inp[readonly]:focus{box-shadow:none;border-color:var(--border);}
|
||||
textarea.inp{height:auto;min-height:64px;padding:.45rem .7rem;resize:vertical;line-height:1.55;}
|
||||
.help{font-size:.68rem;color:var(--text-3);margin-top:.35rem;}
|
||||
|
||||
select.inp{padding-right:2rem;appearance:none;-webkit-appearance:none;
|
||||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a1a1aa' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");
|
||||
background-repeat:no-repeat;background-position:right .55rem center;background-size:12px;}
|
||||
html.dark select.inp{background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");}
|
||||
|
||||
/* Segmented */
|
||||
.seg{display:inline-flex;padding:2px;border-radius:6px;background:var(--sunk);width:fit-content;}
|
||||
.seg button{padding:.32rem .65rem;border:none;border-radius:4px;background:transparent;color:var(--text-3);
|
||||
font-family:inherit;font-size:.7rem;font-weight:500;cursor:pointer;transition:color .15s;
|
||||
display:inline-flex;align-items:center;gap:.3rem;}
|
||||
.seg button:hover{color:var(--text-2);}
|
||||
.seg button.on{background:var(--panel);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.05);font-weight:600;}
|
||||
html.dark .seg button.on{background:var(--panel-2);}
|
||||
.seg button svg{width:12px;height:12px;}
|
||||
|
||||
/* Toggle */
|
||||
.tg{display:inline-flex;align-items:center;gap:.5rem;cursor:pointer;user-select:none;}
|
||||
.tg .sw{width:32px;height:18px;border-radius:999px;background:var(--border);position:relative;transition:background .2s;}
|
||||
.tg .sw::after{content:'';position:absolute;width:14px;height:14px;border-radius:50%;background:#fff;
|
||||
top:2px;left:2px;transition:left .2s;box-shadow:0 1px 2px rgba(0,0,0,.15);}
|
||||
html.dark .tg .sw{background:var(--border);}
|
||||
html.dark .tg .sw::after{background:#d4d4d8;}
|
||||
.tg.on .sw{background:var(--accent);}
|
||||
.tg.on .sw::after{left:16px;background:#fff;}
|
||||
.tg .tg-l{font-size:.72rem;color:var(--text-2);font-weight:500;}
|
||||
.tg.on .tg-l{color:var(--text);}
|
||||
|
||||
.mono{font-family:'JetBrains Mono',monospace;font-size:.7rem;color:var(--text-3);}
|
||||
|
||||
/* Inspector footer */
|
||||
.insp-ft{display:flex;align-items:center;justify-content:space-between;gap:.5rem;
|
||||
padding:.85rem 1.75rem;flex-shrink:0;border-top:1px solid var(--border);background:var(--panel);}
|
||||
.insp-ft-l{display:flex;gap:.4rem;}
|
||||
.insp-ft-r{display:flex;gap:.4rem;align-items:center;}
|
||||
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:.3rem;
|
||||
height:30px;padding:0 .85rem;border-radius:6px;border:1px solid var(--border);
|
||||
background:var(--panel);color:var(--text-2);
|
||||
font-family:inherit;font-size:.72rem;font-weight:500;cursor:pointer;transition:all .15s;outline:none;}
|
||||
.btn:hover{border-color:var(--text-3);color:var(--text);}
|
||||
.btn svg{width:12px;height:12px;}
|
||||
.btn.primary{background:var(--accent);color:white;border-color:var(--accent);font-weight:600;}
|
||||
.btn.primary:hover{background:#5c4ed4;border-color:#5c4ed4;color:white;}
|
||||
html.dark .btn.primary{color:#0b0b0f;}
|
||||
html.dark .btn.primary:hover{background:#b8b2ff;border-color:#b8b2ff;color:#0b0b0f;}
|
||||
.btn.danger{color:var(--red);}
|
||||
.btn.danger:hover{border-color:var(--red);background:rgba(220,38,38,.04);}
|
||||
.btn.ghost{border-color:transparent;background:transparent;}
|
||||
.btn.ghost:hover{background:var(--sunk);border-color:transparent;color:var(--text);}
|
||||
.btn.sm{height:26px;padding:0 .6rem;font-size:.65rem;}
|
||||
|
||||
.unsaved{font-size:.68rem;color:var(--amber);font-weight:500;margin-right:.25rem;}
|
||||
|
||||
@media(max-width:820px){
|
||||
.main{grid-template-columns:1fr;}
|
||||
.tree-pane{max-height:40vh;border-right:none;border-bottom:1px solid var(--border);}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="shell">
|
||||
|
||||
<header class="hdr">
|
||||
<div class="hdr-l">
|
||||
<div class="logo">INVYONE</div>
|
||||
<div class="bc">
|
||||
<span>관리자</span><span class="sep">/</span><span>시스템</span><span class="sep">/</span><span class="cur">메뉴 관리</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hdr-r">
|
||||
<div class="tt">
|
||||
<button id="th-l" class="on" onclick="setTheme('light')">Light</button>
|
||||
<button id="th-d" onclick="setTheme('dark')">Dark</button>
|
||||
</div>
|
||||
<div class="av">GP</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pb">
|
||||
<div>
|
||||
<h1>메뉴 관리</h1>
|
||||
<div class="sub">트리에서 선택하면 우측에서 편집합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="main">
|
||||
|
||||
<aside class="tree-pane">
|
||||
<div class="type">
|
||||
<div class="type-tabs">
|
||||
<button class="type-tab on" onclick="selType(this)">관리자 <span class="cnt">37</span></button>
|
||||
<button class="type-tab" onclick="selType(this)">사용자 <span class="cnt">18</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tree-tb">
|
||||
<div class="tree-srch">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input placeholder="검색…"/>
|
||||
<span class="kb">⌘K</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tree-body">
|
||||
|
||||
<div class="node lvl-1 open" onclick="toggleNode(this)">
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="dot on"></span>
|
||||
<span class="name">시스템</span>
|
||||
<span class="meta">10</span>
|
||||
</div>
|
||||
<div class="subtree open">
|
||||
<div class="node lvl-2 leaf sel" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">메뉴 관리</span><span class="meta">/admin/menu</span>
|
||||
</div>
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">사용자 관리</span><span class="meta">/admin/userMng</span>
|
||||
</div>
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot"></span>
|
||||
<span class="name">화면 관리</span><span class="meta">/admin/screenMng</span>
|
||||
</div>
|
||||
<div class="node lvl-2 open" onclick="toggleNode(this)">
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="dot on"></span>
|
||||
<span class="name">공통 코드</span><span class="meta">3</span>
|
||||
</div>
|
||||
<div class="subtree open">
|
||||
<div class="node lvl-3 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">코드 그룹</span><span class="meta">/admin/code/groups</span>
|
||||
</div>
|
||||
<div class="node lvl-3 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">코드 값</span><span class="meta">/admin/code/values</span>
|
||||
</div>
|
||||
<div class="node lvl-3 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot"></span>
|
||||
<span class="name">다국어</span><span class="meta">/admin/i18n</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="node lvl-1" onclick="toggleNode(this)">
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="dot on"></span>
|
||||
<span class="name">업무</span><span class="meta">12</span>
|
||||
</div>
|
||||
<div class="subtree">
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">수주</span><span class="meta">/biz/orders</span>
|
||||
</div>
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">생산</span><span class="meta">/biz/production</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="node lvl-1" onclick="toggleNode(this)">
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="dot on"></span>
|
||||
<span class="name">AI Assistant</span><span class="meta">5</span>
|
||||
</div>
|
||||
<div class="subtree">
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">대시보드</span><span class="meta">/ai/dashboard</span>
|
||||
</div>
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">채팅</span><span class="meta">/ai/chat</span>
|
||||
</div>
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot"></span>
|
||||
<span class="name">API 키</span><span class="meta">/ai/keys</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="node lvl-1" onclick="toggleNode(this)">
|
||||
<span class="caret"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg></span>
|
||||
<span class="dot on"></span>
|
||||
<span class="name">리포트</span><span class="meta">7</span>
|
||||
</div>
|
||||
<div class="subtree">
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">생산 리포트</span><span class="meta">/report/production</span>
|
||||
</div>
|
||||
<div class="node lvl-2 leaf" onclick="selNode(this,event)">
|
||||
<span class="caret"></span><span class="dot on"></span>
|
||||
<span class="name">품질 리포트</span><span class="meta">/report/quality</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="tree-ft">
|
||||
<button>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
|
||||
새 메뉴
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="insp">
|
||||
|
||||
<div class="insp-hd">
|
||||
<div class="insp-path">
|
||||
<span>관리자</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg>
|
||||
<span>시스템</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m9 5 7 7-7 7"/></svg>
|
||||
<span class="cur">메뉴 관리</span>
|
||||
</div>
|
||||
<div class="insp-title">
|
||||
<h2>메뉴 관리</h2>
|
||||
<span class="tag on"><span class="td"></span>Active</span>
|
||||
<span class="tag">공용 *</span>
|
||||
<span class="tag">L2</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="insp-body">
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-hd">기본 정보</div>
|
||||
<div class="sect-sub">메뉴 이름과 다국어 키를 관리합니다.</div>
|
||||
<div class="row">
|
||||
<label>메뉴명<span class="req">*</span></label>
|
||||
<div class="val"><input class="inp" value="메뉴 관리"/></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>다국어 키</label>
|
||||
<div class="val">
|
||||
<select class="inp">
|
||||
<option>menu.management</option>
|
||||
<option>user.management</option>
|
||||
<option>screen.management</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row top">
|
||||
<label>설명</label>
|
||||
<div class="val"><textarea class="inp">시스템 메뉴 트리를 관리하고 화면을 할당합니다</textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-hd">연결</div>
|
||||
<div class="sect-sub">메뉴 클릭 시 어떤 콘텐츠로 이동할지 결정합니다.</div>
|
||||
<div class="row">
|
||||
<label>유형</label>
|
||||
<div class="val">
|
||||
<div class="seg">
|
||||
<button class="on" onclick="selSeg(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3h18v18H3z"/><path d="M3 9h18M9 21V9"/></svg>
|
||||
화면
|
||||
</button>
|
||||
<button onclick="selSeg(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
|
||||
대시보드
|
||||
</button>
|
||||
<button onclick="selSeg(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2"/></svg>
|
||||
POP
|
||||
</button>
|
||||
<button onclick="selSeg(this)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>
|
||||
URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>화면 / URL</label>
|
||||
<div class="val"><input class="inp" value="/admin/menu"/></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>아이콘</label>
|
||||
<div class="val"><input class="inp" value="Menu" placeholder="Lucide 아이콘 이름"/></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-hd">스코프 & 표시</div>
|
||||
<div class="sect-sub">어느 회사에서 보이고, 어느 위치에 나타날지 설정합니다.</div>
|
||||
<div class="row">
|
||||
<label>회사</label>
|
||||
<div class="val">
|
||||
<select class="inp">
|
||||
<option>공용 (*)</option>
|
||||
<option>덕일하이텍</option>
|
||||
<option>비엑스플로러</option>
|
||||
</select>
|
||||
<div class="help">하위 메뉴는 상위 스코프를 상속합니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>부모</label>
|
||||
<div class="val"><input class="inp" value="시스템" readonly/></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>순서</label>
|
||||
<div class="val"><input class="inp" type="number" value="1" style="width:120px;"/></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>상태</label>
|
||||
<div class="val">
|
||||
<label class="tg on" onclick="this.classList.toggle('on')">
|
||||
<span class="sw"></span>
|
||||
<span class="tg-l">활성</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sect">
|
||||
<div class="sect-hd">메타</div>
|
||||
<div class="row">
|
||||
<label>OBJID</label>
|
||||
<div class="val"><span class="mono">mnu_00231</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>수정</label>
|
||||
<div class="val"><span class="mono">2026-04-12 14:23 · gbpark</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="insp-ft">
|
||||
<div class="insp-ft-l">
|
||||
<button class="btn danger sm">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
|
||||
삭제
|
||||
</button>
|
||||
<button class="btn ghost sm">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<div class="insp-ft-r">
|
||||
<span class="unsaved">● 저장되지 않음</span>
|
||||
<button class="btn">취소</button>
|
||||
<button class="btn primary">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function setTheme(t){
|
||||
document.documentElement.classList.toggle('dark',t==='dark');
|
||||
document.getElementById('th-l').classList.toggle('on',t==='light');
|
||||
document.getElementById('th-d').classList.toggle('on',t==='dark');
|
||||
}
|
||||
function selType(el){el.parentElement.querySelectorAll('.type-tab').forEach(c=>c.classList.remove('on'));el.classList.add('on');}
|
||||
function selSeg(el){el.parentElement.querySelectorAll('button').forEach(c=>c.classList.remove('on'));el.classList.add('on');}
|
||||
function toggleNode(el){
|
||||
el.classList.toggle('open');
|
||||
const st=el.nextElementSibling;
|
||||
if(st && st.classList.contains('subtree'))st.classList.toggle('open');
|
||||
}
|
||||
function selNode(el,e){
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll('.node').forEach(n=>n.classList.remove('sel'));
|
||||
el.classList.add('sel');
|
||||
const name=el.querySelector('.name').textContent;
|
||||
const url=el.querySelector('.meta').textContent;
|
||||
document.querySelector('.insp-title h2').textContent=name;
|
||||
document.querySelector('.insp-path .cur').textContent=name;
|
||||
const inputs=document.querySelectorAll('.insp-body input.inp');
|
||||
if(inputs[0])inputs[0].value=name;
|
||||
if(inputs[2])inputs[2].value=url;
|
||||
}
|
||||
document.addEventListener('keydown',e=>{
|
||||
if((e.metaKey||e.ctrlKey)&&e.key==='k'){e.preventDefault();document.querySelector('.tree-srch input').focus();}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user